@elvatis_com/openclaw-cli-bridge-elvatis 2.10.1 → 3.1.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.1`
5
+ **Current version:** `3.1.0`
6
6
 
7
7
  ---
8
8
 
@@ -406,6 +406,21 @@ npm run ci # lint + typecheck + test
406
406
 
407
407
  ## Changelog
408
408
 
409
+ ### v3.1.0
410
+ - **feat:** cross-provider fallback chains — Sonnet → Haiku → Gemini Flash → Codex (was single-model fallback only)
411
+ - **feat:** fallback chain loop — tries each model in order until one succeeds, logs each attempt
412
+ - **fix:** live logs newest-on-top — latest entries now appear at the top of the log viewer
413
+ - **feat:** SSE fallback notifications for each chain attempt so user sees what's happening
414
+
415
+ ### v3.0.0
416
+ - **feat:** dashboard v2 — sidebar navigation with 9 sections (Overview, Providers, Active, Requests, Fallbacks, Sessions, Live Logs, Timeouts, Models)
417
+ - **feat:** live log viewer — SSE-powered real-time log streaming with color-coded categories, auto-scroll, pause/resume, 500-line client buffer
418
+ - **feat:** AJAX polling — replaces full-page meta-refresh with incremental section updates every 10s (preserves scroll position and active section)
419
+ - **feat:** `/api/dashboard-data` endpoint — returns pre-rendered HTML sections as JSON for client-side updates
420
+ - **feat:** `/api/logs/stream` SSE endpoint — streams debug.log in real-time with initial tail of last 100 lines
421
+ - **feat:** mobile-responsive sidebar with hamburger toggle
422
+ - **feat:** CLAUDE.md added with full project documentation
423
+
409
424
  ### v2.10.1
410
425
  - **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.
411
426
  - **fix:** reduce stale-output timeout 120s→60s — faster fallback when Sonnet goes silent
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.1
71
+ **Version:** 3.1.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.1",
5
+ "version": "3.1.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.1",
3
+ "version": "3.1.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,9 +562,11 @@ 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();
@@ -641,9 +645,11 @@ export async function runCodex(
641
645
  // Codex requires a git repo in the working directory
642
646
  ensureGitRepo(cwd);
643
647
 
644
- // 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.
645
651
  const effectivePrompt = opts?.tools?.length
646
- ? 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."
647
653
  : prompt;
648
654
 
649
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 = 60_000; // 1 min of silence → kill (Sonnet goes silent when rate-limited)
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;
@@ -138,15 +138,21 @@ export const DEFAULT_MODEL_TIMEOUTS: Record<string, number> = {
138
138
  // ──────────────────────────────────────────────────────────────────────────────
139
139
 
140
140
  /**
141
- * Default fallback chain: when a primary model fails (timeout, error),
142
- * retry once with the lighter variant.
141
+ * Default fallback chains: when a primary model fails (timeout, stale, error),
142
+ * try each fallback in order. Cross-provider chains ensure we use all available
143
+ * models instead of just falling back within one provider.
144
+ *
145
+ * Strategy: same-provider fast model first, then cross-provider alternatives.
143
146
  */
144
- export const DEFAULT_MODEL_FALLBACKS: Record<string, string> = {
145
- "cli-gemini/gemini-2.5-pro": "cli-gemini/gemini-2.5-flash",
146
- "cli-gemini/gemini-3-pro-preview": "cli-gemini/gemini-3-flash-preview",
147
- "cli-claude/claude-opus-4-6": "cli-claude/claude-sonnet-4-6",
148
- "cli-claude/claude-sonnet-4-6": "cli-claude/claude-haiku-4-5",
149
- "gemini-api/gemini-2.5-pro": "gemini-api/gemini-2.5-flash",
147
+ export const DEFAULT_MODEL_FALLBACKS: Record<string, string[]> = {
148
+ "cli-claude/claude-opus-4-6": ["cli-claude/claude-sonnet-4-6", "cli-gemini/gemini-2.5-pro", "cli-claude/claude-haiku-4-5"],
149
+ "cli-claude/claude-sonnet-4-6": ["cli-claude/claude-haiku-4-5", "cli-gemini/gemini-2.5-flash", "openai-codex/gpt-5.3-codex"],
150
+ "cli-claude/claude-haiku-4-5": ["cli-gemini/gemini-2.5-flash", "openai-codex/gpt-5.1-codex-mini"],
151
+ "cli-gemini/gemini-2.5-pro": ["cli-gemini/gemini-2.5-flash", "cli-claude/claude-haiku-4-5"],
152
+ "cli-gemini/gemini-3-pro-preview": ["cli-gemini/gemini-3-flash-preview", "cli-gemini/gemini-2.5-flash"],
153
+ "openai-codex/gpt-5.4": ["openai-codex/gpt-5.3-codex", "cli-claude/claude-haiku-4-5"],
154
+ "openai-codex/gpt-5.3-codex": ["openai-codex/gpt-5.1-codex-mini", "cli-gemini/gemini-2.5-flash"],
155
+ "gemini-api/gemini-2.5-pro": ["gemini-api/gemini-2.5-flash"],
150
156
  };
151
157
 
152
158
  // ──────────────────────────────────────────────────────────────────────────────
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).reverse().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";
@@ -34,7 +34,7 @@ import {
34
34
  DEFAULT_MODEL_TIMEOUTS,
35
35
  TOOL_ROUTING_THRESHOLD,
36
36
  } from "./config.js";
37
- import { debugLog, DEBUG_LOG_PATH } from "./debug-log.js";
37
+ import { debugLog, DEBUG_LOG_PATH, getLogTail, watchLogFile } from "./debug-log.js";
38
38
 
39
39
  // ── Active request tracking ─────────────────────────────────────────────────
40
40
 
@@ -117,7 +117,7 @@ export interface ProxyServerOptions {
117
117
  * When a CLI model fails (timeout, error), the request is retried once
118
118
  * with the fallback model. Example: "cli-gemini/gemini-2.5-pro" → "cli-gemini/gemini-2.5-flash"
119
119
  */
120
- modelFallbacks?: Record<string, string>;
120
+ modelFallbacks?: Record<string, string | string[]>;
121
121
  /**
122
122
  * Per-model timeout overrides (ms). Keys are model IDs (without "vllm/" prefix).
123
123
  * Use this to give heavy models more time or limit fast models.
@@ -316,6 +316,58 @@ async function handleRequest(
316
316
  return;
317
317
  }
318
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
+
319
371
  // Model list
320
372
  if (url === "/v1/models" && req.method === "GET") {
321
373
  const now = Math.floor(Date.now() / 1000);
@@ -792,16 +844,11 @@ async function handleRequest(
792
844
  let result: CliToolResult;
793
845
  let usedModel = model;
794
846
 
795
- // ── Smart tool routing: heavy tool requests Haiku for speed ──────────
796
- // Sonnet hangs intermittently on large tool prompts (20KB+, 21 tools).
797
- // Haiku handles tool calls in ~11s vs Sonnet's 80-120s (when it works).
798
- // Route tool-heavy requests directly to Haiku, keep Sonnet for reasoning.
799
- if (hasTools && tools!.length > TOOL_ROUTING_THRESHOLD && model === "cli-claude/claude-sonnet-4-6") {
800
- const toolModel = "cli-claude/claude-haiku-4-5";
801
- opts.log(`[cli-bridge] tool-routing: ${model} → ${toolModel} (${tools!.length} tools)`);
802
- debugLog("TOOL-ROUTE", `${model} → ${toolModel}`, { tools: tools!.length, threshold: TOOL_ROUTING_THRESHOLD });
803
- usedModel = toolModel;
804
- }
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.
805
852
 
806
853
  const routeOpts = { workdir, tools: hasTools ? tools : undefined, mediaFiles: mediaFiles.length ? mediaFiles : undefined, log: opts.log };
807
854
 
@@ -870,36 +917,55 @@ async function handleRequest(
870
917
  debugLog("FAIL", `${model} failed after ${(primaryDuration / 1000).toFixed(1)}s`, { isTimeout, error: msg.slice(0, 200) });
871
918
  // Record the run (with timeout flag) — session is preserved, not deleted
872
919
  providerSessions.recordRun(session.id, isTimeout);
873
- const fallbackModel = opts.modelFallbacks?.[model];
874
- if (fallbackModel) {
920
+ // ── Multi-model fallback chain: try each fallback in order ──────────
921
+ // Chains cross providers: Sonnet → Haiku → Gemini Flash → Codex
922
+ const rawFallbacks = opts.modelFallbacks?.[model];
923
+ const fallbackChain: string[] = Array.isArray(rawFallbacks) ? rawFallbacks
924
+ : typeof rawFallbacks === "string" ? [rawFallbacks]
925
+ : [];
926
+
927
+ if (fallbackChain.length > 0) {
875
928
  metrics.recordRequest(model, primaryDuration, false, estPromptTokens, undefined, promptPreview);
876
929
  const reason = isTimeout ? `timeout by supervisor, session=${session.id} preserved` : msg;
877
- opts.warn(`[cli-bridge] ${model} failed (${reason}), falling back to ${fallbackModel}`);
878
- debugLog("FALLBACK", `${model} → ${fallbackModel}`, { reason: isTimeout ? "timeout" : "error", primaryDuration: Math.round(primaryDuration / 1000) });
879
- // Notify the user via SSE that we're retrying with a different model
880
- if (sseHeadersSent) {
881
- res.write(`: fallback ${model} ${isTimeout ? "timed out" : "failed"} after ${Math.round(primaryDuration / 1000)}s, retrying with ${fallbackModel}\n\n`);
930
+ opts.warn(`[cli-bridge] ${model} failed (${reason}), trying fallback chain: ${fallbackChain.join(" → ")}`);
931
+
932
+ let chainSuccess = false;
933
+ for (const fallbackModel of fallbackChain) {
934
+ debugLog("FALLBACK", `${model} ${fallbackModel}`, { reason: isTimeout ? "timeout" : "error", primaryDuration: Math.round(primaryDuration / 1000), chain: fallbackChain });
935
+ if (sseHeadersSent) {
936
+ res.write(`: fallback — trying ${fallbackModel}\n\n`);
937
+ }
938
+ const fallbackStart = Date.now();
939
+ try {
940
+ result = await routeToCliRunner(fallbackModel, cleanMessages, effectiveTimeout, routeOpts);
941
+ const fbCompTokens = estimateTokens(result.content ?? "");
942
+ metrics.recordRequest(fallbackModel, Date.now() - fallbackStart, true, estPromptTokens, fbCompTokens, promptPreview);
943
+ metrics.recordFallback(model, fallbackModel, isTimeout ? "timeout" : "error", primaryDuration, true);
944
+ usedModel = fallbackModel;
945
+ debugLog("FALLBACK-OK", `${fallbackModel} succeeded in ${((Date.now() - fallbackStart) / 1000).toFixed(1)}s`, { toolCalls: result.tool_calls?.length ?? 0 });
946
+ opts.log(`[cli-bridge] fallback to ${fallbackModel} succeeded`);
947
+ chainSuccess = true;
948
+ break;
949
+ } catch (fallbackErr) {
950
+ const fbDuration = Date.now() - fallbackStart;
951
+ metrics.recordRequest(fallbackModel, fbDuration, false, estPromptTokens, undefined, promptPreview);
952
+ metrics.recordFallback(model, fallbackModel, isTimeout ? "timeout" : "error", primaryDuration, false);
953
+ const fallbackMsg = (fallbackErr as Error).message;
954
+ debugLog("FALLBACK-FAIL", `${fallbackModel} failed after ${(fbDuration / 1000).toFixed(1)}s`, { error: fallbackMsg.slice(0, 150) });
955
+ opts.warn(`[cli-bridge] fallback ${fallbackModel} failed: ${fallbackMsg.slice(0, 100)}`);
956
+ // Continue to next fallback in chain
957
+ }
882
958
  }
883
- const fallbackStart = Date.now();
884
- try {
885
- result = await routeToCliRunner(fallbackModel, cleanMessages, effectiveTimeout, routeOpts);
886
- const fbCompTokens = estimateTokens(result.content ?? "");
887
- metrics.recordRequest(fallbackModel, Date.now() - fallbackStart, true, estPromptTokens, fbCompTokens, promptPreview);
888
- metrics.recordFallback(model, fallbackModel, isTimeout ? "timeout" : "error", primaryDuration, true);
889
- usedModel = fallbackModel;
890
- opts.log(`[cli-bridge] fallback to ${fallbackModel} succeeded (response will report original model: ${model})`);
891
- } catch (fallbackErr) {
892
- metrics.recordRequest(fallbackModel, Date.now() - fallbackStart, false, estPromptTokens, undefined, promptPreview);
893
- metrics.recordFallback(model, fallbackModel, isTimeout ? "timeout" : "error", primaryDuration, false);
894
- const fallbackMsg = (fallbackErr as Error).message;
895
- opts.warn(`[cli-bridge] fallback ${fallbackModel} also failed: ${fallbackMsg}`);
959
+
960
+ if (!chainSuccess) {
961
+ const chainStr = fallbackChain.join(", ");
896
962
  if (sseHeadersSent) {
897
- res.write(`data: ${JSON.stringify({ error: { message: `${model}: ${msg} | fallback ${fallbackModel}: ${fallbackMsg}`, type: "cli_error" } })}\n\n`);
963
+ res.write(`data: ${JSON.stringify({ error: { message: `${model} and all fallbacks (${chainStr}) failed`, type: "cli_error" } })}\n\n`);
898
964
  res.write("data: [DONE]\n\n");
899
965
  res.end();
900
966
  } else {
901
967
  res.writeHead(500, { "Content-Type": "application/json" });
902
- res.end(JSON.stringify({ error: { message: `${model}: ${msg} | fallback ${fallbackModel}: ${fallbackMsg}`, type: "cli_error" } }));
968
+ res.end(JSON.stringify({ error: { message: `${model} and all fallbacks (${chainStr}) failed`, type: "cli_error" } }));
903
969
  }
904
970
  return;
905
971
  }
@@ -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,211 @@ 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
+ // Newest on top — prepend lines in reverse order
698
+ var html = '';
699
+ lines.forEach(function(line) {
700
+ html = colorLogLine(line.replace(/</g, '&lt;').replace(/>/g, '&gt;')) + '\\n' + html;
701
+ logLineCount++;
702
+ });
703
+ logOutput.innerHTML = html + logOutput.innerHTML;
704
+ // Trim old lines from bottom
705
+ while (logLineCount > MAX_LOG_LINES) {
706
+ var idx = logOutput.innerHTML.lastIndexOf('\\n');
707
+ if (idx === -1) break;
708
+ logOutput.innerHTML = logOutput.innerHTML.slice(0, idx);
709
+ logLineCount--;
710
+ }
711
+ }
484
712
 
485
- ${recentHtml}
713
+ function connectLog() {
714
+ if (logSource) logSource.close();
715
+ logSource = new EventSource('/api/logs/stream');
716
+ logSource.onopen = function() {
717
+ if (logStatus) { logStatus.textContent = 'connected'; logStatus.className = 'badge badge-ok'; }
718
+ };
719
+ logSource.onmessage = function(e) { appendLog(e.data); };
720
+ logSource.onerror = function() {
721
+ if (logStatus) { logStatus.textContent = 'disconnected'; logStatus.className = 'badge badge-error'; }
722
+ // Reconnect after 3s
723
+ setTimeout(function() { if (!logPaused) connectLog(); }, 3000);
724
+ };
725
+ }
486
726
 
487
- <div class="two-col">
488
- <div>${fallbackHtml}</div>
489
- <div>${sessionsHtml}</div>
490
- </div>
727
+ function toggleLogPause() {
728
+ logPaused = !logPaused;
729
+ var btn = document.getElementById('log-pause-btn');
730
+ if (logPaused) {
731
+ if (logSource) logSource.close();
732
+ if (btn) btn.textContent = 'Resume';
733
+ if (logStatus) { logStatus.textContent = 'paused'; logStatus.className = 'badge badge-warn'; }
734
+ } else {
735
+ connectLog();
736
+ if (btn) btn.textContent = 'Pause';
737
+ }
738
+ }
491
739
 
492
- ${timeoutHtml}
740
+ function clearLogs() {
741
+ if (logOutput) { logOutput.innerHTML = ''; logLineCount = 0; }
742
+ }
493
743
 
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>
744
+ // Auto-scroll detection
745
+ if (logOutput) {
746
+ logOutput.addEventListener('scroll', function() {
747
+ autoScroll = (logOutput.scrollTop + logOutput.clientHeight >= logOutput.scrollHeight - 50);
748
+ });
749
+ }
510
750
 
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>
751
+ // Start log connection
752
+ connectLog();
753
+ </script>
512
754
  </body>
513
755
  </html>`;
