@stackmeter/cli 0.1.4 → 0.2.1

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.
@@ -4,7 +4,7 @@ import { createInterface } from "node:readline/promises";
4
4
  import { exec, execSync } from "node:child_process";
5
5
  import {
6
6
  readFileSync, writeFileSync, existsSync, mkdirSync,
7
- unlinkSync, chmodSync, copyFileSync,
7
+ unlinkSync, chmodSync, copyFileSync, readdirSync,
8
8
  } from "node:fs";
9
9
  import { homedir, platform } from "node:os";
10
10
  import { join, dirname, resolve } from "node:path";
@@ -18,6 +18,12 @@ const args = process.argv.slice(2);
18
18
  const command = args[0];
19
19
  const target = args[1];
20
20
 
21
+ function getFlag(name) {
22
+ const idx = args.indexOf(name);
23
+ if (idx === -1 || idx + 1 >= args.length) return null;
24
+ return args[idx + 1];
25
+ }
26
+
21
27
  // ── Constants ────────────────────────────────────────────
22
28
 
23
29
  const DEFAULT_BASE_URL = "https://stackmeter.app";
@@ -174,9 +180,12 @@ async function connectOpenClaw() {
174
180
  console.log("");
175
181
 
176
182
  // ── Step 1: Get StackMeter base URL ────────────────
177
- let baseUrl = process.env.STACKMETER_URL
178
- ? process.env.STACKMETER_URL.replace(/\/api\/usage-events\/?$/, "")
179
- : "";
183
+ const flagUrl = getFlag("--url");
184
+ let baseUrl = flagUrl
185
+ ? flagUrl.replace(/\/api\/usage-events\/?$/, "")
186
+ : process.env.STACKMETER_URL
187
+ ? process.env.STACKMETER_URL.replace(/\/api\/usage-events\/?$/, "")
188
+ : "";
180
189
 
181
190
  if (!baseUrl) {
182
191
  baseUrl = await rl.question(
@@ -184,17 +193,17 @@ async function connectOpenClaw() {
184
193
  );
185
194
  if (!baseUrl.trim()) baseUrl = DEFAULT_BASE_URL;
186
195
  } else {
187
- console.log(` Using STACKMETER_URL: ${baseUrl}`);
196
+ console.log(` Using URL: ${baseUrl}`);
188
197
  }
189
198
  baseUrl = baseUrl.replace(/\/+$/, "");
190
199
  const apiUrl = `${baseUrl}/api/usage-events`;
191
200
 
192
201
  // ── Step 2: Get or create tracking token ───────────
193
- let token = process.env.STACKMETER_TOKEN || "";
202
+ let token = getFlag("--token") || process.env.STACKMETER_TOKEN || "";
194
203
 
195
204
  if (token) {
196
205
  console.log(
197
- ` Existing STACKMETER_TOKEN found (sm_...${token.slice(-4)})`
206
+ ` Using token (sm_...${token.slice(-4)})`
198
207
  );
199
208
  } else {
200
209
  console.log("");
@@ -298,7 +307,7 @@ async function connectOpenClaw() {
298
307
  console.log(
299
308
  " \u26A0\uFE0F OpenClaw config not found at ~/.openclaw/openclaw.json"
300
309
  );
301
- console.log(" The log watcher will start anyway and wait for OpenClaw logs.");
310
+ console.log(" The watcher will start anyway and wait for OpenClaw session files.");
302
311
  console.log("");
303
312
  }
304
313
 
@@ -359,7 +368,7 @@ async function connectOpenClaw() {
359
368
  console.log(
360
369
  " No changes to OpenClaw config needed \u2014 just run OpenClaw normally."
361
370
  );
362
- console.log(" The log watcher reads OpenClaw's logs in /tmp/openclaw/");
371
+ console.log(" The watcher reads session files from ~/.openclaw/agents/");
363
372
  console.log(` View your usage at ${baseUrl}/app/ai-usage`);
364
373
  console.log("");
365
374
  rl.close();
@@ -483,16 +492,30 @@ async function selfTest() {
483
492
  console.log(` \u274C Could not reach StackMeter: ${e.message}`);
484
493
  }
485
494
 
486
- // 2/2 — Check OpenClaw log file exists
487
- console.log(" 2/2 Checking OpenClaw logs...");
488
- const d = new Date();
489
- const ymd = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
490
- const logPath = `/tmp/openclaw/openclaw-${ymd}.log`;
495
+ // 2/2 — Check OpenClaw session files
496
+ console.log(" 2/2 Checking OpenClaw sessions...");
497
+ const ocHome = process.env.OPENCLAW_STATE_DIR || process.env.CLAWDBOT_STATE_DIR || process.env.OPENCLAW_HOME || join(homedir(), ".openclaw");
498
+ const agentsDir = join(ocHome, "agents");
491
499
 
492
- if (existsSync(logPath)) {
493
- console.log(` \u2705 OpenClaw log found: ${logPath}`);
500
+ if (existsSync(agentsDir)) {
501
+ const agents = readdirSync(agentsDir).filter(d =>
502
+ existsSync(join(agentsDir, d, "sessions"))
503
+ );
504
+ if (agents.length > 0) {
505
+ console.log(` \u2705 Found ${agents.length} agent(s): ${agents.join(", ")}`);
506
+ let totalSessions = 0;
507
+ for (const agent of agents) {
508
+ const sessDir = join(agentsDir, agent, "sessions");
509
+ const files = readdirSync(sessDir).filter(f => f.endsWith(".jsonl"));
510
+ totalSessions += files.length;
511
+ }
512
+ console.log(` ${totalSessions} session file(s) total`);
513
+ } else {
514
+ console.log(` \u26A0\uFE0F ${agentsDir} exists but no agents with sessions found`);
515
+ console.log(" Start OpenClaw first, then re-run this test.");
516
+ }
494
517
  } else {
495
- console.log(` \u26A0\uFE0F No log found at ${logPath}`);
518
+ console.log(` \u26A0\uFE0F No agents directory at ${agentsDir}`);
496
519
  console.log(" Start OpenClaw first, then re-run this test.");
497
520
  }
498
521
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackmeter/cli",
3
- "version": "0.1.4",
3
+ "version": "0.2.1",
4
4
  "description": "Track your SaaS AI costs from the terminal. One-command OpenClaw setup.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,81 +1,11 @@
1
- // StackMeter Log Watcher — Tails OpenClaw logs and emits usage events.
2
- // Replaces the proxy gateway approach since OpenClaw doesn't support
3
- // base URL overrides for its HTTP client.
1
+ // StackMeter Session Watcher — Reads OpenClaw session JSONL files for exact usage data.
2
+ // Watches ~/.openclaw/agents/*/sessions/*.jsonl for assistant messages with token usage.
4
3
 
5
- import { readFileSync, existsSync, statSync, watchFile, unwatchFile } from "node:fs";
4
+ import { readFileSync, existsSync, statSync, watchFile, unwatchFile, readdirSync } from "node:fs";
5
+ import { watch } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { homedir } from "node:os";
8
8
 
9
- const OC_LOG_DIR = "/tmp/openclaw";
10
-
11
- // Cost per 1M tokens in cents (best-effort estimates)
12
- const MODEL_PRICING = {
13
- // OpenAI
14
- "gpt-4o": { input: 250, output: 1000 },
15
- "gpt-4o-mini": { input: 15, output: 60 },
16
- "gpt-4-turbo": { input: 1000, output: 3000 },
17
- "gpt-4": { input: 3000, output: 6000 },
18
- "gpt-3.5-turbo": { input: 50, output: 150 },
19
- "o1": { input: 1500, output: 6000 },
20
- "o1-mini": { input: 300, output: 1200 },
21
- "o3-mini": { input: 110, output: 440 },
22
- // Anthropic
23
- "claude-opus-4-6": { input: 1500, output: 7500 },
24
- "claude-opus-4-5": { input: 1500, output: 7500 },
25
- "claude-sonnet-4-5": { input: 300, output: 1500 },
26
- "claude-haiku-4-5": { input: 80, output: 400 },
27
- "claude-3-opus": { input: 1500, output: 7500 },
28
- "claude-3-5-sonnet": { input: 300, output: 1500 },
29
- "claude-3-5-haiku": { input: 80, output: 400 },
30
- "claude-3-haiku": { input: 25, output: 125 },
31
- };
32
-
33
- // Rough tokens/second estimates by model class for duration-based estimation
34
- const TOKENS_PER_SEC = {
35
- "gpt-4o": 80,
36
- "gpt-4o-mini": 120,
37
- "gpt-4-turbo": 40,
38
- "gpt-4": 30,
39
- "gpt-3.5-turbo": 100,
40
- "o1": 20,
41
- "o1-mini": 40,
42
- "o3-mini": 60,
43
- "claude-opus-4-6": 30,
44
- "claude-opus-4-5": 30,
45
- "claude-sonnet-4-5": 60,
46
- "claude-haiku-4-5": 120,
47
- "claude-3-opus": 30,
48
- "claude-3-5-sonnet": 60,
49
- "claude-3-5-haiku": 120,
50
- "claude-3-haiku": 150,
51
- };
52
-
53
- function matchModel(model) {
54
- for (const key of Object.keys(MODEL_PRICING)) {
55
- if (model === key || model.startsWith(key + "-")) return key;
56
- }
57
- return null;
58
- }
59
-
60
- function estimateFromDuration(model, durationMs) {
61
- const matched = matchModel(model);
62
- const tps = matched ? (TOKENS_PER_SEC[matched] || 60) : 60;
63
- const secs = durationMs / 1000;
64
- // Rough split: ~30% input time, ~70% output time
65
- const outputTokens = Math.round(secs * 0.7 * tps);
66
- const inputTokens = Math.round(secs * 0.3 * tps * 0.5); // input is faster
67
- return { inputTokens, outputTokens };
68
- }
69
-
70
- function estimateCostCents(model, inputTokens, outputTokens) {
71
- const matched = matchModel(model);
72
- if (!matched) return 0;
73
- const pricing = MODEL_PRICING[matched];
74
- return Math.round(
75
- (inputTokens * pricing.input + outputTokens * pricing.output) / 1_000_000
76
- );
77
- }
78
-
79
9
  /* ── Config ──────────────────────────────────────────────── */
80
10
 
81
11
  function readStackMeterConfig() {
@@ -85,6 +15,16 @@ function readStackMeterConfig() {
85
15
  catch { return {}; }
86
16
  }
87
17
 
18
+ function getOpenClawHome(smConfig = {}) {
19
+ return (
20
+ process.env.OPENCLAW_STATE_DIR ||
21
+ process.env.CLAWDBOT_STATE_DIR ||
22
+ process.env.OPENCLAW_HOME ||
23
+ smConfig.openclawHome ||
24
+ join(homedir(), ".openclaw")
25
+ );
26
+ }
27
+
88
28
  /* ── Emit usage event to StackMeter ──────────────────────── */
89
29
 
90
30
  async function emitUsageEvent(event, smUrl, smToken) {
@@ -100,8 +40,8 @@ async function emitUsageEvent(event, smUrl, smToken) {
100
40
 
101
41
  if (res.ok) {
102
42
  console.log(
103
- ` [watcher] ${event.provider}/${event.model} ~${event.inputTokens}+${event.outputTokens} tokens` +
104
- ` \u2192 ${event.costCents}\u00A2 (${res.status})`
43
+ ` [watcher] ${event.provider}/${event.model} ${event.inputTokens}+${event.outputTokens} tokens` +
44
+ ` \u2192 ${event.costCents.toFixed(4)}\u00A2 (${res.status})`
105
45
  );
106
46
  } else {
107
47
  const err = await res.text().catch(() => "");
@@ -114,21 +54,64 @@ async function emitUsageEvent(event, smUrl, smToken) {
114
54
  }
115
55
  }
116
56
 
117
- /* ── Log parser ──────────────────────────────────────────── */
57
+ /* ── JSONL line parser ───────────────────────────────────── */
118
58
 
119
- function parseLogLine(line) {
59
+ function parseSessionLine(line) {
120
60
  try {
121
61
  const obj = JSON.parse(line);
122
- const subsystem = obj["0"] || "";
123
- const msg = typeof obj["1"] === "string" ? obj["1"] : "";
124
- const ts = obj.time || obj._meta?.date;
125
- return { subsystem, msg, data: obj["1"], ts };
62
+
63
+ // Only process assistant messages with usage data
64
+ if (obj.type !== "message") return null;
65
+ const msg = obj.message;
66
+ if (!msg || msg.role !== "assistant" || !msg.usage) return null;
67
+
68
+ const usage = msg.usage;
69
+ const cost = usage.cost || {};
70
+
71
+ return {
72
+ messageId: obj.id,
73
+ timestamp: obj.timestamp || new Date().toISOString(),
74
+ provider: msg.provider || "unknown",
75
+ model: msg.model || "unknown",
76
+ inputTokens: (usage.input || 0) + (usage.cacheRead || 0) + (usage.cacheWrite || 0),
77
+ outputTokens: usage.output || 0,
78
+ costCents: (cost.total || 0) * 100, // dollars → cents
79
+ };
126
80
  } catch {
127
81
  return null;
128
82
  }
129
83
  }
130
84
 
131
- /* ── Log watcher ─────────────────────────────────────────── */
85
+ /* ── Directory discovery ─────────────────────────────────── */
86
+
87
+ function discoverAgentDirs(ocHome) {
88
+ const agentsDir = join(ocHome, "agents");
89
+ if (!existsSync(agentsDir)) return [];
90
+
91
+ try {
92
+ return readdirSync(agentsDir, { withFileTypes: true })
93
+ .filter(d => d.isDirectory())
94
+ .map(d => ({
95
+ agentName: d.name,
96
+ sessionsDir: join(agentsDir, d.name, "sessions"),
97
+ }))
98
+ .filter(a => existsSync(a.sessionsDir));
99
+ } catch {
100
+ return [];
101
+ }
102
+ }
103
+
104
+ function listSessionFiles(sessionsDir) {
105
+ try {
106
+ return readdirSync(sessionsDir)
107
+ .filter(f => f.endsWith(".jsonl"))
108
+ .map(f => join(sessionsDir, f));
109
+ } catch {
110
+ return [];
111
+ }
112
+ }
113
+
114
+ /* ── Session watcher ─────────────────────────────────────── */
132
115
 
133
116
  export function startLogWatcher(opts = {}) {
134
117
  const smConfig = readStackMeterConfig();
@@ -147,157 +130,162 @@ export function startLogWatcher(opts = {}) {
147
130
  process.exit(1);
148
131
  }
149
132
 
150
- // Track active runs: runId -> { provider, model, startTime }
151
- const activeRuns = new Map();
152
- // Track emitted dedupKeys to avoid re-emitting on file re-read
153
- const emittedRuns = new Set();
133
+ const ocHome = getOpenClawHome(smConfig);
134
+ const emittedKeys = new Set();
154
135
 
155
- let currentLogPath = null;
156
- let fileOffset = 0;
136
+ // Track watched files: filePath -> { offset, partialLine }
137
+ const watchedFiles = new Map();
138
+ // Track watched directories
139
+ const watchedDirs = new Set();
140
+ // fs.watch handles for cleanup
141
+ const dirWatchers = [];
157
142
 
158
- function getLogPath() {
159
- const d = new Date();
160
- const ymd = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
161
- return join(OC_LOG_DIR, `openclaw-${ymd}.log`);
143
+ function extractSessionId(filePath) {
144
+ // /path/to/sessions/136ae359-fceb-4464-9d5a-9e7c2a15cdf7.jsonl 136ae359
145
+ const base = filePath.split("/").pop().replace(".jsonl", "");
146
+ return base.split("-")[0] || base;
162
147
  }
163
148
 
164
- function processLine(line) {
165
- if (!line.trim()) return;
166
- const parsed = parseLogLine(line);
167
- if (!parsed) return;
149
+ function processNewBytes(filePath, agentName) {
150
+ if (!existsSync(filePath)) return;
168
151
 
169
- const { msg, data, ts } = parsed;
152
+ const state = watchedFiles.get(filePath);
153
+ if (!state) return;
170
154
 
171
- // Match: "embedded run start: runId=xxx ... provider=yyy model=zzz"
172
- if (typeof msg === "string" && msg.includes("embedded run start:")) {
173
- const runId = msg.match(/runId=(\S+)/)?.[1];
174
- const provider = msg.match(/provider=(\S+)/)?.[1];
175
- const model = msg.match(/model=(\S+)/)?.[1];
176
- if (runId && provider && model) {
177
- activeRuns.set(runId, { provider, model, startTime: ts });
178
- }
179
- return;
180
- }
155
+ const stat = statSync(filePath);
156
+ if (stat.size <= state.offset) return;
181
157
 
182
- // Match: "embedded run done: runId=xxx ... durationMs=NNN"
183
- if (typeof msg === "string" && msg.includes("embedded run done:")) {
184
- const runId = msg.match(/runId=(\S+)/)?.[1];
185
- const durationMs = parseInt(msg.match(/durationMs=(\d+)/)?.[1] || "0", 10);
186
- const aborted = msg.includes("aborted=true");
158
+ // Read only new bytes
159
+ const content = readFileSync(filePath, "utf-8");
160
+ const newContent = (state.partialLine || "") + content.slice(state.offset);
161
+ state.offset = content.length;
162
+
163
+ const lines = newContent.split("\n");
187
164
 
188
- if (!runId || aborted) return;
189
- if (emittedRuns.has(runId)) return;
165
+ // Last element might be a partial line (incomplete write)
166
+ state.partialLine = lines.pop() || "";
190
167
 
191
- const run = activeRuns.get(runId);
192
- if (!run) return;
168
+ const sessionId = extractSessionId(filePath);
193
169
 
194
- activeRuns.delete(runId);
195
- emittedRuns.add(runId);
170
+ for (const line of lines) {
171
+ if (!line.trim()) continue;
196
172
 
197
- // Estimate tokens from duration
198
- const { inputTokens, outputTokens } = estimateFromDuration(run.model, durationMs);
199
- const costCents = estimateCostCents(run.model, inputTokens, outputTokens);
173
+ const parsed = parseSessionLine(line);
174
+ if (!parsed) continue;
175
+
176
+ const dedupKey = `oc-${agentName}-${sessionId}-${parsed.messageId}`;
177
+ if (emittedKeys.has(dedupKey)) continue;
178
+ emittedKeys.add(dedupKey);
200
179
 
201
180
  const event = {
202
- provider: run.provider,
203
- model: run.model,
204
- inputTokens,
205
- outputTokens,
206
- costCents,
181
+ provider: parsed.provider,
182
+ model: parsed.model,
183
+ inputTokens: parsed.inputTokens,
184
+ outputTokens: parsed.outputTokens,
185
+ costCents: parsed.costCents,
207
186
  sourceType: "openclaw",
208
- sourceId: "log-watcher",
209
- ts: run.startTime || new Date().toISOString(),
210
- dedupKey: `oc-${runId}`,
187
+ sourceId: `agent/${agentName}`,
188
+ ts: parsed.timestamp,
189
+ dedupKey,
211
190
  };
212
191
 
213
192
  const p = emitUsageEvent(event, smUrl, smToken);
214
193
  if (onEmit) p.then(onEmit);
215
- return;
216
194
  }
217
- }
218
-
219
- function readNewLines() {
220
- const logPath = getLogPath();
221
195
 
222
- // Date rolled over switch to new file
223
- if (logPath !== currentLogPath) {
224
- if (currentLogPath) {
225
- unwatchFile(currentLogPath);
226
- }
227
- currentLogPath = logPath;
228
- fileOffset = 0;
196
+ // Prune emittedKeys if it gets too large
197
+ if (emittedKeys.size > 100_000) {
198
+ emittedKeys.clear();
199
+ }
200
+ }
229
201
 
230
- // If file already exists, start from the end (don't replay old events)
231
- if (existsSync(logPath)) {
232
- const stat = statSync(logPath);
233
- fileOffset = stat.size;
234
- }
202
+ function startWatchingFile(filePath, agentName) {
203
+ if (watchedFiles.has(filePath)) return;
235
204
 
236
- watchLogFile(logPath);
237
- return;
205
+ // Start from end of file (skip historical data)
206
+ let offset = 0;
207
+ if (existsSync(filePath)) {
208
+ offset = statSync(filePath).size;
238
209
  }
239
210
 
240
- if (!existsSync(logPath)) return;
211
+ watchedFiles.set(filePath, { offset, partialLine: "" });
241
212
 
242
- const stat = statSync(logPath);
243
- if (stat.size <= fileOffset) return;
213
+ watchFile(filePath, { interval: 2000 }, () => {
214
+ processNewBytes(filePath, agentName);
215
+ });
216
+ }
244
217
 
245
- // Read only new bytes
246
- const fd = readFileSync(logPath, "utf-8");
247
- const newContent = fd.slice(fileOffset);
248
- fileOffset = fd.length;
218
+ function startWatchingDir(agentName, sessionsDir) {
219
+ if (watchedDirs.has(sessionsDir)) return;
220
+ watchedDirs.add(sessionsDir);
249
221
 
250
- const lines = newContent.split("\n");
251
- for (const line of lines) {
252
- processLine(line);
222
+ // Watch existing session files
223
+ for (const filePath of listSessionFiles(sessionsDir)) {
224
+ startWatchingFile(filePath, agentName);
253
225
  }
254
- }
255
226
 
256
- function watchLogFile(logPath) {
257
- if (!existsSync(logPath)) {
258
- // File doesn't exist yet poll until it does
259
- const check = setInterval(() => {
260
- if (existsSync(logPath)) {
261
- clearInterval(check);
262
- watchLogFile(logPath);
227
+ // Watch for new session files
228
+ try {
229
+ const watcher = watch(sessionsDir, (eventType, filename) => {
230
+ if (!filename || !filename.endsWith(".jsonl")) return;
231
+ const filePath = join(sessionsDir, filename);
232
+ if (existsSync(filePath)) {
233
+ startWatchingFile(filePath, agentName);
263
234
  }
264
- }, 5000);
265
- return;
235
+ });
236
+ dirWatchers.push(watcher);
237
+ } catch {
238
+ // fs.watch not available — rely on periodic scan below
266
239
  }
240
+ }
267
241
 
268
- watchFile(logPath, { interval: 1000 }, () => {
269
- readNewLines();
270
- });
242
+ function scanForAgents() {
243
+ const agents = discoverAgentDirs(ocHome);
244
+ for (const { agentName, sessionsDir } of agents) {
245
+ startWatchingDir(agentName, sessionsDir);
246
+ }
271
247
  }
272
248
 
273
- // Check for date rollover every 60s
274
- setInterval(() => {
275
- const newPath = getLogPath();
276
- if (newPath !== currentLogPath) {
277
- readNewLines(); // triggers switchover
249
+ function scanForNewFiles() {
250
+ for (const sessionsDir of watchedDirs) {
251
+ const agentName = sessionsDir.split("/").at(-2) || "unknown";
252
+ for (const filePath of listSessionFiles(sessionsDir)) {
253
+ startWatchingFile(filePath, agentName);
254
+ }
278
255
  }
279
- }, 60_000);
256
+ }
280
257
 
281
- // Start watching
282
- currentLogPath = getLogPath();
258
+ // Initial scan
259
+ scanForAgents();
283
260
 
284
- if (existsSync(currentLogPath)) {
285
- const stat = statSync(currentLogPath);
286
- fileOffset = stat.size; // start from end
287
- }
261
+ // Periodic scans: new agents every 30s, new session files every 10s
262
+ const agentScanInterval = setInterval(scanForAgents, 30_000);
263
+ const fileScanInterval = setInterval(scanForNewFiles, 10_000);
288
264
 
289
- watchLogFile(currentLogPath);
265
+ const agentDirs = discoverAgentDirs(ocHome);
266
+ const agentNames = agentDirs.map(a => a.agentName);
290
267
 
291
268
  console.log("");
292
- console.log(" StackMeter Log Watcher");
293
- console.log(` Watching: ${OC_LOG_DIR}/openclaw-*.log`);
269
+ console.log(" StackMeter Session Watcher");
270
+ console.log(` Watching: ${ocHome}/agents/*/sessions/*.jsonl`);
271
+ if (agentNames.length > 0) {
272
+ console.log(` Agents: ${agentNames.join(", ")}`);
273
+ } else {
274
+ console.log(" Agents: (none found yet — waiting for OpenClaw sessions)");
275
+ }
294
276
  console.log(` Reporting: ${smUrl}`);
295
277
  console.log("");
296
278
 
297
- // Return a cleanup function
298
279
  return {
299
280
  stop() {
300
- if (currentLogPath) unwatchFile(currentLogPath);
281
+ clearInterval(agentScanInterval);
282
+ clearInterval(fileScanInterval);
283
+ for (const [filePath] of watchedFiles) {
284
+ unwatchFile(filePath);
285
+ }
286
+ for (const watcher of dirWatchers) {
287
+ watcher.close();
288
+ }
301
289
  }
302
290
  };
303
291
  }