@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 +33 -3
- package/SKILL.md +1 -1
- package/index.ts +38 -23
- package/openclaw.plugin.json +21 -2
- package/package.json +1 -1
- package/src/cli-runner.ts +119 -33
- package/src/config.ts +217 -0
- package/src/provider-sessions.ts +264 -0
- package/src/proxy-server.ts +76 -15
- package/src/session-manager.ts +24 -7
- package/test/cli-runner-extended.test.ts +72 -0
- package/test/config.test.ts +102 -0
- package/test/provider-sessions.test.ts +294 -0
- package/test/session-manager.test.ts +14 -0
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.
|
|
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":
|
|
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 (
|
|
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
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
|
-
|
|
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 —
|
|
112
|
-
const GROK_PROFILE_DIR =
|
|
113
|
-
const GEMINI_PROFILE_DIR =
|
|
114
|
-
const CLAUDE_PROFILE_DIR =
|
|
115
|
-
const CHATGPT_PROFILE_DIR =
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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 =
|
|
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 =
|
|
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(
|
|
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 ??
|
|
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
|
-
|
|
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,
|
package/openclaw.plugin.json
CHANGED
|
@@ -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.
|
|
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": "
|
|
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.
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
);
|