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