@elvatis_com/openclaw-cli-bridge-elvatis 2.8.5 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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.8.5`
5
+ **Current version:** `2.9.0`
6
6
 
7
7
  ---
8
8
 
@@ -406,6 +406,16 @@ npm run ci # lint + typecheck + test
406
406
 
407
407
  ## Changelog
408
408
 
409
+ ### v2.9.0
410
+ - **feat:** enhanced `/status` dashboard with 5 new panels:
411
+ - **Active Requests**: live in-flight requests with model, elapsed time, message/tool count, prompt preview
412
+ - **Recent Request Log**: last 20 requests with latency, success/fail, prompt preview, token counts
413
+ - **Fallback History**: last 10 fallback events with reason, timing, and outcome
414
+ - **Provider Sessions**: CLI session state (active/idle/expired), run count, timeout count
415
+ - **Timeout Configuration**: per-model base timeouts and dynamic scaling formula
416
+ - **feat:** auto-refresh reduced from 30s to 10s for more responsive monitoring
417
+ - **feat:** responsive two-column layout for fallback history and provider sessions
418
+
409
419
  ### v2.8.5
410
420
  - **fix:** sync `openclaw.plugin.json` configSchema defaults with code: Sonnet/Opus 300s to 420s, Haiku 90s to 120s. The schema `default` block was overriding `DEFAULT_MODEL_TIMEOUTS` via `cfg.modelTimeouts`, making all code-level timeout bumps ineffective.
411
421
 
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.8.5
71
+ **Version:** 2.9.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.8.5",
5
+ "version": "2.9.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.8.5",
3
+ "version": "2.9.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/metrics.ts CHANGED
@@ -23,11 +23,32 @@ export interface ModelMetrics {
23
23
  lastRequestAt: number | null;
24
24
  }
25
25
 
26
+ export interface RequestLogEntry {
27
+ timestamp: number;
28
+ model: string;
29
+ latencyMs: number;
30
+ success: boolean;
31
+ promptPreview: string;
32
+ promptTokens: number;
33
+ completionTokens: number;
34
+ }
35
+
36
+ export interface FallbackEvent {
37
+ timestamp: number;
38
+ originalModel: string;
39
+ fallbackModel: string;
40
+ reason: "timeout" | "error";
41
+ failedDurationMs: number;
42
+ fallbackSuccess: boolean;
43
+ }
44
+
26
45
  export interface MetricsSnapshot {
27
46
  startedAt: number;
28
47
  totalRequests: number;
29
48
  totalErrors: number;
30
49
  models: ModelMetrics[]; // sorted by requests desc
50
+ recentRequests: RequestLogEntry[];
51
+ fallbackHistory: FallbackEvent[];
31
52
  }
32
53
 
33
54
  // ── Token estimation ────────────────────────────────────────────────────────
@@ -42,6 +63,19 @@ export function estimateTokens(text: string): number {
42
63
  return Math.ceil(text.length / 4);
43
64
  }
44
65
 
66
+ // ── Circular buffer ─────────────────────────────────────────────────────────
67
+
68
+ class CircularBuffer<T> {
69
+ private items: T[] = [];
70
+ constructor(private capacity: number) {}
71
+ push(item: T): void {
72
+ if (this.items.length >= this.capacity) this.items.shift();
73
+ this.items.push(item);
74
+ }
75
+ toArray(): T[] { return [...this.items]; }
76
+ clear(): void { this.items.length = 0; }
77
+ }
78
+
45
79
  // ── Persistence format ──────────────────────────────────────────────────────
46
80
 
47
81
  interface PersistedMetrics {
@@ -57,6 +91,8 @@ class MetricsCollector {
57
91
  private data = new Map<string, ModelMetrics>();
58
92
  private flushTimer: ReturnType<typeof setTimeout> | null = null;
59
93
  private dirty = false;
94
+ private recentRequests = new CircularBuffer<RequestLogEntry>(20);
95
+ private fallbackEvents = new CircularBuffer<FallbackEvent>(10);
60
96
 
61
97
  constructor() {
62
98
  this.load();
@@ -68,6 +104,7 @@ class MetricsCollector {
68
104
  success: boolean,
69
105
  promptTokens?: number,
70
106
  completionTokens?: number,
107
+ promptPreview?: string,
71
108
  ): void {
72
109
  let entry = this.data.get(model);
73
110
  if (!entry) {
@@ -88,6 +125,15 @@ class MetricsCollector {
88
125
  if (promptTokens) entry.promptTokens += promptTokens;
89
126
  if (completionTokens) entry.completionTokens += completionTokens;
90
127
  entry.lastRequestAt = Date.now();
128
+ this.recentRequests.push({
129
+ timestamp: Date.now(),
130
+ model,
131
+ latencyMs: durationMs,
132
+ success,
133
+ promptPreview: promptPreview ?? "",
134
+ promptTokens: promptTokens ?? 0,
135
+ completionTokens: completionTokens ?? 0,
136
+ });
91
137
  this.scheduleSave();
92
138
  }
93
139
 
@@ -109,12 +155,33 @@ class MetricsCollector {
109
155
  totalRequests,
110
156
  totalErrors,
111
157
  models,
158
+ recentRequests: this.recentRequests.toArray(),
159
+ fallbackHistory: this.fallbackEvents.toArray(),
112
160
  };
113
161
  }
114
162
 
163
+ recordFallback(
164
+ originalModel: string,
165
+ fallbackModel: string,
166
+ reason: "timeout" | "error",
167
+ failedDurationMs: number,
168
+ fallbackSuccess: boolean,
169
+ ): void {
170
+ this.fallbackEvents.push({
171
+ timestamp: Date.now(),
172
+ originalModel,
173
+ fallbackModel,
174
+ reason,
175
+ failedDurationMs,
176
+ fallbackSuccess,
177
+ });
178
+ }
179
+
115
180
  reset(): void {
116
181
  this.startedAt = Date.now();
117
182
  this.data.clear();
183
+ this.recentRequests.clear();
184
+ this.fallbackEvents.clear();
118
185
  this.saveNow();
119
186
  }
120
187
 
@@ -31,8 +31,26 @@ import {
31
31
  DEFAULT_BITNET_SERVER_URL,
32
32
  BITNET_MAX_MESSAGES,
33
33
  BITNET_SYSTEM_PROMPT,
34
+ DEFAULT_MODEL_TIMEOUTS,
34
35
  } from "./config.js";
35
36
 
37
+ // ── Active request tracking ─────────────────────────────────────────────────
38
+
39
+ export interface ActiveRequest {
40
+ id: string;
41
+ model: string;
42
+ startedAt: number;
43
+ messageCount: number;
44
+ toolCount: number;
45
+ promptPreview: string;
46
+ }
47
+
48
+ const activeRequests = new Map<string, ActiveRequest>();
49
+
50
+ export function getActiveRequests(): ActiveRequest[] {
51
+ return [...activeRequests.values()];
52
+ }
53
+
36
54
  export type GrokCompleteOptions = Parameters<typeof grokComplete>[1];
37
55
  export type GrokCompleteStreamOptions = Parameters<typeof grokCompleteStream>[1];
38
56
  export type GrokCompleteResult = Awaited<ReturnType<typeof grokComplete>>;
@@ -276,7 +294,20 @@ async function handleRequest(
276
294
  { name: "ChatGPT", icon: "◉", expiry: expiry.chatgpt, loginCmd: "/chatgpt-login", ctx: opts.getChatGPTContext?.() ?? null },
277
295
  ];
278
296
 
279
- const html = renderStatusPage({ version, port: opts.port, providers, models: CLI_MODELS, modelCommands: opts.modelCommands, metrics: metrics.getMetrics() });
297
+ const html = renderStatusPage({
298
+ version, port: opts.port, providers, models: CLI_MODELS,
299
+ modelCommands: opts.modelCommands,
300
+ metrics: metrics.getMetrics(),
301
+ activeRequests: getActiveRequests(),
302
+ providerSessionsList: providerSessions.listSessions(),
303
+ timeoutConfig: {
304
+ defaults: { ...DEFAULT_MODEL_TIMEOUTS, ...(opts.modelTimeouts ?? {}) },
305
+ baseDefault: opts.timeoutMs ?? DEFAULT_PROXY_TIMEOUT_MS,
306
+ maxEffective: MAX_EFFECTIVE_TIMEOUT_MS,
307
+ perExtraMsg: TIMEOUT_PER_EXTRA_MSG_MS,
308
+ perTool: TIMEOUT_PER_TOOL_MS,
309
+ },
310
+ });
280
311
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
281
312
  res.end(html);
282
313
  return;
@@ -354,6 +385,13 @@ async function handleRequest(
354
385
  const id = `chatcmpl-cli-${randomBytes(6).toString("hex")}`;
355
386
  const created = Math.floor(Date.now() / 1000);
356
387
 
388
+ // Extract prompt preview from last user message for dashboard
389
+ const lastUserMsg = [...cleanMessages].reverse().find(m => m.role === "user");
390
+ const promptPreview = typeof lastUserMsg?.content === "string" ? lastUserMsg.content.slice(0, 80) : "";
391
+
392
+ // Track active request for dashboard
393
+ activeRequests.set(id, { id, model, startedAt: Date.now(), messageCount: cleanMessages.length, toolCount: tools?.length ?? 0, promptPreview });
394
+
357
395
  // ── Grok web-session routing ──────────────────────────────────────────────
358
396
  if (model.startsWith("web-grok/")) {
359
397
  let grokCtx = opts.getGrokContext?.() ?? null;
@@ -787,7 +825,7 @@ async function handleRequest(
787
825
  try {
788
826
  result = await routeToCliRunner(model, cleanMessages, effectiveTimeout, routeOpts);
789
827
  const estCompletionTokens = estimateTokens(result.content ?? "");
790
- metrics.recordRequest(model, Date.now() - cliStart, true, estPromptTokens, estCompletionTokens);
828
+ metrics.recordRequest(model, Date.now() - cliStart, true, estPromptTokens, estCompletionTokens, promptPreview);
791
829
  providerSessions.recordRun(session.id, false);
792
830
  } catch (err) {
793
831
  const primaryDuration = Date.now() - cliStart;
@@ -798,18 +836,20 @@ async function handleRequest(
798
836
  providerSessions.recordRun(session.id, isTimeout);
799
837
  const fallbackModel = opts.modelFallbacks?.[model];
800
838
  if (fallbackModel) {
801
- metrics.recordRequest(model, primaryDuration, false, estPromptTokens);
839
+ metrics.recordRequest(model, primaryDuration, false, estPromptTokens, undefined, promptPreview);
802
840
  const reason = isTimeout ? `timeout by supervisor, session=${session.id} preserved` : msg;
803
841
  opts.warn(`[cli-bridge] ${model} failed (${reason}), falling back to ${fallbackModel}`);
804
842
  const fallbackStart = Date.now();
805
843
  try {
806
844
  result = await routeToCliRunner(fallbackModel, cleanMessages, effectiveTimeout, routeOpts);
807
845
  const fbCompTokens = estimateTokens(result.content ?? "");
808
- metrics.recordRequest(fallbackModel, Date.now() - fallbackStart, true, estPromptTokens, fbCompTokens);
846
+ metrics.recordRequest(fallbackModel, Date.now() - fallbackStart, true, estPromptTokens, fbCompTokens, promptPreview);
847
+ metrics.recordFallback(model, fallbackModel, isTimeout ? "timeout" : "error", primaryDuration, true);
809
848
  usedModel = fallbackModel;
810
849
  opts.log(`[cli-bridge] fallback to ${fallbackModel} succeeded (response will report original model: ${model})`);
811
850
  } catch (fallbackErr) {
812
- metrics.recordRequest(fallbackModel, Date.now() - fallbackStart, false, estPromptTokens);
851
+ metrics.recordRequest(fallbackModel, Date.now() - fallbackStart, false, estPromptTokens, undefined, promptPreview);
852
+ metrics.recordFallback(model, fallbackModel, isTimeout ? "timeout" : "error", primaryDuration, false);
813
853
  const fallbackMsg = (fallbackErr as Error).message;
814
854
  opts.warn(`[cli-bridge] fallback ${fallbackModel} also failed: ${fallbackMsg}`);
815
855
  if (sseHeadersSent) {
@@ -823,7 +863,7 @@ async function handleRequest(
823
863
  return;
824
864
  }
825
865
  } else {
826
- metrics.recordRequest(model, primaryDuration, false, estPromptTokens);
866
+ metrics.recordRequest(model, primaryDuration, false, estPromptTokens, undefined, promptPreview);
827
867
  opts.warn(`[cli-bridge] CLI error for ${model}: ${msg}`);
828
868
  if (sseHeadersSent) {
829
869
  res.write(`data: ${JSON.stringify({ error: { message: msg, type: "cli_error" } })}\n\n`);
@@ -838,6 +878,7 @@ async function handleRequest(
838
878
  } finally {
839
879
  if (keepaliveInterval) clearInterval(keepaliveInterval);
840
880
  cleanupMediaFiles(mediaFiles);
881
+ activeRequests.delete(id);
841
882
  }
842
883
 
843
884
  const hasToolCalls = !!(result.tool_calls?.length);
@@ -6,7 +6,9 @@
6
6
  */
7
7
 
8
8
  import type { BrowserContext } from "playwright";
9
- import type { MetricsSnapshot } from "./metrics.js";
9
+ import type { MetricsSnapshot, RequestLogEntry, FallbackEvent } from "./metrics.js";
10
+ import type { ProviderSession } from "./provider-sessions.js";
11
+ import type { ActiveRequest } from "./proxy-server.js";
10
12
 
11
13
  export interface StatusProvider {
12
14
  name: string;
@@ -16,15 +18,24 @@ export interface StatusProvider {
16
18
  ctx: BrowserContext | null;
17
19
  }
18
20
 
21
+ export interface TimeoutConfigInfo {
22
+ defaults: Record<string, number>;
23
+ baseDefault: number;
24
+ maxEffective: number;
25
+ perExtraMsg: number;
26
+ perTool: number;
27
+ }
28
+
19
29
  export interface StatusTemplateOptions {
20
30
  version: string;
21
31
  port: number;
22
32
  providers: StatusProvider[];
23
33
  models: Array<{ id: string; name: string; contextWindow: number; maxTokens: number }>;
24
- /** Maps model ID → slash command name (e.g. "openai-codex/gpt-5.3-codex" → "/cli-codex") */
25
34
  modelCommands?: Record<string, string>;
26
- /** In-memory metrics snapshot — optional for backward compat */
27
35
  metrics?: MetricsSnapshot;
36
+ activeRequests?: ActiveRequest[];
37
+ providerSessionsList?: ProviderSession[];
38
+ timeoutConfig?: TimeoutConfigInfo;
28
39
  }
29
40
 
30
41
  function statusBadge(p: StatusProvider): { label: string; color: string; dot: string } {
@@ -44,14 +55,14 @@ function formatDuration(ms: number): string {
44
55
  }
45
56
 
46
57
  function formatTokens(n: number): string {
47
- if (n === 0) return "";
58
+ if (n === 0) return "\u2014";
48
59
  if (n < 1000) return String(n);
49
60
  if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
50
61
  return `${(n / 1_000_000).toFixed(2)}M`;
51
62
  }
52
63
 
53
64
  function timeAgo(epochMs: number | null): string {
54
- if (!epochMs) return "";
65
+ if (!epochMs) return "\u2014";
55
66
  const diff = Date.now() - epochMs;
56
67
  if (diff < 60_000) return "just now";
57
68
  if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
@@ -75,6 +86,223 @@ function escapeHtml(s: string): string {
75
86
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
76
87
  }
77
88
 
89
+ function truncateId(id: string): string {
90
+ if (id.length <= 20) return id;
91
+ return id.slice(0, 8) + "\u2026" + id.slice(-8);
92
+ }
93
+
94
+ // ── Active Requests ────────────────────────────────────────────────────────
95
+
96
+ function renderActiveRequests(active: ActiveRequest[]): string {
97
+ if (active.length === 0) {
98
+ return `
99
+ <div class="card">
100
+ <div class="card-header">Active Requests <span class="badge badge-ok">0</span></div>
101
+ <div class="empty-state">No active requests</div>
102
+ </div>`;
103
+ }
104
+
105
+ const rows = active.map(r => {
106
+ const elapsed = Date.now() - r.startedAt;
107
+ const elapsedClass = elapsed > 300_000 ? ' style="color:#ef4444;font-weight:600"' : elapsed > 120_000 ? ' style="color:#f59e0b"' : "";
108
+ return `
109
+ <tr>
110
+ <td class="metrics-cell"><span class="pulse-dot"></span></td>
111
+ <td class="metrics-cell"><code class="model-id">${escapeHtml(r.model)}</code></td>
112
+ <td class="metrics-cell" style="text-align:right"${elapsedClass}>${formatDuration(elapsed)}</td>
113
+ <td class="metrics-cell" style="text-align:right">${r.messageCount}</td>
114
+ <td class="metrics-cell" style="text-align:right">${r.toolCount}</td>
115
+ <td class="metrics-cell prompt-preview">${escapeHtml(r.promptPreview || "\u2014")}</td>
116
+ </tr>`;
117
+ }).join("");
118
+
119
+ return `
120
+ <div class="card">
121
+ <div class="card-header">Active Requests <span class="badge badge-active">${active.length}</span></div>
122
+ <table class="metrics-table">
123
+ <thead>
124
+ <tr class="table-head">
125
+ <th class="metrics-th" style="width:24px"></th>
126
+ <th class="metrics-th" style="text-align:left">Model</th>
127
+ <th class="metrics-th" style="text-align:right">Elapsed</th>
128
+ <th class="metrics-th" style="text-align:right">Msgs</th>
129
+ <th class="metrics-th" style="text-align:right">Tools</th>
130
+ <th class="metrics-th" style="text-align:left">Prompt</th>
131
+ </tr>
132
+ </thead>
133
+ <tbody>${rows}</tbody>
134
+ </table>
135
+ </div>`;
136
+ }
137
+
138
+ // ── Recent Request Log ────────────────────────────────────────────────────���
139
+
140
+ function renderRecentRequestLog(entries: RequestLogEntry[]): string {
141
+ if (entries.length === 0) {
142
+ return `
143
+ <div class="card">
144
+ <div class="card-header">Recent Requests</div>
145
+ <div class="empty-state">No requests recorded yet</div>
146
+ </div>`;
147
+ }
148
+
149
+ const rows = [...entries].reverse().map(r => {
150
+ const statusIcon = r.success
151
+ ? '<span style="color:#22c55e">&#10003;</span>'
152
+ : '<span style="color:#ef4444">&#10007;</span>';
153
+ return `
154
+ <tr>
155
+ <td class="metrics-cell" style="color:#6b7280;font-size:12px;white-space:nowrap">${timeAgo(r.timestamp)}</td>
156
+ <td class="metrics-cell"><code class="model-id">${escapeHtml(r.model)}</code></td>
157
+ <td class="metrics-cell" style="text-align:right">${formatDuration(r.latencyMs)}</td>
158
+ <td class="metrics-cell" style="text-align:center">${statusIcon}</td>
159
+ <td class="metrics-cell prompt-preview">${escapeHtml(r.promptPreview || "\u2014")}</td>
160
+ <td class="metrics-cell" style="text-align:right;color:#6b7280;font-size:12px">${formatTokens(r.promptTokens)} / ${formatTokens(r.completionTokens)}</td>
161
+ </tr>`;
162
+ }).join("");
163
+
164
+ return `
165
+ <div class="card">
166
+ <div class="card-header">Recent Requests <span style="color:#4b5563;font-weight:400">(last ${entries.length})</span></div>
167
+ <table class="metrics-table">
168
+ <thead>
169
+ <tr class="table-head">
170
+ <th class="metrics-th" style="text-align:left">Time</th>
171
+ <th class="metrics-th" style="text-align:left">Model</th>
172
+ <th class="metrics-th" style="text-align:right">Latency</th>
173
+ <th class="metrics-th" style="text-align:center">OK</th>
174
+ <th class="metrics-th" style="text-align:left">Prompt</th>
175
+ <th class="metrics-th" style="text-align:right">Tokens (in/out)</th>
176
+ </tr>
177
+ </thead>
178
+ <tbody>${rows}</tbody>
179
+ </table>
180
+ </div>`;
181
+ }
182
+
183
+ // ── Fallback History ───────────────────────────────────────────────────────
184
+
185
+ function renderFallbackHistory(events: FallbackEvent[]): string {
186
+ if (events.length === 0) {
187
+ return `
188
+ <div class="card">
189
+ <div class="card-header">Fallback History</div>
190
+ <div class="empty-state">No fallback events</div>
191
+ </div>`;
192
+ }
193
+
194
+ const rows = [...events].reverse().map(e => {
195
+ const reasonBadge = e.reason === "timeout"
196
+ ? '<span class="badge badge-warn">timeout</span>'
197
+ : '<span class="badge badge-error">error</span>';
198
+ const outcomeBadge = e.fallbackSuccess
199
+ ? '<span class="badge badge-ok">success</span>'
200
+ : '<span class="badge badge-error">failed</span>';
201
+ return `
202
+ <tr>
203
+ <td class="metrics-cell" style="color:#6b7280;font-size:12px;white-space:nowrap">${timeAgo(e.timestamp)}</td>
204
+ <td class="metrics-cell"><code class="model-id">${escapeHtml(e.originalModel)}</code></td>
205
+ <td class="metrics-cell"><code class="model-id">${escapeHtml(e.fallbackModel)}</code></td>
206
+ <td class="metrics-cell">${reasonBadge}</td>
207
+ <td class="metrics-cell" style="text-align:right">${formatDuration(e.failedDurationMs)}</td>
208
+ <td class="metrics-cell">${outcomeBadge}</td>
209
+ </tr>`;
210
+ }).join("");
211
+
212
+ return `
213
+ <div class="card">
214
+ <div class="card-header">Fallback History <span style="color:#4b5563;font-weight:400">(last ${events.length})</span></div>
215
+ <table class="metrics-table">
216
+ <thead>
217
+ <tr class="table-head">
218
+ <th class="metrics-th" style="text-align:left">Time</th>
219
+ <th class="metrics-th" style="text-align:left">Original Model</th>
220
+ <th class="metrics-th" style="text-align:left">Fallback Model</th>
221
+ <th class="metrics-th" style="text-align:left">Reason</th>
222
+ <th class="metrics-th" style="text-align:right">Failed After</th>
223
+ <th class="metrics-th" style="text-align:left">Outcome</th>
224
+ </tr>
225
+ </thead>
226
+ <tbody>${rows}</tbody>
227
+ </table>
228
+ </div>`;
229
+ }
230
+
231
+ // ── Provider Sessions ──────────────────────────────────────────────────────
232
+
233
+ function renderProviderSessions(sessions: ProviderSession[]): string {
234
+ if (sessions.length === 0) {
235
+ return `
236
+ <div class="card">
237
+ <div class="card-header">Provider Sessions</div>
238
+ <div class="empty-state">No active sessions</div>
239
+ </div>`;
240
+ }
241
+
242
+ const sorted = [...sessions].sort((a, b) => b.updatedAt - a.updatedAt);
243
+ const rows = sorted.map(s => {
244
+ const stateColor = s.state === "active" ? "#22c55e" : s.state === "idle" ? "#3b82f6" : "#6b7280";
245
+ const stateBadge = `<span class="badge" style="background:${stateColor}22;color:${stateColor};border-color:${stateColor}44">${s.state}</span>`;
246
+ const timeoutWarn = s.timeoutCount > 0 ? ` <span style="color:#ef4444;font-size:11px">(${s.timeoutCount} timeouts)</span>` : "";
247
+ return `
248
+ <tr>
249
+ <td class="metrics-cell" style="font-family:monospace;font-size:12px;color:#9ca3af">${truncateId(s.id)}</td>
250
+ <td class="metrics-cell"><code class="model-id">${escapeHtml(s.modelAlias)}</code></td>
251
+ <td class="metrics-cell">${stateBadge}</td>
252
+ <td class="metrics-cell" style="text-align:right">${s.runCount}${timeoutWarn}</td>
253
+ <td class="metrics-cell" style="text-align:right;color:#6b7280;font-size:12px">${timeAgo(s.updatedAt)}</td>
254
+ </tr>`;
255
+ }).join("");
256
+
257
+ return `
258
+ <div class="card">
259
+ <div class="card-header">Provider Sessions <span style="color:#4b5563;font-weight:400">(${sessions.length})</span></div>
260
+ <table class="metrics-table">
261
+ <thead>
262
+ <tr class="table-head">
263
+ <th class="metrics-th" style="text-align:left">Session ID</th>
264
+ <th class="metrics-th" style="text-align:left">Model</th>
265
+ <th class="metrics-th" style="text-align:left">State</th>
266
+ <th class="metrics-th" style="text-align:right">Runs</th>
267
+ <th class="metrics-th" style="text-align:right">Last Activity</th>
268
+ </tr>
269
+ </thead>
270
+ <tbody>${rows}</tbody>
271
+ </table>
272
+ </div>`;
273
+ }
274
+
275
+ // ── Timeout Configuration ──────────────────────────────────────────────────
276
+
277
+ function renderTimeoutConfig(config: TimeoutConfigInfo): string {
278
+ const entries = Object.entries(config.defaults).sort(([a], [b]) => a.localeCompare(b));
279
+ const rows = entries.map(([model, ms]) => {
280
+ return `
281
+ <tr>
282
+ <td class="metrics-cell"><code class="model-id">${escapeHtml(model)}</code></td>
283
+ <td class="metrics-cell" style="text-align:right">${Math.round(ms / 1000)}s</td>
284
+ </tr>`;
285
+ }).join("");
286
+
287
+ return `
288
+ <div class="card">
289
+ <div class="card-header">Timeout Configuration</div>
290
+ <div style="padding:12px 16px;color:#9ca3af;font-size:13px;border-bottom:1px solid #1f2335">
291
+ <strong style="color:#d1d5db">Formula:</strong> base timeout + (msgs beyond 10 &times; ${config.perExtraMsg / 1000}s) + (tools &times; ${config.perTool / 1000}s), capped at ${Math.round(config.maxEffective / 1000)}s
292
+ <br><span style="color:#6b7280">Default base: ${Math.round(config.baseDefault / 1000)}s</span>
293
+ </div>
294
+ <table class="metrics-table">
295
+ <thead>
296
+ <tr class="table-head">
297
+ <th class="metrics-th" style="text-align:left">Model</th>
298
+ <th class="metrics-th" style="text-align:right">Base Timeout</th>
299
+ </tr>
300
+ </thead>
301
+ <tbody>${rows}</tbody>
302
+ </table>
303
+ </div>`;
304
+ }
305
+
78
306
  // ── Metrics sections ────────────────────────────────────────────────────────
79
307
 
80
308
  function renderMetricsSection(m: MetricsSnapshot): string {
@@ -112,7 +340,7 @@ function renderMetricsSection(m: MetricsSnapshot): string {
112
340
  const modErrorRate = mod.requests > 0 ? ((mod.errors / mod.requests) * 100).toFixed(1) : "0.0";
113
341
  return `
