@elvatis_com/openclaw-cli-bridge-elvatis 3.1.2 → 3.3.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 +11 -1
- package/SKILL.md +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/cli-runner.ts +173 -42
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.3.0`
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -406,6 +406,16 @@ npm run ci # lint + typecheck + test
|
|
|
406
406
|
|
|
407
407
|
## Changelog
|
|
408
408
|
|
|
409
|
+
### v3.3.0
|
|
410
|
+
- **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
|
+
- **feat:** auto-rotation: sessions expire after 2 hours OR 50 requests (whichever first) to prevent context bloat
|
|
412
|
+
- **feat:** per-provider debug logging: `[GEMINI]`, `[CODEX]` categories with session state
|
|
413
|
+
|
|
414
|
+
### v3.2.0
|
|
415
|
+
- **feat:** Claude session resume — persistent sessions eliminate the 20KB prompt replay that caused Sonnet to hang. First request creates a session (`--session-id`), subsequent requests resume it (`--resume`). Claude keeps the conversation context; the bridge only sends the new message.
|
|
416
|
+
- **feat:** session registry persisted to `~/.openclaw/cli-bridge/claude-sessions.json` — survives gateway restarts, auto-expires after 2 hours of inactivity
|
|
417
|
+
- **feat:** auto-recovery: corrupted/expired sessions are detected and recreated transparently
|
|
418
|
+
|
|
409
419
|
### v3.1.2
|
|
410
420
|
- **fix:** fallback models returning text instead of tool_calls in a tool loop now trigger the next model in the chain. Previously Haiku would say "Lass mich das starten:" as text but never call a tool — conversation died.
|
|
411
421
|
- **feat:** `[FALLBACK-NO-TOOLS]` debug log category for tool-format violations
|
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.3.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.3.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,9 +18,9 @@
|
|
|
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 } from "node:fs";
|
|
21
|
+
import { existsSync, writeFileSync, unlinkSync, mkdirSync, readFileSync } from "node:fs";
|
|
22
22
|
import { join } from "node:path";
|
|
23
|
-
import { randomBytes } from "node:crypto";
|
|
23
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
24
24
|
import { ensureClaudeToken, refreshClaudeToken } from "./claude-auth.js";
|
|
25
25
|
import {
|
|
26
26
|
type ToolDefinition,
|
|
@@ -503,18 +503,26 @@ export async function runGemini(
|
|
|
503
503
|
opts?: { tools?: ToolDefinition[]; log?: (msg: string) => void }
|
|
504
504
|
): Promise<string> {
|
|
505
505
|
const model = stripPrefix(modelId);
|
|
506
|
+
const session = getOrCreateSession("gemini", model);
|
|
507
|
+
const isResume = session.requestCount > 0;
|
|
508
|
+
|
|
506
509
|
// -p "" = headless mode trigger; actual prompt arrives via stdin
|
|
507
510
|
// --approval-mode yolo: auto-approve all tool executions, never ask questions
|
|
508
511
|
const args = ["-m", model, "-p", "", "--approval-mode", "yolo"];
|
|
512
|
+
if (isResume) {
|
|
513
|
+
args.push("--resume", session.sessionId);
|
|
514
|
+
}
|
|
509
515
|
const cwd = workdir ?? tmpdir();
|
|
510
516
|
|
|
511
517
|
// When tools are present, sandwich the conversation between tool instructions.
|
|
512
|
-
// The reminder at the end ensures models (especially Haiku) remember the JSON format
|
|
513
|
-
// after processing a long conversation history.
|
|
514
518
|
const effectivePrompt = opts?.tools?.length
|
|
515
519
|
? buildToolPromptBlock(opts.tools) + "\n\n" + prompt + "\n\nREMINDER: You MUST respond with ONLY valid JSON — either {\"tool_calls\":[...]} or {\"content\":\"...\"}. Nothing else."
|
|
516
520
|
: prompt;
|
|
517
521
|
|
|
522
|
+
debugLog("GEMINI", `${isResume ? "resume" : "new"} ${model} session=${session.sessionId.slice(0, 8)}`, {
|
|
523
|
+
promptLen: effectivePrompt.length, requestCount: session.requestCount,
|
|
524
|
+
});
|
|
525
|
+
|
|
518
526
|
const result = await runCli("gemini", args, effectivePrompt, timeoutMs, { cwd, log: opts?.log });
|
|
519
527
|
|
|
520
528
|
// Filter out [WARN] lines from stderr (Gemini emits noisy permission warnings)
|
|
@@ -525,9 +533,14 @@ export async function runGemini(
|
|
|
525
533
|
.trim();
|
|
526
534
|
|
|
527
535
|
if (result.exitCode !== 0 && result.stdout.length === 0) {
|
|
536
|
+
// Session might be invalid — invalidate and let next request create a fresh one
|
|
537
|
+
if (cleanStderr.includes("session") || cleanStderr.includes("resume") || cleanStderr.includes("not found")) {
|
|
538
|
+
invalidateSession(model);
|
|
539
|
+
}
|
|
528
540
|
throw new Error(`gemini exited ${result.exitCode}: ${annotateExitError(result.exitCode, cleanStderr, result.timedOut, modelId)}`);
|
|
529
541
|
}
|
|
530
542
|
|
|
543
|
+
recordSessionSuccess(model);
|
|
531
544
|
return result.stdout || cleanStderr;
|
|
532
545
|
}
|
|
533
546
|
|
|
@@ -535,10 +548,89 @@ export async function runGemini(
|
|
|
535
548
|
// Claude Code CLI
|
|
536
549
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
537
550
|
|
|
551
|
+
// ── Claude session registry ─────────────────────────────────────────────────
|
|
552
|
+
// Persistent sessions avoid re-sending the full 20KB prompt on every request.
|
|
553
|
+
// First call creates a session; subsequent calls resume it with just the new message.
|
|
554
|
+
|
|
555
|
+
// ── Generic CLI session registry ────────────────────────────────────────────
|
|
556
|
+
// Shared by Claude, Gemini, and Codex — persistent sessions avoid replaying
|
|
557
|
+
// the full conversation on every request.
|
|
558
|
+
|
|
559
|
+
const CLI_SESSIONS_FILE = join(homedir(), ".openclaw", "cli-bridge", "cli-sessions.json");
|
|
560
|
+
const SESSION_TTL = 2 * 60 * 60 * 1000; // 2 hours
|
|
561
|
+
const SESSION_MAX_REQUESTS = 50;
|
|
562
|
+
|
|
563
|
+
interface CliSessionEntry {
|
|
564
|
+
sessionId: string;
|
|
565
|
+
provider: string; // "claude" | "gemini" | "codex"
|
|
566
|
+
model: string;
|
|
567
|
+
createdAt: number;
|
|
568
|
+
lastUsedAt: number;
|
|
569
|
+
requestCount: number;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const cliSessions = new Map<string, CliSessionEntry>();
|
|
573
|
+
let sessionsLoaded = false;
|
|
574
|
+
|
|
575
|
+
function loadCliSessions(): void {
|
|
576
|
+
if (sessionsLoaded) return;
|
|
577
|
+
sessionsLoaded = true;
|
|
578
|
+
try {
|
|
579
|
+
const data = JSON.parse(readFileSync(CLI_SESSIONS_FILE, "utf8"));
|
|
580
|
+
if (Array.isArray(data.sessions)) {
|
|
581
|
+
for (const s of data.sessions) cliSessions.set(s.model, s);
|
|
582
|
+
}
|
|
583
|
+
} catch { /* no sessions file yet */ }
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function saveCliSessions(): void {
|
|
587
|
+
try {
|
|
588
|
+
mkdirSync(join(homedir(), ".openclaw", "cli-bridge"), { recursive: true });
|
|
589
|
+
writeFileSync(CLI_SESSIONS_FILE, JSON.stringify({
|
|
590
|
+
version: 1,
|
|
591
|
+
sessions: [...cliSessions.values()],
|
|
592
|
+
}, null, 2));
|
|
593
|
+
} catch { /* best effort */ }
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function getOrCreateSession(provider: string, model: string): CliSessionEntry {
|
|
597
|
+
loadCliSessions();
|
|
598
|
+
const existing = cliSessions.get(model);
|
|
599
|
+
if (existing && (Date.now() - existing.lastUsedAt) < SESSION_TTL && existing.requestCount < SESSION_MAX_REQUESTS) {
|
|
600
|
+
return existing;
|
|
601
|
+
}
|
|
602
|
+
if (existing) {
|
|
603
|
+
debugLog("SESSION", `${provider} session ${existing.sessionId.slice(0, 8)} expired`, { reason: existing.requestCount >= SESSION_MAX_REQUESTS ? "max_requests" : "ttl", requestCount: existing.requestCount });
|
|
604
|
+
}
|
|
605
|
+
const entry: CliSessionEntry = {
|
|
606
|
+
sessionId: randomUUID(),
|
|
607
|
+
provider,
|
|
608
|
+
model,
|
|
609
|
+
createdAt: Date.now(),
|
|
610
|
+
lastUsedAt: Date.now(),
|
|
611
|
+
requestCount: 0,
|
|
612
|
+
};
|
|
613
|
+
cliSessions.set(model, entry);
|
|
614
|
+
saveCliSessions();
|
|
615
|
+
return entry;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function recordSessionSuccess(model: string): void {
|
|
619
|
+
const s = cliSessions.get(model);
|
|
620
|
+
if (s) { s.requestCount++; s.lastUsedAt = Date.now(); saveCliSessions(); }
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function invalidateSession(model: string): void {
|
|
624
|
+
cliSessions.delete(model);
|
|
625
|
+
saveCliSessions();
|
|
626
|
+
}
|
|
627
|
+
|
|
538
628
|
/**
|
|
539
|
-
* Run Claude Code CLI in headless mode with
|
|
540
|
-
*
|
|
541
|
-
*
|
|
629
|
+
* Run Claude Code CLI in headless mode with session resume.
|
|
630
|
+
*
|
|
631
|
+
* First request: creates a new session with --session-id.
|
|
632
|
+
* Subsequent requests: --resume <session-id> with only the new message.
|
|
633
|
+
* This eliminates the 20KB prompt replay that causes Sonnet to hang.
|
|
542
634
|
*/
|
|
543
635
|
export async function runClaude(
|
|
544
636
|
prompt: string,
|
|
@@ -547,13 +639,12 @@ export async function runClaude(
|
|
|
547
639
|
workdir?: string,
|
|
548
640
|
opts?: { tools?: ToolDefinition[]; log?: (msg: string) => void }
|
|
549
641
|
): Promise<string> {
|
|
550
|
-
// Proactively refresh OAuth token if it's about to expire (< 5 min remaining).
|
|
551
|
-
// No-op for API-key users.
|
|
552
642
|
await ensureClaudeToken();
|
|
553
643
|
|
|
554
644
|
const model = stripPrefix(modelId);
|
|
555
|
-
|
|
556
|
-
|
|
645
|
+
const session = getOrCreateSession("claude", model);
|
|
646
|
+
const isResume = session.requestCount > 0;
|
|
647
|
+
|
|
557
648
|
const args: string[] = [
|
|
558
649
|
"-p",
|
|
559
650
|
"--output-format", "text",
|
|
@@ -562,44 +653,77 @@ export async function runClaude(
|
|
|
562
653
|
"--model", model,
|
|
563
654
|
];
|
|
564
655
|
|
|
656
|
+
if (isResume) {
|
|
657
|
+
args.push("--resume", session.sessionId);
|
|
658
|
+
} else {
|
|
659
|
+
args.push("--session-id", session.sessionId);
|
|
660
|
+
}
|
|
661
|
+
|
|
565
662
|
// When tools are present, sandwich the conversation between tool instructions.
|
|
566
|
-
//
|
|
567
|
-
//
|
|
663
|
+
// On resume: only send the last user message (Claude has the full history).
|
|
664
|
+
// On first request: send the full prompt with tool block.
|
|
568
665
|
const effectivePrompt = opts?.tools?.length
|
|
569
666
|
? buildToolPromptBlock(opts.tools) + "\n\n" + prompt + "\n\nREMINDER: You MUST respond with ONLY valid JSON — either {\"tool_calls\":[...]} or {\"content\":\"...\"}. Nothing else."
|
|
570
667
|
: prompt;
|
|
571
668
|
|
|
572
669
|
const cwd = workdir ?? homedir();
|
|
573
|
-
debugLog("CLAUDE",
|
|
670
|
+
debugLog("CLAUDE", `${isResume ? "resume" : "new"} ${model} session=${session.sessionId.slice(0, 8)}`, {
|
|
671
|
+
promptLen: effectivePrompt.length, promptKB: Math.round(effectivePrompt.length / 1024),
|
|
672
|
+
requestCount: session.requestCount, cwd, timeoutMs: Math.round(timeoutMs / 1000),
|
|
673
|
+
});
|
|
674
|
+
|
|
574
675
|
const result = await runCli("claude", args, effectivePrompt, timeoutMs, { cwd, log: opts?.log });
|
|
575
676
|
|
|
576
|
-
//
|
|
577
|
-
if (result.exitCode
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
677
|
+
// Session succeeded — update registry
|
|
678
|
+
if (result.exitCode === 0 || result.stdout.length > 0) {
|
|
679
|
+
recordSessionSuccess(model);
|
|
680
|
+
return result.stdout;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Session failed — check if it's a timeout or auth issue
|
|
684
|
+
if (result.timedOut) {
|
|
685
|
+
// Don't invalidate session on timeout — it's still valid, just slow
|
|
686
|
+
recordSessionSuccess(model); // keep session alive
|
|
687
|
+
throw new Error(`claude exited ${result.exitCode}: ${annotateExitError(result.exitCode, result.stderr, true, modelId)}`);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const stderr = result.stderr || "(no output)";
|
|
691
|
+
|
|
692
|
+
// Session might be corrupted or expired — invalidate and retry with a fresh session
|
|
693
|
+
if (stderr.includes("session") || stderr.includes("resume") || stderr.includes("not found")) {
|
|
694
|
+
debugLog("CLAUDE", `session ${session.sessionId.slice(0, 8)} invalid, creating fresh`, { error: stderr.slice(0, 100) });
|
|
695
|
+
invalidateSession(model);
|
|
696
|
+
// Retry once with a fresh session
|
|
697
|
+
const freshSession = getOrCreateSession("claude", model);
|
|
698
|
+
const freshArgs = [
|
|
699
|
+
"-p", "--output-format", "text",
|
|
700
|
+
"--permission-mode", "bypassPermissions", "--dangerously-skip-permissions",
|
|
701
|
+
"--model", model, "--session-id", freshSession.sessionId,
|
|
702
|
+
];
|
|
703
|
+
const retry = await runCli("claude", freshArgs, effectivePrompt, timeoutMs, { cwd, log: opts?.log });
|
|
704
|
+
if (retry.exitCode === 0 || retry.stdout.length > 0) {
|
|
705
|
+
recordSessionSuccess(model);
|
|
706
|
+
return retry.stdout;
|
|
581
707
|
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
"Claude CLI OAuth token refresh failed. " +
|
|
592
|
-
"Re-login required: run `claude auth logout && claude auth login` in a terminal."
|
|
593
|
-
);
|
|
594
|
-
}
|
|
595
|
-
throw new Error(`claude exited ${retry.exitCode} (after token refresh): ${retryStderr}`);
|
|
596
|
-
}
|
|
708
|
+
throw new Error(`claude exited ${retry.exitCode}: ${annotateExitError(retry.exitCode, retry.stderr || "(no output)", false, modelId)}`);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Auth failure — refresh token and retry
|
|
712
|
+
if (stderr.includes("401") || stderr.includes("Invalid authentication credentials") || stderr.includes("authentication_error")) {
|
|
713
|
+
await refreshClaudeToken();
|
|
714
|
+
const retry = await runCli("claude", args, effectivePrompt, timeoutMs, { cwd, log: opts?.log });
|
|
715
|
+
if (retry.exitCode === 0 || retry.stdout.length > 0) {
|
|
716
|
+
recordSessionSuccess(model);
|
|
597
717
|
return retry.stdout;
|
|
598
718
|
}
|
|
599
|
-
|
|
719
|
+
const retryStderr = retry.stderr || "(no output)";
|
|
720
|
+
if (retryStderr.includes("401") || retryStderr.includes("authentication_error")) {
|
|
721
|
+
throw new Error("Claude CLI OAuth token refresh failed. Re-login required: run `claude auth logout && claude auth login`.");
|
|
722
|
+
}
|
|
723
|
+
throw new Error(`claude exited ${retry.exitCode} (after token refresh): ${retryStderr}`);
|
|
600
724
|
}
|
|
601
725
|
|
|
602
|
-
|
|
726
|
+
throw new Error(`claude exited ${result.exitCode}: ${annotateExitError(result.exitCode, stderr, false, modelId)}`);
|
|
603
727
|
}
|
|
604
728
|
|
|
605
729
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
@@ -629,7 +753,13 @@ export async function runCodex(
|
|
|
629
753
|
opts?: { tools?: ToolDefinition[]; mediaFiles?: MediaFile[]; log?: (msg: string) => void }
|
|
630
754
|
): Promise<string> {
|
|
631
755
|
const model = stripPrefix(modelId);
|
|
632
|
-
const
|
|
756
|
+
const session = getOrCreateSession("codex", model);
|
|
757
|
+
const isResume = session.requestCount > 0;
|
|
758
|
+
|
|
759
|
+
// Codex uses "exec resume <session-id>" for resume, "exec" for new
|
|
760
|
+
const args = isResume
|
|
761
|
+
? ["exec", "resume", session.sessionId, "--model", model, "--full-auto"]
|
|
762
|
+
: ["exec", "--model", model, "--full-auto"];
|
|
633
763
|
|
|
634
764
|
// Codex supports native image input via -i flag
|
|
635
765
|
if (opts?.mediaFiles?.length) {
|
|
@@ -641,23 +771,24 @@ export async function runCodex(
|
|
|
641
771
|
}
|
|
642
772
|
|
|
643
773
|
const cwd = workdir ?? homedir();
|
|
644
|
-
|
|
645
|
-
// Codex requires a git repo in the working directory
|
|
646
774
|
ensureGitRepo(cwd);
|
|
647
775
|
|
|
648
|
-
// When tools are present, sandwich the conversation between tool instructions.
|
|
649
|
-
// The reminder at the end ensures models (especially Haiku) remember the JSON format
|
|
650
|
-
// after processing a long conversation history.
|
|
651
776
|
const effectivePrompt = opts?.tools?.length
|
|
652
777
|
? buildToolPromptBlock(opts.tools) + "\n\n" + prompt + "\n\nREMINDER: You MUST respond with ONLY valid JSON — either {\"tool_calls\":[...]} or {\"content\":\"...\"}. Nothing else."
|
|
653
778
|
: prompt;
|
|
654
779
|
|
|
780
|
+
debugLog("CODEX", `${isResume ? "resume" : "new"} ${model} session=${session.sessionId.slice(0, 8)}`, {
|
|
781
|
+
promptLen: effectivePrompt.length, requestCount: session.requestCount,
|
|
782
|
+
});
|
|
783
|
+
|
|
655
784
|
const result = await runCli("codex", args, effectivePrompt, timeoutMs, { cwd, log: opts?.log });
|
|
656
785
|
|
|
657
786
|
if (result.exitCode !== 0 && result.stdout.length === 0) {
|
|
787
|
+
if (isResume) invalidateSession(model); // session might be stale
|
|
658
788
|
throw new Error(`codex exited ${result.exitCode}: ${annotateExitError(result.exitCode, result.stderr, result.timedOut, modelId)}`);
|
|
659
789
|
}
|
|
660
790
|
|
|
791
|
+
recordSessionSuccess(model);
|
|
661
792
|
return result.stdout || result.stderr;
|
|
662
793
|
}
|
|
663
794
|
|