514
756
  }
@@ -71,11 +71,15 @@ describe("config.ts exports", () => {
71
71
  expect(DEFAULT_MODEL_TIMEOUTS["gemini-api/gemini-2.5-flash"]).toBe(180_000);
72
72
  });
73
73
 
74
- it("exports model fallback chains", () => {
75
- expect(DEFAULT_MODEL_FALLBACKS["cli-claude/claude-sonnet-4-6"]).toBe("cli-claude/claude-haiku-4-5");
76
- expect(DEFAULT_MODEL_FALLBACKS["cli-claude/claude-opus-4-6"]).toBe("cli-claude/claude-sonnet-4-6");
77
- expect(DEFAULT_MODEL_FALLBACKS["cli-gemini/gemini-2.5-pro"]).toBe("cli-gemini/gemini-2.5-flash");
78
- expect(DEFAULT_MODEL_FALLBACKS["gemini-api/gemini-2.5-pro"]).toBe("gemini-api/gemini-2.5-flash");
74
+ it("exports model fallback chains as arrays", () => {
75
+ expect(DEFAULT_MODEL_FALLBACKS["cli-claude/claude-sonnet-4-6"]).toEqual(["cli-claude/claude-haiku-4-5", "cli-gemini/gemini-2.5-flash", "openai-codex/gpt-5.3-codex"]);
76
+ expect(DEFAULT_MODEL_FALLBACKS["cli-claude/claude-opus-4-6"]).toContain("cli-claude/claude-sonnet-4-6");
77
+ expect(DEFAULT_MODEL_FALLBACKS["cli-gemini/gemini-2.5-pro"]).toContain("cli-gemini/gemini-2.5-flash");
78
+ expect(DEFAULT_MODEL_FALLBACKS["gemini-api/gemini-2.5-pro"]).toContain("gemini-api/gemini-2.5-flash");
79
+ // All values must be arrays
80
+ for (const chain of Object.values(DEFAULT_MODEL_FALLBACKS)) {
81
+ expect(Array.isArray(chain)).toBe(true);
82
+ }
79
83
  });
80
84
 
81
85
  it("exports path constants rooted in ~/.openclaw", () => {