@elvatis_com/openclaw-cli-bridge-elvatis 3.3.0 → 3.4.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/.ai/handoff-session-resume.md +187 -0
- package/CLAUDE.md +4 -0
- package/README.md +5 -1
- package/SKILL.md +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/cli-runner.ts +91 -5
- package/src/config.ts +3 -0
- package/src/debug-log.ts +8 -0
- package/src/proxy-server.ts +4 -1
- package/test/cli-runner-extended.test.ts +1 -1
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# Handover: CLI Session Resume Pattern
|
|
2
|
+
|
|
3
|
+
## Problem Solved
|
|
4
|
+
|
|
5
|
+
Spawning fresh CLI processes (`claude -p`, `gemini -p`, `codex exec`) for every request forces the model to re-process the entire conversation history (20KB+) from scratch. This causes:
|
|
6
|
+
- **Silent hangs** — Sonnet goes completely silent (zero stdout) ~50% of the time on large prompts
|
|
7
|
+
- **Slow responses** — 80-120s per request instead of 5-10s
|
|
8
|
+
- **Wasted tokens** — the full history is re-tokenized on every call
|
|
9
|
+
|
|
10
|
+
## Solution: Session Resume
|
|
11
|
+
|
|
12
|
+
Instead of one-shot processes, maintain persistent sessions per model. First request creates a session, subsequent requests resume it — the CLI keeps the full conversation context.
|
|
13
|
+
|
|
14
|
+
## Implementation by CLI Tool
|
|
15
|
+
|
|
16
|
+
### Claude Code (`claude`)
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
# First request — create session
|
|
20
|
+
echo "user prompt" | claude -p \
|
|
21
|
+
--session-id "550e8400-e29b-41d4-a716-446655440000" \
|
|
22
|
+
--model claude-sonnet-4-6 \
|
|
23
|
+
--output-format text \
|
|
24
|
+
--permission-mode bypassPermissions \
|
|
25
|
+
--dangerously-skip-permissions
|
|
26
|
+
|
|
27
|
+
# Subsequent requests — resume (Claude has full context, only new message needed)
|
|
28
|
+
echo "follow-up prompt" | claude -p \
|
|
29
|
+
--resume "550e8400-e29b-41d4-a716-446655440000" \
|
|
30
|
+
--model claude-sonnet-4-6 \
|
|
31
|
+
--output-format text \
|
|
32
|
+
--permission-mode bypassPermissions \
|
|
33
|
+
--dangerously-skip-permissions
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Key flags:**
|
|
37
|
+
- `--session-id <uuid>` — creates a new session with this ID (first request)
|
|
38
|
+
- `--resume <uuid>` — resumes an existing session (subsequent requests)
|
|
39
|
+
- Both work with `-p` (print/headless mode)
|
|
40
|
+
- Session files stored by Claude CLI internally (~/.claude/projects/)
|
|
41
|
+
|
|
42
|
+
### Gemini CLI (`gemini`)
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# First request — auto-creates session
|
|
46
|
+
echo "user prompt" | gemini -m gemini-2.5-flash -p "" --approval-mode yolo
|
|
47
|
+
|
|
48
|
+
# Subsequent requests — resume by UUID
|
|
49
|
+
echo "follow-up" | gemini -m gemini-2.5-flash -p "" --resume "ad79893c-4e3d-40e6-83e7-400e49dba0d6" --approval-mode yolo
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**Key flags:**
|
|
53
|
+
- `--resume <uuid>` — resume by session UUID
|
|
54
|
+
- `--list-sessions` — list available sessions
|
|
55
|
+
- Session UUID is visible in `--list-sessions` output
|
|
56
|
+
|
|
57
|
+
**Note:** Gemini doesn't have a `--session-id` flag to create a specific UUID. The session is auto-created and the UUID is extracted from `--list-sessions` or from the output. For the bridge, we generate a UUID and pass it as `--resume` — Gemini creates a new session if the UUID doesn't exist.
|
|
58
|
+
|
|
59
|
+
### OpenAI Codex (`codex`)
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
# First request — auto-creates session
|
|
63
|
+
echo "user prompt" | codex exec --model gpt-5.3-codex --full-auto
|
|
64
|
+
|
|
65
|
+
# Subsequent requests — resume subcommand
|
|
66
|
+
echo "follow-up" | codex exec resume "550e8400-xxxx" --model gpt-5.3-codex --full-auto
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**Key flags:**
|
|
70
|
+
- `codex exec resume <session-id>` — resume subcommand (not a flag)
|
|
71
|
+
- `--ephemeral` — skip session persistence (opposite of what we want)
|
|
72
|
+
- Session ID is a UUID
|
|
73
|
+
|
|
74
|
+
## Session Registry Pattern (TypeScript)
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
interface CliSessionEntry {
|
|
78
|
+
sessionId: string; // UUID
|
|
79
|
+
provider: string; // "claude" | "gemini" | "codex"
|
|
80
|
+
model: string; // e.g. "claude-sonnet-4-6"
|
|
81
|
+
createdAt: number; // epoch ms
|
|
82
|
+
lastUsedAt: number; // epoch ms
|
|
83
|
+
requestCount: number; // total requests in this session
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Persist to JSON file
|
|
87
|
+
const SESSIONS_FILE = "~/.openclaw/cli-bridge/cli-sessions.json";
|
|
88
|
+
|
|
89
|
+
// Session lifecycle
|
|
90
|
+
function getOrCreateSession(provider: string, model: string): CliSessionEntry {
|
|
91
|
+
const existing = sessions.get(model);
|
|
92
|
+
|
|
93
|
+
// Reuse if fresh enough
|
|
94
|
+
const TTL = 2 * 60 * 60 * 1000; // 2 hours
|
|
95
|
+
const MAX_REQUESTS = 50; // context rotation
|
|
96
|
+
if (existing &&
|
|
97
|
+
(Date.now() - existing.lastUsedAt) < TTL &&
|
|
98
|
+
existing.requestCount < MAX_REQUESTS) {
|
|
99
|
+
return existing;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Create fresh session
|
|
103
|
+
return { sessionId: randomUUID(), provider, model, ... };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// After successful response
|
|
107
|
+
function recordSuccess(model: string): void {
|
|
108
|
+
session.requestCount++;
|
|
109
|
+
session.lastUsedAt = Date.now();
|
|
110
|
+
saveToDisk();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// On session error (corrupted, expired, not found)
|
|
114
|
+
function invalidate(model: string): void {
|
|
115
|
+
sessions.delete(model);
|
|
116
|
+
saveToDisk();
|
|
117
|
+
// Next request will auto-create a fresh session
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Session Expiry Strategy
|
|
122
|
+
|
|
123
|
+
| Condition | Action | Why |
|
|
124
|
+
|-----------|--------|-----|
|
|
125
|
+
| `lastUsedAt > 2 hours` | Create new session | Context may be stale |
|
|
126
|
+
| `requestCount >= 50` | Create new session | Prevent context bloat |
|
|
127
|
+
| CLI returns "session not found" | Invalidate + retry | Session file was cleaned up |
|
|
128
|
+
| CLI returns auth error | Refresh token + retry | OAuth token expired |
|
|
129
|
+
| CLI timeout (exit 143) | Keep session alive | Session is valid, API was slow |
|
|
130
|
+
|
|
131
|
+
## Performance Impact (measured on openclaw-cli-bridge)
|
|
132
|
+
|
|
133
|
+
| Metric | Before (one-shot) | After (session resume) |
|
|
134
|
+
|--------|-------------------|----------------------|
|
|
135
|
+
| Prompt size per request | 18-25 KB | < 1 KB (new message only) |
|
|
136
|
+
| Sonnet response time | 80-120s (50% hang rate) | 5-10s |
|
|
137
|
+
| Haiku response time | 5-15s | 3-5s |
|
|
138
|
+
| Silent hang rate | ~50% | Near 0% |
|
|
139
|
+
|
|
140
|
+
## Stream-JSON Mode (Future Enhancement)
|
|
141
|
+
|
|
142
|
+
Claude CLI supports bidirectional streaming via `--input-format stream-json --output-format stream-json --verbose`. This enables:
|
|
143
|
+
- **Persistent process** — don't spawn/kill per request, keep one running
|
|
144
|
+
- **Real-time streaming** — token-by-token output via SSE
|
|
145
|
+
- **Native tool calls** — Claude's own tools (Bash, Read, Write, Edit, Grep)
|
|
146
|
+
- **Rate limit visibility** — `rate_limit_event` messages show quota state
|
|
147
|
+
- **Cost tracking** — per-request cost in USD
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
# Bidirectional streaming session
|
|
151
|
+
echo '{"type":"user","message":{"role":"user","content":"hello"}}' | \
|
|
152
|
+
claude -p \
|
|
153
|
+
--model claude-sonnet-4-6 \
|
|
154
|
+
--input-format stream-json \
|
|
155
|
+
--output-format stream-json \
|
|
156
|
+
--verbose \
|
|
157
|
+
--permission-mode bypassPermissions \
|
|
158
|
+
--dangerously-skip-permissions
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Response includes `session_id`, tool list, model info, thinking blocks, and full usage metrics. This is the path to a fully persistent agent process.
|
|
162
|
+
|
|
163
|
+
## Files Reference (openclaw-cli-bridge-elvatis)
|
|
164
|
+
|
|
165
|
+
| File | What it does |
|
|
166
|
+
|------|-------------|
|
|
167
|
+
| `src/cli-runner.ts` | Session registry + `runClaude()`, `runGemini()`, `runCodex()` with resume |
|
|
168
|
+
| `src/config.ts` | `STALE_OUTPUT_TIMEOUT_MS = 30_000` (kill silent processes fast) |
|
|
169
|
+
| `src/tool-protocol.ts` | Tool schema injection + JSON response parsing |
|
|
170
|
+
| `src/proxy-server.ts` | Cross-provider fallback chains, empty-response detection |
|
|
171
|
+
| `src/debug-log.ts` | File-based debug log + SSE streaming |
|
|
172
|
+
| `~/.openclaw/cli-bridge/cli-sessions.json` | Persisted session registry |
|
|
173
|
+
| `~/.openclaw/cli-bridge/debug.log` | Real-time request lifecycle log |
|
|
174
|
+
|
|
175
|
+
## Key Learnings
|
|
176
|
+
|
|
177
|
+
1. **Claude Sonnet hangs silently** on large prompts (~50% of the time). NOT RAM (28GB free). Likely API-side rate limiting. Session resume fixes it by keeping prompts small.
|
|
178
|
+
|
|
179
|
+
2. **Exit code 143 = SIGTERM**, not OOM. Our stale-output detector sends it when the CLI produces zero stdout for 30 seconds.
|
|
180
|
+
|
|
181
|
+
3. **Haiku ignores JSON tool format** in long conversations — returns conversational text instead of `{"tool_calls":[...]}`. Fix: JSON reminder at the END of the prompt + reject text responses during tool loops.
|
|
182
|
+
|
|
183
|
+
4. **Empty responses (0 bytes) must trigger fallback**, not be treated as success. The model exits 0 but produces nothing useful.
|
|
184
|
+
|
|
185
|
+
5. **Cross-provider fallback chains** are essential: `Sonnet → Haiku → Gemini Flash → Codex`. Each provider has different failure modes.
|
|
186
|
+
|
|
187
|
+
6. **The gateway loads plugins from `~/.openclaw/extensions/`**, NOT from the workspace. Must rsync + `openclaw gateway restart` after every change.
|
package/CLAUDE.md
CHANGED
|
@@ -30,6 +30,8 @@ OpenClaw Gateway ──(HTTP)──> proxy-server.ts ──(spawn)──> claude
|
|
|
30
30
|
- **Smart fallback** — Sonnet tries first (better tool selection), 30s stale timeout kills it fast, Haiku takes over (~10s, reliable but picks wrong tools sometimes)
|
|
31
31
|
- **Compact tool schema** — when >8 tools, only send name+params (skip descriptions/full JSON schema), cuts prompt ~60%
|
|
32
32
|
- **Exit 143 = our SIGTERM** — not OOM, not crash. The bridge's timeout/stale-output detector sends SIGTERM, Claude CLI exits 143
|
|
33
|
+
- **Consecutive timeout rotation** — after 3 timeouts in a row on the same session, auto-expire it and create a fresh one. Prevents poisoned sessions from blocking all requests
|
|
34
|
+
- **Workspace project auto-detection** — scans `~/.openclaw/workspace/` for project directories; when the prompt contains an exact match of a project name, auto-sets `workdir` and injects `[Context: Working directory is ...]` into the prompt
|
|
33
35
|
|
|
34
36
|
## Build & Test
|
|
35
37
|
|
|
@@ -69,6 +71,8 @@ All magic numbers live here. Key values:
|
|
|
69
71
|
| `TOOL_HEAVY_THRESHOLD` | 10 | Reduce MAX_MESSAGES from 20 to 12 when tools exceed this |
|
|
70
72
|
| `COMPACT_TOOL_THRESHOLD` | 8 | Switch to compact tool schema (name+params only) |
|
|
71
73
|
| `TOOL_ROUTING_THRESHOLD` | 8 | (in proxy-server) Was used for Haiku routing, now Sonnet-first with fast fallback |
|
|
74
|
+
| `CONSECUTIVE_TIMEOUT_LIMIT` | 3 | (in cli-runner) Auto-expire session after N consecutive timeouts |
|
|
75
|
+
| `WORKSPACE_DIR` | `~/.openclaw/workspace` | Project directory scanned for auto-detection |
|
|
72
76
|
|
|
73
77
|
## Tool Protocol (src/tool-protocol.ts)
|
|
74
78
|
|
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:** `3.
|
|
5
|
+
**Current version:** `3.4.0`
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -406,6 +406,10 @@ npm run ci # lint + typecheck + test
|
|
|
406
406
|
|
|
407
407
|
## Changelog
|
|
408
408
|
|
|
409
|
+
### v3.3.1
|
|
410
|
+
- **fix:** test requests no longer pollute `debug.log` — test instances (port 0) now skip file logging
|
|
411
|
+
- **fix:** Codex test updated for session resume args
|
|
412
|
+
|
|
409
413
|
### v3.3.0
|
|
410
414
|
- **feat:** session resume for ALL CLI providers — Claude, Gemini, and Codex all now use persistent sessions with `--resume`. Unified session registry at `~/.openclaw/cli-bridge/cli-sessions.json`.
|
|
411
415
|
- **feat:** auto-rotation: sessions expire after 2 hours OR 50 requests (whichever first) to prevent context bloat
|
package/SKILL.md
CHANGED
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": "3.
|
|
5
|
+
"version": "3.4.0",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"description": "Phase 1: openai-codex auth bridge. Phase 2: local HTTP proxy routing model calls through gemini/claude CLIs (vllm provider).",
|
|
8
8
|
"providers": [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elvatis_com/openclaw-cli-bridge-elvatis",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.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
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import { spawn, execSync } from "node:child_process";
|
|
20
20
|
import { tmpdir, homedir } from "node:os";
|
|
21
|
-
import { existsSync, writeFileSync, unlinkSync, mkdirSync, readFileSync } from "node:fs";
|
|
21
|
+
import { existsSync, writeFileSync, unlinkSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
22
22
|
import { join } from "node:path";
|
|
23
23
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
24
24
|
import { ensureClaudeToken, refreshClaudeToken } from "./claude-auth.js";
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
TIMEOUT_GRACE_MS,
|
|
38
38
|
MEDIA_TMP_DIR,
|
|
39
39
|
STALE_OUTPUT_TIMEOUT_MS,
|
|
40
|
+
WORKSPACE_DIR,
|
|
40
41
|
} from "./config.js";
|
|
41
42
|
import { debugLog } from "./debug-log.js";
|
|
42
43
|
|
|
@@ -567,8 +568,11 @@ interface CliSessionEntry {
|
|
|
567
568
|
createdAt: number;
|
|
568
569
|
lastUsedAt: number;
|
|
569
570
|
requestCount: number;
|
|
571
|
+
consecutiveTimeouts: number;
|
|
570
572
|
}
|
|
571
573
|
|
|
574
|
+
const CONSECUTIVE_TIMEOUT_LIMIT = 3;
|
|
575
|
+
|
|
572
576
|
const cliSessions = new Map<string, CliSessionEntry>();
|
|
573
577
|
let sessionsLoaded = false;
|
|
574
578
|
|
|
@@ -609,6 +613,7 @@ function getOrCreateSession(provider: string, model: string): CliSessionEntry {
|
|
|
609
613
|
createdAt: Date.now(),
|
|
610
614
|
lastUsedAt: Date.now(),
|
|
611
615
|
requestCount: 0,
|
|
616
|
+
consecutiveTimeouts: 0,
|
|
612
617
|
};
|
|
613
618
|
cliSessions.set(model, entry);
|
|
614
619
|
saveCliSessions();
|
|
@@ -617,7 +622,21 @@ function getOrCreateSession(provider: string, model: string): CliSessionEntry {
|
|
|
617
622
|
|
|
618
623
|
function recordSessionSuccess(model: string): void {
|
|
619
624
|
const s = cliSessions.get(model);
|
|
620
|
-
if (s) { s.requestCount++; s.lastUsedAt = Date.now(); saveCliSessions(); }
|
|
625
|
+
if (s) { s.requestCount++; s.lastUsedAt = Date.now(); s.consecutiveTimeouts = 0; saveCliSessions(); }
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function recordSessionTimeout(model: string): void {
|
|
629
|
+
const s = cliSessions.get(model);
|
|
630
|
+
if (!s) return;
|
|
631
|
+
s.consecutiveTimeouts++;
|
|
632
|
+
s.lastUsedAt = Date.now();
|
|
633
|
+
if (s.consecutiveTimeouts >= CONSECUTIVE_TIMEOUT_LIMIT) {
|
|
634
|
+
debugLog("SESSION", `${s.provider} session ${s.sessionId.slice(0, 8)} expired`, {
|
|
635
|
+
reason: "consecutive_timeouts", consecutiveTimeouts: s.consecutiveTimeouts, requestCount: s.requestCount,
|
|
636
|
+
});
|
|
637
|
+
cliSessions.delete(model);
|
|
638
|
+
}
|
|
639
|
+
saveCliSessions();
|
|
621
640
|
}
|
|
622
641
|
|
|
623
642
|
function invalidateSession(model: string): void {
|
|
@@ -682,8 +701,8 @@ export async function runClaude(
|
|
|
682
701
|
|
|
683
702
|
// Session failed — check if it's a timeout or auth issue
|
|
684
703
|
if (result.timedOut) {
|
|
685
|
-
//
|
|
686
|
-
|
|
704
|
+
// Track consecutive timeouts — after 3 in a row, expire the session
|
|
705
|
+
recordSessionTimeout(model);
|
|
687
706
|
throw new Error(`claude exited ${result.exitCode}: ${annotateExitError(result.exitCode, result.stderr, true, modelId)}`);
|
|
688
707
|
}
|
|
689
708
|
|
|
@@ -920,6 +939,63 @@ export interface RouteOptions {
|
|
|
920
939
|
log?: (msg: string) => void;
|
|
921
940
|
}
|
|
922
941
|
|
|
942
|
+
// ── Workspace project detection ──────────────────────────────────────────────
|
|
943
|
+
// Scans WORKSPACE_DIR for project directories. When the user's prompt contains
|
|
944
|
+
// an exact match of a project name, auto-sets workdir and injects context.
|
|
945
|
+
|
|
946
|
+
let _workspaceProjects: string[] | null = null;
|
|
947
|
+
let _workspaceProjectsRefreshedAt = 0;
|
|
948
|
+
const WORKSPACE_CACHE_TTL = 60_000; // refresh project list every 60s
|
|
949
|
+
|
|
950
|
+
function getWorkspaceProjects(): string[] {
|
|
951
|
+
const now = Date.now();
|
|
952
|
+
if (_workspaceProjects && (now - _workspaceProjectsRefreshedAt) < WORKSPACE_CACHE_TTL) {
|
|
953
|
+
return _workspaceProjects;
|
|
954
|
+
}
|
|
955
|
+
try {
|
|
956
|
+
// Find all .openclaw/workspace dirs — default location + any custom ones
|
|
957
|
+
const candidates = [WORKSPACE_DIR];
|
|
958
|
+
_workspaceProjects = [];
|
|
959
|
+
for (const wsDir of candidates) {
|
|
960
|
+
if (!existsSync(wsDir)) continue;
|
|
961
|
+
const entries = readdirSync(wsDir);
|
|
962
|
+
for (const name of entries) {
|
|
963
|
+
try {
|
|
964
|
+
if (statSync(join(wsDir, name)).isDirectory()) {
|
|
965
|
+
_workspaceProjects.push(name);
|
|
966
|
+
}
|
|
967
|
+
} catch { /* skip unreadable entries */ }
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
_workspaceProjectsRefreshedAt = now;
|
|
971
|
+
} catch {
|
|
972
|
+
_workspaceProjects = [];
|
|
973
|
+
}
|
|
974
|
+
return _workspaceProjects;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function detectProjectFromPrompt(prompt: string): { name: string; path: string } | null {
|
|
978
|
+
const projects = getWorkspaceProjects();
|
|
979
|
+
if (!projects.length) return null;
|
|
980
|
+
|
|
981
|
+
// Sort by name length descending — match longest project name first
|
|
982
|
+
// (e.g. "openclaw-cli-bridge-elvatis" before "openclaw-cli-bridge")
|
|
983
|
+
const sorted = [...projects].sort((a, b) => b.length - a.length);
|
|
984
|
+
|
|
985
|
+
for (const name of sorted) {
|
|
986
|
+
// Case-insensitive exact word match — the project name must appear as a
|
|
987
|
+
// distinct token in the prompt (not a substring of a longer word)
|
|
988
|
+
const regex = new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i");
|
|
989
|
+
if (regex.test(prompt)) {
|
|
990
|
+
const projectPath = join(WORKSPACE_DIR, name);
|
|
991
|
+
if (existsSync(projectPath)) {
|
|
992
|
+
return { name, path: projectPath };
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
return null;
|
|
997
|
+
}
|
|
998
|
+
|
|
923
999
|
/**
|
|
924
1000
|
* Route a chat completion to the correct CLI based on model prefix.
|
|
925
1001
|
* cli-gemini/<id> → gemini CLI
|
|
@@ -941,9 +1017,19 @@ export async function routeToCliRunner(
|
|
|
941
1017
|
opts: RouteOptions = {}
|
|
942
1018
|
): Promise<CliToolResult> {
|
|
943
1019
|
const toolCount = opts.tools?.length ?? 0;
|
|
944
|
-
|
|
1020
|
+
let prompt = formatPrompt(messages, toolCount);
|
|
945
1021
|
const hasTools = toolCount > 0;
|
|
946
1022
|
|
|
1023
|
+
// Auto-detect project from prompt and set workdir + inject context
|
|
1024
|
+
if (!opts.workdir) {
|
|
1025
|
+
const detected = detectProjectFromPrompt(prompt);
|
|
1026
|
+
if (detected) {
|
|
1027
|
+
opts = { ...opts, workdir: detected.path };
|
|
1028
|
+
prompt = `[Context: Working directory is ${detected.path}]\n\n${prompt}`;
|
|
1029
|
+
debugLog("WORKSPACE", `auto-detected project "${detected.name}"`, { path: detected.path });
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
947
1033
|
// Strip "vllm/" prefix if present — OpenClaw sends the full provider path
|
|
948
1034
|
// (e.g. "vllm/cli-claude/claude-sonnet-4-6") but the router only needs the
|
|
949
1035
|
// "cli-<type>/<model>" portion.
|
package/src/config.ts
CHANGED
|
@@ -162,6 +162,9 @@ export const DEFAULT_MODEL_FALLBACKS: Record<string, string[]> = {
|
|
|
162
162
|
/** Base directory for all CLI bridge state files. */
|
|
163
163
|
export const OPENCLAW_DIR = join(homedir(), ".openclaw");
|
|
164
164
|
|
|
165
|
+
/** Workspace directory containing all projects. */
|
|
166
|
+
export const WORKSPACE_DIR = join(OPENCLAW_DIR, "workspace");
|
|
167
|
+
|
|
165
168
|
/** State file — persists the model active before the last /cli-* switch. */
|
|
166
169
|
export const STATE_FILE = join(OPENCLAW_DIR, "cli-bridge-state.json");
|
|
167
170
|
|
package/src/debug-log.ts
CHANGED
|
@@ -38,11 +38,19 @@ function ts(): string {
|
|
|
38
38
|
return new Date().toISOString();
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Suppress logging in test mode (vitest sets NODE_ENV or uses port 0).
|
|
43
|
+
* Without this, every test run pollutes the production debug log with 43+ fake requests.
|
|
44
|
+
*/
|
|
45
|
+
let _enabled = true;
|
|
46
|
+
export function setDebugLogEnabled(enabled: boolean): void { _enabled = enabled; }
|
|
47
|
+
|
|
41
48
|
/**
|
|
42
49
|
* Append a debug line to the log file.
|
|
43
50
|
* Non-blocking, never throws — logging must not crash the bridge.
|
|
44
51
|
*/
|
|
45
52
|
export function debugLog(category: string, message: string, data?: Record<string, unknown>): void {
|
|
53
|
+
if (!_enabled) return;
|
|
46
54
|
try {
|
|
47
55
|
ensureDir();
|
|
48
56
|
rotate();
|
package/src/proxy-server.ts
CHANGED
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
DEFAULT_MODEL_TIMEOUTS,
|
|
35
35
|
TOOL_ROUTING_THRESHOLD,
|
|
36
36
|
} from "./config.js";
|
|
37
|
-
import { debugLog, DEBUG_LOG_PATH, getLogTail, watchLogFile } from "./debug-log.js";
|
|
37
|
+
import { debugLog, DEBUG_LOG_PATH, getLogTail, watchLogFile, setDebugLogEnabled } from "./debug-log.js";
|
|
38
38
|
|
|
39
39
|
// ── Active request tracking ─────────────────────────────────────────────────
|
|
40
40
|
|
|
@@ -212,6 +212,9 @@ export function startProxyServer(opts: ProxyServerOptions): Promise<http.Server>
|
|
|
212
212
|
reject(err);
|
|
213
213
|
}
|
|
214
214
|
});
|
|
215
|
+
// Disable debug file logging for test instances (port 0) to avoid polluting production logs
|
|
216
|
+
if (opts.port === 0) setDebugLogEnabled(false);
|
|
217
|
+
|
|
215
218
|
server.listen(opts.port, "127.0.0.1", () => {
|
|
216
219
|
opts.log(
|
|
217
220
|
`[cli-bridge] proxy listening on :${opts.port}`
|
|
@@ -87,7 +87,7 @@ describe("runCodex()", () => {
|
|
|
87
87
|
expect(result).toBe("codex result");
|
|
88
88
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
89
89
|
"codex",
|
|
90
|
-
["exec", "--model", "gpt-5.3-codex", "--full-auto"],
|
|
90
|
+
expect.arrayContaining(["exec", "--model", "gpt-5.3-codex", "--full-auto"]),
|
|
91
91
|
expect.any(Object)
|
|
92
92
|
);
|
|
93
93
|
});
|