@elvatis_com/openclaw-cli-bridge-elvatis 2.8.5 → 2.10.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.10.0`
6
6
 
7
7
  ---
8
8
 
@@ -406,6 +406,28 @@ npm run ci # lint + typecheck + test
406
406
 
407
407
  ## Changelog
408
408
 
409
+ ### v2.10.0
410
+ - **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
+ - **fix:** reduce Sonnet base timeout 420s→300s, Opus 420s→360s — ensures fallback triggers faster for stuck CLI sessions
412
+ - **feat:** compact tool schema mode — when >8 tools, compress definitions to name+params only, cutting prompt size ~60%
413
+ - **feat:** stale-output detection — if CLI produces no stdout for 120s, SIGTERM early instead of waiting full timeout
414
+ - **feat:** adaptive message limits — reduce history from 20→12 messages when >10 tools to keep prompts smaller
415
+ - **feat:** file-based debug log at `~/.openclaw/cli-bridge/debug.log` — `tail -f` for real-time request lifecycle visibility
416
+ - **feat:** SSE progress comments every 30s so the webchat connection stays informed during long CLI runs
417
+ - **feat:** SSE fallback notification — visible comment when a model times out and the bridge retries with fallback
418
+ - **fix:** rescue tool_calls embedded inside content strings — handles models that wrap `{"tool_calls":[...]}` inside a `{"content":"..."}` wrapper
419
+ - **fix:** parse robustness — debug logging on all parse paths to diagnose raw-JSON-instead-of-tool-calls issues
420
+
421
+ ### v2.9.0
422
+ - **feat:** enhanced `/status` dashboard with 5 new panels:
423
+ - **Active Requests**: live in-flight requests with model, elapsed time, message/tool count, prompt preview
424
+ - **Recent Request Log**: last 20 requests with latency, success/fail, prompt preview, token counts
425
+ - **Fallback History**: last 10 fallback events with reason, timing, and outcome
426
+ - **Provider Sessions**: CLI session state (active/idle/expired), run count, timeout count
427
+ - **Timeout Configuration**: per-model base timeouts and dynamic scaling formula
428
+ - **feat:** auto-refresh reduced from 30s to 10s for more responsive monitoring
429
+ - **feat:** responsive two-column layout for fallback history and provider sessions
430
+
409
431
  ### v2.8.5
410
432
  - **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
433
 
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.10.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.10.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": [
@@ -43,8 +43,8 @@
43
43
  "type": "number"
44
44
  },
45
45
  "default": {
46
- "cli-claude/claude-opus-4-6": 420000,
47
- "cli-claude/claude-sonnet-4-6": 420000,
46
+ "cli-claude/claude-opus-4-6": 360000,
47
+ "cli-claude/claude-sonnet-4-6": 300000,
48
48
  "cli-claude/claude-haiku-4-5": 120000,
49
49
  "cli-gemini/gemini-2.5-pro": 300000,
50
50
  "cli-gemini/gemini-2.5-flash": 180000,
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.10.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
@@ -30,11 +30,15 @@ import {
30
30
  } from "./tool-protocol.js";
31
31
  import {
32
32
  MAX_MESSAGES,
33
+ MAX_MESSAGES_HEAVY_TOOLS,
34
+ TOOL_HEAVY_THRESHOLD,
33
35
  MAX_MSG_CHARS,
34
36
  DEFAULT_CLI_TIMEOUT_MS,
35
37
  TIMEOUT_GRACE_MS,
36
38
  MEDIA_TMP_DIR,
39
+ STALE_OUTPUT_TIMEOUT_MS,
37
40
  } from "./config.js";
41
+ import { debugLog } from "./debug-log.js";
38
42
 
39
43
  // ──────────────────────────────────────────────────────────────────────────────
40
44
  // Message formatting
@@ -69,13 +73,16 @@ export type { ToolDefinition, CliToolResult } from "./tool-protocol.js";
69
73
  * - role "tool": formatted as [Tool Result: name]
70
74
  * - role "assistant" with tool_calls: formatted as [Assistant Tool Call: name(args)]
71
75
  */
72
- export function formatPrompt(messages: ChatMessage[]): string {
76
+ export function formatPrompt(messages: ChatMessage[], toolCount = 0): string {
73
77
  if (messages.length === 0) return "";
74
78
 
79
+ // Reduce history when tool schemas dominate the prompt
80
+ const maxMsgs = toolCount > TOOL_HEAVY_THRESHOLD ? MAX_MESSAGES_HEAVY_TOOLS : MAX_MESSAGES;
81
+
75
82
  // Keep system message (if any) + last N non-system messages
76
83
  const system = messages.find((m) => m.role === "system");
77
84
  const nonSystem = messages.filter((m) => m.role !== "system");
78
- const recent = nonSystem.slice(-MAX_MESSAGES);
85
+ const recent = nonSystem.slice(-maxMsgs);
79
86
  const truncated = system ? [system, ...recent] : recent;
80
87
 
81
88
  // Single short user message — send bare (no wrapping needed)
@@ -331,17 +338,20 @@ export function runCli(
331
338
  let timedOut = false;
332
339
  let killTimer: ReturnType<typeof setTimeout> | null = null;
333
340
  let timeoutTimer: ReturnType<typeof setTimeout> | null = null;
341
+ let staleTimer: ReturnType<typeof setInterval> | null = null;
342
+ let lastOutputAt = Date.now();
334
343
 
335
344
  const clearTimers = () => {
336
345
  if (timeoutTimer) { clearTimeout(timeoutTimer); timeoutTimer = null; }
337
346
  if (killTimer) { clearTimeout(killTimer); killTimer = null; }
347
+ if (staleTimer) { clearInterval(staleTimer); staleTimer = null; }
338
348
  };
339
349
 
340
- // ── Timeout sequence: SIGTERM grace → SIGKILL ──────────────────────
341
- timeoutTimer = setTimeout(() => {
350
+ const doKill = (reason: string) => {
351
+ if (timedOut) return; // already killing
342
352
  timedOut = true;
343
- const elapsed = Math.round(timeoutMs / 1000);
344
- log(`[cli-bridge] timeout after ${elapsed}s for ${cmd}, sending SIGTERM`);
353
+ log(`[cli-bridge] ${reason} for ${cmd}, sending SIGTERM`);
354
+ debugLog("KILL", `${cmd} ${reason}`, { stdoutLen: stdout.length, stderrLen: stderr.length });
345
355
  proc.kill("SIGTERM");
346
356
 
347
357
  killTimer = setTimeout(() => {
@@ -350,14 +360,36 @@ export function runCli(
350
360
  proc.kill("SIGKILL");
351
361
  }
352
362
  }, TIMEOUT_GRACE_MS);
363
+ };
364
+
365
+ // ── Hard timeout: SIGTERM → grace → SIGKILL ──────────────────────────
366
+ timeoutTimer = setTimeout(() => {
367
+ doKill(`timeout after ${Math.round(timeoutMs / 1000)}s`);
353
368
  }, timeoutMs);
354
369
 
370
+ // ── Stale-output detection: kill if no stdout for STALE_OUTPUT_TIMEOUT_MS
371
+ if (STALE_OUTPUT_TIMEOUT_MS > 0) {
372
+ const checkInterval = 15_000; // check every 15s
373
+ staleTimer = setInterval(() => {
374
+ const silent = Date.now() - lastOutputAt;
375
+ if (silent >= STALE_OUTPUT_TIMEOUT_MS) {
376
+ doKill(`stale output — no stdout for ${Math.round(silent / 1000)}s`);
377
+ }
378
+ }, checkInterval);
379
+ }
380
+
355
381
  proc.stdin.write(prompt, "utf8", () => {
356
382
  proc.stdin.end();
357
383
  });
358
384
 
359
- proc.stdout.on("data", (d: Buffer) => { stdout += d.toString(); });
360
- proc.stderr.on("data", (d: Buffer) => { stderr += d.toString(); });
385
+ proc.stdout.on("data", (d: Buffer) => {
386
+ stdout += d.toString();
387
+ lastOutputAt = Date.now();
388
+ });
389
+ proc.stderr.on("data", (d: Buffer) => {
390
+ stderr += d.toString();
391
+ lastOutputAt = Date.now(); // stderr also counts as activity
392
+ });
361
393
 
362
394
  proc.on("close", (code) => {
363
395
  clearTimers();
@@ -770,8 +802,9 @@ export async function routeToCliRunner(
770
802
  timeoutMs: number,
771
803
  opts: RouteOptions = {}
772
804
  ): Promise<CliToolResult> {
773
- const prompt = formatPrompt(messages);
774
- const hasTools = !!(opts.tools?.length);
805
+ const toolCount = opts.tools?.length ?? 0;
806
+ const prompt = formatPrompt(messages, toolCount);
807
+ const hasTools = toolCount > 0;
775
808
 
776
809
  // Strip "vllm/" prefix if present — OpenClaw sends the full provider path
777
810
  // (e.g. "vllm/cli-claude/claude-sonnet-4-6") but the router only needs the
package/src/config.ts CHANGED
@@ -25,8 +25,13 @@ export const DEFAULT_PROXY_API_KEY = "cli-bridge";
25
25
  /** Default base timeout for CLI subprocess responses (ms). Scales dynamically. */
26
26
  export const DEFAULT_PROXY_TIMEOUT_MS = 300_000; // 5 min
27
27
 
28
- /** Maximum effective timeout after dynamic scaling (ms). */
29
- export const MAX_EFFECTIVE_TIMEOUT_MS = 900_000; // 15 min
28
+ /**
29
+ * Maximum effective timeout after dynamic scaling (ms).
30
+ * MUST be lower than the OpenClaw gateway's idleTimeoutSeconds (600s)
31
+ * so the bridge's own fallback fires BEFORE the gateway kills the request.
32
+ * 580s gives a 20s safety margin under the gateway's 600s hard limit.
33
+ */
34
+ export const MAX_EFFECTIVE_TIMEOUT_MS = 580_000; // 9m 40s — under gateway's 600s
30
35
 
31
36
  /** Extra timeout per message beyond 10 in the conversation (ms). */
32
37
  export const TIMEOUT_PER_EXTRA_MSG_MS = 2_000;
@@ -47,9 +52,25 @@ export const DEFAULT_CLI_TIMEOUT_MS = 120_000; // 2 min
47
52
  /** Grace period between SIGTERM and SIGKILL when a timeout fires (ms). */
48
53
  export const TIMEOUT_GRACE_MS = 5_000;
49
54
 
55
+ /**
56
+ * Stale output timeout — if a CLI subprocess produces no stdout for this long,
57
+ * assume it's stuck and SIGTERM early. 0 = disabled.
58
+ * Prevents waiting the full timeout when Claude CLI hangs silently.
59
+ */
60
+ export const STALE_OUTPUT_TIMEOUT_MS = 120_000; // 2 min of silence → kill
61
+
50
62
  /** Max messages to include in the prompt sent to CLI subprocesses. */
51
63
  export const MAX_MESSAGES = 20;
52
64
 
65
+ /**
66
+ * Reduced message limit when tools are heavy (> TOOL_HEAVY_THRESHOLD).
67
+ * Fewer history messages = smaller prompt = faster CLI response.
68
+ */
69
+ export const MAX_MESSAGES_HEAVY_TOOLS = 12;
70
+
71
+ /** Tool count threshold that triggers reduced message limit. */
72
+ export const TOOL_HEAVY_THRESHOLD = 10;
73
+
53
74
  /** Max characters per message content before truncation. */
54
75
  export const MAX_MSG_CHARS = 4_000;
55
76
 
@@ -91,8 +112,8 @@ export const PROVIDER_SESSION_SWEEP_MS = 10 * 60 * 1_000; // 10 min
91
112
  * - Fast/lightweight (Haiku, Flash, Mini): 120s
92
113
  */
93
114
  export const DEFAULT_MODEL_TIMEOUTS: Record<string, number> = {
94
- "cli-claude/claude-opus-4-6": 420_000, // 7 min
95
- "cli-claude/claude-sonnet-4-6": 420_000, // 7 min — prevent timeout→Haiku fallback on large sessions
115
+ "cli-claude/claude-opus-4-6": 360_000, // 6 min — leaves room for dynamic scaling up to 580s cap
116
+ "cli-claude/claude-sonnet-4-6": 300_000, // 5 min — was 7 min, reduced so fallback fires before gateway's 600s
96
117
  "cli-claude/claude-haiku-4-5": 120_000, // 2 min
97
118
  "cli-gemini/gemini-2.5-pro": 300_000, // 5 min — image generation needs more time
98
119
  "cli-gemini/gemini-2.5-flash": 180_000, // 3 min
@@ -0,0 +1,55 @@
1
+ /**
2
+ * debug-log.ts
3
+ *
4
+ * File-based debug logger for the CLI bridge.
5
+ * Writes to ~/.openclaw/cli-bridge/debug.log with automatic rotation at 5 MB.
6
+ *
7
+ * Usage:
8
+ * tail -f ~/.openclaw/cli-bridge/debug.log
9
+ */
10
+
11
+ import { appendFileSync, statSync, renameSync, mkdirSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { homedir } from "node:os";
14
+
15
+ const LOG_DIR = join(homedir(), ".openclaw", "cli-bridge");
16
+ const LOG_FILE = join(LOG_DIR, "debug.log");
17
+ const LOG_FILE_PREV = join(LOG_DIR, "debug.log.1");
18
+ const MAX_LOG_SIZE = 5 * 1024 * 1024; // 5 MB
19
+
20
+ let initialized = false;
21
+
22
+ function ensureDir(): void {
23
+ if (initialized) return;
24
+ try { mkdirSync(LOG_DIR, { recursive: true }); } catch { /* exists */ }
25
+ initialized = true;
26
+ }
27
+
28
+ function rotate(): void {
29
+ try {
30
+ const stat = statSync(LOG_FILE);
31
+ if (stat.size > MAX_LOG_SIZE) {
32
+ try { renameSync(LOG_FILE, LOG_FILE_PREV); } catch { /* best effort */ }
33
+ }
34
+ } catch { /* file doesn't exist yet */ }
35
+ }
36
+
37
+ function ts(): string {
38
+ return new Date().toISOString();
39
+ }
40
+
41
+ /**
42
+ * Append a debug line to the log file.
43
+ * Non-blocking, never throws — logging must not crash the bridge.
44
+ */
45
+ export function debugLog(category: string, message: string, data?: Record<string, unknown>): void {
46
+ try {
47
+ ensureDir();
48
+ rotate();
49
+ const extra = data ? ` ${JSON.stringify(data)}` : "";
50
+ appendFileSync(LOG_FILE, `${ts()} [${category}] ${message}${extra}\n`);
51
+ } catch { /* never crash on log failure */ }
52
+ }
53
+
54
+ /** Log path for display on status page / startup messages. */
55
+ export const DEBUG_LOG_PATH = LOG_FILE;
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,7 +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";
36
+ import { debugLog, DEBUG_LOG_PATH } from "./debug-log.js";
37
+
38
+ // ── Active request tracking ─────────────────────────────────────────────────
39
+
40
+ export interface ActiveRequest {
41
+ id: string;
42
+ model: string;
43
+ startedAt: number;
44
+ messageCount: number;
45
+ toolCount: number;
46
+ promptPreview: string;
47
+ }
48
+
49
+ const activeRequests = new Map<string, ActiveRequest>();
50
+
51
+ export function getActiveRequests(): ActiveRequest[] {
52
+ return [...activeRequests.values()];
53
+ }
35
54
 
36
55
  export type GrokCompleteOptions = Parameters<typeof grokComplete>[1];
37
56
  export type GrokCompleteStreamOptions = Parameters<typeof grokCompleteStream>[1];
@@ -196,6 +215,7 @@ export function startProxyServer(opts: ProxyServerOptions): Promise<http.Server>
196
215
  opts.log(
197
216
  `[cli-bridge] proxy listening on :${opts.port}`
198
217
  );
218
+ debugLog("STARTUP", `proxy listening on :${opts.port}`, { debugLog: DEBUG_LOG_PATH });
199
219
  // unref() so the proxy server does not keep the Node.js event loop alive
200
220
  // when openclaw doctor or other short-lived CLI commands load plugins.
201
221
  // The gateway's own main loop keeps the process alive during normal operation.
@@ -276,7 +296,20 @@ async function handleRequest(
276
296
  { name: "ChatGPT", icon: "◉", expiry: expiry.chatgpt, loginCmd: "/chatgpt-login", ctx: opts.getChatGPTContext?.() ?? null },
277
297
  ];
278
298
 
279
- const html = renderStatusPage({ version, port: opts.port, providers, models: CLI_MODELS, modelCommands: opts.modelCommands, metrics: metrics.getMetrics() });
299
+ const html = renderStatusPage({
300
+ version, port: opts.port, providers, models: CLI_MODELS,
301
+ modelCommands: opts.modelCommands,
302
+ metrics: metrics.getMetrics(),
303
+ activeRequests: getActiveRequests(),
304
+ providerSessionsList: providerSessions.listSessions(),
305
+ timeoutConfig: {
306
+ defaults: { ...DEFAULT_MODEL_TIMEOUTS, ...(opts.modelTimeouts ?? {}) },
307
+ baseDefault: opts.timeoutMs ?? DEFAULT_PROXY_TIMEOUT_MS,
308
+ maxEffective: MAX_EFFECTIVE_TIMEOUT_MS,
309
+ perExtraMsg: TIMEOUT_PER_EXTRA_MSG_MS,
310
+ perTool: TIMEOUT_PER_TOOL_MS,
311
+ },
312
+ });
280
313
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
281
314
  res.end(html);
282
315
  return;
@@ -354,6 +387,15 @@ async function handleRequest(
354
387
  const id = `chatcmpl-cli-${randomBytes(6).toString("hex")}`;
355
388
  const created = Math.floor(Date.now() / 1000);
356
389
 
390
+ // Extract prompt preview from last user message for dashboard
391
+ const lastUserMsg = [...cleanMessages].reverse().find(m => m.role === "user");
392
+ const promptPreview = typeof lastUserMsg?.content === "string" ? lastUserMsg.content.slice(0, 80) : "";
393
+
394
+ debugLog("REQ", `${model} start`, { msgs: cleanMessages.length, tools: tools?.length ?? 0, stream, media: mediaFiles.length, promptPreview: promptPreview.slice(0, 60) });
395
+
396
+ // Track active request for dashboard
397
+ activeRequests.set(id, { id, model, startedAt: Date.now(), messageCount: cleanMessages.length, toolCount: tools?.length ?? 0, promptPreview });
398
+
357
399
  // ── Grok web-session routing ──────────────────────────────────────────────
358
400
  if (model.startsWith("web-grok/")) {
359
401
  let grokCtx = opts.getGrokContext?.() ?? null;
@@ -767,6 +809,7 @@ async function handleRequest(
767
809
  const toolExtra = (tools?.length ?? 0) * TIMEOUT_PER_TOOL_MS;
768
810
  const effectiveTimeout = Math.min(baseTimeout + msgExtra + toolExtra, MAX_EFFECTIVE_TIMEOUT_MS);
769
811
  opts.log(`[cli-bridge] ${model} session=${session.id} timeout: ${Math.round(effectiveTimeout / 1000)}s (base=${Math.round(baseTimeout / 1000)}s${perModelTimeout ? " per-model" : ""}, +${Math.round(msgExtra / 1000)}s msgs, +${Math.round(toolExtra / 1000)}s tools)`);
812
+ debugLog("TIMEOUT", `${model} effective=${Math.round(effectiveTimeout / 1000)}s`, { base: Math.round(baseTimeout / 1000), msgExtra: Math.round(msgExtra / 1000), toolExtra: Math.round(toolExtra / 1000), cap: Math.round(MAX_EFFECTIVE_TIMEOUT_MS / 1000) });
770
813
 
771
814
  // ── SSE keepalive: send headers early so OpenClaw doesn't read-timeout ──
772
815
  let sseHeadersSent = false;
@@ -783,33 +826,58 @@ async function handleRequest(
783
826
  keepaliveInterval = setInterval(() => { res.write(": keepalive\n\n"); }, SSE_KEEPALIVE_INTERVAL_MS);
784
827
  }
785
828
 
829
+ // ── Progress notifications: send visible status updates to the webchat ──
830
+ // Users shouldn't stare at a blank screen for minutes without feedback.
831
+ let progressInterval: ReturnType<typeof setInterval> | null = null;
832
+ const PROGRESS_INTERVAL_MS = 30_000; // 30s between updates
833
+ if (stream && sseHeadersSent) {
834
+ const progressStart = Date.now();
835
+ progressInterval = setInterval(() => {
836
+ const elapsed = Math.round((Date.now() - progressStart) / 1000);
837
+ const timeoutSec = Math.round(effectiveTimeout / 1000);
838
+ // Send an SSE comment with progress info — visible in raw SSE but won't render as content
839
+ // Also send a small content delta that OpenClaw can show as typing indicator
840
+ res.write(`: progress ${elapsed}s/${timeoutSec}s — ${model} processing\n\n`);
841
+ }, PROGRESS_INTERVAL_MS);
842
+ }
843
+
786
844
  const cliStart = Date.now();
787
845
  try {
788
846
  result = await routeToCliRunner(model, cleanMessages, effectiveTimeout, routeOpts);
847
+ const latencyMs = Date.now() - cliStart;
789
848
  const estCompletionTokens = estimateTokens(result.content ?? "");
790
- metrics.recordRequest(model, Date.now() - cliStart, true, estPromptTokens, estCompletionTokens);
849
+ metrics.recordRequest(model, latencyMs, true, estPromptTokens, estCompletionTokens, promptPreview);
791
850
  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 });
792
852
  } catch (err) {
793
853
  const primaryDuration = Date.now() - cliStart;
794
854
  const msg = (err as Error).message;
795
855
  // ── Model fallback: retry once with a lighter model if configured ────
796
856
  const isTimeout = msg.includes("timeout:") || msg.includes("exit 143") || msg.includes("exited 143");
857
+ debugLog("FAIL", `${model} failed after ${(primaryDuration / 1000).toFixed(1)}s`, { isTimeout, error: msg.slice(0, 200) });
797
858
  // Record the run (with timeout flag) — session is preserved, not deleted
798
859
  providerSessions.recordRun(session.id, isTimeout);
799
860
  const fallbackModel = opts.modelFallbacks?.[model];
800
861
  if (fallbackModel) {
801
- metrics.recordRequest(model, primaryDuration, false, estPromptTokens);
862
+ metrics.recordRequest(model, primaryDuration, false, estPromptTokens, undefined, promptPreview);
802
863
  const reason = isTimeout ? `timeout by supervisor, session=${session.id} preserved` : msg;
803
864
  opts.warn(`[cli-bridge] ${model} failed (${reason}), falling back to ${fallbackModel}`);
865
+ debugLog("FALLBACK", `${model} → ${fallbackModel}`, { reason: isTimeout ? "timeout" : "error", primaryDuration: Math.round(primaryDuration / 1000) });
866
+ // Notify the user via SSE that we're retrying with a different model
867
+ if (sseHeadersSent) {
868
+ res.write(`: fallback — ${model} ${isTimeout ? "timed out" : "failed"} after ${Math.round(primaryDuration / 1000)}s, retrying with ${fallbackModel}\n\n`);
869
+ }
804
870
  const fallbackStart = Date.now();
805
871
  try {
806
872
  result = await routeToCliRunner(fallbackModel, cleanMessages, effectiveTimeout, routeOpts);
807
873
  const fbCompTokens = estimateTokens(result.content ?? "");
808
- metrics.recordRequest(fallbackModel, Date.now() - fallbackStart, true, estPromptTokens, fbCompTokens);
874
+ metrics.recordRequest(fallbackModel, Date.now() - fallbackStart, true, estPromptTokens, fbCompTokens, promptPreview);
875
+ metrics.recordFallback(model, fallbackModel, isTimeout ? "timeout" : "error", primaryDuration, true);
809
876
  usedModel = fallbackModel;
810
877
  opts.log(`[cli-bridge] fallback to ${fallbackModel} succeeded (response will report original model: ${model})`);
811
878
  } catch (fallbackErr) {
812
- metrics.recordRequest(fallbackModel, Date.now() - fallbackStart, false, estPromptTokens);
879
+ metrics.recordRequest(fallbackModel, Date.now() - fallbackStart, false, estPromptTokens, undefined, promptPreview);
880
+ metrics.recordFallback(model, fallbackModel, isTimeout ? "timeout" : "error", primaryDuration, false);
813
881
  const fallbackMsg = (fallbackErr as Error).message;
814
882
  opts.warn(`[cli-bridge] fallback ${fallbackModel} also failed: ${fallbackMsg}`);
815
883
  if (sseHeadersSent) {
@@ -823,7 +891,7 @@ async function handleRequest(
823
891
  return;
824
892
  }
825
893
  } else {
826
- metrics.recordRequest(model, primaryDuration, false, estPromptTokens);
894
+ metrics.recordRequest(model, primaryDuration, false, estPromptTokens, undefined, promptPreview);
827
895
  opts.warn(`[cli-bridge] CLI error for ${model}: ${msg}`);
828
896
  if (sseHeadersSent) {
829
897
  res.write(`data: ${JSON.stringify({ error: { message: msg, type: "cli_error" } })}\n\n`);
@@ -837,7 +905,9 @@ async function handleRequest(
837
905
  }
838
906
  } finally {
839
907
  if (keepaliveInterval) clearInterval(keepaliveInterval);
908
+ if (progressInterval) clearInterval(progressInterval);
840
909
  cleanupMediaFiles(mediaFiles);
910
+ activeRequests.delete(id);
841
911
  }
842
912
 
843
913
  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
  }
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import { randomBytes } from "node:crypto";
13
+ import { debugLog } from "./debug-log.js";
13
14
 
14
15
  // ──────────────────────────────────────────────────────────────────────────────
15
16
  // Types
@@ -46,13 +47,34 @@ export interface CliToolResult {
46
47
  * Build a text block describing available tools and response format instructions.
47
48
  * This block is prepended to the system message (or added as a new system message).
48
49
  */
50
+ /** Threshold: when tool count exceeds this, use compact schema to reduce prompt size. */
51
+ const COMPACT_TOOL_THRESHOLD = 8;
52
+
53
+ /**
54
+ * Build a compact tool description: name + required param names only.
55
+ * Cuts prompt size by ~60-70% for large tool sets.
56
+ */
57
+ function compactToolDescription(t: ToolDefinition): string {
58
+ const fn = t.function;
59
+ const params = fn.parameters as { properties?: Record<string, unknown>; required?: string[] };
60
+ const required = params?.required ?? Object.keys(params?.properties ?? {});
61
+ const paramList = required.length > 0 ? `(${required.join(", ")})` : "()";
62
+ return `- ${fn.name}${paramList}: ${fn.description}`;
63
+ }
64
+
65
+ /**
66
+ * Build a full tool description: name, description, and full JSON schema.
67
+ */
68
+ function fullToolDescription(t: ToolDefinition): string {
69
+ const fn = t.function;
70
+ const params = JSON.stringify(fn.parameters);
71
+ return `- name: ${fn.name}\n description: ${fn.description}\n parameters: ${params}`;
72
+ }
73
+
49
74
  export function buildToolPromptBlock(tools: ToolDefinition[]): string {
75
+ const useCompact = tools.length > COMPACT_TOOL_THRESHOLD;
50
76
  const toolDescriptions = tools
51
- .map((t) => {
52
- const fn = t.function;
53
- const params = JSON.stringify(fn.parameters);
54
- return `- name: ${fn.name}\n description: ${fn.description}\n parameters: ${params}`;
55
- })
77
+ .map(useCompact ? compactToolDescription : fullToolDescription)
56
78
  .join("\n");
57
79
 
58
80
  return [
@@ -67,6 +89,7 @@ export function buildToolPromptBlock(tools: ToolDefinition[]): string {
67
89
  '{"content":"<your text response>"}',
68
90
  "",
69
91
  "Do NOT include any text outside the JSON. Do NOT wrap in markdown code blocks.",
92
+ useCompact ? "Call ONE tool at a time. Do NOT batch multiple tool calls." : "",
70
93
  "",
71
94
  "Available tools:",
72
95
  toolDescriptions,
@@ -117,6 +140,7 @@ export function buildToolCallJsonSchema(): object {
117
140
  */
118
141
  export function parseToolCallResponse(text: string): CliToolResult {
119
142
  const trimmed = text.trim();
143
+ const preview = trimmed.slice(0, 120);
120
144
 
121
145
  // Check for Claude's --output-format json wrapper FIRST.
122
146
  // Claude returns: { "type": "result", "result": "..." }
@@ -124,30 +148,48 @@ export function parseToolCallResponse(text: string): CliToolResult {
124
148
  const claudeResult = tryExtractClaudeJsonResult(trimmed);
125
149
  if (claudeResult) {
126
150
  const inner = tryParseJson(claudeResult);
127
- if (inner) return normalizeResult(inner);
151
+ if (inner) {
152
+ const result = normalizeResult(inner);
153
+ debugLog("PARSE", `claude-json → ${result.tool_calls ? "tool_calls" : "content"}`, { toolCalls: result.tool_calls?.length ?? 0 });
154
+ return result;
155
+ }
128
156
  // Claude result is plain text
157
+ debugLog("PARSE", "claude-json → plain text", { len: claudeResult.length });
129
158
  return { content: claudeResult };
130
159
  }
131
160
 
132
161
  // Try direct JSON parse (for non-Claude outputs)
133
162
  const parsed = tryParseJson(trimmed);
134
- if (parsed) return normalizeResult(parsed);
163
+ if (parsed) {
164
+ const result = normalizeResult(parsed);
165
+ debugLog("PARSE", `direct-json → ${result.tool_calls ? "tool_calls" : "content"}`, { toolCalls: result.tool_calls?.length ?? 0 });
166
+ return result;
167
+ }
135
168
 
136
169
  // Try extracting JSON from markdown code blocks: ```json ... ```
137
170
  const codeBlock = tryExtractCodeBlock(trimmed);
138
171
  if (codeBlock) {
139
172
  const inner = tryParseJson(codeBlock);
140
- if (inner) return normalizeResult(inner);
173
+ if (inner) {
174
+ const result = normalizeResult(inner);
175
+ debugLog("PARSE", `code-block → ${result.tool_calls ? "tool_calls" : "content"}`, { toolCalls: result.tool_calls?.length ?? 0 });
176
+ return result;
177
+ }
141
178
  }
142
179
 
143
180
  // Try finding a JSON object anywhere in the text
144
181
  const embedded = tryExtractEmbeddedJson(trimmed);
145
182
  if (embedded) {
146
183
  const inner = tryParseJson(embedded);
147
- if (inner) return normalizeResult(inner);
184
+ if (inner) {
185
+ const result = normalizeResult(inner);
186
+ debugLog("PARSE", `embedded-json → ${result.tool_calls ? "tool_calls" : "content"}`, { toolCalls: result.tool_calls?.length ?? 0 });
187
+ return result;
188
+ }
148
189
  }
149
190
 
150
191
  // Fallback: treat entire text as content
192
+ debugLog("PARSE", "no JSON found → raw content", { len: trimmed.length, preview });
151
193
  return { content: trimmed || null };
152
194
  }
153
195
 
@@ -167,11 +209,17 @@ function normalizeResult(obj: Record<string, unknown>): CliToolResult {
167
209
  : JSON.stringify(tc.arguments ?? {}),
168
210
  },
169
211
  }));
170
- return { content: null, tool_calls: toolCalls };
212
+ // If the model also returned a content string alongside tool_calls, include it
213
+ const content = typeof obj.content === "string" ? obj.content : null;
214
+ return { content, tool_calls: toolCalls };
171
215
  }
172
216
 
173
- // Check for content field
217
+ // Check for content field — but rescue embedded tool_calls JSON from inside content strings.
218
+ // Models sometimes wrap tool calls inside a content string:
219
+ // {"content":"I'll write that file.\n{\"tool_calls\":[...]}"}
174
220
  if (typeof obj.content === "string") {
221
+ const rescued = tryRescueToolCallsFromContent(obj.content);
222
+ if (rescued) return rescued;
175
223
  return { content: obj.content };
176
224
  }
177
225
 
@@ -179,6 +227,41 @@ function normalizeResult(obj: Record<string, unknown>): CliToolResult {
179
227
  return { content: JSON.stringify(obj) };
180
228
  }
181
229
 
230
+ /**
231
+ * Rescue tool_calls embedded inside a content string.
232
+ * Handles cases where the model wraps tool calls in a content field:
233
+ * {"content":"Some text\n{\"tool_calls\":[...]}"}
234
+ * {"content":"{\"tool_calls\":[{\"name\":\"write\",...}]}"}
235
+ */
236
+ function tryRescueToolCallsFromContent(content: string): CliToolResult | null {
237
+ // Only attempt rescue if content contains the tool_calls signature
238
+ if (!content.includes('"tool_calls"') && !content.includes("tool_calls")) return null;
239
+
240
+ // Try to find embedded JSON with tool_calls
241
+ const embedded = tryExtractEmbeddedJson(content);
242
+ if (!embedded) return null;
243
+
244
+ const parsed = tryParseJson(embedded);
245
+ if (!parsed || !Array.isArray(parsed.tool_calls) || parsed.tool_calls.length === 0) return null;
246
+
247
+ // Extract the text content before the JSON (if any)
248
+ const jsonStart = content.indexOf(embedded);
249
+ const textBefore = jsonStart > 0 ? content.slice(0, jsonStart).trim() : null;
250
+
251
+ const toolCalls: ToolCall[] = parsed.tool_calls.map((tc: Record<string, unknown>) => ({
252
+ id: generateCallId(),
253
+ type: "function" as const,
254
+ function: {
255
+ name: String(tc.name ?? ""),
256
+ arguments: typeof tc.arguments === "string"
257
+ ? tc.arguments
258
+ : JSON.stringify(tc.arguments ?? {}),
259
+ },
260
+ }));
261
+
262
+ return { content: textBefore || null, tool_calls: toolCalls };
263
+ }
264
+
182
265
  function tryParseJson(text: string): Record<string, unknown> | null {
183
266
  try {
184
267
  const obj = JSON.parse(text);
@@ -38,7 +38,7 @@ describe("config.ts exports", () => {
38
38
  expect(DEFAULT_PROXY_TIMEOUT_MS).toBe(300_000);
39
39
  expect(DEFAULT_CLI_TIMEOUT_MS).toBe(120_000);
40
40
  expect(TIMEOUT_GRACE_MS).toBe(5_000);
41
- expect(MAX_EFFECTIVE_TIMEOUT_MS).toBe(900_000);
41
+ expect(MAX_EFFECTIVE_TIMEOUT_MS).toBe(580_000); // under gateway's 600s
42
42
  expect(SESSION_TTL_MS).toBe(30 * 60 * 1000);
43
43
  expect(CLEANUP_INTERVAL_MS).toBe(5 * 60 * 1000);
44
44
  expect(SESSION_KILL_GRACE_MS).toBe(5_000);
@@ -61,8 +61,8 @@ describe("config.ts exports", () => {
61
61
  });
62
62
 
63
63
  it("exports per-model timeouts for all major models", () => {
64
- expect(DEFAULT_MODEL_TIMEOUTS["cli-claude/claude-opus-4-6"]).toBe(420_000);
65
- expect(DEFAULT_MODEL_TIMEOUTS["cli-claude/claude-sonnet-4-6"]).toBe(420_000);
64
+ expect(DEFAULT_MODEL_TIMEOUTS["cli-claude/claude-opus-4-6"]).toBe(360_000);
65
+ expect(DEFAULT_MODEL_TIMEOUTS["cli-claude/claude-sonnet-4-6"]).toBe(300_000);
66
66
  expect(DEFAULT_MODEL_TIMEOUTS["cli-claude/claude-haiku-4-5"]).toBe(120_000);
67
67
  expect(DEFAULT_MODEL_TIMEOUTS["cli-gemini/gemini-2.5-pro"]).toBe(300_000);
68
68
  expect(DEFAULT_MODEL_TIMEOUTS["cli-gemini/gemini-2.5-flash"]).toBe(180_000);
@@ -63,8 +63,10 @@ vi.mock("../src/workdir.js", () => ({
63
63
  }));
64
64
 
65
65
  // Mock config module — provide all constants needed by session-manager.ts and cli-runner.ts
66
- vi.mock("../src/config.js", async () => {
66
+ vi.mock("../src/config.js", async (importOriginal) => {
67
+ const actual = await importOriginal<typeof import("../src/config.js")>();
67
68
  return {
69
+ ...actual,
68
70
  SESSION_TTL_MS: 30 * 60 * 1000,
69
71
  CLEANUP_INTERVAL_MS: 5 * 60 * 1000,
70
72
  SESSION_KILL_GRACE_MS: 5_000,