@elvatis_com/openclaw-cli-bridge-elvatis 3.1.2 → 3.2.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:** `3.1.2`
5
+ **Current version:** `3.2.0`
6
6
 
7
7
  ---
8
8
 
@@ -406,6 +406,11 @@ npm run ci # lint + typecheck + test
406
406
 
407
407
  ## Changelog
408
408
 
409
+ ### v3.2.0
410
+ - **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.
411
+ - **feat:** session registry persisted to `~/.openclaw/cli-bridge/claude-sessions.json` — survives gateway restarts, auto-expires after 2 hours of inactivity
412
+ - **feat:** auto-recovery: corrupted/expired sessions are detected and recreated transparently
413
+
409
414
  ### v3.1.2
410
415
  - **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
416
  - **feat:** `[FALLBACK-NO-TOOLS]` debug log category for tool-format violations
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:** 3.1.2
71
+ **Version:** 3.2.0
@@ -2,7 +2,7 @@
2
2
  "id": "openclaw-cli-bridge-elvatis",
3
3
  "slug": "openclaw-cli-bridge-elvatis",
4
4
  "name": "OpenClaw CLI Bridge",
5
- "version": "3.1.2",
5
+ "version": "3.2.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.1.2",
3
+ "version": "3.2.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,
@@ -535,10 +535,71 @@ export async function runGemini(
535
535
  // Claude Code CLI
536
536
  // ──────────────────────────────────────────────────────────────────────────────
537
537
 
538
+ // ── Claude session registry ─────────────────────────────────────────────────
539
+ // Persistent sessions avoid re-sending the full 20KB prompt on every request.
540
+ // First call creates a session; subsequent calls resume it with just the new message.
541
+
542
+ const CLAUDE_SESSIONS_FILE = join(homedir(), ".openclaw", "cli-bridge", "claude-sessions.json");
543
+
544
+ interface ClaudeSessionEntry {
545
+ sessionId: string;
546
+ model: string;
547
+ createdAt: number;
548
+ lastUsedAt: number;
549
+ requestCount: number;
550
+ }
551
+
552
+ const claudeSessions = new Map<string, ClaudeSessionEntry>();
553
+
554
+ function loadClaudeSessions(): void {
555
+ try {
556
+ const data = JSON.parse(readFileSync(CLAUDE_SESSIONS_FILE, "utf8"));
557
+ if (Array.isArray(data.sessions)) {
558
+ for (const s of data.sessions) claudeSessions.set(s.model, s);
559
+ }
560
+ } catch { /* no sessions file yet */ }
561
+ }
562
+
563
+ function saveClaudeSessions(): void {
564
+ try {
565
+ mkdirSync(join(homedir(), ".openclaw", "cli-bridge"), { recursive: true });
566
+ writeFileSync(CLAUDE_SESSIONS_FILE, JSON.stringify({
567
+ version: 1,
568
+ sessions: [...claudeSessions.values()],
569
+ }, null, 2));
570
+ } catch { /* best effort */ }
571
+ }
572
+
573
+ function getOrCreateSession(model: string): ClaudeSessionEntry {
574
+ if (claudeSessions.size === 0) loadClaudeSessions();
575
+ const existing = claudeSessions.get(model);
576
+ // Reuse session if it's less than 2 hours old
577
+ if (existing && (Date.now() - existing.lastUsedAt) < 2 * 60 * 60 * 1000) {
578
+ return existing;
579
+ }
580
+ const entry: ClaudeSessionEntry = {
581
+ sessionId: randomUUID(),
582
+ model,
583
+ createdAt: Date.now(),
584
+ lastUsedAt: Date.now(),
585
+ requestCount: 0,
586
+ };
587
+ claudeSessions.set(model, entry);
588
+ saveClaudeSessions();
589
+ return entry;
590
+ }
591
+
592
+ function invalidateSession(model: string): void {
593
+ claudeSessions.delete(model);
594
+ saveClaudeSessions();
595
+ }
596
+
538
597
  /**
539
- * Run Claude Code CLI in headless mode with prompt delivered via stdin.
540
- * Strips the model prefix ("cli-claude/claude-opus-4-6" → "claude-opus-4-6").
541
- * cwd = homedir() by default. Override with explicit workdir.
598
+ * Run Claude Code CLI in headless mode with session resume.
599
+ *
600
+ * First request: creates a new session with --session-id.
601
+ * Subsequent requests: --resume <session-id> with only the new message.
602
+ * This eliminates the 20KB prompt replay that causes Sonnet to hang.
542
603
  */
543
604
  export async function runClaude(
544
605
  prompt: string,
@@ -547,13 +608,12 @@ export async function runClaude(
547
608
  workdir?: string,
548
609
  opts?: { tools?: ToolDefinition[]; log?: (msg: string) => void }
549
610
  ): Promise<string> {
550
- // Proactively refresh OAuth token if it's about to expire (< 5 min remaining).
551
- // No-op for API-key users.
552
611
  await ensureClaudeToken();
553
612
 
554
613
  const model = stripPrefix(modelId);
555
- // Always use bypassPermissions to ensure fully autonomous execution (never asks questions).
556
- // Use text output for all cases — JSON schema is unreliable with Claude Code's system prompt.
614
+ const session = getOrCreateSession(model);
615
+ const isResume = session.requestCount > 0;
616
+
557
617
  const args: string[] = [
558
618
  "-p",
559
619
  "--output-format", "text",
@@ -562,44 +622,84 @@ export async function runClaude(
562
622
  "--model", model,
563
623
  ];
564
624
 
625
+ if (isResume) {
626
+ args.push("--resume", session.sessionId);
627
+ } else {
628
+ args.push("--session-id", session.sessionId);
629
+ }
630
+
565
631
  // When tools are present, sandwich the conversation between tool instructions.
566
- // The reminder at the end ensures models (especially Haiku) remember the JSON format
567
- // after processing a long conversation history.
632
+ // On resume: only send the last user message (Claude has the full history).
633
+ // On first request: send the full prompt with tool block.
568
634
  const effectivePrompt = opts?.tools?.length
569
635
  ? buildToolPromptBlock(opts.tools) + "\n\n" + prompt + "\n\nREMINDER: You MUST respond with ONLY valid JSON — either {\"tool_calls\":[...]} or {\"content\":\"...\"}. Nothing else."
570
636
  : prompt;
571
637
 
572
638
  const cwd = workdir ?? homedir();
573
- debugLog("CLAUDE", `spawn ${model}`, { promptLen: effectivePrompt.length, promptKB: Math.round(effectivePrompt.length / 1024), cwd, timeoutMs: Math.round(timeoutMs / 1000) });
639
+ debugLog("CLAUDE", `${isResume ? "resume" : "new"} ${model} session=${session.sessionId.slice(0, 8)}`, {
640
+ promptLen: effectivePrompt.length, promptKB: Math.round(effectivePrompt.length / 1024),
641
+ requestCount: session.requestCount, cwd, timeoutMs: Math.round(timeoutMs / 1000),
642
+ });
643
+
574
644
  const result = await runCli("claude", args, effectivePrompt, timeoutMs, { cwd, log: opts?.log });
575
645
 
576
- // On 401: attempt one token refresh + retry before giving up.
577
- if (result.exitCode !== 0 && result.stdout.length === 0) {
578
- // If this was a timeout, don't bother with auth retry — it's a supervisor kill, not a 401.
579
- if (result.timedOut) {
580
- throw new Error(`claude exited ${result.exitCode}: ${annotateExitError(result.exitCode, result.stderr, true, modelId)}`);
646
+ // Session succeeded update registry
647
+ if (result.exitCode === 0 || result.stdout.length > 0) {
648
+ session.requestCount++;
649
+ session.lastUsedAt = Date.now();
650
+ saveClaudeSessions();
651
+ return result.stdout;
652
+ }
653
+
654
+ // Session failed — check if it's a timeout or auth issue
655
+ if (result.timedOut) {
656
+ // Don't invalidate session on timeout — it's still valid, just slow
657
+ session.lastUsedAt = Date.now();
658
+ saveClaudeSessions();
659
+ throw new Error(`claude exited ${result.exitCode}: ${annotateExitError(result.exitCode, result.stderr, true, modelId)}`);
660
+ }
661
+
662
+ const stderr = result.stderr || "(no output)";
663
+
664
+ // Session might be corrupted or expired — invalidate and retry with a fresh session
665
+ if (stderr.includes("session") || stderr.includes("resume") || stderr.includes("not found")) {
666
+ debugLog("CLAUDE", `session ${session.sessionId.slice(0, 8)} invalid, creating fresh`, { error: stderr.slice(0, 100) });
667
+ invalidateSession(model);
668
+ // Retry once with a fresh session
669
+ const freshSession = getOrCreateSession(model);
670
+ const freshArgs = [
671
+ "-p", "--output-format", "text",
672
+ "--permission-mode", "bypassPermissions", "--dangerously-skip-permissions",
673
+ "--model", model, "--session-id", freshSession.sessionId,
674
+ ];
675
+ const retry = await runCli("claude", freshArgs, effectivePrompt, timeoutMs, { cwd, log: opts?.log });
676
+ if (retry.exitCode === 0 || retry.stdout.length > 0) {
677
+ freshSession.requestCount++;
678
+ freshSession.lastUsedAt = Date.now();
679
+ saveClaudeSessions();
680
+ return retry.stdout;
581
681
  }
582
- const stderr = result.stderr || "(no output)";
583
- if (stderr.includes("401") || stderr.includes("Invalid authentication credentials") || stderr.includes("authentication_error")) {
584
- // Refresh and retry once
585
- await refreshClaudeToken();
586
- const retry = await runCli("claude", args, effectivePrompt, timeoutMs, { cwd, log: opts?.log });
587
- if (retry.exitCode !== 0 && retry.stdout.length === 0) {
588
- const retryStderr = retry.stderr || "(no output)";
589
- if (retryStderr.includes("401") || retryStderr.includes("authentication_error") || retryStderr.includes("Invalid authentication credentials")) {
590
- throw new Error(
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
- }
682
+ throw new Error(`claude exited ${retry.exitCode}: ${annotateExitError(retry.exitCode, retry.stderr || "(no output)", false, modelId)}`);
683
+ }
684
+
685
+ // Auth failure — refresh token and retry
686
+ if (stderr.includes("401") || stderr.includes("Invalid authentication credentials") || stderr.includes("authentication_error")) {
687
+ await refreshClaudeToken();
688
+ const retry = await runCli("claude", args, effectivePrompt, timeoutMs, { cwd, log: opts?.log });
689
+ if (retry.exitCode === 0 || retry.stdout.length > 0) {
690
+ session.requestCount++;
691
+ session.lastUsedAt = Date.now();
692
+ saveClaudeSessions();
597
693
  return retry.stdout;
598
694
  }
599
- throw new Error(`claude exited ${result.exitCode}: ${annotateExitError(result.exitCode, stderr, false, modelId)}`);
695
+ const retryStderr = retry.stderr || "(no output)";
696
+ if (retryStderr.includes("401") || retryStderr.includes("authentication_error")) {
697
+ throw new Error("Claude CLI OAuth token refresh failed. Re-login required: run `claude auth logout && claude auth login`.");
698
+ }
699
+ throw new Error(`claude exited ${retry.exitCode} (after token refresh): ${retryStderr}`);
600
700
  }
601
701
 
602
- return result.stdout;
702
+ throw new Error(`claude exited ${result.exitCode}: ${annotateExitError(result.exitCode, stderr, false, modelId)}`);
603
703
  }
604
704
 
605
705
  // ──────────────────────────────────────────────────────────────────────────────