@elvatis_com/openclaw-cli-bridge-elvatis 2.10.0 → 3.0.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/CLAUDE.md ADDED
@@ -0,0 +1,113 @@
1
+ # OpenClaw CLI Bridge
2
+
3
+ ## Project Overview
4
+
5
+ OpenClaw plugin that bridges AI CLIs (Claude, Gemini, Codex, Grok, ChatGPT) as model providers via a local OpenAI-compatible HTTP proxy on `127.0.0.1:31337`. The gateway routes `vllm/` model requests here; the bridge spawns CLI subprocesses and translates between OpenAI protocol and CLI text I/O.
6
+
7
+ ## Architecture
8
+
9
+ ```
10
+ OpenClaw Gateway ──(HTTP)──> proxy-server.ts ──(spawn)──> claude/gemini/codex CLI
11
+ :31337 │
12
+ ├── cli-runner.ts (subprocess spawn, stdin prompt, timeout, stale-output detection)
13
+ ├── tool-protocol.ts (tool schema injection, JSON response parsing, tool_calls rescue)
14
+ ├── config.ts (all timeouts, thresholds, paths — single source of truth)
15
+ ├── debug-log.ts (file-based log: ~/.openclaw/cli-bridge/debug.log)
16
+ ├── metrics.ts (request metrics, persisted to ~/.openclaw/cli-bridge/metrics.json)
17
+ ├── provider-sessions.ts (persistent session registry per model)
18
+ ├── status-template.ts (HTML dashboard at GET /status)
19
+ ├── session-manager.ts (long-running session spawn/poll/kill)
20
+ ├── *-browser.ts (Playwright web-session providers: Grok, Gemini, Claude, ChatGPT)
21
+ └── gemini-api-runner.ts (native Gemini API via @google/genai SDK)
22
+ ```
23
+
24
+ ## Key Design Decisions
25
+
26
+ - **Prompt via stdin, never CLI args** — avoids E2BIG on large sessions
27
+ - **Tool calls via text injection** — CLI tools don't support native tool_use, so tool schemas are injected into the prompt text and responses are parsed for JSON
28
+ - **JSON reminder sandwich** — tool instructions appear at start AND end of prompt; models (especially Haiku) forget format instructions after long conversations
29
+ - **Stale-output detection** — if a CLI subprocess produces zero stdout for 30s, SIGTERM early instead of waiting full timeout. Claude Sonnet intermittently hangs silently on large tool prompts (API-side issue, not RAM — confirmed 28GB free, zero swap)
30
+ - **Smart fallback** — Sonnet tries first (better tool selection), 30s stale timeout kills it fast, Haiku takes over (~10s, reliable but picks wrong tools sometimes)
31
+ - **Compact tool schema** — when >8 tools, only send name+params (skip descriptions/full JSON schema), cuts prompt ~60%
32
+ - **Exit 143 = our SIGTERM** — not OOM, not crash. The bridge's timeout/stale-output detector sends SIGTERM, Claude CLI exits 143
33
+
34
+ ## Build & Test
35
+
36
+ ```bash
37
+ npm run build # tsc — always has 5 pre-existing errors (openclaw/plugin-sdk not found at compile time, only at runtime). Dist output is still generated correctly.
38
+ npx vitest run # 278+ tests across 19 files. All must pass.
39
+ ```
40
+
41
+ ## Deploy Workflow
42
+
43
+ The gateway loads plugins from `~/.openclaw/extensions/`, NOT from this workspace:
44
+
45
+ ```bash
46
+ npm run build
47
+ rsync -a --exclude node_modules --exclude .git ./ ~/.openclaw/extensions/openclaw-cli-bridge-elvatis/
48
+ openclaw gateway restart
49
+ ```
50
+
51
+ ## Version Bump Checklist
52
+
53
+ Bump version string in 4 files before every release:
54
+ 1. `package.json`
55
+ 2. `openclaw.plugin.json`
56
+ 3. `README.md` (line 5: "Current version")
57
+ 4. `SKILL.md` (last line: "Version:")
58
+
59
+ Then: `git commit && git push && gh release create vX.Y.Z`
60
+
61
+ ## Config (src/config.ts)
62
+
63
+ All magic numbers live here. Key values:
64
+
65
+ | Constant | Value | Purpose |
66
+ |----------|-------|---------|
67
+ | `MAX_EFFECTIVE_TIMEOUT_MS` | 580s | Must stay UNDER gateway's `idleTimeoutSeconds` (600s) |
68
+ | `STALE_OUTPUT_TIMEOUT_MS` | 30s | Kill silent CLI processes fast |
69
+ | `TOOL_HEAVY_THRESHOLD` | 10 | Reduce MAX_MESSAGES from 20 to 12 when tools exceed this |
70
+ | `COMPACT_TOOL_THRESHOLD` | 8 | Switch to compact tool schema (name+params only) |
71
+ | `TOOL_ROUTING_THRESHOLD` | 8 | (in proxy-server) Was used for Haiku routing, now Sonnet-first with fast fallback |
72
+
73
+ ## Tool Protocol (src/tool-protocol.ts)
74
+
75
+ Models receive tool schemas as text and must respond with:
76
+ - `{"tool_calls":[{"name":"...","arguments":{...}}]}` — to call a tool
77
+ - `{"content":"..."}` — to respond with text
78
+
79
+ Parser tries 5 strategies: Claude JSON wrapper, direct JSON, code blocks, embedded JSON, rescue from content string. Debug logging on every path.
80
+
81
+ ## Known Issues
82
+
83
+ - **Sonnet intermittent hangs** — `claude -p` with Sonnet goes completely silent (~50% of the time) on large tool prompts (20KB+). First call often works, subsequent calls hang. NOT RAM-related. Likely API-side rate limiting or request dedup. Workaround: 30s stale-output detection + Haiku fallback.
84
+ - **Haiku empty responses** — occasionally returns zero stdout (len:0). Cause unclear. The JSON reminder at prompt end helps but doesn't fully solve it.
85
+ - **Pre-existing tsc errors** — 5 errors about `openclaw/plugin-sdk` module not found. These are expected — the SDK is injected at runtime by the gateway. Dist output is still generated.
86
+
87
+ ## Testing
88
+
89
+ ```bash
90
+ npx vitest run # full suite (278+ tests)
91
+ npx vitest run test/config.test.ts # just config
92
+ npx vitest run test/proxy-e2e.test.ts # proxy integration
93
+ ```
94
+
95
+ Tests that mock `config.js` must use `importOriginal` spread pattern — the mock must include all exports since `debug-log.ts` and `cli-runner.ts` import from config at module level.
96
+
97
+ ## Debug
98
+
99
+ ```bash
100
+ tail -f ~/.openclaw/cli-bridge/debug.log # real-time request lifecycle
101
+ curl http://127.0.0.1:31337/status # dashboard (auto-refresh 10s)
102
+ cat ~/.openclaw/cli-bridge/metrics.json # persisted metrics
103
+ cat ~/.openclaw/cli-bridge/sessions.json # provider session state
104
+ ```
105
+
106
+ ## Roadmap (v3.0)
107
+
108
+ - [ ] Dashboard v2: sidebar navigation, live log viewer, model config editor, routing visualization
109
+ - [ ] User-configurable routing engine via dashboard UI
110
+ - [ ] Multi-model fallback chains: Claude → Gemini → Codex → Haiku
111
+ - [ ] Per-tool routing: write/exec → fast model, search/analyze → smart model
112
+ - [ ] Model health scoring: track success rates, auto-demote unreliable models
113
+ - [ ] Session resume: use `claude --resume` for conversation continuity instead of fresh `-p` each time
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > OpenClaw plugin that bridges locally installed AI CLIs (Codex, Gemini, Claude Code, OpenCode, Pi) as model providers — with slash commands for instant model switching, restore, health testing, and model listing.
4
4
 
