@elvatis_com/openclaw-cli-bridge-elvatis 2.4.0 → 2.6.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.3.0`
5
+ **Current version:** `2.6.0`
6
6
 
7
7
  ---
8
8
 
@@ -282,7 +282,17 @@ In `~/.openclaw/openclaw.json` → `plugins.entries.openclaw-cli-bridge-elvatis.
282
282
  "enableProxy": true, // start local CLI proxy server (default: true)
283
283
  "proxyPort": 31337, // proxy port (default: 31337)
284
284
  "proxyApiKey": "cli-bridge", // key between OpenClaw vllm provider and proxy (default: "cli-bridge")
285
- "proxyTimeoutMs": 120000 // CLI subprocess timeout in ms (default: 120s)
285
+ "proxyTimeoutMs": 300000, // base CLI subprocess timeout in ms (default: 300s, scales dynamically)
286
+ "modelTimeouts": { // per-model timeout overrides in ms (optional)
287
+ "cli-claude/claude-opus-4-6": 300000, // 5 min — heavy/agentic tasks
288
+ "cli-claude/claude-sonnet-4-6": 180000, // 3 min — interactive chat
289
+ "cli-claude/claude-haiku-4-5": 90000, // 90s — fast responses
290
+ "cli-gemini/gemini-2.5-pro": 180000,
291
+ "cli-gemini/gemini-2.5-flash": 90000,
292
+ "openai-codex/gpt-5.4": 300000,
293
+ "openai-codex/gpt-5.3-codex": 180000,
294
+ "openai-codex/gpt-5.1-codex-mini": 90000
295
+ }
286
296
  }
287
297
  ```
288
298
 
@@ -368,7 +378,7 @@ Model fallback (v1.9.0):
368
378
  ```bash
369
379
  npm run lint # eslint (TypeScript-aware)
370
380
  npm run typecheck # tsc --noEmit
371
- npm test # vitest run (121 tests)
381
+ npm test # vitest run (252 tests)
372
382
  npm run ci # lint + typecheck + test
