clawmonitor 1.0.1 → 1.1.2

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,733 @@
1
+ #!/usr/bin/env node
2
+ // clawmonitor — Real-time OpenClaw tool call monitor
3
+ // Zero dependencies. Node.js >= 20.
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const os = require('os');
8
+
9
+ // === Args ===
10
+ const argv = process.argv.slice(2);
11
+ const opts = { compact: argv.includes('--compact'), full: argv.includes('--full'), history: 10 };
12
+ for (let i = 0; i < argv.length; i++) {
13
+ if (argv[i] === '--history') { opts.history = parseInt(argv[i + 1]) || 10; i++; }
14
+ else if (argv[i] === '--help' || argv[i] === '-h') {
15
+ console.log(`clawmonitor — Real-time OpenClaw tool call monitor
16
+
17
+ Usage: clawmonitor [options]
18
+
19
+ --compact Compact one-line output
20
+ --history N Show last N history entries (default: 10)
21
+ --full Show full input/output (no truncation)
22
+ --help Show this help
23
+
24
+ Environment:
25
+ OPENCLAW_HOME Custom OpenClaw data directory
26
+ NO_COLOR Disable colored output`);
27
+ process.exit(0);
28
+ }
29
+ }
30
+
31
+ // === Theme ===
32
+ const NC = process.env.NO_COLOR || !process.stdout.isTTY;
33
+ const T = NC ? {
34
+ s: (v) => v,
35
+ r: (s) => s, g: (s) => s, y: (s) => s, b: (s) => s,
36
+ m: (s) => s, c: (s) => s, w: (s) => s, x: (s) => s,
37
+ d: (s) => s, bd: (s) => s,
38
+ ok: (s) => s, err: (s) => s, tag: (s) => s,
39
+ badge: (name) => ` ${name} `,
40
+ } : {
41
+ s: (v) => v,
42
+ // Foregrounds (call as functions)
43
+ r: (s) => `\x1b[31m${s}\x1b[0m`,
44
+ g: (s) => `\x1b[32m${s}\x1b[0m`,
45
+ y: (s) => `\x1b[33m${s}\x1b[0m`,
46
+ b: (s) => `\x1b[34m${s}\x1b[0m`,
47
+ m: (s) => `\x1b[35m${s}\x1b[0m`,
48
+ c: (s) => `\x1b[36m${s}\x1b[0m`,
49
+ w: (s) => `\x1b[37m${s}\x1b[0m`,
50
+ x: (s) => `\x1b[90m${s}\x1b[0m`, // gray
51
+ d: (s) => `\x1b[2m${s}\x1b[0m`, // dim
52
+ bd: (s) => `\x1b[1m${s}\x1b[0m`, // bold
53
+ // Badges
54
+ // Badges — no emoji, pure ASCII for predictable width
55
+ ok: NC ? (s) => s : (s) => `\x1b[42m\x1b[30m ${s} \x1b[0m`,
56
+ err: NC ? (s) => s : (s) => `\x1b[41m\x1b[97m ${s} \x1b[0m`,
57
+ tag: NC ? (s) => `<${s}>` : (s) => `\x1b[48;5;237m\x1b[37m<${s}>\x1b[0m`,
58
+ badge: NC
59
+ ? (name) => ` ${name} `
60
+ : (name) => {
61
+ const map = {
62
+ exec: '\x1b[48;5;22m', read: '\x1b[48;5;24m', write: '\x1b[48;5;52m',
63
+ edit: '\x1b[48;5;58m', web_search: '\x1b[48;5;28m', web_fetch: '\x1b[48;5;25m',
64
+ browser: '\x1b[48;5;23m', message: '\x1b[48;5;55m', tts: '\x1b[48;5;54m',
65
+ memory_search: '\x1b[48;5;26m', gateway: '\x1b[48;5;60m',
66
+ };
67
+ return `${map[name] || '\x1b[48;5;237m'}\x1b[37m ${name} \x1b[0m`;
68
+ },
69
+ };
70
+
71
+ // === OpenClaw dir ===
72
+ function findDir() {
73
+ for (const d of [
74
+ process.env.OPENCLAW_HOME && path.join(process.env.OPENCLAW_HOME, 'agents'),
75
+ path.join(os.homedir(), '.openclaw', 'agents'),
76
+ process.env.XDG_DATA_HOME && path.join(process.env.XDG_DATA_HOME, 'openclaw', 'agents'),
77
+ process.platform === 'win32' && process.env.APPDATA && path.join(process.env.APPDATA, 'openclaw', 'agents'),
78
+ ].filter(Boolean)) {
79
+ if (fs.existsSync(d)) return d;
80
+ }
81
+ console.error(`Cannot find OpenClaw data directory. Set OPENCLAW_HOME.`);
82
+ process.exit(1);
83
+ }
84
+
85
+ const DIR = findDir();
86
+ const TIME = 0;
87
+
88
+ // === Sessions ===
89
+ function findSessions() {
90
+ const now = Date.now(), out = [];
91
+ if (!fs.existsSync(DIR)) return out;
92
+ for (const ag of fs.readdirSync(DIR)) {
93
+ const sd = path.join(DIR, ag, 'sessions');
94
+ if (!fs.existsSync(sd)) continue;
95
+ for (const f of fs.readdirSync(sd)) {
96
+ if (!f.endsWith('.jsonl')) continue;
97
+ const fp = path.join(sd, f);
98
+ try {
99
+ const st = fs.statSync(fp);
100
+ if (TIME > 0 && (now - st.mtimeMs) > TIME * 6e4) continue;
101
+ out.push(fp);
102
+ } catch {}
103
+ }
104
+ }
105
+ return out.sort();
106
+ }
107
+
108
+ function getLabel(fp) {
109
+ const ag = path.relative(DIR, fp).split(path.sep)[0];
110
+ const bn = path.basename(fp, '.jsonl');
111
+ try {
112
+ for (const ln of fs.readFileSync(fp, 'utf8').split('\n').slice(0, 80)) {
113
+ if (!ln.trim()) continue;
114
+ try {
115
+ const o = JSON.parse(ln);
116
+ if (o.type === 'message' && o.message?.role === 'user') {
117
+ for (const t of (o.message.content?.filter(c => c.type === 'text') || [])) {
118
+ const m = t.text?.match(/"conversation_label"\s*:\s*"([^"]+)"/);
119
+ if (m) {
120
+ const l = m[1], tp = l.match(/topic:(\d+)/)?.[1], gn = l.split(' ')[0];
121
+ if (tp) return `${ag}/${gn}/t${tp}`;
122
+ if (gn) return `${ag}/${gn}`;
123
+ }
124
+ }
125
+ }
126
+ } catch {}
127
+ }
128
+ } catch {}
129
+ return `${ag}/DM/${bn.replace(/-topic-\d+$/, '').replace(/-([0-9a-f]{4}).*/, '..*$1')}`;
130
+ }
131
+
132
+ // === Extract & Pair ===
133
+ function extract(fp) {
134
+ const out = [], fn = path.basename(fp);
135
+ let data;
136
+ try { data = fs.readFileSync(fp, 'utf8'); } catch { return out; }
137
+ for (const ln of data.split('\n')) {
138
+ if (!ln.trim()) continue;
139
+ let o; try { o = JSON.parse(ln); } catch { continue; }
140
+ if (o.type !== 'message' || !o.timestamp) continue;
141
+ if (o.message.role === 'assistant') {
142
+ for (const tc of (o.message.content || []).filter(c => c.type === 'toolCall'))
143
+ out.push({ ts: o.timestamp, file: fn, role: 'call', id: tc.id, name: tc.name, args: tc.arguments || {} });
144
+ } else if (o.message.role === 'toolResult') {
145
+ const t = o.message.content?.[0]?.text || '';
146
+ out.push({ ts: o.timestamp, file: fn, role: 'result', id: o.message.toolCallId, name: o.message.toolName, result: typeof t === 'string' ? t : JSON.stringify(t) });
147
+ }
148
+ }
149
+ return out;
150
+ }
151
+
152
+ function pair(entries) {
153
+ entries.sort((a, b) => a.ts.localeCompare(b.ts));
154
+ const p = {}, out = [];
155
+ for (const e of entries) {
156
+ if (e.role === 'call') p[e.id] = e;
157
+ else if (e.role === 'result' && p[e.id]) {
158
+ out.push({ ...p[e.id], result: e.result, rts: e.ts });
159
+ delete p[e.id];
160
+ }
161
+ }
162
+ for (const k of Object.keys(p)) out.push(p[k]);
163
+ out.sort((a, b) => a.ts.localeCompare(b.ts));
164
+ return out;
165
+ }
166
+
167
+ // === Helpers ===
168
+ const fmtT = (ts) => {
169
+ if (!ts || ts === 'null') return '??:??:??';
170
+ const d = new Date(ts);
171
+ return isNaN(d) ? ts.substring(11, 19) : d.toLocaleTimeString('en-GB', { hour12: false });
172
+ };
173
+ const fmtDur = (a, b) => {
174
+ const ms = new Date(b) - new Date(a);
175
+ if (isNaN(ms)) return null;
176
+ if (ms >= 6e4) return `${Math.floor(ms / 6e4)}m${Math.floor((ms % 6e4) / 1e3)}s`;
177
+ if (ms >= 1e3) return `${(ms / 1e3).toFixed(1)}s`;
178
+ return `${ms}ms`;
179
+ };
180
+ const fmtSz = (b) => b >= 1048576 ? `${(b / 1048576).toFixed(1)}MB` : b >= 1024 ? `${Math.floor(b / 1024)}KB` : `${b}B`;
181
+ const trunc = (v, n) => {
182
+ if (v == null) return '';
183
+ let s = typeof v === 'string' ? v : JSON.stringify(v);
184
+ const lines = s.split('\n');
185
+ if (lines.length > 1) {
186
+ const shown = lines.slice(0, 3).map(l => l.trim()).filter(l => l);
187
+ if (lines.length > 3) return truncTo(shown.join(' '), n - 15) + ` … (+${lines.length - 3} lines)`;
188
+ return truncTo(shown.join(' '), n);
189
+ }
190
+ s = s.replace(/\s+/g, ' ').trim();
191
+ return truncTo(s, n);
192
+ };
193
+
194
+ const durColor = (d) => {
195
+ if (!d) return '';
196
+ const ms = d.endsWith('ms') ? +d.slice(0, -2) : d.includes('m') ? (+d.match(/(\d+)m/)[1] * 6e4 + (+d.match(/(\d+)s/)?.[1] || 0) * 1e3) : +d.slice(0, -1) * 1e3;
197
+ if (ms < 100) return T.g(d);
198
+ if (ms < 1000) return T.c(d);
199
+ if (ms < 5000) return T.y(d);
200
+ return T.r(d);
201
+ };
202
+
203
+ // === Format Entry ===
204
+ // Wrap a multi-line arg value into display lines that fit the border.
205
+ // Returns array of {key, value} lines. Max 3 lines per arg.
206
+ // Pad a string to exactly N terminal columns (right-pad with spaces)
207
+ const padTo = (s, n) => {
208
+ const w = strWidth(s);
209
+ if (w >= n) return s;
210
+ return s + ' '.repeat(n - w);
211
+ };
212
+
213
+ const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
214
+
215
+ // === Width measurement (based on string-width by sindresorhus) ===
216
+ // Uses Intl.Segmenter (Node 16+) for grapheme clustering
217
+ // + Unicode East Asian Width for CJK
218
+ // + RGI Emoji detection for emoji width
219
+ // Zero dependencies — all built-in JS APIs
220
+
221
+ const segmenter = new Intl.Segmenter();
222
+ const reZeroWidth = /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+$/v;
223
+ const reEmoji = /^\p{RGI_Emoji}$/v;
224
+ const reExtPicto = /\p{Extended_Pictographic}/gv;
225
+
226
+ const charWidth = (segment) => {
227
+ const cp = segment.codePointAt(0);
228
+ // Zero-width clusters
229
+ if (reZeroWidth.test(segment)) return 0;
230
+ // RGI Emoji → 2 cols
231
+ if (reEmoji.test(segment)) return 2;
232
+ // Keycap sequences
233
+ if (/^[\d#*]\u20E3$/.test(segment)) return 2;
234
+ // ZWJ sequences with 2+ Extended_Pictographic
235
+ if (segment.includes('\u200D') && segment.length <= 50) {
236
+ const p = segment.match(reExtPicto);
237
+ if (p && p.length >= 2) return 2;
238
+ }
239
+ // East Asian Width: Wide/Fullwidth ranges → 2 cols
240
+ if ((cp >= 0x4E00 && cp <= 0x9FFF) || (cp >= 0x3400 && cp <= 0x4DBF) ||
241
+ (cp >= 0xF900 && cp <= 0xFAFF) || (cp >= 0xFF01 && cp <= 0xFFEF) ||
242
+ (cp >= 0x3000 && cp <= 0x303F) || (cp >= 0xFE30 && cp <= 0xFE6F) ||
243
+ (cp >= 0xAC00 && cp <= 0xD7AF) || (cp >= 0x3040 && cp <= 0x30FF) ||
244
+ (cp >= 0x3100 && cp <= 0x312F) || (cp >= 0x3130 && cp <= 0x318F) ||
245
+ (cp >= 0x2E80 && cp <= 0x2EFF) || (cp >= 0x2F00 && cp <= 0x2FDF) ||
246
+ (cp >= 0x20000 && cp <= 0x3FFFF)) return 2;
247
+ return 1;
248
+ };
249
+
250
+ const strWidth = (s) => {
251
+ const plain = stripAnsi(s);
252
+ if (!plain) return 0;
253
+ // Fast path: pure ASCII
254
+ if (/^[\x20-\x7E]*$/.test(plain)) return plain.length;
255
+ let w = 0;
256
+ for (const {segment} of segmenter.segment(plain)) w += charWidth(segment);
257
+ return w;
258
+ };
259
+
260
+ // truncTo returns the truncated string. Also sets truncTo.consumed for caller.
261
+ let _truncConsumed = 0;
262
+ const truncTo = (s, maxCols) => {
263
+ _truncConsumed = 0;
264
+ if (!s) return s;
265
+ // Fast path: pure ASCII
266
+ if (/^[\x20-\x7E]*$/.test(s)) {
267
+ if (s.length <= maxCols) { _truncConsumed = s.length; return s; }
268
+ _truncConsumed = maxCols;
269
+ return s.slice(0, maxCols) + '\u2026';
270
+ }
271
+ let w = 0, chars = [];
272
+ for (const {segment} of segmenter.segment(s)) {
273
+ const cw = charWidth(segment);
274
+ if (cw === 0) { _truncConsumed += segment.length; continue; }
275
+ if (w + cw > maxCols) break;
276
+ w += cw;
277
+ _truncConsumed += segment.length;
278
+ chars.push(segment);
279
+ }
280
+ const result = chars.join('');
281
+ if (_truncConsumed < s.length) {
282
+ return result + '\u2026';
283
+ }
284
+ return result;
285
+ };
286
+
287
+ // Visible length of a string (ignoring ANSI codes)
288
+ function wrapArg(key, val, maxCols, keyW) {
289
+ const pad = keyW + 2; // key + space after key
290
+ const maxValW = Math.max(20, maxCols - pad - 3); // 3 for "│ "
291
+ const indent = ' '.repeat(pad + 2); // "│ " + key padding
292
+
293
+ let s = val == null ? '' : (typeof val === 'string' ? val : JSON.stringify(val));
294
+ // Flatten newlines to spaces
295
+ s = s.replace(/[\r\n]+/g, ' ').replace(/\s+/g, ' ').trim();
296
+
297
+ const isJson = s.startsWith('{') || s.startsWith('[');
298
+
299
+ if (isJson) {
300
+ // Tokenize → wrap at token boundaries → color
301
+ const tokens = highlightJson(s);
302
+ const tokenLines = wrapTokens(tokens, maxValW, opts.full ? 999 : 3);
303
+ const out = [];
304
+ for (let i = 0; i < tokenLines.length; i++) {
305
+ out.push({ key, indent: i === 0 ? null : indent, val: renderTokenLines([tokenLines[i]])[0] });
306
+ }
307
+ if (out.length === 0) out.push({ key, indent: null, val: '' });
308
+ return out;
309
+ }
310
+
311
+ // Non-JSON: plain text wrapping
312
+ const out = [];
313
+ let text = s;
314
+ let lineIdx = 0;
315
+ while (text.length > 0 && lineIdx < (opts.full ? 999 : 3)) {
316
+ const maxL = opts.full ? 999 : 3;
317
+ const isLast = lineIdx === maxL - 1;
318
+ const budget = isLast ? maxValW - 1 : maxValW;
319
+ const chunk = truncTo(text, budget);
320
+ out.push({ key, indent: lineIdx === 0 ? null : indent, val: chunk });
321
+ text = text.slice(_truncConsumed);
322
+ lineIdx++;
323
+ if (!text) break;
324
+ if (isLast && text) {
325
+ out[out.length - 1].val = truncTo(chunk, budget - 1) + '\u2026';
326
+ break;
327
+ }
328
+ }
329
+ if (out.length === 0) out.push({ key, indent: null, val: '' });
330
+ return out;
331
+ }
332
+
333
+ // JSON syntax highlighting
334
+ function highlightJson(s) {
335
+ // Tokenize JSON, return array of {type, text}
336
+ const tokens = [];
337
+ let i = 0;
338
+ while (i < s.length) {
339
+ const ch = s[i];
340
+ if (ch === '"') {
341
+ let j = i + 1;
342
+ while (j < s.length && s[j] !== '"') { if (s[j] === '\\') j++; j++; }
343
+ const str = s.slice(i, j < s.length ? j + 1 : j);
344
+ // Key if followed by :
345
+ let k = j + 1; while (k < s.length && s[k] === ' ') k++;
346
+ tokens.push({ type: s[k] === ':' ? 'key' : 'str', text: str });
347
+ i = j + 1;
348
+ } else if (ch === ':' || ch === ',' || ch === '{' || ch === '}' || ch === '[' || ch === ']') {
349
+ tokens.push({ type: 'punct', text: ch });
350
+ i++;
351
+ } else if (ch === ' ' || ch === '\t') {
352
+ let j = i; while (j < s.length && (s[j] === ' ' || s[j] === '\t')) j++;
353
+ tokens.push({ type: 'ws', text: s.slice(i, j) });
354
+ i = j;
355
+ } else if (ch === 't' && s.slice(i, i + 4) === 'true') {
356
+ tokens.push({ type: 'bool', text: 'true' }); i += 4;
357
+ } else if (ch === 'f' && s.slice(i, i + 5) === 'false') {
358
+ tokens.push({ type: 'bool', text: 'false' }); i += 5;
359
+ } else if (ch === 'n' && s.slice(i, i + 4) === 'null') {
360
+ tokens.push({ type: 'null', text: 'null' }); i += 4;
361
+ } else if (ch >= '0' && ch <= '9' || ch === '-') {
362
+ let j = i; while (j < s.length && /[0-9.eE+\-]/.test(s[j])) j++;
363
+ tokens.push({ type: 'num', text: s.slice(i, j) }); i = j;
364
+ } else {
365
+ tokens.push({ type: 'plain', text: ch }); i++;
366
+ }
367
+ }
368
+ return tokens;
369
+ }
370
+
371
+ // Apply ANSI color to a token
372
+ const colorToken = (t) => {
373
+ const colors = {
374
+ key: '\x1b[36m', // cyan
375
+ str: '\x1b[32m', // green
376
+ num: '\x1b[33m', // yellow
377
+ bool: '\x1b[33m', // yellow
378
+ null: '\x1b[90m', // dim
379
+ punct: '', // no color
380
+ ws: '', // no color
381
+ plain: '', // no color
382
+ };
383
+ const c = colors[t.type] || '';
384
+ return c ? `${c}${t.text}\x1b[0m` : t.text;
385
+ };
386
+
387
+ // Wrap tokens into lines, respecting token boundaries
388
+ function wrapTokens(tokens, maxCols, maxLines) {
389
+ const lines = [];
390
+ let lineTokens = [];
391
+ let lineW = 0;
392
+ let linesUsed = 0;
393
+
394
+ for (let ti = 0; ti < tokens.length; ti++) {
395
+ const t = tokens[ti];
396
+ if (lineTokens.length === 0 && t.type === 'ws') continue;
397
+
398
+ let tw = strWidth(t.text);
399
+ const remaining = maxCols - lineW;
400
+
401
+ // Token fits entirely
402
+ if (lineW + tw <= maxCols) {
403
+ lineTokens.push(t);
404
+ lineW += tw;
405
+ continue;
406
+ }
407
+
408
+ // Token doesn't fit — split it to fill remaining space
409
+ if (lineTokens.length > 0 && remaining > 5) {
410
+ // Split token: put first part on current line
411
+ const head = truncTo(t.text, remaining);
412
+ lineTokens.push({ ...t, text: head });
413
+ lines.push(lineTokens);
414
+ linesUsed++;
415
+ lineTokens = [];
416
+ lineW = 0;
417
+
418
+ // Put rest on next line(s)
419
+ let rest = t.text.slice(_truncConsumed);
420
+ if (rest && linesUsed < maxLines) {
421
+ while (rest && linesUsed < maxLines) {
422
+ const isLast = linesUsed === maxLines - 1;
423
+ const budget = isLast ? maxCols - 1 : maxCols;
424
+ const chunk = truncTo(rest, budget);
425
+ lines.push([{ ...t, text: chunk }]);
426
+ linesUsed++;
427
+ rest = rest.slice(_truncConsumed);
428
+ if (chunk.endsWith('\u2026')) break;
429
+ }
430
+ }
431
+ } else {
432
+ // Flush current line, start fresh
433
+ if (lineTokens.length > 0) {
434
+ lines.push(lineTokens);
435
+ linesUsed++;
436
+ lineTokens = [];
437
+ lineW = 0;
438
+ }
439
+ if (linesUsed >= maxLines) break;
440
+
441
+ // Split token onto new line(s)
442
+ let rest = t.text;
443
+ while (rest && linesUsed < maxLines) {
444
+ const isLast = linesUsed === maxLines - 1;
445
+ const budget = isLast ? maxCols - 1 : maxCols;
446
+ const chunk = truncTo(rest, budget);
447
+ lines.push([{ ...t, text: chunk }]);
448
+ linesUsed++;
449
+ rest = rest.slice(_truncConsumed);
450
+ if (chunk.endsWith('\u2026')) break;
451
+ }
452
+ }
453
+ }
454
+ if (lineTokens.length > 0 && linesUsed < maxLines) lines.push(lineTokens);
455
+
456
+ while (lines.length > maxLines) lines.pop();
457
+ if (lines.length >= maxLines) {
458
+ const last = lines[lines.length - 1];
459
+ const lastStr = last.map(t => t.text).join('');
460
+ if (!lastStr.endsWith('\u2026')) {
461
+ const truncated = truncTo(lastStr, maxCols - 1);
462
+ lines[lines.length - 1] = [{ type: 'plain', text: truncated + '\u2026' }];
463
+ }
464
+ }
465
+
466
+ return lines;
467
+ }
468
+
469
+ // Render token lines as colored strings
470
+ const renderTokenLines = (tokenLines) => tokenLines.map(line => line.map(colorToken).join(''));
471
+
472
+ // Wrap result text into up to N lines, each fitting within maxCols
473
+ function wrapResult(text, maxCols, maxLines) {
474
+ const flat = text.replace(/[\r\n]/g, ' ').replace(/\s+/g, ' ').trim();
475
+ const lines = [];
476
+ let remaining = flat;
477
+ while (remaining && lines.length < maxLines) {
478
+ const isLast = lines.length === maxLines - 1;
479
+ const budget = isLast ? maxCols - 1 : maxCols;
480
+ const chunk = truncTo(remaining, budget);
481
+ lines.push(chunk);
482
+ remaining = remaining.slice(_truncConsumed);
483
+ if (!remaining) break;
484
+ if (isLast) {
485
+ // Force … on last line if more text remains
486
+ if (!lines[lines.length - 1].endsWith('\u2026'))
487
+ lines[lines.length - 1] = truncTo(chunk, budget - 1) + '\u2026';
488
+ break;
489
+ }
490
+ }
491
+ return lines;
492
+ }
493
+
494
+ function fmt(e, label) {
495
+ const t = fmtT(e.ts), id = e.id?.slice(0, 10) || '?';
496
+
497
+ if (opts.compact) {
498
+ const args = Object.entries(e.args || {}).slice(0, 2).map(([k, v]) => T.x(`${k}=`) + trunc(v, 50)).join(' ');
499
+ let line = `${T.x(t)} ${T.badge(e.name)} ${T.b(trunc(label, 20))} ${args}`;
500
+ if (e.result != null) {
501
+ const sz = fmtSz(Buffer.byteLength(e.result, 'utf8'));
502
+ const dur = e.rts ? durColor(fmtDur(e.ts, e.rts)) : '';
503
+ const meta = [dur, T.x(sz)].filter(Boolean).join(T.x(' · '));
504
+ line += `\n ${T.x('↳')} ${meta} ${T.d(trunc(e.result, 70))}`;
505
+ }
506
+ return line;
507
+ }
508
+
509
+ // Card layout — full width
510
+ const W = process.stdout.columns || 80;
511
+ const lines = [];
512
+
513
+ // Top border
514
+ lines.push(T.x('┌' + '─'.repeat(W - 2) + '┐'));
515
+
516
+ // Row 1: time · session · tool badge · id
517
+ const badge = T.badge(e.name);
518
+ const row1 = `${T.x('│')} ${T.x(t)} ${T.b(trunc(label, W - 38))} ${badge} ${T.x(id)}`;
519
+ lines.push(padTo(row1, W - 1) + T.x('│'));
520
+
521
+ // Args
522
+ const args = Object.entries(e.args || {}).slice(0, 4);
523
+ const keyW = args.reduce((m, [k]) => Math.max(m, k.length), 0);
524
+ for (const [k, v] of args) {
525
+ for (const wl of wrapArg(k, v, W - 4, keyW)) {
526
+ const valPart = wl.indent ? wl.indent + wl.val : T.c(wl.key.padEnd(keyW)) + ' ' + wl.val;
527
+ lines.push(padTo(`${T.x('│')} ${valPart}`, W - 1) + T.x('│'));
528
+ }
529
+ }
530
+
531
+ // Result
532
+ if (e.result != null) {
533
+ const isErr = e.result.includes('"status": "error"') || e.result.includes('"error"');
534
+ const icon = isErr ? T.err('✗') : T.ok('✓');
535
+ const dur = e.rts ? durColor(fmtDur(e.ts, e.rts)) : '';
536
+ const sz = T.x(fmtSz(Buffer.byteLength(e.result, 'utf8')));
537
+ const meta = [dur, sz].filter(Boolean).join(T.x(' · '));
538
+ lines.push(padTo(`${T.x('│')} ${icon} ${T.b(e.name || '?')} ${meta}`, W - 1) + T.x('│'));
539
+ const rText = (typeof e.result === 'string' ? e.result : JSON.stringify(e.result)).trim();
540
+ const flat = rText.replace(/[\r\n]/g, ' ').replace(/\s+/g, ' ').trim();
541
+ const isJson = flat.startsWith('{') || flat.startsWith('[');
542
+ if (isJson) {
543
+ const tokens = highlightJson(flat);
544
+ const tokenLines = wrapTokens(tokens, W - 10, opts.full ? 999 : 2);
545
+ for (const tl of renderTokenLines(tokenLines)) {
546
+ lines.push(padTo(`${T.x('│')} ${tl}`, W - 1) + T.x('│'));
547
+ }
548
+ } else {
549
+ const rLines = wrapResult(flat, W - 10, opts.full ? 999 : 2);
550
+ for (const rl of rLines) {
551
+ lines.push(padTo(`${T.x('│')} ${T.d(rl)}`, W - 1) + T.x('│'));
552
+ }
553
+ }
554
+ }
555
+
556
+ // Bottom border
557
+ lines.push(T.x('└' + '─'.repeat(W - 2) + '┘'));
558
+ return lines.join('\n');
559
+ }
560
+
561
+ // === Live output helpers ===
562
+ function fmtLiveCall(tc, label) {
563
+ const t = fmtT(tc.timestamp);
564
+ const id = tc.id?.slice(0, 10) || '?';
565
+ const W = process.stdout.columns || 80;
566
+
567
+ if (opts.compact) {
568
+ const args = Object.entries(tc.arguments || {}).slice(0, 2).map(([k, v]) => T.x(`${k}=`) + trunc(v, 50)).join(' ');
569
+ console.log(`${T.x(t)} ${T.badge(tc.name)} ${T.b(trunc(label, 20))} ${args}`);
570
+ return;
571
+ }
572
+
573
+ console.log(T.x('┌' + '─'.repeat(W - 2) + '┐'));
574
+ const badge = T.badge(tc.name);
575
+ console.log(padTo(`${T.x('│')} ${T.x(t)} ${T.b(trunc(label, W - 38))} ${badge} ${T.x(id)}`, W - 1) + T.x('│'));
576
+ const args = Object.entries(tc.arguments || {}).slice(0, 4);
577
+ const keyW = args.reduce((m, [k]) => Math.max(m, k.length), 0);
578
+ for (const [k, v] of args) {
579
+ for (const wl of wrapArg(k, v, W - 4, keyW)) {
580
+ const valPart = wl.indent ? wl.indent + wl.val : T.c(wl.key.padEnd(keyW)) + ' ' + wl.val;
581
+ console.log(padTo(`${T.x('│')} ${valPart}`, W - 1) + T.x('│'));
582
+ }
583
+ }
584
+ }
585
+
586
+ function fmtLiveResult(msg, ts) {
587
+ const callId = msg.toolCallId;
588
+ const id = callId?.slice(0, 10) || '?';
589
+ const name = msg.toolName || '?';
590
+ const rStr = typeof (msg.content?.[0]?.text || '') === 'string' ? (msg.content?.[0]?.text || '') : JSON.stringify(msg.content?.[0]?.text || '');
591
+ const isErr = rStr.includes('"status": "error"') || rStr.includes('"error"');
592
+ const icon = isErr ? T.err('✗') : T.ok('✓');
593
+ const sz = T.x(fmtSz(Buffer.byteLength(rStr, 'utf8')));
594
+ let dur = '';
595
+ if (pending[callId]) {
596
+ dur = durColor(fmtDur(pending[callId], ts));
597
+ delete pending[callId];
598
+ }
599
+ const meta = [dur, sz].filter(Boolean).join(T.x(' · '));
600
+
601
+ if (opts.compact) {
602
+ console.log(` ${T.x('↳')} ${meta} ${T.d(trunc(rStr, 70))}`);
603
+ } else {
604
+ const W = process.stdout.columns || 80;
605
+ console.log(padTo(`${T.x('│')} ${icon} ${T.b(name)} ${meta}`, W - 1) + T.x('│'));
606
+ const flat = rStr.replace(/[\r\n]/g, ' ').replace(/\s+/g, ' ').trim();
607
+ const isJson = flat.startsWith('{') || flat.startsWith('[');
608
+ if (isJson) {
609
+ const tokens = highlightJson(flat);
610
+ const tokenLines = wrapTokens(tokens, W - 10, opts.full ? 999 : 2);
611
+ for (const tl of renderTokenLines(tokenLines)) {
612
+ console.log(padTo(`${T.x('│')} ${tl}`, W - 1) + T.x('│'));
613
+ }
614
+ } else {
615
+ const rLines = wrapResult(flat, W - 10, opts.full ? 999 : 2);
616
+ for (const rl of rLines) {
617
+ console.log(padTo(`${T.x('│')} ${T.d(rl)}`, W - 1) + T.x('│'));
618
+ }
619
+ }
620
+ console.log(T.x('└' + '─'.repeat(W - 2) + '┘'));
621
+ }
622
+ }
623
+
624
+ // === Main ===
625
+ const sessions = findSessions();
626
+ if (!sessions.length) {
627
+ console.error(`No sessions found under ${DIR}. Try --all.`);
628
+ process.exit(1);
629
+ }
630
+
631
+ const labels = {};
632
+ for (const f of sessions) labels[path.basename(f)] = getLabel(f);
633
+
634
+ // Header
635
+ console.log('');
636
+ console.log(` ${T.bd('clawmonitor')} ${T.x(`${sessions.length} sessions · ${os.type()} · Ctrl+C to stop`)}`);
637
+
638
+ // Sessions (compact inline)
639
+ console.log('');
640
+ const sessLine = sessions.map(f => {
641
+ const fn = path.basename(f);
642
+ return T.tag(labels[fn]?.split('/').pop() || fn.slice(0, 8));
643
+ }).join(' ');
644
+ console.log(` ${sessLine}`);
645
+
646
+ // History
647
+ console.log('');
648
+ let all = [];
649
+ for (const f of sessions) all.push(...extract(f));
650
+ const paired = pair(all).slice(-opts.history);
651
+ for (const e of paired) console.log(fmt(e, labels[e.file] || e.file));
652
+
653
+ // Live
654
+ console.log('');
655
+ console.log(` ${T.bd('Live')}`);
656
+
657
+ // === Polling ===
658
+ const pending = {};
659
+ const watched = new Set();
660
+ const pos = {};
661
+
662
+ function getFiles() {
663
+ const out = [];
664
+ if (!fs.existsSync(DIR)) return out;
665
+ for (const ag of fs.readdirSync(DIR)) {
666
+ const sd = path.join(DIR, ag, 'sessions');
667
+ if (!fs.existsSync(sd)) continue;
668
+ for (const f of fs.readdirSync(sd))
669
+ if (f.endsWith('.jsonl')) out.push(path.join(sd, f));
670
+ }
671
+ return out;
672
+ }
673
+
674
+ // Init at end of files
675
+ for (const f of getFiles()) {
676
+ watched.add(f);
677
+ try { pos[f] = fs.statSync(f).size; if (!labels[path.basename(f)]) labels[path.basename(f)] = getLabel(f); } catch {}
678
+ }
679
+
680
+ function poll() {
681
+ for (const f of getFiles()) {
682
+ if (!watched.has(f)) {
683
+ watched.add(f); pos[f] = 0;
684
+ try { labels[path.basename(f)] = getLabel(f); } catch {}
685
+ }
686
+ let p = pos[f] || 0;
687
+ let data;
688
+ try {
689
+ const st = fs.statSync(f);
690
+ if (st.size < p) { p = 0; }
691
+ if (st.size === p) continue;
692
+ // Skip stale data — if delta > 1MB, re-seek to last 100KB
693
+ if (st.size - p > 1048576) { p = Math.max(0, st.size - 102400); }
694
+ const fd = fs.openSync(f, 'r');
695
+ const buf = Buffer.alloc(st.size - p);
696
+ fs.readSync(fd, buf, 0, buf.length, p);
697
+ fs.closeSync(fd);
698
+ data = buf.toString('utf8');
699
+ pos[f] = st.size;
700
+ } catch { continue; }
701
+
702
+ for (const ln of data.split('\n')) {
703
+ if (!ln.trim()) continue;
704
+ let o; try { o = JSON.parse(ln); } catch { continue; }
705
+ if (o.type !== 'message' || !o.timestamp) continue;
706
+ const ts = o.timestamp;
707
+ const fn = path.basename(f);
708
+ const label = labels[fn] || fn;
709
+
710
+ if (o.message.role === 'assistant') {
711
+ for (const tc of (o.message.content || []).filter(c => c.type === 'toolCall')) {
712
+ pending[tc.id] = ts;
713
+ fmtLiveCall({ ...tc, timestamp: ts }, label);
714
+ }
715
+ } else if (o.message.role === 'toolResult') {
716
+ fmtLiveResult(o.message, ts);
717
+ }
718
+ }
719
+ }
720
+ }
721
+
722
+ setInterval(poll, 200);
723
+
724
+ // Also re-scan for new files every 10s
725
+ setInterval(() => {
726
+ for (const f of getFiles()) {
727
+ if (!watched.has(f)) {
728
+ watched.add(f); pos[f] = 0;
729
+ try { labels[path.basename(f)] = getLabel(f); } catch {}
730
+ }
731
+ }
732
+ }, 10000);
733
+ process.on('SIGINT', () => { console.log(`\n ${T.x('Stopped.')}`); process.exit(0); });