@stackmeter/cli 0.1.4 → 0.2.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/bin/stackmeter.mjs +25 -11
- package/package.json +1 -1
- package/src/gateway/log-watcher.mjs +171 -189
package/bin/stackmeter.mjs
CHANGED
|
@@ -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";
|
|
@@ -298,7 +298,7 @@ async function connectOpenClaw() {
|
|
|
298
298
|
console.log(
|
|
299
299
|
" \u26A0\uFE0F OpenClaw config not found at ~/.openclaw/openclaw.json"
|
|
300
300
|
);
|
|
301
|
-
console.log(" The
|
|
301
|
+
console.log(" The watcher will start anyway and wait for OpenClaw session files.");
|
|
302
302
|
console.log("");
|
|
303
303
|
}
|
|
304
304
|
|
|
@@ -359,7 +359,7 @@ async function connectOpenClaw() {
|
|
|
359
359
|
console.log(
|
|
360
360
|
" No changes to OpenClaw config needed \u2014 just run OpenClaw normally."
|
|
361
361
|
);
|
|
362
|
-
console.log(" The
|
|
362
|
+
console.log(" The watcher reads session files from ~/.openclaw/agents/");
|
|
363
363
|
console.log(` View your usage at ${baseUrl}/app/ai-usage`);
|
|
364
364
|
console.log("");
|
|
365
365
|
rl.close();
|
|
@@ -483,16 +483,30 @@ async function selfTest() {
|
|
|
483
483
|
console.log(` \u274C Could not reach StackMeter: ${e.message}`);
|
|
484
484
|
}
|
|
485
485
|
|
|
486
|
-
// 2/2 — Check OpenClaw
|
|
487
|
-
console.log(" 2/2 Checking OpenClaw
|
|
488
|
-
const
|
|
489
|
-
const
|
|
490
|
-
const logPath = `/tmp/openclaw/openclaw-${ymd}.log`;
|
|
486
|
+
// 2/2 — Check OpenClaw session files
|
|
487
|
+
console.log(" 2/2 Checking OpenClaw sessions...");
|
|
488
|
+
const ocHome = process.env.OPENCLAW_HOME || join(homedir(), ".openclaw");
|
|
489
|
+
const agentsDir = join(ocHome, "agents");
|
|
491
490
|
|
|
492
|
-
if (existsSync(
|
|
493
|
-
|
|
491
|
+
if (existsSync(agentsDir)) {
|
|
492
|
+
const agents = readdirSync(agentsDir).filter(d =>
|
|
493
|
+
existsSync(join(agentsDir, d, "sessions"))
|
|
494
|
+
);
|
|
495
|
+
if (agents.length > 0) {
|
|
496
|
+
console.log(` \u2705 Found ${agents.length} agent(s): ${agents.join(", ")}`);
|
|
497
|
+
let totalSessions = 0;
|
|
498
|
+
for (const agent of agents) {
|
|
499
|
+
const sessDir = join(agentsDir, agent, "sessions");
|
|
500
|
+
const files = readdirSync(sessDir).filter(f => f.endsWith(".jsonl"));
|
|
501
|
+
totalSessions += files.length;
|
|
502
|
+
}
|
|
503
|
+
console.log(` ${totalSessions} session file(s) total`);
|
|
504
|
+
} else {
|
|
505
|
+
console.log(` \u26A0\uFE0F ${agentsDir} exists but no agents with sessions found`);
|
|
506
|
+
console.log(" Start OpenClaw first, then re-run this test.");
|
|
507
|
+
}
|
|
494
508
|
} else {
|
|
495
|
-
console.log(` \u26A0\uFE0F No
|
|
509
|
+
console.log(` \u26A0\uFE0F No agents directory at ${agentsDir}`);
|
|
496
510
|
console.log(" Start OpenClaw first, then re-run this test.");
|
|
497
511
|
}
|
|
498
512
|
|
package/package.json
CHANGED
|
@@ -1,81 +1,11 @@
|
|
|
1
|
-
// StackMeter
|
|
2
|
-
//
|
|
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,10 @@ function readStackMeterConfig() {
|
|
|
85
15
|
catch { return {}; }
|
|
86
16
|
}
|
|
87
17
|
|
|
18
|
+
function getOpenClawHome(smConfig = {}) {
|
|
19
|
+
return process.env.OPENCLAW_HOME || smConfig.openclawHome || join(homedir(), ".openclaw");
|
|
20
|
+
}
|
|
21
|
+
|
|
88
22
|
/* ── Emit usage event to StackMeter ──────────────────────── */
|
|
89
23
|
|
|
90
24
|
async function emitUsageEvent(event, smUrl, smToken) {
|
|
@@ -100,8 +34,8 @@ async function emitUsageEvent(event, smUrl, smToken) {
|
|
|
100
34
|
|
|
101
35
|
if (res.ok) {
|
|
102
36
|
console.log(
|
|
103
|
-
` [watcher] ${event.provider}/${event.model}
|
|
104
|
-
` \u2192 ${event.costCents}\u00A2 (${res.status})`
|
|
37
|
+
` [watcher] ${event.provider}/${event.model} ${event.inputTokens}+${event.outputTokens} tokens` +
|
|
38
|
+
` \u2192 ${event.costCents.toFixed(4)}\u00A2 (${res.status})`
|
|
105
39
|
);
|
|
106
40
|
} else {
|
|
107
41
|
const err = await res.text().catch(() => "");
|
|
@@ -114,21 +48,64 @@ async function emitUsageEvent(event, smUrl, smToken) {
|
|
|
114
48
|
}
|
|
115
49
|
}
|
|
116
50
|
|
|
117
|
-
/* ──
|
|
51
|
+
/* ── JSONL line parser ───────────────────────────────────── */
|
|
118
52
|
|
|
119
|
-
function
|
|
53
|
+
function parseSessionLine(line) {
|
|
120
54
|
try {
|
|
121
55
|
const obj = JSON.parse(line);
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
56
|
+
|
|
57
|
+
// Only process assistant messages with usage data
|
|
58
|
+
if (obj.type !== "message") return null;
|
|
59
|
+
const msg = obj.message;
|
|
60
|
+
if (!msg || msg.role !== "assistant" || !msg.usage) return null;
|
|
61
|
+
|
|
62
|
+
const usage = msg.usage;
|
|
63
|
+
const cost = usage.cost || {};
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
messageId: obj.id,
|
|
67
|
+
timestamp: obj.timestamp || new Date().toISOString(),
|
|
68
|
+
provider: msg.provider || "unknown",
|
|
69
|
+
model: msg.model || "unknown",
|
|
70
|
+
inputTokens: (usage.input || 0) + (usage.cacheRead || 0) + (usage.cacheWrite || 0),
|
|
71
|
+
outputTokens: usage.output || 0,
|
|
72
|
+
costCents: (cost.total || 0) * 100, // dollars → cents
|
|
73
|
+
};
|
|
126
74
|
} catch {
|
|
127
75
|
return null;
|
|
128
76
|
}
|
|
129
77
|
}
|
|
130
78
|
|
|
131
|
-
/* ──
|
|
79
|
+
/* ── Directory discovery ─────────────────────────────────── */
|
|
80
|
+
|
|
81
|
+
function discoverAgentDirs(ocHome) {
|
|
82
|
+
const agentsDir = join(ocHome, "agents");
|
|
83
|
+
if (!existsSync(agentsDir)) return [];
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
return readdirSync(agentsDir, { withFileTypes: true })
|
|
87
|
+
.filter(d => d.isDirectory())
|
|
88
|
+
.map(d => ({
|
|
89
|
+
agentName: d.name,
|
|
90
|
+
sessionsDir: join(agentsDir, d.name, "sessions"),
|
|
91
|
+
}))
|
|
92
|
+
.filter(a => existsSync(a.sessionsDir));
|
|
93
|
+
} catch {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function listSessionFiles(sessionsDir) {
|
|
99
|
+
try {
|
|
100
|
+
return readdirSync(sessionsDir)
|
|
101
|
+
.filter(f => f.endsWith(".jsonl"))
|
|
102
|
+
.map(f => join(sessionsDir, f));
|
|
103
|
+
} catch {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* ── Session watcher ─────────────────────────────────────── */
|
|
132
109
|
|
|
133
110
|
export function startLogWatcher(opts = {}) {
|
|
134
111
|
const smConfig = readStackMeterConfig();
|
|
@@ -147,157 +124,162 @@ export function startLogWatcher(opts = {}) {
|
|
|
147
124
|
process.exit(1);
|
|
148
125
|
}
|
|
149
126
|
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
// Track emitted dedupKeys to avoid re-emitting on file re-read
|
|
153
|
-
const emittedRuns = new Set();
|
|
127
|
+
const ocHome = getOpenClawHome(smConfig);
|
|
128
|
+
const emittedKeys = new Set();
|
|
154
129
|
|
|
155
|
-
|
|
156
|
-
|
|
130
|
+
// Track watched files: filePath -> { offset, partialLine }
|
|
131
|
+
const watchedFiles = new Map();
|
|
132
|
+
// Track watched directories
|
|
133
|
+
const watchedDirs = new Set();
|
|
134
|
+
// fs.watch handles for cleanup
|
|
135
|
+
const dirWatchers = [];
|
|
157
136
|
|
|
158
|
-
function
|
|
159
|
-
|
|
160
|
-
const
|
|
161
|
-
return
|
|
137
|
+
function extractSessionId(filePath) {
|
|
138
|
+
// /path/to/sessions/136ae359-fceb-4464-9d5a-9e7c2a15cdf7.jsonl → 136ae359
|
|
139
|
+
const base = filePath.split("/").pop().replace(".jsonl", "");
|
|
140
|
+
return base.split("-")[0] || base;
|
|
162
141
|
}
|
|
163
142
|
|
|
164
|
-
function
|
|
165
|
-
if (!
|
|
166
|
-
const parsed = parseLogLine(line);
|
|
167
|
-
if (!parsed) return;
|
|
143
|
+
function processNewBytes(filePath, agentName) {
|
|
144
|
+
if (!existsSync(filePath)) return;
|
|
168
145
|
|
|
169
|
-
const
|
|
146
|
+
const state = watchedFiles.get(filePath);
|
|
147
|
+
if (!state) return;
|
|
170
148
|
|
|
171
|
-
|
|
172
|
-
if (
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
149
|
+
const stat = statSync(filePath);
|
|
150
|
+
if (stat.size <= state.offset) return;
|
|
151
|
+
|
|
152
|
+
// Read only new bytes
|
|
153
|
+
const content = readFileSync(filePath, "utf-8");
|
|
154
|
+
const newContent = (state.partialLine || "") + content.slice(state.offset);
|
|
155
|
+
state.offset = content.length;
|
|
156
|
+
|
|
157
|
+
const lines = newContent.split("\n");
|
|
181
158
|
|
|
182
|
-
//
|
|
183
|
-
|
|
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");
|
|
159
|
+
// Last element might be a partial line (incomplete write)
|
|
160
|
+
state.partialLine = lines.pop() || "";
|
|
187
161
|
|
|
188
|
-
|
|
189
|
-
if (emittedRuns.has(runId)) return;
|
|
162
|
+
const sessionId = extractSessionId(filePath);
|
|
190
163
|
|
|
191
|
-
|
|
192
|
-
if (!
|
|
164
|
+
for (const line of lines) {
|
|
165
|
+
if (!line.trim()) continue;
|
|
193
166
|
|
|
194
|
-
|
|
195
|
-
|
|
167
|
+
const parsed = parseSessionLine(line);
|
|
168
|
+
if (!parsed) continue;
|
|
196
169
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
170
|
+
const dedupKey = `oc-${agentName}-${sessionId}-${parsed.messageId}`;
|
|
171
|
+
if (emittedKeys.has(dedupKey)) continue;
|
|
172
|
+
emittedKeys.add(dedupKey);
|
|
200
173
|
|
|
201
174
|
const event = {
|
|
202
|
-
provider:
|
|
203
|
-
model:
|
|
204
|
-
inputTokens,
|
|
205
|
-
outputTokens,
|
|
206
|
-
costCents,
|
|
175
|
+
provider: parsed.provider,
|
|
176
|
+
model: parsed.model,
|
|
177
|
+
inputTokens: parsed.inputTokens,
|
|
178
|
+
outputTokens: parsed.outputTokens,
|
|
179
|
+
costCents: parsed.costCents,
|
|
207
180
|
sourceType: "openclaw",
|
|
208
|
-
sourceId:
|
|
209
|
-
ts:
|
|
210
|
-
dedupKey
|
|
181
|
+
sourceId: `agent/${agentName}`,
|
|
182
|
+
ts: parsed.timestamp,
|
|
183
|
+
dedupKey,
|
|
211
184
|
};
|
|
212
185
|
|
|
213
186
|
const p = emitUsageEvent(event, smUrl, smToken);
|
|
214
187
|
if (onEmit) p.then(onEmit);
|
|
215
|
-
return;
|
|
216
188
|
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function readNewLines() {
|
|
220
|
-
const logPath = getLogPath();
|
|
221
189
|
|
|
222
|
-
//
|
|
223
|
-
if (
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
currentLogPath = logPath;
|
|
228
|
-
fileOffset = 0;
|
|
190
|
+
// Prune emittedKeys if it gets too large
|
|
191
|
+
if (emittedKeys.size > 100_000) {
|
|
192
|
+
emittedKeys.clear();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
229
195
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const stat = statSync(logPath);
|
|
233
|
-
fileOffset = stat.size;
|
|
234
|
-
}
|
|
196
|
+
function startWatchingFile(filePath, agentName) {
|
|
197
|
+
if (watchedFiles.has(filePath)) return;
|
|
235
198
|
|
|
236
|
-
|
|
237
|
-
|
|
199
|
+
// Start from end of file (skip historical data)
|
|
200
|
+
let offset = 0;
|
|
201
|
+
if (existsSync(filePath)) {
|
|
202
|
+
offset = statSync(filePath).size;
|
|
238
203
|
}
|
|
239
204
|
|
|
240
|
-
|
|
205
|
+
watchedFiles.set(filePath, { offset, partialLine: "" });
|
|
241
206
|
|
|
242
|
-
|
|
243
|
-
|
|
207
|
+
watchFile(filePath, { interval: 2000 }, () => {
|
|
208
|
+
processNewBytes(filePath, agentName);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
244
211
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
fileOffset = fd.length;
|
|
212
|
+
function startWatchingDir(agentName, sessionsDir) {
|
|
213
|
+
if (watchedDirs.has(sessionsDir)) return;
|
|
214
|
+
watchedDirs.add(sessionsDir);
|
|
249
215
|
|
|
250
|
-
|
|
251
|
-
for (const
|
|
252
|
-
|
|
216
|
+
// Watch existing session files
|
|
217
|
+
for (const filePath of listSessionFiles(sessionsDir)) {
|
|
218
|
+
startWatchingFile(filePath, agentName);
|
|
253
219
|
}
|
|
254
|
-
}
|
|
255
220
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
221
|
+
// Watch for new session files
|
|
222
|
+
try {
|
|
223
|
+
const watcher = watch(sessionsDir, (eventType, filename) => {
|
|
224
|
+
if (!filename || !filename.endsWith(".jsonl")) return;
|
|
225
|
+
const filePath = join(sessionsDir, filename);
|
|
226
|
+
if (existsSync(filePath)) {
|
|
227
|
+
startWatchingFile(filePath, agentName);
|
|
263
228
|
}
|
|
264
|
-
}
|
|
265
|
-
|
|
229
|
+
});
|
|
230
|
+
dirWatchers.push(watcher);
|
|
231
|
+
} catch {
|
|
232
|
+
// fs.watch not available — rely on periodic scan below
|
|
266
233
|
}
|
|
234
|
+
}
|
|
267
235
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
})
|
|
236
|
+
function scanForAgents() {
|
|
237
|
+
const agents = discoverAgentDirs(ocHome);
|
|
238
|
+
for (const { agentName, sessionsDir } of agents) {
|
|
239
|
+
startWatchingDir(agentName, sessionsDir);
|
|
240
|
+
}
|
|
271
241
|
}
|
|
272
242
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
243
|
+
function scanForNewFiles() {
|
|
244
|
+
for (const sessionsDir of watchedDirs) {
|
|
245
|
+
const agentName = sessionsDir.split("/").at(-2) || "unknown";
|
|
246
|
+
for (const filePath of listSessionFiles(sessionsDir)) {
|
|
247
|
+
startWatchingFile(filePath, agentName);
|
|
248
|
+
}
|
|
278
249
|
}
|
|
279
|
-
}
|
|
250
|
+
}
|
|
280
251
|
|
|
281
|
-
//
|
|
282
|
-
|
|
252
|
+
// Initial scan
|
|
253
|
+
scanForAgents();
|
|
283
254
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
}
|
|
255
|
+
// Periodic scans: new agents every 30s, new session files every 10s
|
|
256
|
+
const agentScanInterval = setInterval(scanForAgents, 30_000);
|
|
257
|
+
const fileScanInterval = setInterval(scanForNewFiles, 10_000);
|
|
288
258
|
|
|
289
|
-
|
|
259
|
+
const agentDirs = discoverAgentDirs(ocHome);
|
|
260
|
+
const agentNames = agentDirs.map(a => a.agentName);
|
|
290
261
|
|
|
291
262
|
console.log("");
|
|
292
|
-
console.log(" StackMeter
|
|
293
|
-
console.log(` Watching: ${
|
|
263
|
+
console.log(" StackMeter Session Watcher");
|
|
264
|
+
console.log(` Watching: ${ocHome}/agents/*/sessions/*.jsonl`);
|
|
265
|
+
if (agentNames.length > 0) {
|
|
266
|
+
console.log(` Agents: ${agentNames.join(", ")}`);
|
|
267
|
+
} else {
|
|
268
|
+
console.log(" Agents: (none found yet — waiting for OpenClaw sessions)");
|
|
269
|
+
}
|
|
294
270
|
console.log(` Reporting: ${smUrl}`);
|
|
295
271
|
console.log("");
|
|
296
272
|
|
|
297
|
-
// Return a cleanup function
|
|
298
273
|
return {
|
|
299
274
|
stop() {
|
|
300
|
-
|
|
275
|
+
clearInterval(agentScanInterval);
|
|
276
|
+
clearInterval(fileScanInterval);
|
|
277
|
+
for (const [filePath] of watchedFiles) {
|
|
278
|
+
unwatchFile(filePath);
|
|
279
|
+
}
|
|
280
|
+
for (const watcher of dirWatchers) {
|
|
281
|
+
watcher.close();
|
|
282
|
+
}
|
|
301
283
|
}
|
|
302
284
|
};
|
|
303
285
|
}
|