5
- **Current version:** `2.10.0`
5
+ **Current version:** `3.0.0`
6
6
 
7
7
  ---
8
8
 
@@ -406,6 +406,20 @@ npm run ci # lint + typecheck + test
406
406
 
407
407
  ## Changelog
408
408
 
409
+ ### v3.0.0
410
+ - **feat:** dashboard v2 — sidebar navigation with 9 sections (Overview, Providers, Active, Requests, Fallbacks, Sessions, Live Logs, Timeouts, Models)
411
+ - **feat:** live log viewer — SSE-powered real-time log streaming with color-coded categories, auto-scroll, pause/resume, 500-line client buffer
412
+ - **feat:** AJAX polling — replaces full-page meta-refresh with incremental section updates every 10s (preserves scroll position and active section)
413
+ - **feat:** `/api/dashboard-data` endpoint — returns pre-rendered HTML sections as JSON for client-side updates
414
+ - **feat:** `/api/logs/stream` SSE endpoint — streams debug.log in real-time with initial tail of last 100 lines
415
+ - **feat:** mobile-responsive sidebar with hamburger toggle
416
+ - **feat:** CLAUDE.md added with full project documentation
417
+
418
+ ### v2.10.1
419
+ - **feat:** smart tool-routing — tool-heavy requests (>8 tools) auto-route to Haiku instead of Sonnet. Haiku handles tool calls in ~11s vs Sonnet's 80-120s (with intermittent hangs). Sonnet is preserved for reasoning/text responses.
420
+ - **fix:** reduce stale-output timeout 120s→60s — faster fallback when Sonnet goes silent
421
+ - **feat:** per-model spawn logging with prompt size for debugging
422
+
409
423
  ### v2.10.0