114
342
  <tr>
115
- <td class="metrics-cell"><code style="color:#93c5fd">${escapeHtml(mod.model)}</code></td>
343
+ <td class="metrics-cell"><code class="model-id">${escapeHtml(mod.model)}</code></td>
116
344
  <td class="metrics-cell" style="text-align:right">${mod.requests}</td>
117
345
  <td class="metrics-cell" style="text-align:right;color:${mod.errors > 0 ? '#ef4444' : '#6b7280'}">${mod.errors} <span style="color:#6b7280;font-size:11px">(${modErrorRate}%)</span></td>
118
346
  <td class="metrics-cell" style="text-align:right">${formatDuration(avgLatency)}</td>
@@ -127,7 +355,7 @@ function renderMetricsSection(m: MetricsSnapshot): string {
127
355
  <div class="card-header">Per-Model Stats</div>
128
356
  <table class="metrics-table">
129
357
  <thead>
130
- <tr style="background:#13151f">
358
+ <tr class="table-head">
131
359
  <th class="metrics-th" style="text-align:left">Model</th>
132
360
  <th class="metrics-th" style="text-align:right">Requests</th>
133
361
  <th class="metrics-th" style="text-align:right">Errors</th>
@@ -150,7 +378,7 @@ export function renderStatusPage(opts: StatusTemplateOptions): string {
150
378
  const badge = statusBadge(p);
151
379
  const expiryText = p.expiry
152
380
  ? p.expiry.replace(/[⚠️🚨✅🕐]/gu, "").trim()
153
- : `Not logged in run <code>${p.loginCmd}</code>`;
381
+ : `Not logged in \u2014 run <code>${p.loginCmd}</code>`;
154
382
  return `
155
383
  <tr>
156
384
  <td style="padding:12px 16px;font-weight:600;font-size:15px">${p.icon} ${p.name}</td>
@@ -174,10 +402,15 @@ export function renderStatusPage(opts: StatusTemplateOptions): string {
174
402
  items.map(m => {
175
403
  const cmd = cmds[m.id];
176
404
  const cmdBadge = cmd ? `<span style="color:#6b7280;font-size:11px;margin-left:8px">${cmd}</span>` : "";
177
- return `<li style="margin:2px 0;font-size:13px;color:#d1d5db"><code style="color:#93c5fd">${m.id}</code>${cmdBadge}</li>`;
405
+ return `<li style="margin:2px 0;font-size:13px;color:#d1d5db"><code class="model-id">${m.id}</code>${cmdBadge}</li>`;
178
406
  }).join("");
179
407
 
180
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) : "";
181
414
 
182
415
  return `<!DOCTYPE html>
183
416
  <html lang="en">
@@ -185,20 +418,24 @@ export function renderStatusPage(opts: StatusTemplateOptions): string {
185
418
  <meta charset="UTF-8">
186
419
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
187
420
  <title>CLI Bridge Status</title>
188
- <meta http-equiv="refresh" content="30">
421
+ <meta http-equiv="refresh" content="10">
189
422
  <style>
190
423
  * { box-sizing: border-box; margin: 0; padding: 0; }
191
424
  body { background: #0f1117; color: #e5e7eb; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; min-height: 100vh; padding: 32px 24px; }
192
425
  h1 { font-size: 22px; font-weight: 700; color: #f9fafb; margin-bottom: 4px; }
193
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; }
194
429
  .card { background: #1a1d27; border: 1px solid #2d3148; border-radius: 12px; overflow: hidden; margin-bottom: 24px; }
195
430
  .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; }
196
431
  table { width: 100%; border-collapse: collapse; }
197
432
  tr:not(:last-child) td { border-bottom: 1px solid #1f2335; }
433
+ .table-head { background: #13151f; }
198
434
  .models { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
199
435
  ul { list-style: none; padding: 12px 16px; }
200
436
  .footer { color: #374151; font-size: 12px; text-align: center; margin-top: 16px; }
201
437
  code { background: #1e2130; padding: 1px 5px; border-radius: 4px; }
438
+ .model-id { color: #93c5fd; }
202
439
  .summary-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px; }
203
440
  .summary-card { background: #1a1d27; border: 1px solid #2d3148; border-radius: 12px; padding: 20px 16px; text-align: center; }
204
441
  .summary-value { font-size: 28px; font-weight: 700; color: #f9fafb; margin-bottom: 4px; }
@@ -206,17 +443,31 @@ export function renderStatusPage(opts: StatusTemplateOptions): string {
206
443
  .metrics-table { width: 100%; border-collapse: collapse; }
207
444
  .metrics-th { padding: 10px 16px; font-size: 12px; color: #4b5563; font-weight: 600; }
208
445
  .metrics-cell { padding: 10px 16px; font-size: 13px; }
446
+ .empty-state { padding: 24px 16px; color: #4b5563; text-align: center; font-style: italic; font-size: 13px; }
447
+ .prompt-preview { max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #9ca3af; font-family: monospace; font-size: 12px; }
448
+ .badge { display: inline-block; border-radius: 6px; padding: 2px 8px; font-size: 11px; font-weight: 600; border: 1px solid transparent; }
449
+ .badge-ok { background: #22c55e22; color: #22c55e; border-color: #22c55e44; }
450
+ .badge-warn { background: #f59e0b22; color: #f59e0b; border-color: #f59e0b44; }
451
+ .badge-error { background: #ef444422; color: #ef4444; border-color: #ef444444; }
452
+ .badge-active { background: #3b82f622; color: #3b82f6; border-color: #3b82f644; }
453
+ .pulse-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #22c55e; animation: pulse 1.5s ease-in-out infinite; }
454
+ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
455
+ .two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
456
+ @media (max-width: 768px) {
457
+ .summary-grid { grid-template-columns: repeat(2, 1fr); }
458
+ .models, .two-col { grid-template-columns: 1fr; }
459
+ }
209
460
  </style>
210
461
  </head>
211
462
  <body>
212
463
  <h1>CLI Bridge</h1>
213
- <p class="subtitle">v${version} &nbsp;&middot;&nbsp; Port ${port} &nbsp;&middot;&nbsp; Auto-refreshes every 30s</p>
464
+ <p class="subtitle">v${version} &middot; Port ${port} &middot; Auto-refreshes every 10s &middot; <a href="/status">\u21bb Refresh</a></p>
214
465
 
215
466
  <div class="card">
216
467
  <div class="card-header">Web Session Providers</div>
217
468
  <table>
218
469
  <thead>
219
- <tr style="background:#13151f">
470
+ <tr class="table-head">
220
471
  <th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Provider</th>
221
472
  <th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Status</th>
222
473
  <th style="padding:10px 16px;text-align:left;font-size:12px;color:#4b5563;font-weight:600">Session</th>
@@ -229,6 +480,17 @@ export function renderStatusPage(opts: StatusTemplateOptions): string {
229
480
 
230
481
  ${metricsHtml}
231
482
 
483
+ ${activeHtml}
484
+
485
+ ${recentHtml}
486
+
487
+ <div class="two-col">
488
+ <div>${fallbackHtml}</div>
489
+ <div>${sessionsHtml}</div>
490
+ </div>
491
+
492
+ ${timeoutHtml}
493
+
232
494
  <div class="models">
233
495
  <div class="card">
234
496
  <div class="card-header">CLI Models (${cliModels.length})</div>
@@ -246,7 +508,7 @@ export function renderStatusPage(opts: StatusTemplateOptions): string {
246
508
  </div>
247
509
  </div>
248
510
 
249
- <p class="footer">openclaw-cli-bridge-elvatis v${version} &nbsp;&middot;&nbsp; <a href="/v1/models" style="color:#4b5563">/v1/models</a> &nbsp;&middot;&nbsp; <a href="/health" style="color:#4b5563">/health</a> &nbsp;&middot;&nbsp; <a href="/healthz" style="color:#4b5563">/healthz</a></p>
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>
250
512
  </body>
251
513
  </html>`;
252
514
  }