oc-inspector 1.3.0 → 1.4.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/README.md CHANGED
@@ -1,6 +1,29 @@
1
1
  # oc-inspector
2
2
 
3
- Real-time API traffic inspector for [OpenClaw](https://openclaw.ai). Intercepts LLM provider requests (Anthropic, OpenAI, BytePlus, Ollama, and more), shows token usage, costs, and message flow in a live web dashboard.
3
+ A debugging and monitoring tool for [OpenClaw](https://openclaw.ai) that helps you understand **where your tokens and money are going**.
4
+
5
+ When working with LLM agents you often have no visibility into what's actually happening under the hood — how many tokens each request burns, which models cost the most, what system prompts look like, how tool calls are structured. `oc-inspector` sits between OpenClaw and your LLM providers as a transparent proxy, capturing every request and response in real time so you can see the full picture: token usage, costs, message flow, thinking blocks, tool calls — everything in a clear, human-readable format.
6
+
7
+ Use it to:
8
+ - **Debug** agent behavior by inspecting actual API requests and responses
9
+ - **Find token leaks** — see which conversations, models, or providers consume the most
10
+ - **Track costs** — per-request, per-model, per-day breakdowns with persistent history
11
+ - **Understand** what OpenClaw sends to each provider (system prompts, tool definitions, context)
12
+
13
+ > **⚠️ USE AT YOUR OWN RISK**
14
+ >
15
+ > This tool works by patching `openclaw.json` to route API traffic through a local proxy. While it includes safeguards (automatic config restore on stop/shutdown, backup files, double-enable protection), **always keep a manual backup of your `openclaw.json` before using the inspector**:
16
+ >
17
+ > ```bash
18
+ > cp ~/.openclaw/openclaw.json ~/.openclaw/openclaw.json.manual-backup
19
+ > ```
20
+ >
21
+ > If something goes wrong, restore it:
22
+ >
23
+ > ```bash
24
+ > cp ~/.openclaw/openclaw.json.manual-backup ~/.openclaw/openclaw.json
25
+ > openclaw gateway restart
26
+ > ```
4
27
 
5
28
  ## Quick Start
6
29
 
@@ -8,7 +31,7 @@ Real-time API traffic inspector for [OpenClaw](https://openclaw.ai). Intercepts
8
31
  npx oc-inspector
9
32
  ```
10
33
 
11
- Opens the inspector proxy on `localhost:18800` with a live web dashboard.
34
+ Starts the inspector daemon in background on `localhost:18800` with a live web dashboard.
12
35
 
13
36
  ---
14
37
 
package/bin/cli.mjs CHANGED
@@ -38,6 +38,17 @@ import { fileURLToPath } from "node:url";
38
38
  const __filename = fileURLToPath(import.meta.url);
39
39
  const args = process.argv.slice(2);
40
40
 
41
+ // ── ANSI escapes & column widths (must be before command routing) ──
42
+ const E = {
43
+ R: "\x1b[0m", B: "\x1b[1m", D: "\x1b[90m",
44
+ RED: "\x1b[31m", GRN: "\x1b[32m", YEL: "\x1b[33m",
45
+ CYN: "\x1b[36m", ORG: "\x1b[38;5;208m",
46
+ BG: "\x1b[48;5;236m",
47
+ CLR: "\x1b[2J\x1b[H",
48
+ HIDE: "\x1b[?25l", SHOW: "\x1b[?25h",
49
+ };
50
+ const COL = { name: 24, bar: 20, tokens: 8, reqs: 7, cost: 10 };
51
+
41
52
  /** Inspector state directory for PID/log files. */
42
53
  const INSPECTOR_DIR = join(homedir(), ".openclaw", ".inspector-runtime");
43
54
 
@@ -59,6 +70,7 @@ function parseArgs(argv) {
59
70
  open: false,
60
71
  config: undefined,
61
72
  json: false,
73
+ live: false,
62
74
  days: 7,
63
75
  lines: 50,
64
76
  help: false,
@@ -67,7 +79,7 @@ function parseArgs(argv) {
67
79
  "start", "stop", "run", "restart",
68
80
  "enable", "disable", "status",
69
81
  "stats", "providers", "history", "pricing", "config",
70
- "logs", "_serve",
82
+ "logs", "help", "_serve",
71
83
  ]);
72
84
  for (let i = 0; i < argv.length; i++) {
73
85
  const arg = argv[i];
@@ -76,6 +88,7 @@ function parseArgs(argv) {
76
88
  if (arg === "--open") { opts.open = true; continue; }
77
89
  if (arg === "--config" && argv[i + 1]) { opts.config = argv[++i]; continue; }
78
90
  if (arg === "--json") { opts.json = true; continue; }
91
+ if (arg === "--live" || arg === "-w") { opts.live = true; continue; }
79
92
  if (arg === "--days" && argv[i + 1]) { opts.days = parseInt(argv[++i], 10); continue; }
80
93
  if (arg === "--lines" && argv[i + 1]) { opts.lines = parseInt(argv[++i], 10); continue; }
81
94
  if (arg === "--help" || arg === "-h") { opts.help = true; continue; }
@@ -117,6 +130,7 @@ if (opts.help) {
117
130
  --open Auto-open the dashboard in a browser
118
131
  --config <path> Custom path to openclaw.json
119
132
  --json Output as JSON (for stats, status, providers, history)
133
+ --live, -w Live auto-refreshing stats (for stats command)
120
134
  --days <number> Number of days to show in history (default: 7)
121
135
  --lines <number> Number of log lines to show (default: 50)
122
136
  --help, -h Show this help message
@@ -128,7 +142,8 @@ if (opts.help) {
128
142
  npx oc-inspector restart # Restart daemon
129
143
  npx oc-inspector enable # Enable interception
130
144
  npx oc-inspector disable # Disable interception
131
- npx oc-inspector stats # Show live token stats
145
+ npx oc-inspector stats # Show token stats
146
+ npx oc-inspector stats --live # Live auto-refreshing dashboard
132
147
  npx oc-inspector stats --json # Stats as JSON
133
148
  npx oc-inspector history --days 30 # Last 30 days
134
149
  npx oc-inspector pricing # Show pricing table
@@ -141,6 +156,15 @@ if (opts.help) {
141
156
  // Command routing
142
157
  // ═══════════════════════════════════════════════════════════════
143
158
 
159
+ // help: show commands
160
+ if (opts.command === "help") {
161
+ console.log("");
162
+ console.log(" \x1b[38;5;208m🦞 oc-inspector\x1b[0m — Real-time API traffic inspector for OpenClaw");
163
+ console.log("");
164
+ printCommandsHelp();
165
+ process.exit(0);
166
+ }
167
+
144
168
  // Hidden: _serve is the actual foreground server (spawned by start)
145
169
  if (opts.command === "_serve") {
146
170
  await runServe(opts);
@@ -152,15 +176,16 @@ if (opts.command === "start") {
152
176
  process.exit(0);
153
177
  }
154
178
 
155
- // stop: kill daemon
179
+ // stop: kill daemon + restore config
156
180
  if (opts.command === "stop") {
157
- runDaemonStop();
181
+ console.log("");
182
+ await runDaemonStop(false, opts);
158
183
  process.exit(0);
159
184
  }
160
185
 
161
186
  // restart: stop + start
162
187
  if (opts.command === "restart") {
163
- runDaemonStop(/* silent */ true);
188
+ await runDaemonStop(/* silent */ true, opts);
164
189
  await new Promise((r) => setTimeout(r, 800));
165
190
  await runDaemonStart(opts);
166
191
  process.exit(0);
@@ -212,8 +237,7 @@ async function runDaemonStart(opts) {
212
237
  console.log(` \x1b[33m⚠\x1b[0m Already running (PID ${running.pid})`);
213
238
  console.log(` \x1b[32m✓\x1b[0m Dashboard: http://127.0.0.1:${opts.port}`);
214
239
  console.log("");
215
- console.log(` Run \x1b[1moc-inspector restart\x1b[0m to restart`);
216
- console.log("");
240
+ printCommandsHelp();
217
241
  return;
218
242
  }
219
243
 
@@ -254,9 +278,7 @@ async function runDaemonStart(opts) {
254
278
  console.log(` \x1b[32m✓\x1b[0m Dashboard: http://127.0.0.1:${opts.port}`);
255
279
  console.log(` \x1b[32m✓\x1b[0m Log file: ${LOG_FILE}`);
256
280
  console.log("");
257
- console.log(` \x1b[90mStop with:\x1b[0m oc-inspector stop`);
258
- console.log(` \x1b[90mView logs:\x1b[0m oc-inspector logs`);
259
- console.log("");
281
+ printCommandsHelp();
260
282
  } else {
261
283
  console.log(` \x1b[31m✗ Failed to start — check logs:\x1b[0m`);
262
284
  console.log(` ${LOG_FILE}`);
@@ -272,11 +294,38 @@ async function runDaemonStart(opts) {
272
294
  }
273
295
 
274
296
  /**
275
- * Stop the running daemon process.
297
+ * Stop the running daemon and restore original OpenClaw config.
298
+ *
299
+ * Automatically calls `disable` to restore the original provider URLs
300
+ * and restart the gateway, so OpenClaw doesn't keep routing through
301
+ * the dead proxy.
276
302
  *
277
303
  * @param {boolean} [silent=false] - Suppress output (used by restart).
304
+ * @param {object} [cmdOpts] - CLI opts (for config path).
278
305
  */
279
- function runDaemonStop(silent = false) {
306
+ async function runDaemonStop(silent = false, cmdOpts = {}) {
307
+ // 1. Restore original config before killing the proxy
308
+ try {
309
+ const { detect, disable, status } = await import("../src/config.mjs");
310
+ const oc = detect(cmdOpts.config);
311
+ if (oc.exists) {
312
+ const st = status(oc.dir);
313
+ if (st.enabled) {
314
+ const result = disable({ configPath: oc.configPath, openclawDir: oc.dir });
315
+ if (!silent) {
316
+ if (result.ok) {
317
+ console.log(` \x1b[32m✓\x1b[0m Config restored — ${result.message}`);
318
+ } else {
319
+ console.log(` \x1b[33m⚠\x1b[0m Config restore: ${result.message}`);
320
+ }
321
+ }
322
+ }
323
+ }
324
+ } catch {
325
+ // Non-critical — still kill the process
326
+ }
327
+
328
+ // 2. Kill the daemon process
280
329
  const st = getDaemonStatus();
281
330
 
282
331
  if (!st.pid) {
@@ -292,13 +341,11 @@ function runDaemonStop(silent = false) {
292
341
  try {
293
342
  process.kill(st.pid, "SIGTERM");
294
343
  if (!silent) {
295
- console.log("");
296
344
  console.log(` \x1b[32m✓\x1b[0m Stopped inspector (PID ${st.pid})`);
297
345
  console.log("");
298
346
  }
299
347
  } catch (err) {
300
348
  if (!silent) {
301
- console.log("");
302
349
  console.log(` \x1b[31m✗ Failed to stop PID ${st.pid}: ${err.message}\x1b[0m`);
303
350
  console.log("");
304
351
  }
@@ -404,8 +451,14 @@ async function runServe(opts) {
404
451
  process.exit(1);
405
452
  }
406
453
 
407
- process.on("SIGINT", () => { console.log("\n Shutting down..."); process.exit(0); });
408
- process.on("SIGTERM", () => { console.log("\n Shutting down (SIGTERM)..."); process.exit(0); });
454
+ const gracefulShutdown = async (signal) => {
455
+ console.log(`\n Shutting down (${signal})...`);
456
+ await restoreConfigOnExit(opts);
457
+ process.exit(0);
458
+ };
459
+
460
+ process.on("SIGINT", () => gracefulShutdown("SIGINT"));
461
+ process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
409
462
  }
410
463
 
411
464
  /**
@@ -438,8 +491,36 @@ async function runForeground(opts) {
438
491
  process.exit(1);
439
492
  }
440
493
 
441
- process.on("SIGINT", () => { console.log("\n Shutting down..."); process.exit(0); });
442
- process.on("SIGTERM", () => process.exit(0));
494
+ const gracefulShutdown = async (signal) => {
495
+ console.log(`\n Shutting down (${signal})...`);
496
+ await restoreConfigOnExit(opts);
497
+ process.exit(0);
498
+ };
499
+
500
+ process.on("SIGINT", () => gracefulShutdown("SIGINT"));
501
+ process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
502
+ }
503
+
504
+ /**
505
+ * Restore original OpenClaw config on process exit.
506
+ * Used by graceful shutdown handlers in _serve and run modes.
507
+ *
508
+ * @param {object} cmdOpts - CLI opts (for config path).
509
+ */
510
+ async function restoreConfigOnExit(cmdOpts = {}) {
511
+ try {
512
+ const { detect, disable, status } = await import("../src/config.mjs");
513
+ const oc = detect(cmdOpts.config);
514
+ if (oc.exists) {
515
+ const st = status(oc.dir);
516
+ if (st.enabled) {
517
+ const result = disable({ configPath: oc.configPath, openclawDir: oc.dir });
518
+ console.log(` \x1b[32m✓\x1b[0m Config restored — ${result.message}`);
519
+ }
520
+ }
521
+ } catch {
522
+ // Best-effort — don't block exit
523
+ }
443
524
  }
444
525
 
445
526
  // ═══════════════════════════════════════════════════════════════
@@ -516,6 +597,10 @@ async function runRemoteCommand(opts) {
516
597
  const base = `http://127.0.0.1:${opts.port}`;
517
598
 
518
599
  if (opts.command === "stats") {
600
+ if (opts.live) {
601
+ await runLiveStats(base);
602
+ return;
603
+ }
519
604
  const data = await fetchApi(`${base}/api/stats`);
520
605
  if (!data) return;
521
606
  if (opts.json) { console.log(JSON.stringify(data, null, 2)); return; }
@@ -564,6 +649,36 @@ async function runRemoteCommand(opts) {
564
649
  // Helpers / Printers
565
650
  // ═══════════════════════════════════════════════════════════════
566
651
 
652
+ /**
653
+ * Print a compact command reference table.
654
+ * Shown after successful daemon start and via `help` command.
655
+ */
656
+ function printCommandsHelp() {
657
+ const C = "\x1b[36m"; // cyan
658
+ const D = "\x1b[90m"; // dim
659
+ const R = "\x1b[0m"; // reset
660
+ const B = "\x1b[1m"; // bold
661
+
662
+ console.log(` ${B}Commands:${R}`);
663
+ console.log(` ${C}oc-inspector${R} Start daemon ${D}(default)${R}`);
664
+ console.log(` ${C}oc-inspector stop${R} Stop daemon`);
665
+ console.log(` ${C}oc-inspector restart${R} Restart daemon`);
666
+ console.log(` ${C}oc-inspector run${R} Foreground mode ${D}(interactive)${R}`);
667
+ console.log(` ${C}oc-inspector status${R} Daemon + interception status`);
668
+ console.log(` ${C}oc-inspector enable${R} Enable interception`);
669
+ console.log(` ${C}oc-inspector disable${R} Disable interception`);
670
+ console.log(` ${C}oc-inspector stats${R} Token/cost statistics ${D}(--live for auto-refresh)${R}`);
671
+ console.log(` ${C}oc-inspector history${R} Daily usage history`);
672
+ console.log(` ${C}oc-inspector pricing${R} Model pricing table`);
673
+ console.log(` ${C}oc-inspector providers${R} Active providers list`);
674
+ console.log(` ${C}oc-inspector config${R} Inspector config info`);
675
+ console.log(` ${C}oc-inspector logs${R} Daemon log output`);
676
+ console.log(` ${C}oc-inspector help${R} Show this help`);
677
+ console.log("");
678
+ console.log(` ${D}Use --json for machine-readable output. See oc-inspector --help for all options.${R}`);
679
+ console.log("");
680
+ }
681
+
567
682
  async function fetchApi(url) {
568
683
  try {
569
684
  const res = await fetch(url);
@@ -575,64 +690,214 @@ async function fetchApi(url) {
575
690
  }
576
691
  }
577
692
 
693
+ // ── Formatting helpers ──
694
+
578
695
  function fmtNum(n) {
579
696
  if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + "M";
580
697
  if (n >= 1_000) return (n / 1_000).toFixed(1) + "k";
581
698
  return String(n);
582
699
  }
583
700
 
584
- function printStats(s) {
585
- const bar = (label, val, max, width = 24) => {
586
- const pct = max > 0 ? Math.min(val / max, 1) : 0;
587
- const filled = Math.round(pct * width);
588
- const empty = width - filled;
589
- return `${label} ${"█".repeat(filled)}${"░".repeat(empty)} ${fmtNum(val)}`;
590
- };
701
+ function fmtCost(n) {
702
+ if (!n || n === 0) return "$0.00";
703
+ if (n < 0.01) return "$" + n.toFixed(4);
704
+ return "$" + n.toFixed(2);
705
+ }
591
706
 
592
- console.log("");
593
- console.log(" \x1b[38;5;208m🦞 OpenClaw Inspector Stats\x1b[0m");
594
- console.log(" " + "".repeat(50));
595
- console.log("");
707
+ function fmtMs(ms) {
708
+ if (ms >= 60_000) return (ms / 60_000).toFixed(1) + "m";
709
+ if (ms >= 1_000) return (ms / 1_000).toFixed(1) + "s";
710
+ return ms + "ms";
711
+ }
712
+
713
+ /**
714
+ * Build a horizontal bar string.
715
+ *
716
+ * @param {number} val - Current value.
717
+ * @param {number} max - Maximum value (100%).
718
+ * @param {number} [width=20] - Bar width in characters.
719
+ * @returns {string} Bar characters.
720
+ */
721
+ function bar(val, max, width = 20) {
722
+ const pct = max > 0 ? Math.min(val / max, 1) : 0;
723
+ const filled = Math.round(pct * width);
724
+ const empty = width - filled;
725
+ return "█".repeat(filled) + "░".repeat(empty);
726
+ }
727
+
728
+ /**
729
+ * Render stats data to an array of strings (no ANSI clears).
730
+ *
731
+ * @param {object} s - Stats from /api/stats.
732
+ * @param {object} [opts] - Render options.
733
+ * @param {boolean} [opts.compact=false] - Skip header/footer.
734
+ * @returns {string[]} Lines to print.
735
+ */
736
+ function renderStats(s, { compact = false } = {}) {
737
+ const lines = [];
738
+ const w = COL;
739
+
740
+ if (!compact) {
741
+ lines.push("");
742
+ lines.push(` ${E.ORG}🦞 OpenClaw Inspector — Stats${E.R}`);
743
+ lines.push(" " + "─".repeat(68));
744
+ }
745
+
746
+ // ── Summary box ──
747
+ lines.push("");
748
+ const errStr = s.errors > 0 ? ` ${E.RED}(${s.errors} errors)${E.R}` : "";
749
+ lines.push(` ${E.B}Requests${E.R} ${String(s.totalRequests).padStart(6)}${errStr}`);
750
+
751
+ const inTok = fmtNum(s.totalInputTokens);
752
+ const outTok = fmtNum(s.totalOutputTokens);
753
+ lines.push(` ${E.B}Tokens${E.R} ${fmtNum(s.totalTokens).padStart(6)} ${E.D}in ${inTok} out ${outTok}${E.R}`);
596
754
 
597
- console.log(` \x1b[1mRequests:\x1b[0m ${s.totalRequests}${s.errors > 0 ? ` (\x1b[31m${s.errors} errors\x1b[0m)` : ""}`);
598
- console.log(` \x1b[1mTokens:\x1b[0m ${fmtNum(s.totalTokens)} total (in: ${fmtNum(s.totalInputTokens)} out: ${fmtNum(s.totalOutputTokens)})`);
599
755
  if (s.totalCachedTokens > 0) {
600
- console.log(` \x1b[1mCached:\x1b[0m ${fmtNum(s.totalCachedTokens)}`);
756
+ lines.push(` ${E.B}Cached${E.R} ${fmtNum(s.totalCachedTokens).padStart(6)}`);
601
757
  }
602
- console.log(` \x1b[1mCost:\x1b[0m \x1b[32m$${(s.totalCost || 0).toFixed(4)}\x1b[0m`);
603
- if (s.totalDuration > 0) {
604
- const avgMs = Math.round(s.totalDuration / s.totalRequests);
605
- console.log(` \x1b[1mAvg latency:\x1b[0m ${avgMs}ms`);
758
+
759
+ lines.push(` ${E.B}Cost${E.R} ${E.GRN}${fmtCost(s.totalCost || 0).padStart(8)}${E.R}`);
760
+
761
+ if (s.totalDuration > 0 && s.totalRequests > 0) {
762
+ const avg = Math.round(s.totalDuration / s.totalRequests);
763
+ lines.push(` ${E.B}Avg latency${E.R} ${fmtMs(avg).padStart(6)}`);
606
764
  }
607
765
 
608
- const providers = Object.entries(s.byProvider);
766
+ // ── By Provider ──
767
+ const providers = Object.entries(s.byProvider || {});
609
768
  if (providers.length > 0) {
610
- console.log("");
611
- console.log(" \x1b[1mBy Provider\x1b[0m");
612
- console.log(" " + "─".repeat(50));
613
- const maxTokens = Math.max(...providers.map(([, v]) => v.inputTokens + v.outputTokens));
614
- for (const [name, v] of providers.sort((a, b) => (b[1].cost || 0) - (a[1].cost || 0) || (b[1].inputTokens + b[1].outputTokens) - (a[1].inputTokens + a[1].outputTokens))) {
615
- const total = v.inputTokens + v.outputTokens;
616
- const costStr = v.cost > 0 ? ` \x1b[32m$${v.cost.toFixed(4)}\x1b[0m` : "";
617
- console.log(` \x1b[36m${name.padEnd(18)}\x1b[0m ${bar("", total, maxTokens)} (${v.requests} reqs)${costStr}`);
769
+ lines.push("");
770
+ lines.push(` ${E.B}${"Provider".padEnd(w.name)} ${"Tokens".padStart(w.tokens)} ${"Bar".padEnd(w.bar)} ${"Reqs".padStart(w.reqs)} ${"Cost".padStart(w.cost)}${E.R}`);
771
+ lines.push(" " + "─".repeat(68));
772
+
773
+ const maxT = Math.max(...providers.map(([, v]) => (v.inputTokens || 0) + (v.outputTokens || 0)));
774
+ const sorted = providers.sort((a, b) =>
775
+ (b[1].cost || 0) - (a[1].cost || 0) ||
776
+ ((b[1].inputTokens || 0) + (b[1].outputTokens || 0)) - ((a[1].inputTokens || 0) + (a[1].outputTokens || 0))
777
+ );
778
+
779
+ for (const [name, v] of sorted) {
780
+ const total = (v.inputTokens || 0) + (v.outputTokens || 0);
781
+ const n = name.length > w.name - 2 ? name.slice(0, w.name - 3) + "…" : name;
782
+ const costStr = v.cost > 0 ? `${E.GRN}${fmtCost(v.cost).padStart(w.cost)}${E.R}` : `${E.D}${fmtCost(0).padStart(w.cost)}${E.R}`;
783
+ lines.push(` ${E.CYN}${n.padEnd(w.name)}${E.R} ${fmtNum(total).padStart(w.tokens)} ${bar(total, maxT, w.bar)} ${String(v.requests).padStart(w.reqs)} ${costStr}`);
618
784
  }
619
785
  }
620
786
 
621
- const models = Object.entries(s.byModel);
787
+ // ── By Model ──
788
+ const models = Object.entries(s.byModel || {});
622
789
  if (models.length > 0) {
623
- console.log("");
624
- console.log(" \x1b[1mBy Model\x1b[0m");
625
- console.log(" " + "─".repeat(50));
626
- const maxTokens = Math.max(...models.map(([, v]) => v.inputTokens + v.outputTokens));
627
- for (const [name, v] of models.sort((a, b) => (b[1].cost || 0) - (a[1].cost || 0) || (b[1].inputTokens + b[1].outputTokens) - (a[1].inputTokens + a[1].outputTokens))) {
628
- const total = v.inputTokens + v.outputTokens;
629
- const shortName = name.length > 28 ? name.slice(0, 27) + "…" : name;
630
- const costStr = v.cost > 0 ? ` \x1b[32m$${v.cost.toFixed(4)}\x1b[0m` : "";
631
- console.log(` \x1b[33m${shortName.padEnd(30)}\x1b[0m ${bar("", total, maxTokens)} (${v.requests} reqs)${costStr}`);
790
+ lines.push("");
791
+ lines.push(` ${E.B}${"Model".padEnd(w.name)} ${"Tokens".padStart(w.tokens)} ${"Bar".padEnd(w.bar)} ${"Reqs".padStart(w.reqs)} ${"Cost".padStart(w.cost)}${E.R}`);
792
+ lines.push(" " + "─".repeat(68));
793
+
794
+ const maxT = Math.max(...models.map(([, v]) => (v.inputTokens || 0) + (v.outputTokens || 0)));
795
+ const sorted = models.sort((a, b) =>
796
+ (b[1].cost || 0) - (a[1].cost || 0) ||
797
+ ((b[1].inputTokens || 0) + (b[1].outputTokens || 0)) - ((a[1].inputTokens || 0) + (a[1].outputTokens || 0))
798
+ );
799
+
800
+ for (const [name, v] of sorted) {
801
+ const total = (v.inputTokens || 0) + (v.outputTokens || 0);
802
+ const n = name.length > w.name - 2 ? name.slice(0, w.name - 3) + "…" : name;
803
+ const costStr = v.cost > 0 ? `${E.GRN}${fmtCost(v.cost).padStart(w.cost)}${E.R}` : `${E.D}${fmtCost(0).padStart(w.cost)}${E.R}`;
804
+ lines.push(` ${E.YEL}${n.padEnd(w.name)}${E.R} ${fmtNum(total).padStart(w.tokens)} ${bar(total, maxT, w.bar)} ${String(v.requests).padStart(w.reqs)} ${costStr}`);
632
805
  }
633
806
  }
634
807
 
635
- console.log("");
808
+ lines.push("");
809
+ return lines;
810
+ }
811
+
812
+ function printStats(s) {
813
+ for (const line of renderStats(s)) process.stdout.write(line + "\n");
814
+ }
815
+
816
+ /**
817
+ * Live auto-refreshing stats dashboard.
818
+ * Clears the terminal and redraws every 2 seconds.
819
+ * Press q or Ctrl+C to exit.
820
+ *
821
+ * @param {string} base - Inspector API base URL.
822
+ */
823
+ async function runLiveStats(base) {
824
+ const url = `${base}/api/stats`;
825
+
826
+ // Enable raw mode for keypress detection
827
+ if (process.stdin.isTTY) {
828
+ process.stdin.setRawMode(true);
829
+ process.stdin.resume();
830
+ process.stdin.setEncoding("utf8");
831
+ }
832
+
833
+ process.stdout.write(E.HIDE); // hide cursor
834
+
835
+ let running = true;
836
+ let tick = 0;
837
+
838
+ const cleanup = () => {
839
+ running = false;
840
+ process.stdout.write(E.SHOW); // show cursor
841
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
842
+ console.log("");
843
+ process.exit(0);
844
+ };
845
+
846
+ process.on("SIGINT", cleanup);
847
+ process.on("SIGTERM", cleanup);
848
+
849
+ if (process.stdin.isTTY) {
850
+ process.stdin.on("data", (key) => {
851
+ if (key === "q" || key === "Q" || key === "\x03") cleanup(); // q or Ctrl+C
852
+ });
853
+ }
854
+
855
+ while (running) {
856
+ try {
857
+ const res = await fetch(url);
858
+ const data = await res.json();
859
+
860
+ const lines = [];
861
+ lines.push(E.CLR); // clear screen
862
+ lines.push(` ${E.ORG}🦞 OpenClaw Inspector${E.R} ${E.D}— Live Stats${E.R}`);
863
+ lines.push(" " + "═".repeat(68));
864
+
865
+ // Uptime spinner
866
+ const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
867
+ const sp = spinner[tick % spinner.length];
868
+ const now = new Date().toLocaleTimeString();
869
+ lines.push(` ${E.GRN}${sp}${E.R} ${E.D}Updated ${now} Press ${E.B}q${E.R}${E.D} to exit${E.R}`);
870
+
871
+ // Render stats body (compact, no header)
872
+ const body = renderStats(data, { compact: true });
873
+ lines.push(...body);
874
+
875
+ // Recently seen models (last 5 entries by model)
876
+ const recent = data.recentEntries || [];
877
+ if (recent.length > 0) {
878
+ lines.push(` ${E.B}Recent Requests${E.R}`);
879
+ lines.push(" " + "─".repeat(68));
880
+ for (const e of recent.slice(0, 8)) {
881
+ const model = (e.model || "?").length > 22 ? (e.model || "?").slice(0, 21) + "…" : (e.model || "?");
882
+ const st = e.state === "done" ? `${E.GRN}✓${E.R}` : e.state === "error" ? `${E.RED}✗${E.R}` : `${E.YEL}…${E.R}`;
883
+ const dur = e.duration ? fmtMs(e.duration) : "—";
884
+ const tok = e.totalTokens ? fmtNum(e.totalTokens) : "—";
885
+ const cost = e.cost > 0 ? `${E.GRN}${fmtCost(e.cost)}${E.R}` : `${E.D}—${E.R}`;
886
+ lines.push(` ${st} ${E.YEL}${model.padEnd(24)}${E.R} ${E.CYN}${(e.provider || "?").padEnd(12)}${E.R} ${tok.padStart(8)} tok ${dur.padStart(6)} ${cost}`);
887
+ }
888
+ lines.push("");
889
+ }
890
+
891
+ process.stdout.write(lines.join("\n") + "\n");
892
+ } catch {
893
+ process.stdout.write(E.CLR);
894
+ process.stdout.write(`\n ${E.RED}✗ Cannot connect to inspector at ${base}${E.R}\n`);
895
+ process.stdout.write(` ${E.D}Retrying...${E.R}\n`);
896
+ }
897
+
898
+ tick++;
899
+ await new Promise((r) => setTimeout(r, 2000));
900
+ }
636
901
  }
637
902
 
638
903
  function printPricing(models) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oc-inspector",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Real-time API traffic inspector for OpenClaw — intercepts LLM provider requests, shows token usage, costs, and message flow in a live web dashboard.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/config.mjs CHANGED
@@ -151,9 +151,22 @@ export function restartGateway() {
151
151
  export function enable({ configPath, openclawDir, port }) {
152
152
  const proxyBase = `http://127.0.0.1:${port}`;
153
153
 
154
- // 1. Backup
154
+ // 0. Guard: if already enabled, refuse to double-enable
155
+ // This prevents overwriting originals with proxy URLs.
156
+ const existingState = readState(openclawDir);
157
+ if (existingState?.enabled && Object.keys(existingState.originals || {}).length > 0) {
158
+ return {
159
+ ok: true,
160
+ message: "Already enabled",
161
+ providers: Object.keys(existingState.originals),
162
+ };
163
+ }
164
+
165
+ // 1. Backup — only if no existing backup (prevents overwriting clean backup)
155
166
  const backupPath = configPath + ".inspector-backup";
156
- copyFileSync(configPath, backupPath);
167
+ if (!existsSync(backupPath)) {
168
+ copyFileSync(configPath, backupPath);
169
+ }
157
170
 
158
171
  // 2. Read config
159
172
  const config = readConfig(configPath);
@@ -170,6 +183,13 @@ export function enable({ configPath, openclawDir, port }) {
170
183
  originals[name] = { baseUrl: cfg.baseUrl, wasCustom: true };
171
184
  cfg.baseUrl = `${proxyBase}/${name}`;
172
185
  enabled.push(name);
186
+ } else if (cfg.baseUrl && cfg.baseUrl.startsWith(proxyBase)) {
187
+ // Already proxied — skip but don't lose the original
188
+ // Try to resolve from BUILTIN_URLS
189
+ if (BUILTIN_URLS[name]) {
190
+ originals[name] = { baseUrl: BUILTIN_URLS[name], wasCustom: true };
191
+ }
192
+ enabled.push(name);
173
193
  }
174
194
  }
175
195
 
@@ -187,7 +207,16 @@ export function enable({ configPath, openclawDir, port }) {
187
207
  enabled.push(name);
188
208
  }
189
209
 
190
- // 5. Save state for restore
210
+ // 5. Validate: refuse to save if originals is empty (means nothing to restore)
211
+ if (Object.keys(originals).length === 0 && enabled.length === 0) {
212
+ return {
213
+ ok: false,
214
+ message: "No providers found to intercept",
215
+ providers: [],
216
+ };
217
+ }
218
+
219
+ // 6. Save state for restore
191
220
  writeState(openclawDir, {
192
221
  enabled: true,
193
222
  port,
@@ -196,10 +225,10 @@ export function enable({ configPath, openclawDir, port }) {
196
225
  backupPath,
197
226
  });
198
227
 
199
- // 6. Write patched config
228
+ // 7. Write patched config
200
229
  writeConfig(configPath, config);
201
230
 
202
- // 7. Restart gateway
231
+ // 8. Restart gateway
203
232
  const restart = restartGateway();
204
233
 
205
234
  return {
@@ -222,27 +251,49 @@ export function enable({ configPath, openclawDir, port }) {
222
251
  export function disable({ configPath, openclawDir }) {
223
252
  const state = readState(openclawDir);
224
253
 
225
- if (!state || !state.originals) {
226
- // Try restoring from backup
227
- const backupPath = configPath + ".inspector-backup";
254
+ const hasOriginals = state?.originals && Object.keys(state.originals).length > 0;
255
+
256
+ if (!hasOriginals) {
257
+ // No valid originals — try restoring from backup file
258
+ const backupPath = state?.backupPath || configPath + ".inspector-backup";
228
259
  if (existsSync(backupPath)) {
229
- copyFileSync(backupPath, configPath);
260
+ // Verify backup is clean (doesn't contain proxy URLs)
261
+ try {
262
+ const backupContent = readFileSync(backupPath, "utf-8");
263
+ if (!backupContent.includes("127.0.0.1:18800")) {
264
+ copyFileSync(backupPath, configPath);
265
+ removeState(openclawDir);
266
+ const restart = restartGateway();
267
+ return {
268
+ ok: restart.ok,
269
+ message: restart.ok ? "Restored from clean backup" : "Restored config but gateway restart failed",
270
+ };
271
+ }
272
+ // Backup is also corrupted — fall through to manual cleanup
273
+ } catch { /* ignore */ }
274
+ }
275
+
276
+ // Last resort: scan config for proxy URLs and replace with BUILTIN_URLS
277
+ const cleaned = cleanProxyUrls(configPath);
278
+ if (cleaned) {
279
+ removeState(openclawDir);
230
280
  const restart = restartGateway();
231
281
  return {
232
282
  ok: restart.ok,
233
- message: restart.ok ? "Restored from backup" : `Restored config but gateway restart failed`,
283
+ message: restart.ok ? "Cleaned proxy URLs from config" : "Config cleaned but gateway restart failed",
234
284
  };
235
285
  }
236
- return { ok: false, message: "No inspector state found — nothing to restore" };
286
+
287
+ removeState(openclawDir);
288
+ return { ok: false, message: "No inspector state or backup found — nothing to restore" };
237
289
  }
238
290
 
239
- // Read current config and restore originals
291
+ // Happy path: restore from originals
240
292
  const config = readConfig(configPath);
241
293
  const providers = config.models?.providers || {};
242
294
 
243
295
  for (const [name, orig] of Object.entries(state.originals)) {
244
296
  if (orig.wasCustom) {
245
- // Restore original baseUrl
246
297
  if (providers[name]) {
247
298
  providers[name].baseUrl = orig.baseUrl;
248
299
  }
@@ -253,10 +304,12 @@ export function disable({ configPath, openclawDir }) {
253
304
  }
254
305
 
255
306
  writeConfig(configPath, config);
256
-
257
- // Clean up state file
258
307
  removeState(openclawDir);
259
308
 
309
+ // Clean up backup file
310
+ const backupPath = state?.backupPath || configPath + ".inspector-backup";
311
+ try { if (existsSync(backupPath)) unlinkSync(backupPath); } catch { /* ignore */ }
312
+
260
313
  const restart = restartGateway();
261
314
 
262
315
  return {
@@ -265,6 +318,46 @@ export function disable({ configPath, openclawDir }) {
265
318
  };
266
319
  }
267
320
 
321
+ /**
322
+ * Emergency cleanup: scan config for proxy URLs and replace with known originals.
323
+ *
324
+ * @param {string} configPath - Path to openclaw.json.
325
+ * @returns {boolean} True if any cleanup was performed.
326
+ */
327
+ function cleanProxyUrls(configPath) {
328
+ try {
329
+ const config = readConfig(configPath);
330
+ const providers = config.models?.providers || {};
331
+ let cleaned = false;
332
+
333
+ for (const [name, cfg] of Object.entries(providers)) {
334
+ if (cfg.baseUrl && cfg.baseUrl.includes("127.0.0.1:18800")) {
335
+ if (BUILTIN_URLS[name]) {
336
+ // Known provider — restore builtin URL
337
+ cfg.baseUrl = BUILTIN_URLS[name];
338
+ cleaned = true;
339
+ } else if (cfg.models && cfg.models.length > 0) {
340
+ // Custom provider with models — remove the broken baseUrl
341
+ // Can't know original URL, but removing it is better than dead proxy
342
+ delete cfg.baseUrl;
343
+ cleaned = true;
344
+ } else {
345
+ // Empty provider entry added by inspector — remove entirely
346
+ delete providers[name];
347
+ cleaned = true;
348
+ }
349
+ }
350
+ }
351
+
352
+ if (cleaned) {
353
+ writeConfig(configPath, config);
354
+ }
355
+ return cleaned;
356
+ } catch {
357
+ return false;
358
+ }
359
+ }
360
+
268
361
  /**
269
362
  * Check current interception status.
270
363
  *
package/src/proxy.mjs CHANGED
@@ -112,6 +112,24 @@ export function getStats() {
112
112
  }
113
113
  }
114
114
 
115
+ // Recent entries (last 10) for live view
116
+ const recentEntries = [];
117
+ const recentIds = entryOrder.slice(-10).reverse();
118
+ for (const id of recentIds) {
119
+ const e = entries.get(id);
120
+ if (!e) continue;
121
+ const u = e.usage || {};
122
+ recentEntries.push({
123
+ id: e.id,
124
+ provider: e.provider || "?",
125
+ model: u.model || e.reqModel || "?",
126
+ state: e.state,
127
+ duration: e.duration || 0,
128
+ totalTokens: (u.inputTokens || 0) + (u.outputTokens || 0),
129
+ cost: e.cost || 0,
130
+ });
131
+ }
132
+
115
133
  return {
116
134
  totalRequests,
117
135
  totalInputTokens,
@@ -123,6 +141,7 @@ export function getStats() {
123
141
  errors,
124
142
  byProvider,
125
143
  byModel,
144
+ recentEntries,
126
145
  };
127
146
  }
128
147