410
424
  - **fix:** cap effective timeout at 580s (under gateway's 600s `idleTimeoutSeconds`) so bridge fallback fires BEFORE gateway kills the request — eliminates the race condition where both compete to handle the timeout
411
425
  - **fix:** reduce Sonnet base timeout 420s→300s, Opus 420s→360s — ensures fallback triggers faster for stuck CLI sessions
package/SKILL.md CHANGED
@@ -68,4 +68,4 @@ On gateway restart, if any session has expired, a **WhatsApp alert** is sent aut
68
68
 
69
69
  See `README.md` for full configuration reference and architecture diagram.
70
70
 
71
- **Version:** 2.10.0
71
+ **Version:** 3.0.0
@@ -2,7 +2,7 @@
2
2
  "id": "openclaw-cli-bridge-elvatis",
3
3
  "slug": "openclaw-cli-bridge-elvatis",
4
4
  "name": "OpenClaw CLI Bridge",
5
- "version": "2.10.0",
5
+ "version": "3.0.0",
6
6
  "license": "MIT",
7
7
  "description": "Phase 1: openai-codex auth bridge. Phase 2: local HTTP proxy routing model calls through gemini/claude CLIs (vllm provider).",
8
8
  "providers": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elvatis_com/openclaw-cli-bridge-elvatis",
3
- "version": "2.10.0",
3
+ "version": "3.0.0",
4
4
  "description": "Bridges gemini, claude, and codex CLI tools as OpenClaw model providers. Reads existing CLI auth without re-login.",
5
5
  "type": "module",
6
6
  "openclaw": {
package/src/cli-runner.ts CHANGED
@@ -508,9 +508,11 @@ export async function runGemini(
508
508
  const args = ["-m", model, "-p", "", "--approval-mode", "yolo"];
509
509
  const cwd = workdir ?? tmpdir();
510
510
 
511
- // When tools are present, prepend tool instructions to prompt
511
+ // When tools are present, sandwich the conversation between tool instructions.
512
+ // The reminder at the end ensures models (especially Haiku) remember the JSON format
513
+ // after processing a long conversation history.
512
514
  const effectivePrompt = opts?.tools?.length
513
- ? buildToolPromptBlock(opts.tools) + "\n\n" + prompt
515
+ ? buildToolPromptBlock(opts.tools) + "\n\n" + prompt + "\n\nREMINDER: You MUST respond with ONLY valid JSON — either {\"tool_calls\":[...]} or {\"content\":\"...\"}. Nothing else."
514
516
  : prompt;
515
517
 
516
518
  const result = await runCli("gemini", args, effectivePrompt, timeoutMs, { cwd, log: opts?.log });
@@ -560,12 +562,15 @@ export async function runClaude(
560
562
  "--model", model,
561
563
  ];
562
564
 
563
- // When tools are present, prepend tool instructions to prompt
565
+ // When tools are present, sandwich the conversation between tool instructions.
566
+ // The reminder at the end ensures models (especially Haiku) remember the JSON format
567
+ // after processing a long conversation history.
564
568
  const effectivePrompt = opts?.tools?.length
565
- ? buildToolPromptBlock(opts.tools) + "\n\n" + prompt
569
+ ? buildToolPromptBlock(opts.tools) + "\n\n" + prompt + "\n\nREMINDER: You MUST respond with ONLY valid JSON — either {\"tool_calls\":[...]} or {\"content\":\"...\"}. Nothing else."
566
570
  : prompt;
567
571
 
568
572
  const cwd = workdir ?? homedir();
573
+ debugLog("CLAUDE", `spawn ${model}`, { promptLen: effectivePrompt.length, promptKB: Math.round(effectivePrompt.length / 1024), cwd, timeoutMs: Math.round(timeoutMs / 1000) });
569
574
  const result = await runCli("claude", args, effectivePrompt, timeoutMs, { cwd, log: opts?.log });
570
575
 
571
576
  // On 401: attempt one token refresh + retry before giving up.
@@ -640,9 +645,11 @@ export async function runCodex(
640
645
  // Codex requires a git repo in the working directory
641
646
  ensureGitRepo(cwd);
642
647
 
643
- // When tools are present, prepend tool instructions to prompt
648
+ // When tools are present, sandwich the conversation between tool instructions.
649
+ // The reminder at the end ensures models (especially Haiku) remember the JSON format
650
+ // after processing a long conversation history.
644
651
  const effectivePrompt = opts?.tools?.length
645
- ? buildToolPromptBlock(opts.tools) + "\n\n" + prompt
652
+ ? buildToolPromptBlock(opts.tools) + "\n\n" + prompt + "\n\nREMINDER: You MUST respond with ONLY valid JSON — either {\"tool_calls\":[...]} or {\"content\":\"...\"}. Nothing else."
646
653
  : prompt;
647
654
 
648
655
  const result = await runCli("codex", args, effectivePrompt, timeoutMs, { cwd, log: opts?.log });
package/src/config.ts CHANGED
@@ -57,7 +57,7 @@ export const TIMEOUT_GRACE_MS = 5_000;
57
57
  * assume it's stuck and SIGTERM early. 0 = disabled.
58
58
  * Prevents waiting the full timeout when Claude CLI hangs silently.
59
59
  */
60
- export const STALE_OUTPUT_TIMEOUT_MS = 120_000; // 2 min of silence → kill
60
+ export const STALE_OUTPUT_TIMEOUT_MS = 30_000; // 30s of silence → kill. Sonnet either starts producing within 30s or it's hung.
61
61
 
62
62
  /** Max messages to include in the prompt sent to CLI subprocesses. */
63
63
  export const MAX_MESSAGES = 20;
@@ -71,6 +71,13 @@ export const MAX_MESSAGES_HEAVY_TOOLS = 12;
71
71
  /** Tool count threshold that triggers reduced message limit. */
72
72
  export const TOOL_HEAVY_THRESHOLD = 10;
73
73
 
74
+ /**
75
+ * Tool count threshold that triggers smart routing to a faster model.
76
+ * When Sonnet receives a request with this many tools, route to Haiku instead.
77
+ * Haiku handles tool calls in ~11s vs Sonnet's 80-120s (and Sonnet hangs intermittently).
78
+ */
79
+ export const TOOL_ROUTING_THRESHOLD = 8;
80
+
74
81
  /** Max characters per message content before truncation. */
75
82
  export const MAX_MSG_CHARS = 4_000;
76
83
 
package/src/debug-log.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  * tail -f ~/.openclaw/cli-bridge/debug.log
9
9
  */
10
10
 
11
- import { appendFileSync, statSync, renameSync, mkdirSync } from "node:fs";
11
+ import { appendFileSync, readFileSync, openSync, readSync, closeSync, statSync, renameSync, mkdirSync, watchFile, unwatchFile } from "node:fs";
12
12
  import { join } from "node:path";
13
13
  import { homedir } from "node:os";
14
14
 
@@ -53,3 +53,49 @@ export function debugLog(category: string, message: string, data?: Record<string
53
53
 
54
54
  /** Log path for display on status page / startup messages. */
55
55
  export const DEBUG_LOG_PATH = LOG_FILE;
56
+
57
+ /**
58
+ * Read the last N lines from the log file.
59
+ * Returns null if the file doesn't exist.
60
+ */
61
+ export function getLogTail(lines = 100): string | null {
62
+ try {
63
+ const content = readFileSync(LOG_FILE, "utf8");
64
+ const allLines = content.split("\n").filter(Boolean);
65
+ return allLines.slice(-lines).join("\n");
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Watch the log file for new content and call the callback for each new line.
73
+ * Returns an unwatch function to stop watching.
74
+ */
75
+ export function watchLogFile(onLine: (line: string) => void): () => void {
76
+ let lastSize = 0;
77
+ try { lastSize = statSync(LOG_FILE).size; } catch { /* file doesn't exist yet */ }
78
+
79
+ const listener = () => {
80
+ try {
81
+ const stat = statSync(LOG_FILE);
82
+ if (stat.size <= lastSize) {
83
+ // File was rotated or truncated — reset
84
+ lastSize = 0;
85
+ }
86
+ if (stat.size > lastSize) {
87
+ const buf = Buffer.alloc(stat.size - lastSize);
88
+ const fd = openSync(LOG_FILE, "r");
89
+ readSync(fd, buf, 0, buf.length, lastSize);
90
+ closeSync(fd);
91
+ const newContent = buf.toString("utf8");
92
+ const lines = newContent.split("\n").filter(Boolean);
93
+ for (const line of lines) onLine(line);
94
+ lastSize = stat.size;
95
+ }
96
+ } catch { /* best effort */ }
97
+ };
98
+
99
+ watchFile(LOG_FILE, { interval: 1000 }, listener);
100
+ return () => { unwatchFile(LOG_FILE, listener); };
101
+ }
@@ -18,7 +18,7 @@ import { claudeComplete, claudeCompleteStream, type ChatMessage as ClaudeBrowser
18
18
  import { chatgptComplete, chatgptCompleteStream, type ChatMessage as ChatGPTBrowserChatMessage } from "./chatgpt-browser.js";
19
19
  import { geminiApiComplete, geminiApiCompleteStream, type GeminiApiResult, type ContentPart } from "./gemini-api-runner.js";
20
20
  import type { BrowserContext } from "playwright";
21
- import { renderStatusPage, type StatusProvider } from "./status-template.js";
21
+ import { renderStatusPage, renderDashboardData, type StatusProvider } from "./status-template.js";
22
22
  import { sessionManager } from "./session-manager.js";
23
23
  import { metrics, estimateTokens } from "./metrics.js";
24
24
  import { providerSessions } from "./provider-sessions.js";
@@ -32,8 +32,9 @@ import {
32
32
  BITNET_MAX_MESSAGES,
33
33
  BITNET_SYSTEM_PROMPT,
34
34
  DEFAULT_MODEL_TIMEOUTS,
35
+ TOOL_ROUTING_THRESHOLD,
35
36
  } from "./config.js";
36
- import { debugLog, DEBUG_LOG_PATH } from "./debug-log.js";
37
+ import { debugLog, DEBUG_LOG_PATH, getLogTail, watchLogFile } from "./debug-log.js";
37
38
 
38
39
  // ── Active request tracking ─────────────────────────────────────────────────
39
40
 
@@ -315,6 +316,58 @@ async function handleRequest(
315
316
  return;
316
317
  }
317
318
 
319
+ // Dashboard data API — returns pre-rendered HTML sections for AJAX polling
320
+ if (url === "/api/dashboard-data" && req.method === "GET") {
321
+ const expiry = opts.getExpiryInfo?.() ?? { grok: null, gemini: null, claude: null, chatgpt: null };
322
+ const version = opts.version ?? "?";
323
+ const providers: StatusProvider[] = [
324
+ { name: "Grok", icon: "\uD835\uDD4F", expiry: expiry.grok, loginCmd: "/grok-login", ctx: opts.getGrokContext?.() ?? null },
325
+ { name: "Gemini", icon: "\u2726", expiry: expiry.gemini, loginCmd: "/gemini-login", ctx: opts.getGeminiContext?.() ?? null },
326
+ { name: "Claude", icon: "\u25C6", expiry: expiry.claude, loginCmd: "/claude-login", ctx: opts.getClaudeContext?.() ?? null },
327
+ { name: "ChatGPT", icon: "\u25C9", expiry: expiry.chatgpt, loginCmd: "/chatgpt-login", ctx: opts.getChatGPTContext?.() ?? null },
328
+ ];
329
+ const sections = renderDashboardData({
330
+ version, port: opts.port, providers, models: CLI_MODELS,
331
+ modelCommands: opts.modelCommands,
332
+ metrics: metrics.getMetrics(),
333
+ activeRequests: getActiveRequests(),
334
+ providerSessionsList: providerSessions.listSessions(),
335
+ timeoutConfig: {
336
+ defaults: { ...DEFAULT_MODEL_TIMEOUTS, ...(opts.modelTimeouts ?? {}) },
337
+ baseDefault: opts.timeoutMs ?? DEFAULT_PROXY_TIMEOUT_MS,
338
+ maxEffective: MAX_EFFECTIVE_TIMEOUT_MS,
339
+ perExtraMsg: TIMEOUT_PER_EXTRA_MSG_MS,
340
+ perTool: TIMEOUT_PER_TOOL_MS,
341
+ },
342
+ });
343
+ res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders() });
344
+ res.end(JSON.stringify(sections));
345
+ return;
346
+ }
347
+
348
+ // Live log streaming via SSE
349
+ if (url === "/api/logs/stream" && req.method === "GET") {
350
+ res.writeHead(200, {
351
+ "Content-Type": "text/event-stream",
352
+ "Cache-Control": "no-cache",
353
+ Connection: "keep-alive",
354
+ ...corsHeaders(),
355
+ });
356
+ // Send initial tail
357
+ const tail = getLogTail(100);
358
+ if (tail) res.write(`data: ${tail.replace(/\n/g, "\ndata: ")}\n\n`);
359
+ // Watch for new lines
360
+ const unwatch = watchLogFile((line) => {
361
+ try { res.write(`data: ${line}\n\n`); } catch { /* client disconnected */ }
362
+ });
363
+ // Keepalive
364
+ const ka = setInterval(() => {
365
+ try { res.write(": keepalive\n\n"); } catch { /* client disconnected */ }
366
+ }, 15_000);
367
+ req.on("close", () => { unwatch(); clearInterval(ka); });
368
+ return;
369
+ }
370
+
318
371
  // Model list
319
372
  if (url === "/v1/models" && req.method === "GET") {
320
373
  const now = Math.floor(Date.now() / 1000);
@@ -790,6 +843,13 @@ async function handleRequest(
790
843
  // ── CLI runner routing (Gemini / Claude Code / Codex) ──────────────────────
791
844
  let result: CliToolResult;
792
845
  let usedModel = model;
846
+
847
+ // ── Smart tool routing: Sonnet first (better reasoning), fast fallback to Haiku ──
848
+ // Sonnet picks the right tools but intermittently hangs on large prompts.
849
+ // Strategy: let Sonnet try first — if it responds, great (better tool selection).
850
+ // The stale-output detector (60s) kills it fast if it hangs, then fallback to Haiku.
851
+ // This preserves Sonnet's intelligence for tool selection while keeping Haiku as safety net.
852
+
793
853
  const routeOpts = { workdir, tools: hasTools ? tools : undefined, mediaFiles: mediaFiles.length ? mediaFiles : undefined, log: opts.log };
794
854
 
795
855
  // ── Provider session: ensure a persistent session for this model ────────
@@ -843,12 +903,12 @@ async function handleRequest(
843
903
 
844
904
  const cliStart = Date.now();
845
905
  try {
846
- result = await routeToCliRunner(model, cleanMessages, effectiveTimeout, routeOpts);
906
+ result = await routeToCliRunner(usedModel, cleanMessages, effectiveTimeout, routeOpts);
847
907
  const latencyMs = Date.now() - cliStart;
848
908
  const estCompletionTokens = estimateTokens(result.content ?? "");
849
- metrics.recordRequest(model, latencyMs, true, estPromptTokens, estCompletionTokens, promptPreview);
909
+ metrics.recordRequest(usedModel, latencyMs, true, estPromptTokens, estCompletionTokens, promptPreview);
850
910
  providerSessions.recordRun(session.id, false);
851
- debugLog("OK", `${model} completed in ${(latencyMs / 1000).toFixed(1)}s`, { toolCalls: result.tool_calls?.length ?? 0, contentLen: result.content?.length ?? 0 });
911
+ debugLog("OK", `${usedModel} completed in ${(latencyMs / 1000).toFixed(1)}s`, { toolCalls: result.tool_calls?.length ?? 0, contentLen: result.content?.length ?? 0 });
852
912
  } catch (err) {
853
913
  const primaryDuration = Date.now() - cliStart;
854
914
  const msg = (err as Error).message;
@@ -2,7 +2,7 @@
2
2
  * status-template.ts
3
3
  *
4
4
  * Generates the HTML dashboard for the /status endpoint.
5
- * Extracted from proxy-server.ts for maintainability.
5
+ * v3.0: Sidebar navigation, section-based layout, JS polling, live log viewer.
6
6
  */
7
7
 
8
8
  import type { BrowserContext } from "playwright";
@@ -39,11 +39,11 @@ export interface StatusTemplateOptions {
39
39
  }
40
40
 
41
41
  function statusBadge(p: StatusProvider): { label: string; color: string; dot: string } {
42
- if (p.ctx !== null) return { label: "Connected", color: "#22c55e", dot: "🟢" };
43
- if (!p.expiry) return { label: "Never logged in", color: "#6b7280", dot: "" };
44
- if (p.expiry.startsWith("⚠️ EXPIRED")) return { label: "Expired", color: "#ef4444", dot: "🔴" };
45
- if (p.expiry.startsWith("🚨")) return { label: "Expiring soon", color: "#f59e0b", dot: "🟡" };
46
- return { label: "Logged in", color: "#3b82f6", dot: "🔵" };
42
+ if (p.ctx !== null) return { label: "Connected", color: "#22c55e", dot: "\u{1F7E2}" };
43
+ if (!p.expiry) return { label: "Never logged in", color: "#6b7280", dot: "\u26AA" };
44
+ if (p.expiry.startsWith("\u26A0\uFE0F EXPIRED")) return { label: "Expired", color: "#ef4444", dot: "\u{1F534}" };
45
+ if (p.expiry.startsWith("\u{1F6A8}")) return { label: "Expiring soon", color: "#f59e0b", dot: "\u{1F7E1}" };
46
+ return { label: "Logged in", color: "#3b82f6", dot: "\u{1F535}" };
47
47
  }
48
48
 
49
49
  // ── Formatting helpers ──────────────────────────────────────────────────────
@@ -91,9 +91,46 @@ function truncateId(id: string): string {
91
91
  return id.slice(0, 8) + "\u2026" + id.slice(-8);
92
92
  }
93
93
 
94
- // ── Active Requests ────────────────────────────────────────────────────────
94
+ // ── Section renderers (each returns an HTML fragment) ──────────────────────
95
95
 
96
- function renderActiveRequests(active: ActiveRequest[]): string {
96
+ export function renderProviders(providers: StatusProvider[]): string {
97
+ const rows = providers.map(p => {
98
+ const badge = statusBadge(p);
99
+ const expiryText = p.expiry
100
+ ? p.expiry.replace(/[\u26A0\uFE0F\u{1F6A8}\u2705\u{1F550}]/gu, "").trim()
101
+ : `Not logged in \u2014 run <code>${p.loginCmd}</code>`;
102
+ return `
103
+ <tr>
104
+ <td style="padding:12px 16px;font-weight:600;font-size:15px">${p.icon} ${p.name}</td>
105
+ <td style="padding:12px 16px">
106
+ <span style="background:${badge.color}22;color:${badge.color};border:1px solid ${badge.color}44;
107
+ border-radius:6px;padding:3px 10px;font-size:13px;font-weight:600">
108
+ ${badge.dot} ${badge.label}
109
+ </span>
110
+ </td>
111
+ <td style="padding:12px 16px;color:#9ca3af;font-size:13px">${expiryText}</td>
112
+ <td style="padding:12px 16px;color:#6b7280;font-size:12px;font-family:monospace">${p.loginCmd}</td>
113
+ </tr>`;
114
+ }).join("");
115
+
116
+ return `
117
+ <div class="card">
118
+ <div class="card-header">Web Session Providers</div>
119
+ <table>
120
+ <thead>
121
+ <tr class="table-head">
122
+ <th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Provider</th>
123
+ <th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Status</th>
124
+ <th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Session</th>
125
+ <th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Login</th>
126
+ </tr>
127
+ </thead>
128
+ <tbody>${rows}</tbody>
129
+ </table>
130
+ </div>`;
131
+ }
132
+
133
+ export function renderActiveRequests(active: ActiveRequest[]): string {
97
134
  if (active.length === 0) {
98
135
  return `
99
136
  <div class="card">
@@ -135,9 +172,7 @@ function renderActiveRequests(active: ActiveRequest[]): string {
135
172
  </div>`;
136
173
  }
137
174
 
138
- // ── Recent Request Log ────────────────────────────────────────────────────���
139
-
140
- function renderRecentRequestLog(entries: RequestLogEntry[]): string {
175
+ export function renderRecentRequestLog(entries: RequestLogEntry[]): string {
141
176
  if (entries.length === 0) {
142
177
  return `
143
178
  <div class="card">
@@ -180,9 +215,7 @@ function renderRecentRequestLog(entries: RequestLogEntry[]): string {
180
215
  </div>`;
181
216
  }
182
217
 
183
- // ── Fallback History ───────────────────────────────────────────────────────
184
-
185
- function renderFallbackHistory(events: FallbackEvent[]): string {
218
+ export function renderFallbackHistory(events: FallbackEvent[]): string {
186
219
  if (events.length === 0) {
187
220
  return `
188
221
  <div class="card">
@@ -228,9 +261,7 @@ function renderFallbackHistory(events: FallbackEvent[]): string {
228
261
  </div>`;
229
262
  }
230
263
 
231
- // ── Provider Sessions ──────────────────────────────────────────────────────
232
-
233
- function renderProviderSessions(sessions: ProviderSession[]): string {
264
+ export function renderProviderSessions(sessions: ProviderSession[]): string {
234
265
  if (sessions.length === 0) {
235
266
  return `
236
267
  <div class="card">
@@ -272,9 +303,7 @@ function renderProviderSessions(sessions: ProviderSession[]): string {
272
303
  </div>`;
273
304
  }
274
305
 
275
- // ── Timeout Configuration ──────────────────────────────────────────────────
276
-
277
- function renderTimeoutConfig(config: TimeoutConfigInfo): string {
306
+ export function renderTimeoutConfig(config: TimeoutConfigInfo): string {
278
307
  const entries = Object.entries(config.defaults).sort(([a], [b]) => a.localeCompare(b));
279
308
  const rows = entries.map(([model, ms]) => {
280
309
  return `
@@ -303,13 +332,10 @@ function renderTimeoutConfig(config: TimeoutConfigInfo): string {
303
332
  </div>`;
304
333
  }
305
334
 
306
- // ── Metrics sections ────────────────────────────────────────────────────────
307
-
308
- function renderMetricsSection(m: MetricsSnapshot): string {
335
+ export function renderMetricsSection(m: MetricsSnapshot): string {
309
336
  const errorRate = m.totalRequests > 0 ? ((m.totalErrors / m.totalRequests) * 100).toFixed(1) : "0.0";
310
337
  const totalTokens = m.models.reduce((sum, mod) => sum + mod.promptTokens + mod.completionTokens, 0);
311
338
 
312
- // Summary cards
313
339
  const summaryCards = `
314
340
  <div class="summary-grid">
315
341
  <div class="summary-card">
@@ -330,7 +356,6 @@ function renderMetricsSection(m: MetricsSnapshot): string {
330
356
  </div>
331
357
  </div>`;
332
358
 
333
- // Per-model stats table
334
359
  let modelRows: string;
335
360
  if (m.models.length === 0) {
336
361
  modelRows = `<tr><td colspan="6" style="padding:16px;color:#6b7280;text-align:center;font-style:italic">No requests recorded yet.</td></tr>`;
@@ -371,33 +396,15 @@ function renderMetricsSection(m: MetricsSnapshot): string {
371
396
  return summaryCards + modelTable;
372
397
  }
373
398
 
374
- export function renderStatusPage(opts: StatusTemplateOptions): string {
375
- const { version, port, providers, models } = opts;
376
-
377
- const rows = providers.map(p => {
378
- const badge = statusBadge(p);
379
- const expiryText = p.expiry
380
- ? p.expiry.replace(/[⚠️🚨✅🕐]/gu, "").trim()
381
- : `Not logged in \u2014 run <code>${p.loginCmd}</code>`;
382
- return `
383
- <tr>
384
- <td style="padding:12px 16px;font-weight:600;font-size:15px">${p.icon} ${p.name}</td>
385
- <td style="padding:12px 16px">
386
- <span style="background:${badge.color}22;color:${badge.color};border:1px solid ${badge.color}44;
387
- border-radius:6px;padding:3px 10px;font-size:13px;font-weight:600">
388
- ${badge.dot} ${badge.label}
389
- </span>
390
- </td>
391
- <td style="padding:12px 16px;color:#9ca3af;font-size:13px">${expiryText}</td>
392
- <td style="padding:12px 16px;color:#6b7280;font-size:12px;font-family:monospace">${p.loginCmd}</td>
393
- </tr>`;
394
- }).join("");
395
-
399
+ function renderModels(
400
+ models: StatusTemplateOptions["models"],
401
+ modelCommands?: Record<string, string>,
402
+ ): string {
396
403
  const cliModels = models.filter(m => m.id.startsWith("cli-"));
397
404
  const codexModels = models.filter(m => m.id.startsWith("openai-codex/"));
398
405
  const webModels = models.filter(m => m.id.startsWith("web-"));
399
406
  const localModels = models.filter(m => m.id.startsWith("local-"));
400
- const cmds = opts.modelCommands ?? {};
407
+ const cmds = modelCommands ?? {};
401
408
  const modelList = (items: typeof models) =>
402
409
  items.map(m => {
403
410
  const cmd = cmds[m.id];
@@ -405,27 +412,114 @@ export function renderStatusPage(opts: StatusTemplateOptions): string {
405
412
  return `<li style="margin:2px 0;font-size:13px;color:#d1d5db"><code class="model-id">${m.id}</code>${cmdBadge}</li>`;
406
413
  }).join("");
407
414
 
408
- const metricsHtml = opts.metrics ? renderMetricsSection(opts.metrics) : "";
409
- const activeHtml = opts.activeRequests ? renderActiveRequests(opts.activeRequests) : "";
410
- const recentHtml = opts.metrics ? renderRecentRequestLog(opts.metrics.recentRequests) : "";
411
- const fallbackHtml = opts.metrics ? renderFallbackHistory(opts.metrics.fallbackHistory) : "";
412
- const sessionsHtml = opts.providerSessionsList ? renderProviderSessions(opts.providerSessionsList) : "";
413
- const timeoutHtml = opts.timeoutConfig ? renderTimeoutConfig(opts.timeoutConfig) : "";
415
+ return `
416
+ <div class="models">
417
+ <div class="card">
418
+ <div class="card-header">CLI Models (${cliModels.length})</div>
419
+ <ul>${modelList(cliModels)}</ul>
420
+ <div class="card-header">Codex Models (${codexModels.length})</div>
421
+ <ul>${modelList(codexModels)}</ul>
422
+ </div>
423
+ <div class="card">
424
+ <div class="card-header">Web Session Models (${webModels.length})</div>
425
+ <ul>${modelList(webModels)}</ul>
426
+ </div>
427
+ <div class="card">
428
+ <div class="card-header">Local Models (${localModels.length})</div>
429
+ <ul>${modelList(localModels)}</ul>
430
+ </div>
431
+ </div>`;
432
+ }
433
+
434
+ function renderLogsSection(): string {
435
+ return `
436
+ <div class="card" style="height:calc(100vh - 160px);display:flex;flex-direction:column">
437
+ <div class="card-header" style="flex-shrink:0;display:flex;justify-content:space-between;align-items:center">
438
+ <span>Live Logs <span id="log-status" class="badge badge-ok" style="margin-left:8px">connecting...</span></span>
439
+ <span>
440
+ <button onclick="toggleLogPause()" id="log-pause-btn" style="background:#1e2130;color:#9ca3af;border:1px solid #2d3148;border-radius:6px;padding:3px 10px;font-size:11px;cursor:pointer;margin-right:4px">Pause</button>
441
+ <button onclick="clearLogs()" style="background:#1e2130;color:#9ca3af;border:1px solid #2d3148;border-radius:6px;padding:3px 10px;font-size:11px;cursor:pointer">Clear</button>
442
+ </span>
443
+ </div>
444
+ <pre id="log-output" style="flex:1;overflow-y:auto;padding:12px 16px;font-size:12px;line-height:1.6;color:#9ca3af;margin:0;white-space:pre-wrap;word-break:break-all"></pre>
445
+ </div>`;
446
+ }
447
+
448
+ // ── Dashboard data (for AJAX polling) ──────────────────────────────────────
449
+
450
+ export interface DashboardSections {
451
+ providers: string;
452
+ metrics: string;
453
+ active: string;
454
+ recent: string;
455
+ fallbacks: string;
456
+ sessions: string;
457
+ timeouts: string;
458
+ models: string;
459
+ }
460
+
461
+ export function renderDashboardData(opts: StatusTemplateOptions): DashboardSections {
462
+ return {
463
+ providers: renderProviders(opts.providers),
464
+ metrics: opts.metrics ? renderMetricsSection(opts.metrics) : "",
465
+ active: opts.activeRequests ? renderActiveRequests(opts.activeRequests) : "",
466
+ recent: opts.metrics ? renderRecentRequestLog(opts.metrics.recentRequests) : "",
467
+ fallbacks: opts.metrics ? renderFallbackHistory(opts.metrics.fallbackHistory) : "",
468
+ sessions: opts.providerSessionsList ? renderProviderSessions(opts.providerSessionsList) : "",
469
+ timeouts: opts.timeoutConfig ? renderTimeoutConfig(opts.timeoutConfig) : "",
470
+ models: renderModels(opts.models, opts.modelCommands),
471
+ };
472
+ }
473
+
474
+ // ── Navigation definitions ────────────────────────────────────────────────
475
+
476
+ const NAV_ITEMS = [
477
+ { id: "overview", label: "Overview", icon: "\u25C9" },
478
+ { id: "providers", label: "Providers", icon: "\u26A1" },
479
+ { id: "active", label: "Active", icon: "\u25CF" },
480
+ { id: "recent", label: "Requests", icon: "\u2630" },
481
+ { id: "fallbacks", label: "Fallbacks", icon: "\u21C4" },
482
+ { id: "sessions", label: "Sessions", icon: "\u29BF" },
483
+ { id: "logs", label: "Live Logs", icon: "\u276F" },
484
+ { id: "timeouts", label: "Timeouts", icon: "\u23F1" },
485
+ { id: "models", label: "Models", icon: "\u2726" },
486
+ ] as const;
487
+
488
+ // ── Full page render ──────────────────────────────────────────────────────
489
+
490
+ export function renderStatusPage(opts: StatusTemplateOptions): string {
491
+ const { version, port } = opts;
492
+ const sections = renderDashboardData(opts);
493
+
494
+ const navHtml = NAV_ITEMS.map(n =>
495
+ `<a href="#${n.id}" class="nav-item" data-nav="${n.id}" onclick="showSection('${n.id}')">${n.icon} ${n.label}</a>`
496
+ ).join("\n ");
414
497
 
415
498
  return `<!DOCTYPE html>
416
499
  <html lang="en">
417
500
  <head>
418
501
  <meta charset="UTF-8">
419
502
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
420
- <title>CLI Bridge Status</title>
421
- <meta http-equiv="refresh" content="10">
503
+ <title>CLI Bridge Dashboard</title>
422
504
  <style>
423
505
  * { box-sizing: border-box; margin: 0; padding: 0; }
424
- body { background: #0f1117; color: #e5e7eb; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; min-height: 100vh; padding: 32px 24px; }
425
- h1 { font-size: 22px; font-weight: 700; color: #f9fafb; margin-bottom: 4px; }
426
- .subtitle { color: #6b7280; font-size: 13px; margin-bottom: 28px; }
427
- .subtitle a { color: #3b82f6; text-decoration: none; }
428
- .subtitle a:hover { text-decoration: underline; }
506
+ body { background: #0f1117; color: #e5e7eb; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; min-height: 100vh; display: grid; grid-template-columns: 200px 1fr; }
507
+
508
+ /* ── Sidebar ── */
509
+ .sidebar { background: #13151f; border-right: 1px solid #2d3148; padding: 20px 0; position: fixed; top: 0; left: 0; bottom: 0; width: 200px; overflow-y: auto; z-index: 10; }
510
+ .sidebar-title { padding: 0 16px 16px; font-size: 16px; font-weight: 700; color: #f9fafb; border-bottom: 1px solid #2d3148; margin-bottom: 8px; }
511
+ .sidebar-version { display: block; font-size: 11px; color: #6b7280; font-weight: 400; margin-top: 2px; }
512
+ .nav-item { display: flex; align-items: center; gap: 8px; padding: 8px 16px; color: #9ca3af; text-decoration: none; font-size: 13px; transition: all 0.15s; border-left: 3px solid transparent; }
513
+ .nav-item:hover { color: #e5e7eb; background: #1a1d27; }
514
+ .nav-item.active { color: #3b82f6; background: #1e2130; border-left-color: #3b82f6; font-weight: 600; }
515
+
516
+ /* ── Main content ── */
517
+ .main { grid-column: 2; padding: 24px; min-height: 100vh; }
518
+ .section { display: none; }
519
+ .section.active { display: block; }
520
+ .section-title { font-size: 18px; font-weight: 700; color: #f9fafb; margin-bottom: 16px; }
521
+
522
+ /* ── Cards & tables ── */
429
523
  .card { background: #1a1d27; border: 1px solid #2d3148; border-radius: 12px; overflow: hidden; margin-bottom: 24px; }
430
524
  .card-header { padding: 14px 16px; border-bottom: 1px solid #2d3148; font-size: 12px; font-weight: 600; color: #6b7280; text-transform: uppercase; letter-spacing: 0.05em; }
431
525
  table { width: 100%; border-collapse: collapse; }
@@ -433,7 +527,6 @@ export function renderStatusPage(opts: StatusTemplateOptions): string {
433
527
  .table-head { background: #13151f; }
434
528
  .models { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
435
529
  ul { list-style: none; padding: 12px 16px; }
436
- .footer { color: #374151; font-size: 12px; text-align: center; margin-top: 16px; }
437
530
  code { background: #1e2130; padding: 1px 5px; border-radius: 4px; }
438
531
  .model-id { color: #93c5fd; }
439
532
  .summary-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px; }
@@ -453,62 +546,209 @@ export function renderStatusPage(opts: StatusTemplateOptions): string {
453
546
  .pulse-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #22c55e; animation: pulse 1.5s ease-in-out infinite; }
454
547
  @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
455
548
  .two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
549
+ .footer { color: #374151; font-size: 12px; text-align: center; margin-top: 16px; }
550
+
551
+ /* ── Log colors ── */
552
+ .log-fail { color: #ef4444; }
553
+ .log-ok { color: #22c55e; }
554
+ .log-warn { color: #f59e0b; }
555
+ .log-kill { color: #f97316; }
556
+ .log-route { color: #8b5cf6; }
557
+ .log-dim { color: #6b7280; }
558
+
559
+ /* ── Mobile ── */
560
+ .mobile-toggle { display: none; position: fixed; top: 8px; left: 8px; z-index: 20; background: #1a1d27; border: 1px solid #2d3148; border-radius: 8px; padding: 6px 10px; color: #e5e7eb; font-size: 18px; cursor: pointer; }
456
561
  @media (max-width: 768px) {
562
+ body { grid-template-columns: 1fr; }
563
+ .sidebar { transform: translateX(-100%); transition: transform 0.2s; }
564
+ .sidebar.open { transform: translateX(0); }
565
+ .main { grid-column: 1; }
566
+ .mobile-toggle { display: block; }
457
567
  .summary-grid { grid-template-columns: repeat(2, 1fr); }
458
568
  .models, .two-col { grid-template-columns: 1fr; }
459
569
  }
460
570
  </style>
461
571
  </head>
462
572
  <body>
463
- <h1>CLI Bridge</h1>
464
- <p class="subtitle">v${version} &middot; Port ${port} &middot; Auto-refreshes every 10s &middot; <a href="/status">\u21bb Refresh</a></p>
465
-
466
- <div class="card">
467
- <div class="card-header">Web Session Providers</div>
468
- <table>
469
- <thead>
470
- <tr class="table-head">
471
- <th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Provider</th>
472
- <th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Status</th>
473
- <th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Session</th>
474
- <th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Login</th>
475
- </tr>
476
- </thead>
477
- <tbody>${rows}</tbody>
478
- </table>
479
- </div>
573
+ <button class="mobile-toggle" onclick="document.querySelector('.sidebar').classList.toggle('open')">\u2630</button>
574
+
575
+ <nav class="sidebar">
576
+ <div class="sidebar-title">CLI Bridge<span class="sidebar-version">v${version} \u00b7 :${port}</span></div>
577
+ ${navHtml}
578
+ <div style="padding:16px;margin-top:auto">
579
+ <div style="font-size:11px;color:#374151">
580
+ <a href="/v1/models" style="color:#4b5563;text-decoration:none">/v1/models</a> \u00b7
581
+ <a href="/healthz" style="color:#4b5563;text-decoration:none">/healthz</a>
582
+ </div>
583
+ </div>
584
+ </nav>
585
+
586
+ <main class="main">
587
+ <section data-section="overview" class="section active">
588
+ <h2 class="section-title">Overview</h2>
589
+ <div id="s-metrics">${sections.metrics}</div>
590
+ <div id="s-active">${sections.active}</div>
591
+ </section>
592
+
593
+ <section data-section="providers" class="section">
594
+ <h2 class="section-title">Providers</h2>
595
+ <div id="s-providers">${sections.providers}</div>
596
+ </section>
597
+
598
+ <section data-section="active" class="section">
599
+ <h2 class="section-title">Active Requests</h2>
600
+ <div id="s-active2">${sections.active}</div>
601
+ </section>
602
+
603
+ <section data-section="recent" class="section">
604
+ <h2 class="section-title">Recent Requests</h2>
605
+ <div id="s-recent">${sections.recent}</div>
606
+ </section>
607
+
608
+ <section data-section="fallbacks" class="section">
609
+ <h2 class="section-title">Fallbacks &amp; Sessions</h2>
610
+ <div class="two-col">
611
+ <div id="s-fallbacks">${sections.fallbacks}</div>
612
+ <div id="s-sessions">${sections.sessions}</div>
613
+ </div>
614
+ </section>
615
+
616
+ <section data-section="sessions" class="section">
617
+ <h2 class="section-title">Provider Sessions</h2>
618
+ <div id="s-sessions2">${sections.sessions}</div>
619
+ </section>
620
+
621
+ <section data-section="logs" class="section">
622
+ <h2 class="section-title">Live Logs</h2>
623
+ ${renderLogsSection()}
624
+ </section>
625
+
626
+ <section data-section="timeouts" class="section">
627
+ <h2 class="section-title">Timeout Configuration</h2>
628
+ <div id="s-timeouts">${sections.timeouts}</div>
629
+ </section>
630
+
631
+ <section data-section="models" class="section">
632
+ <h2 class="section-title">Models</h2>
633
+ <div id="s-models">${sections.models}</div>
634
+ </section>
635
+
636
+ <p class="footer">openclaw-cli-bridge-elvatis v${version}</p>
637
+ </main>
638
+
639
+ <script>
640
+ // ── Section switching ──
641
+ function showSection(id) {
642
+ document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
643
+ document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
644
+ const sec = document.querySelector('[data-section="' + id + '"]');
645
+ const nav = document.querySelector('[data-nav="' + id + '"]');
646
+ if (sec) sec.classList.add('active');
647
+ if (nav) nav.classList.add('active');
648
+ location.hash = id;
649
+ // Close mobile sidebar
650
+ document.querySelector('.sidebar').classList.remove('open');
651
+ }
480
652
 
481
- ${metricsHtml}
653
+ // Init from hash
654
+ (function() {
655
+ var hash = location.hash.slice(1) || 'overview';
656
+ showSection(hash);
657
+ })();
658
+
659
+ // ── AJAX polling (replaces meta-refresh) ──
660
+ setInterval(function() {
661
+ fetch('/api/dashboard-data').then(function(r) { return r.json(); }).then(function(data) {
662
+ var map = {
663
+ 's-metrics': 'metrics', 's-active': 'active', 's-active2': 'active',
664
+ 's-providers': 'providers', 's-recent': 'recent',
665
+ 's-fallbacks': 'fallbacks', 's-sessions': 'sessions', 's-sessions2': 'sessions',
666
+ 's-timeouts': 'timeouts', 's-models': 'models'
667
+ };
668
+ for (var elId in map) {
669
+ var el = document.getElementById(elId);
670
+ if (el && data[map[elId]]) el.innerHTML = data[map[elId]];
671
+ }
672
+ }).catch(function() { /* silent fail on poll */ });
673
+ }, 10000);
674
+
675
+ // ── Live log viewer ──
676
+ var logOutput = document.getElementById('log-output');
677
+ var logStatus = document.getElementById('log-status');
678
+ var logPaused = false;
679
+ var logSource = null;
680
+ var logLineCount = 0;
681
+ var MAX_LOG_LINES = 500;
682
+ var autoScroll = true;
683
+
684
+ function colorLogLine(line) {
685
+ if (line.indexOf('[FAIL]') !== -1 || line.indexOf('[ERROR]') !== -1) return '<span class="log-fail">' + line + '</span>';
686
+ if (line.indexOf('[OK]') !== -1) return '<span class="log-ok">' + line + '</span>';
687
+ if (line.indexOf('[FALLBACK]') !== -1 || line.indexOf('[WARN]') !== -1) return '<span class="log-warn">' + line + '</span>';
688
+ if (line.indexOf('[KILL]') !== -1) return '<span class="log-kill">' + line + '</span>';
689
+ if (line.indexOf('[TOOL-ROUTE]') !== -1 || line.indexOf('[TASK-ROUTE]') !== -1) return '<span class="log-route">' + line + '</span>';
690
+ if (line.indexOf('[TIMEOUT]') !== -1 || line.indexOf('[CLAUDE]') !== -1) return '<span class="log-dim">' + line + '</span>';
691
+ return line;
692
+ }
482
693
 
483
- ${activeHtml}
694
+ function appendLog(text) {
695
+ if (!logOutput) return;
696
+ var lines = text.split('\\n').filter(function(l) { return l.trim(); });
697
+ lines.forEach(function(line) {
698
+ logOutput.innerHTML += colorLogLine(line.replace(/</g, '&lt;').replace(/>/g, '&gt;')) + '\\n';
699
+ logLineCount++;
700
+ });
701
+ // Trim old lines
702
+ while (logLineCount > MAX_LOG_LINES) {
703
+ var idx = logOutput.innerHTML.indexOf('\\n');
704
+ if (idx === -1) break;
705
+ logOutput.innerHTML = logOutput.innerHTML.slice(idx + 1);
706
+ logLineCount--;
707
+ }
708
+ if (autoScroll) logOutput.scrollTop = logOutput.scrollHeight;
709
+ }
484
710
 
485
- ${recentHtml}
711
+ function connectLog() {
712
+ if (logSource) logSource.close();
713
+ logSource = new EventSource('/api/logs/stream');
714
+ logSource.onopen = function() {
715
+ if (logStatus) { logStatus.textContent = 'connected'; logStatus.className = 'badge badge-ok'; }
716
+ };
717
+ logSource.onmessage = function(e) { appendLog(e.data); };
718
+ logSource.onerror = function() {
719
+ if (logStatus) { logStatus.textContent = 'disconnected'; logStatus.className = 'badge badge-error'; }
720
+ // Reconnect after 3s
721
+ setTimeout(function() { if (!logPaused) connectLog(); }, 3000);
722
+ };
723
+ }
486
724
 
487
- <div class="two-col">
488
- <div>${fallbackHtml}</div>
489
- <div>${sessionsHtml}</div>
490
- </div>
725
+ function toggleLogPause() {
726
+ logPaused = !logPaused;
727
+ var btn = document.getElementById('log-pause-btn');
728
+ if (logPaused) {
729
+ if (logSource) logSource.close();
730
+ if (btn) btn.textContent = 'Resume';
731
+ if (logStatus) { logStatus.textContent = 'paused'; logStatus.className = 'badge badge-warn'; }
732
+ } else {
733
+ connectLog();
734
+ if (btn) btn.textContent = 'Pause';
735
+ }
736
+ }
491
737
 
492
- ${timeoutHtml}
738
+ function clearLogs() {
739
+ if (logOutput) { logOutput.innerHTML = ''; logLineCount = 0; }
740
+ }
493
741
 
494
- <div class="models">
495
- <div class="card">
496
- <div class="card-header">CLI Models (${cliModels.length})</div>
497
- <ul>${modelList(cliModels)}</ul>
498
- <div class="card-header">Codex Models (${codexModels.length})</div>
499
- <ul>${modelList(codexModels)}</ul>
500
- </div>
501
- <div class="card">
502
- <div class="card-header">Web Session Models (${webModels.length})</div>
503
- <ul>${modelList(webModels)}</ul>
504
- </div>
505
- <div class="card">
506
- <div class="card-header">Local Models (${localModels.length})</div>
507
- <ul>${modelList(localModels)}</ul>
508
- </div>
509
- </div>
742
+ // Auto-scroll detection
743
+ if (logOutput) {
744
+ logOutput.addEventListener('scroll', function() {
745
+ autoScroll = (logOutput.scrollTop + logOutput.clientHeight >= logOutput.scrollHeight - 50);
746
+ });
747
+ }
510
748
 
511
- <p class="footer">openclaw-cli-bridge-elvatis v${version} &middot; <a href="/v1/models" style="color:#4b5563">/v1/models</a> &middot; <a href="/health" style="color:#4b5563">/health</a> &middot; <a href="/healthz" style="color:#4b5563">/healthz</a></p>
749
+ // Start log connection
750
+ connectLog();
751
+ </script>
512
752
  </body>
513
753
  </html>`;
514
754
  }