373
383
  ```
374
384
 
@@ -376,6 +386,26 @@ npm run ci # lint + typecheck + test
376
386
 
377
387
  ## Changelog
378
388
 
389
+ ### v2.6.0
390
+ - **feat:** Provider session registry (`src/provider-sessions.ts`) — persistent sessions that survive across runs. When a CLI run times out, the session is preserved (not deleted) so follow-up runs can resume in the same context. Sessions are stored in `~/.openclaw/cli-bridge/sessions.json`.
391
+ - **feat:** Centralized config module (`src/config.ts`) — all magic numbers, timeouts, paths, ports, and model defaults extracted into one file. No more scattered hardcoded values.
392
+ - **feat:** Session-aware proxy — every CLI request gets a `provider_session_id` in the response. Pass it back via `providerSessionId` in subsequent requests to reuse the same session.
393
+ - **feat:** New proxy endpoints: `GET /v1/provider-sessions` (list sessions + stats), `DELETE /v1/provider-sessions/:id` (remove a session)
394
+ - **fix:** Version fallback changed from `"0.0.0"` to `"unknown"` with secondary lookup in `openclaw.plugin.json` — prevents Dashboard showing wrong version
395
+ - **refactor:** `index.ts`, `cli-runner.ts`, `session-manager.ts`, `proxy-server.ts` now import all constants from `config.ts` instead of defining them locally
396
+ - **test:** 35 new tests for provider sessions, config exports (252 total)
397
+
398
+ ### v2.5.0
399
+ - **feat:** Graceful timeout handling — replaces Node's `spawn({ timeout })` with manual SIGTERM→SIGKILL sequence (5s grace period). Exit 143 is now clearly annotated as "timeout by supervisor" in logs, not a cryptic model error.
400
+ - **feat:** Per-model timeout profiles — new `modelTimeouts` config option sets sensible defaults per model: Opus 5 min, Sonnet 3 min, Haiku 90s, Flash models 90s. Scales dynamically with conversation size (+2s/msg beyond 10, +5s/tool).
401
+ - **feat:** Timeout logging — every timeout event logs model, elapsed time, SIGTERM/SIGKILL steps. Fallback messages now show "timeout by supervisor" instead of raw exit codes.
402
+ - **fix:** Base timeout raised from 120s to 300s (was causing frequent Exit 143 on normal Sonnet conversations)
403
+ - **fix:** Session manager `kill()`, `cleanup()`, and `stop()` now use graceful SIGTERM→SIGKILL instead of immediate SIGTERM
404
+ - **test:** 7 new tests for timeout handling and exit code annotation (217 total)
405
+
406
+ ### v2.4.0
407
+ - **feat:** Metrics & health dashboard — request volume, latency, errors, token usage
408
+
379
409
  ### v2.3.0
380
410
  - **feat:** OpenAI tool calling protocol support for all CLI models — tool definitions are injected into the prompt, structured `tool_calls` responses are parsed and returned in OpenAI format
381
411
  - **feat:** Multimodal content support — images and audio from webchat are extracted to temp files and passed to CLIs (Codex uses native `-i` flag, Claude/Gemini reference file paths in prompt)
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.1.3
71
+ **Version:** 2.6.0
package/index.ts CHANGED
@@ -41,7 +41,13 @@ const PACKAGE_VERSION: string = (() => {
41
41
  const pkg = JSON.parse(readFileSync(join(__dirname_local, "package.json"), "utf-8")) as { version: string };
42
42
  return pkg.version;
43
43
  } catch {
44
- return "0.0.0"; // fallback should never happen in normal operation
44
+ // Second attempt: try openclaw.plugin.json (always co-located)
45
+ try {
46
+ const manifest = JSON.parse(readFileSync(join(__dirname_local, "openclaw.plugin.json"), "utf-8")) as { version: string };
47
+ return manifest.version;
48
+ } catch {
49
+ return "unknown"; // should never happen — both files are always present
50
+ }
45
51
  }
46
52
  })();
47
53
  import type {
@@ -62,6 +68,18 @@ import {
62
68
  import { importCodexAuth } from "./src/codex-auth-import.js";
63
69
  import { startProxyServer } from "./src/proxy-server.js";
64
70
  import { patchOpencllawConfig } from "./src/config-patcher.js";
71
+ import {
72
+ DEFAULT_PROXY_PORT,
73
+ DEFAULT_PROXY_API_KEY,
74
+ DEFAULT_PROXY_TIMEOUT_MS,
75
+ DEFAULT_MODEL_TIMEOUTS,
76
+ DEFAULT_MODEL_FALLBACKS,
77
+ STATE_FILE as CONFIG_STATE_FILE,
78
+ PENDING_FILE as CONFIG_PENDING_FILE,
79
+ OPENCLAW_DIR,
80
+ CLI_TEST_DEFAULT_MODEL as CONFIG_CLI_TEST_DEFAULT_MODEL,
81
+ PROFILE_DIRS,
82
+ } from "./src/config.js";
65
83
  import {
66
84
  loadSession,
67
85
  deleteSession,
@@ -98,6 +116,7 @@ interface CliPluginConfig {
98
116
  proxyPort?: number;
99
117
  proxyApiKey?: string;
100
118
  proxyTimeoutMs?: number;
119
+ modelTimeouts?: Record<string, number>;
101
120
  grokSessionPath?: string;
102
121
  }
103
122
 
@@ -108,11 +127,11 @@ interface CliPluginConfig {
108
127
  let grokBrowser: Browser | null = null;
109
128
  let grokContext: BrowserContext | null = null;
110
129
 
111
- // Persistent profile dirs — survive gateway restarts, keep cookies intact
112
- const GROK_PROFILE_DIR = join(homedir(), ".openclaw", "grok-profile");
113
- const GEMINI_PROFILE_DIR = join(homedir(), ".openclaw", "gemini-profile");
114
- const CLAUDE_PROFILE_DIR = join(homedir(), ".openclaw", "claude-profile");
115
- const CHATGPT_PROFILE_DIR = join(homedir(), ".openclaw", "chatgpt-profile");
130
+ // Persistent profile dirs — imported from config.ts
131
+ const GROK_PROFILE_DIR = PROFILE_DIRS.grok;
132
+ const GEMINI_PROFILE_DIR = PROFILE_DIRS.gemini;
133
+ const CLAUDE_PROFILE_DIR = PROFILE_DIRS.claude;
134
+ const CHATGPT_PROFILE_DIR = PROFILE_DIRS.chatgpt;
116
135
 
117
136
  // Stealth launch options — prevent Cloudflare/bot detection from flagging the browser
118
137
  const STEALTH_ARGS = [
@@ -684,14 +703,13 @@ async function tryRestoreGrokSession(
684
703
  }
685
704
  }
686
705
 
687
- const DEFAULT_PROXY_PORT = 31337;
688
- const DEFAULT_PROXY_API_KEY = "cli-bridge";
706
+ // DEFAULT_PROXY_PORT, DEFAULT_PROXY_API_KEY imported from config.ts
689
707
 
690
708
  // ──────────────────────────────────────────────────────────────────────────────
691
709
  // State file — persists the model that was active before the last /cli-* switch
692
710
  // Located at ~/.openclaw/cli-bridge-state.json (survives gateway restarts)
693
711
  // ──────────────────────────────────────────────────────────────────────────────
694
- const STATE_FILE = join(homedir(), ".openclaw", "cli-bridge-state.json");
712
+ const STATE_FILE = CONFIG_STATE_FILE;
695
713
 
696
714
  interface CliBridgeState {
697
715
  previousModel: string;
@@ -707,7 +725,7 @@ function readState(): CliBridgeState | null {
707
725
 
708
726
  function writeState(state: CliBridgeState): void {
709
727
  try {
710
- mkdirSync(join(homedir(), ".openclaw"), { recursive: true });
728
+ mkdirSync(OPENCLAW_DIR, { recursive: true });
711
729
  writeFileSync(STATE_FILE, JSON.stringify(state, null, 2) + "\n", "utf8");
712
730
  } catch {
713
731
  // non-fatal — /cli-back will just report no previous model
@@ -779,7 +797,7 @@ const CLI_MODEL_COMMANDS = [
779
797
  ] as const;
780
798
 
781
799
  /** Default model used by /cli-test when no arg is given */
782
- const CLI_TEST_DEFAULT_MODEL = "cli-claude/claude-sonnet-4-6";
800
+ const CLI_TEST_DEFAULT_MODEL = CONFIG_CLI_TEST_DEFAULT_MODEL;
783
801
 
784
802
  // ──────────────────────────────────────────────────────────────────────────────
785
803
  // Staged-switch state file
@@ -787,7 +805,7 @@ const CLI_TEST_DEFAULT_MODEL = "cli-claude/claude-sonnet-4-6";
787
805
  // Written by /cli-* (default), applied by /cli-apply or /cli-* --now.
788
806
  // Located at ~/.openclaw/cli-bridge-pending.json
789
807
  // ──────────────────────────────────────────────────────────────────────────────
790
- const PENDING_FILE = join(homedir(), ".openclaw", "cli-bridge-pending.json");
808
+ const PENDING_FILE = CONFIG_PENDING_FILE;
791
809
 
792
810
  interface CliBridgePending {
793
811
  model: string;
@@ -805,7 +823,7 @@ function readPending(): CliBridgePending | null {
805
823
 
806
824
  function writePending(pending: CliBridgePending): void {
807
825
  try {
808
- mkdirSync(join(homedir(), ".openclaw"), { recursive: true });
826
+ mkdirSync(OPENCLAW_DIR, { recursive: true });
809
827
  writeFileSync(PENDING_FILE, JSON.stringify(pending, null, 2) + "\n", "utf8");
810
828
  } catch {
811
829
  // non-fatal
@@ -987,7 +1005,9 @@ const plugin = {
987
1005
  const enableProxy = cfg.enableProxy ?? true;
988
1006
  const port = cfg.proxyPort ?? DEFAULT_PROXY_PORT;
989
1007
  const apiKey = cfg.proxyApiKey ?? DEFAULT_PROXY_API_KEY;
990
- const timeoutMs = cfg.proxyTimeoutMs ?? 120_000;
1008
+ const timeoutMs = cfg.proxyTimeoutMs ?? DEFAULT_PROXY_TIMEOUT_MS;
1009
+ // Per-model timeout overrides — defaults from config.ts, can be extended via plugin config.
1010
+ const modelTimeouts = { ...DEFAULT_MODEL_TIMEOUTS, ...cfg.modelTimeouts };
991
1011
  const codexAuthPath = cfg.codexAuthPath ?? DEFAULT_CODEX_AUTH_PATH;
992
1012
  const grokSessionPath = cfg.grokSessionPath ?? DEFAULT_SESSION_PATH;
993
1013
 
@@ -999,14 +1019,8 @@ const plugin = {
999
1019
  modelCommands[modelId] = `/${entry.name}`;
1000
1020
  }
1001
1021
 
1002
- // ── Default model fallback chain ──────────────────────────────────────────
1003
- // When a primary model fails (timeout, error), retry once with a lighter variant.
1004
- const modelFallbacks: Record<string, string> = {
1005
- "cli-gemini/gemini-2.5-pro": "cli-gemini/gemini-2.5-flash",
1006
- "cli-gemini/gemini-3-pro-preview": "cli-gemini/gemini-3-flash-preview",
1007
- "cli-claude/claude-opus-4-6": "cli-claude/claude-sonnet-4-6",
1008
- "cli-claude/claude-sonnet-4-6": "cli-claude/claude-haiku-4-5",
1009
- };
1022
+ // ── Default model fallback chain (from config.ts) ──────────────────────────
1023
+ const modelFallbacks = { ...DEFAULT_MODEL_FALLBACKS };
1010
1024
 
1011
1025
  // ── Migrate legacy per-provider cookie expiry files to consolidated store ─
1012
1026
  const migration = migrateLegacyFiles();
@@ -1379,6 +1393,7 @@ const plugin = {
1379
1393
  version: plugin.version,
1380
1394
  modelCommands,
1381
1395
  modelFallbacks,
1396
+ modelTimeouts,
1382
1397
  getExpiryInfo: () => ({
1383
1398
  grok: (() => { const e = loadGrokExpiry(); return e ? formatExpiryInfo(e) : null; })(),
1384
1399
  gemini: (() => { const e = loadGeminiExpiry(); return e ? formatGeminiExpiry(e) : null; })(),
@@ -1415,7 +1430,7 @@ const plugin = {
1415
1430
  // One final attempt
1416
1431
  try {
1417
1432
  const server = await startProxyServer({
1418
- port, apiKey, timeoutMs, modelCommands, modelFallbacks,
1433
+ port, apiKey, timeoutMs, modelCommands, modelFallbacks, modelTimeouts,
1419
1434
  log: (msg) => api.logger.info(msg),
1420
1435
  warn: (msg) => api.logger.warn(msg),
1421
1436
  getGrokContext: () => grokContext,
@@ -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.4.0",
5
+ "version": "2.6.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": [
@@ -34,7 +34,26 @@
34
34
  },
35
35
  "proxyTimeoutMs": {
36
36
  "type": "number",
37
- "description": "Max time to wait for a CLI response in ms (default: 120000)"
37
+ "description": "Base timeout for CLI responses in ms (default: 300000). Scales dynamically with conversation size."
38
+ },
39
+ "modelTimeouts": {
40
+ "type": "object",
41
+ "description": "Per-model timeout overrides in ms. Keys are model IDs (e.g. 'cli-claude/claude-sonnet-4-6'). Use this to give heavy models more time or limit fast models. When not set, falls back to proxyTimeoutMs.",
42
+ "additionalProperties": {
43
+ "type": "number"
44
+ },
45
+ "default": {
46
+ "cli-claude/claude-opus-4-6": 300000,
47
+ "cli-claude/claude-sonnet-4-6": 180000,
48
+ "cli-claude/claude-haiku-4-5": 90000,
49
+ "cli-gemini/gemini-2.5-pro": 180000,
50
+ "cli-gemini/gemini-2.5-flash": 90000,
51
+ "cli-gemini/gemini-3-pro-preview": 180000,
52
+ "cli-gemini/gemini-3-flash-preview": 90000,
53
+ "openai-codex/gpt-5.4": 300000,
54
+ "openai-codex/gpt-5.3-codex": 180000,
55
+ "openai-codex/gpt-5.1-codex-mini": 90000
56
+ }
38
57
  }
39
58
  }
40
59
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elvatis_com/openclaw-cli-bridge-elvatis",
3
- "version": "2.4.0",
3
+ "version": "2.6.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
@@ -28,11 +28,13 @@ import {
28
28
  buildToolPromptBlock,
29
29
  parseToolCallResponse,
30
30
  } from "./tool-protocol.js";
31
-
32
- /** Max messages to include in the prompt sent to the CLI. */
33
- const MAX_MESSAGES = 20;
34
- /** Max characters per message content before truncation. */
35
- const MAX_MSG_CHARS = 4000;
31
+ import {
32
+ MAX_MESSAGES,
33
+ MAX_MSG_CHARS,
34
+ DEFAULT_CLI_TIMEOUT_MS,
35
+ TIMEOUT_GRACE_MS,
36
+ MEDIA_TMP_DIR,
37
+ } from "./config.js";
36
38
 
37
39
  // ──────────────────────────────────────────────────────────────────────────────
38
40
  // Message formatting
@@ -152,7 +154,7 @@ export interface MediaFile {
152
154
  mimeType: string;
153
155
  }
154
156
 
155
- const MEDIA_TMP_DIR = join(tmpdir(), "cli-bridge-media");
157
+ // MEDIA_TMP_DIR imported from config.ts
156
158
 
157
159
  /**
158
160
  * Extract non-text content parts (images, audio) from messages.
@@ -278,6 +280,8 @@ export interface CliRunResult {
278
280
  stdout: string;
279
281
  stderr: string;
280
282
  exitCode: number;
283
+ /** True when the process was killed due to a timeout (exit 143 = SIGTERM). */
284
+ timedOut: boolean;
281
285
  }
282
286
 
283
287
  export interface RunCliOptions {
@@ -287,11 +291,21 @@ export interface RunCliOptions {
287
291
  */
288
292
  cwd?: string;
289
293
  timeoutMs?: number;
294
+ /** Optional logger for timeout events. */
295
+ log?: (msg: string) => void;
290
296
  }
291
297
 
298
+ // TIMEOUT_GRACE_MS imported from config.ts
299
+
292
300
  /**
293
301
  * Spawn a CLI and deliver the prompt via stdin.
294
302
  *
303
+ * Timeout handling (replaces Node's spawn({ timeout }) for better control):
304
+ * 1. After `timeoutMs`, send SIGTERM and log a clear message.
305
+ * 2. If the process doesn't exit within TIMEOUT_GRACE_MS (5s), send SIGKILL.
306
+ * 3. The result's `timedOut` flag is set so callers can distinguish
307
+ * supervisor timeouts from real CLI errors.
308
+ *
295
309
  * cwd defaults to homedir() so CLIs that scan the working directory for
296
310
  * project context (like Gemini) don't accidentally enter agentic mode.
297
311
  */
@@ -299,20 +313,44 @@ export function runCli(
299
313
  cmd: string,
300
314
  args: string[],
301
315
  prompt: string,
302
- timeoutMs = 120_000,
316
+ timeoutMs = DEFAULT_CLI_TIMEOUT_MS,
303
317
  opts: RunCliOptions = {}
304
318
  ): Promise<CliRunResult> {
305
319
  const cwd = opts.cwd ?? homedir();
320
+ const log = opts.log ?? (() => {});
306
321
 
307
322
  return new Promise((resolve, reject) => {
323
+ // Do NOT pass timeout to spawn() — we manage it ourselves for graceful shutdown.
308
324
  const proc = spawn(cmd, args, {
309
- timeout: timeoutMs,
310
325
  env: buildMinimalEnv(),
311
326
  cwd,
312
327
  });
313
328
 
314
329
  let stdout = "";
315
330
  let stderr = "";
331
+ let timedOut = false;
332
+ let killTimer: ReturnType<typeof setTimeout> | null = null;
333
+ let timeoutTimer: ReturnType<typeof setTimeout> | null = null;
334
+
335
+ const clearTimers = () => {
336
+ if (timeoutTimer) { clearTimeout(timeoutTimer); timeoutTimer = null; }
337
+ if (killTimer) { clearTimeout(killTimer); killTimer = null; }
338
+ };
339
+
340
+ // ── Timeout sequence: SIGTERM → grace → SIGKILL ──────────────────────
341
+ timeoutTimer = setTimeout(() => {
342
+ timedOut = true;
343
+ const elapsed = Math.round(timeoutMs / 1000);
344
+ log(`[cli-bridge] timeout after ${elapsed}s for ${cmd}, sending SIGTERM`);
345
+ proc.kill("SIGTERM");
346
+
347
+ killTimer = setTimeout(() => {
348
+ if (!proc.killed) {
349
+ log(`[cli-bridge] ${cmd} still running after ${TIMEOUT_GRACE_MS / 1000}s grace, sending SIGKILL`);
350
+ proc.kill("SIGKILL");
351
+ }
352
+ }, TIMEOUT_GRACE_MS);
353
+ }, timeoutMs);
316
354
 
317
355
  proc.stdin.write(prompt, "utf8", () => {
318
356
  proc.stdin.end();
@@ -322,10 +360,12 @@ export function runCli(
322
360
  proc.stderr.on("data", (d: Buffer) => { stderr += d.toString(); });
323
361
 
324
362
  proc.on("close", (code) => {
325
- resolve({ stdout: stdout.trim(), stderr: stderr.trim(), exitCode: code ?? 0 });
363
+ clearTimers();
364
+ resolve({ stdout: stdout.trim(), stderr: stderr.trim(), exitCode: code ?? 0, timedOut });
326
365
  });
327
366
 
328
367
  proc.on("error", (err) => {
368
+ clearTimers();
329
369
  reject(new Error(`Failed to spawn '${cmd}': ${err.message}`));
330
370
  });
331
371
  });
@@ -334,38 +374,75 @@ export function runCli(
334
374
  /**
335
375
  * Spawn a CLI with the prompt delivered as a CLI argument (not stdin).
336
376
  * Used by OpenCode which expects `opencode run "prompt"`.
377
+ * Uses the same graceful SIGTERM→SIGKILL timeout sequence as runCli.
337
378
  */
338
379
  export function runCliWithArg(
339
380
  cmd: string,
340
381
  args: string[],
341
- timeoutMs = 120_000,
382
+ timeoutMs = DEFAULT_CLI_TIMEOUT_MS,
342
383
  opts: RunCliOptions = {}
343
384
  ): Promise<CliRunResult> {
344
385
  const cwd = opts.cwd ?? homedir();
386
+ const log = opts.log ?? (() => {});
345
387
 
346
388
  return new Promise((resolve, reject) => {
347
389
  const proc = spawn(cmd, args, {
348
- timeout: timeoutMs,
349
390
  env: buildMinimalEnv(),
350
391
  cwd,
351
392
  });
352
393
 
353
394
  let stdout = "";
354
395
  let stderr = "";
396
+ let timedOut = false;
397
+ let killTimer: ReturnType<typeof setTimeout> | null = null;
398
+ let timeoutTimer: ReturnType<typeof setTimeout> | null = null;
399
+
400
+ const clearTimers = () => {
401
+ if (timeoutTimer) { clearTimeout(timeoutTimer); timeoutTimer = null; }
402
+ if (killTimer) { clearTimeout(killTimer); killTimer = null; }
403
+ };
404
+
405
+ timeoutTimer = setTimeout(() => {
406
+ timedOut = true;
407
+ const elapsed = Math.round(timeoutMs / 1000);
408
+ log(`[cli-bridge] timeout after ${elapsed}s for ${cmd}, sending SIGTERM`);
409
+ proc.kill("SIGTERM");
410
+
411
+ killTimer = setTimeout(() => {
412
+ if (!proc.killed) {
413
+ log(`[cli-bridge] ${cmd} still running after ${TIMEOUT_GRACE_MS / 1000}s grace, sending SIGKILL`);
414
+ proc.kill("SIGKILL");
415
+ }
416
+ }, TIMEOUT_GRACE_MS);
417
+ }, timeoutMs);
355
418
 
356
419
  proc.stdout.on("data", (d: Buffer) => { stdout += d.toString(); });
357
420
  proc.stderr.on("data", (d: Buffer) => { stderr += d.toString(); });
358
421
 
359
422
  proc.on("close", (code) => {
360
- resolve({ stdout: stdout.trim(), stderr: stderr.trim(), exitCode: code ?? 0 });
423
+ clearTimers();
424
+ resolve({ stdout: stdout.trim(), stderr: stderr.trim(), exitCode: code ?? 0, timedOut });
361
425
  });
362
426
 
363
427
  proc.on("error", (err) => {
428
+ clearTimers();
364
429
  reject(new Error(`Failed to spawn '${cmd}': ${err.message}`));
365
430
  });
366
431
  });
367
432
  }
368
433
 
434
+ /**
435
+ * Annotate an error message when exit code 143 (SIGTERM) is detected.
436
+ * Makes it clear in logs that this was a supervisor timeout, not a model error.
437
+ */
438
+ export function annotateExitError(exitCode: number, stderr: string, timedOut: boolean, model: string): string {
439
+ const base = stderr || "(no output)";
440
+ if (timedOut || exitCode === 143) {
441
+ return `timeout: ${model} killed by supervisor (exit ${exitCode}, likely timeout) — ${base}`;
442
+ }
443
+ return base;
444
+ }
445
+
369
446
  // ──────────────────────────────────────────────────────────────────────────────
370
447
  // Gemini CLI
371
448
  // ──────────────────────────────────────────────────────────────────────────────
@@ -391,7 +468,7 @@ export async function runGemini(
391
468
  modelId: string,
392
469
  timeoutMs: number,
393
470
  workdir?: string,
394
- opts?: { tools?: ToolDefinition[] }
471
+ opts?: { tools?: ToolDefinition[]; log?: (msg: string) => void }
395
472
  ): Promise<string> {
396
473
  const model = stripPrefix(modelId);
397
474
  // -p "" = headless mode trigger; actual prompt arrives via stdin
@@ -404,7 +481,7 @@ export async function runGemini(
404
481
  ? buildToolPromptBlock(opts.tools) + "\n\n" + prompt
405
482
  : prompt;
406
483
 
407
- const result = await runCli("gemini", args, effectivePrompt, timeoutMs, { cwd });
484
+ const result = await runCli("gemini", args, effectivePrompt, timeoutMs, { cwd, log: opts?.log });
408
485
 
409
486
  // Filter out [WARN] lines from stderr (Gemini emits noisy permission warnings)
410
487
  const cleanStderr = result.stderr
@@ -414,7 +491,7 @@ export async function runGemini(
414
491
  .trim();
415
492
 
416
493
  if (result.exitCode !== 0 && result.stdout.length === 0) {
417
- throw new Error(`gemini exited ${result.exitCode}: ${cleanStderr || "(no output)"}`);
494
+ throw new Error(`gemini exited ${result.exitCode}: ${annotateExitError(result.exitCode, cleanStderr, result.timedOut, modelId)}`);
418
495
  }
419
496
 
420
497
  return result.stdout || cleanStderr;
@@ -434,7 +511,7 @@ export async function runClaude(
434
511
  modelId: string,
435
512
  timeoutMs: number,
436
513
  workdir?: string,
437
- opts?: { tools?: ToolDefinition[] }
514
+ opts?: { tools?: ToolDefinition[]; log?: (msg: string) => void }
438
515
  ): Promise<string> {
439
516
  // Proactively refresh OAuth token if it's about to expire (< 5 min remaining).
440
517
  // No-op for API-key users.
@@ -457,15 +534,19 @@ export async function runClaude(
457
534
  : prompt;
458
535
 
459
536
  const cwd = workdir ?? homedir();
460
- const result = await runCli("claude", args, effectivePrompt, timeoutMs, { cwd });
537
+ const result = await runCli("claude", args, effectivePrompt, timeoutMs, { cwd, log: opts?.log });
461
538
 
462
539
  // On 401: attempt one token refresh + retry before giving up.
463
540
  if (result.exitCode !== 0 && result.stdout.length === 0) {
541
+ // If this was a timeout, don't bother with auth retry — it's a supervisor kill, not a 401.
542
+ if (result.timedOut) {
543
+ throw new Error(`claude exited ${result.exitCode}: ${annotateExitError(result.exitCode, result.stderr, true, modelId)}`);
544
+ }
464
545
  const stderr = result.stderr || "(no output)";
465
546
  if (stderr.includes("401") || stderr.includes("Invalid authentication credentials") || stderr.includes("authentication_error")) {
466
547
  // Refresh and retry once
467
548
  await refreshClaudeToken();
468
- const retry = await runCli("claude", args, effectivePrompt, timeoutMs, { cwd });
549
+ const retry = await runCli("claude", args, effectivePrompt, timeoutMs, { cwd, log: opts?.log });
469
550
  if (retry.exitCode !== 0 && retry.stdout.length === 0) {
470
551
  const retryStderr = retry.stderr || "(no output)";
471
552
  if (retryStderr.includes("401") || retryStderr.includes("authentication_error") || retryStderr.includes("Invalid authentication credentials")) {
@@ -478,7 +559,7 @@ export async function runClaude(
478
559
  }
479
560
  return retry.stdout;
480
561
  }
481
- throw new Error(`claude exited ${result.exitCode}: ${stderr}`);
562
+ throw new Error(`claude exited ${result.exitCode}: ${annotateExitError(result.exitCode, stderr, false, modelId)}`);
482
563
  }
483
564
 
484
565
  return result.stdout;
@@ -508,7 +589,7 @@ export async function runCodex(
508
589
  modelId: string,
509
590
  timeoutMs: number,
510
591
  workdir?: string,
511
- opts?: { tools?: ToolDefinition[]; mediaFiles?: MediaFile[] }
592
+ opts?: { tools?: ToolDefinition[]; mediaFiles?: MediaFile[]; log?: (msg: string) => void }
512
593
  ): Promise<string> {
513
594
  const model = stripPrefix(modelId);
514
595
  const args = ["--model", model, "--quiet", "--full-auto"];
@@ -532,10 +613,10 @@ export async function runCodex(
532
613
  ? buildToolPromptBlock(opts.tools) + "\n\n" + prompt
533
614
  : prompt;
534
615
 
535
- const result = await runCli("codex", args, effectivePrompt, timeoutMs, { cwd });
616
+ const result = await runCli("codex", args, effectivePrompt, timeoutMs, { cwd, log: opts?.log });
536
617
 
537
618
  if (result.exitCode !== 0 && result.stdout.length === 0) {
538
- throw new Error(`codex exited ${result.exitCode}: ${result.stderr || "(no output)"}`);
619
+ throw new Error(`codex exited ${result.exitCode}: ${annotateExitError(result.exitCode, result.stderr, result.timedOut, modelId)}`);
539
620
  }
540
621
 
541
622
  return result.stdout || result.stderr;
@@ -553,14 +634,15 @@ export async function runOpenCode(
553
634
  prompt: string,
554
635
  _modelId: string,
555
636
  timeoutMs: number,
556
- workdir?: string
637
+ workdir?: string,
638
+ opts?: { log?: (msg: string) => void }
557
639
  ): Promise<string> {
558
640
  const args = ["run", prompt];
559
641
  const cwd = workdir ?? homedir();
560
- const result = await runCliWithArg("opencode", args, timeoutMs, { cwd });
642
+ const result = await runCliWithArg("opencode", args, timeoutMs, { cwd, log: opts?.log });
561
643
 
562
644
  if (result.exitCode !== 0 && result.stdout.length === 0) {
563
- throw new Error(`opencode exited ${result.exitCode}: ${result.stderr || "(no output)"}`);
645
+ throw new Error(`opencode exited ${result.exitCode}: ${annotateExitError(result.exitCode, result.stderr, result.timedOut, "opencode")}`);
564
646
  }
565
647
 
566
648
  return result.stdout || result.stderr;
@@ -578,14 +660,15 @@ export async function runPi(
578
660
  prompt: string,
579
661
  _modelId: string,
580
662
  timeoutMs: number,
581
- workdir?: string
663
+ workdir?: string,
664
+ opts?: { log?: (msg: string) => void }
582
665
  ): Promise<string> {
583
666
  const args = ["-p", prompt];
584
667
  const cwd = workdir ?? homedir();
585
- const result = await runCliWithArg("pi", args, timeoutMs, { cwd });
668
+ const result = await runCliWithArg("pi", args, timeoutMs, { cwd, log: opts?.log });
586
669
 
587
670
  if (result.exitCode !== 0 && result.stdout.length === 0) {
588
- throw new Error(`pi exited ${result.exitCode}: ${result.stderr || "(no output)"}`);
671
+ throw new Error(`pi exited ${result.exitCode}: ${annotateExitError(result.exitCode, result.stderr, result.timedOut, "pi")}`);
589
672
  }
590
673
 
591
674
  return result.stdout || result.stderr;
@@ -663,6 +746,8 @@ export interface RouteOptions {
663
746
  * Passed to CLIs that support native media input (e.g. codex -i).
664
747
  */
665
748
  mediaFiles?: MediaFile[];
749
+ /** Logger for timeout and lifecycle events. */
750
+ log?: (msg: string) => void;
666
751
  }
667
752
 
668
753
  /**
@@ -708,12 +793,13 @@ export async function routeToCliRunner(
708
793
  // Resolve aliases (e.g. gemini-3-pro → gemini-3-pro-preview) after allowlist check
709
794
  const resolved = normalizeModelAlias(normalized);
710
795
 
796
+ const log = opts.log;
711
797
  let rawText: string;
712
- if (resolved.startsWith("cli-gemini/")) rawText = await runGemini(prompt, resolved, timeoutMs, opts.workdir, { tools: opts.tools });
713
- else if (resolved.startsWith("cli-claude/")) rawText = await runClaude(prompt, resolved, timeoutMs, opts.workdir, { tools: opts.tools });
714
- else if (resolved.startsWith("openai-codex/")) rawText = await runCodex(prompt, resolved, timeoutMs, opts.workdir, { tools: opts.tools, mediaFiles: opts.mediaFiles });
715
- else if (resolved.startsWith("opencode/")) rawText = await runOpenCode(prompt, resolved, timeoutMs, opts.workdir);
716
- else if (resolved.startsWith("pi/")) rawText = await runPi(prompt, resolved, timeoutMs, opts.workdir);
798
+ if (resolved.startsWith("cli-gemini/")) rawText = await runGemini(prompt, resolved, timeoutMs, opts.workdir, { tools: opts.tools, log });
799
+ else if (resolved.startsWith("cli-claude/")) rawText = await runClaude(prompt, resolved, timeoutMs, opts.workdir, { tools: opts.tools, log });
800
+ else if (resolved.startsWith("openai-codex/")) rawText = await runCodex(prompt, resolved, timeoutMs, opts.workdir, { tools: opts.tools, mediaFiles: opts.mediaFiles, log });
801
+ else if (resolved.startsWith("opencode/")) rawText = await runOpenCode(prompt, resolved, timeoutMs, opts.workdir, { log });
802
+ else if (resolved.startsWith("pi/")) rawText = await runPi(prompt, resolved, timeoutMs, opts.workdir, { log });
717
803
  else throw new Error(
718
804
  `Unknown CLI bridge model: "${model}". Use "vllm/cli-gemini/<model>", "vllm/cli-claude/<model>", "openai-codex/<model>", "opencode/<model>", or "pi/<model>".`
719
805
  );