@elvatis_com/openclaw-cli-bridge-elvatis 3.2.0 → 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 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.2.0`
5
+ **Current version:** `3.3.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.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
+
409
414
  ### v3.2.0
410
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.
411
416
  - **feat:** session registry persisted to `~/.openclaw/cli-bridge/claude-sessions.json` — survives gateway restarts, auto-expires after 2 hours of inactivity
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.2.0
71
+ **Version:** 3.3.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.2.0",
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.2.0",
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
@@ -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
 
@@ -539,59 +552,77 @@ export async function runGemini(
539
552
  // Persistent sessions avoid re-sending the full 20KB prompt on every request.
540
553
  // First call creates a session; subsequent calls resume it with just the new message.
541
554
 
542
- const CLAUDE_SESSIONS_FILE = join(homedir(), ".openclaw", "cli-bridge", "claude-sessions.json");
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;
543
562
 
544
- interface ClaudeSessionEntry {
563
+ interface CliSessionEntry {
545
564
  sessionId: string;
565
+ provider: string; // "claude" | "gemini" | "codex"
546
566
  model: string;
547
567
  createdAt: number;
548
568
  lastUsedAt: number;
549
569
  requestCount: number;
550
570
  }
551
571
 
552
- const claudeSessions = new Map<string, ClaudeSessionEntry>();
572
+ const cliSessions = new Map<string, CliSessionEntry>();
573
+ let sessionsLoaded = false;
553
574
 
554
- function loadClaudeSessions(): void {
575
+ function loadCliSessions(): void {
576
+ if (sessionsLoaded) return;
577
+ sessionsLoaded = true;
555
578
  try {
556
- const data = JSON.parse(readFileSync(CLAUDE_SESSIONS_FILE, "utf8"));
579
+ const data = JSON.parse(readFileSync(CLI_SESSIONS_FILE, "utf8"));
557
580
  if (Array.isArray(data.sessions)) {
558
- for (const s of data.sessions) claudeSessions.set(s.model, s);
581
+ for (const s of data.sessions) cliSessions.set(s.model, s);
559
582
  }
560
583
  } catch { /* no sessions file yet */ }
561
584
  }
562
585
 
563
- function saveClaudeSessions(): void {
586
+ function saveCliSessions(): void {
564
587
  try {
565
588
  mkdirSync(join(homedir(), ".openclaw", "cli-bridge"), { recursive: true });
566
- writeFileSync(CLAUDE_SESSIONS_FILE, JSON.stringify({
589
+ writeFileSync(CLI_SESSIONS_FILE, JSON.stringify({
567
590
  version: 1,
568
- sessions: [...claudeSessions.values()],
591
+ sessions: [...cliSessions.values()],
569
592
  }, null, 2));
570
593
  } catch { /* best effort */ }
571
594
  }
572
595
 
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) {
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) {
578
600
  return existing;
579
601
  }
580
- const entry: ClaudeSessionEntry = {
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 = {
581
606
  sessionId: randomUUID(),
607
+ provider,
582
608
  model,
583
609
  createdAt: Date.now(),
584
610
  lastUsedAt: Date.now(),
585
611
  requestCount: 0,
586
612
  };
587
- claudeSessions.set(model, entry);
588
- saveClaudeSessions();
613
+ cliSessions.set(model, entry);
614
+ saveCliSessions();
589
615
  return entry;
590
616
  }
591
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
+
592
623
  function invalidateSession(model: string): void {
593
- claudeSessions.delete(model);
594
- saveClaudeSessions();
624
+ cliSessions.delete(model);
625
+ saveCliSessions();
595
626
  }
596
627
 
597
628
  /**
@@ -611,7 +642,7 @@ export async function runClaude(
611
642
  await ensureClaudeToken();
612
643
 
613
644
  const model = stripPrefix(modelId);
614
- const session = getOrCreateSession(model);
645
+ const session = getOrCreateSession("claude", model);
615
646
  const isResume = session.requestCount > 0;
616
647
 
617
648
  const args: string[] = [
@@ -645,17 +676,14 @@ export async function runClaude(
645
676
 
646
677
  // Session succeeded — update registry
647
678
  if (result.exitCode === 0 || result.stdout.length > 0) {
648
- session.requestCount++;
649
- session.lastUsedAt = Date.now();
650
- saveClaudeSessions();
679
+ recordSessionSuccess(model);
651
680
  return result.stdout;
652
681
  }
653
682
 
654
683
  // Session failed — check if it's a timeout or auth issue
655
684
  if (result.timedOut) {
656
685
  // Don't invalidate session on timeout — it's still valid, just slow
657
- session.lastUsedAt = Date.now();
658
- saveClaudeSessions();
686
+ recordSessionSuccess(model); // keep session alive
659
687
  throw new Error(`claude exited ${result.exitCode}: ${annotateExitError(result.exitCode, result.stderr, true, modelId)}`);
660
688
  }
661
689
 
@@ -666,7 +694,7 @@ export async function runClaude(
666
694
  debugLog("CLAUDE", `session ${session.sessionId.slice(0, 8)} invalid, creating fresh`, { error: stderr.slice(0, 100) });
667
695
  invalidateSession(model);
668
696
  // Retry once with a fresh session
669
- const freshSession = getOrCreateSession(model);
697
+ const freshSession = getOrCreateSession("claude", model);
670
698
  const freshArgs = [
671
699
  "-p", "--output-format", "text",
672
700
  "--permission-mode", "bypassPermissions", "--dangerously-skip-permissions",
@@ -674,9 +702,7 @@ export async function runClaude(
674
702
  ];
675
703
  const retry = await runCli("claude", freshArgs, effectivePrompt, timeoutMs, { cwd, log: opts?.log });
676
704
  if (retry.exitCode === 0 || retry.stdout.length > 0) {
677
- freshSession.requestCount++;
678
- freshSession.lastUsedAt = Date.now();
679
- saveClaudeSessions();
705
+ recordSessionSuccess(model);
680
706
  return retry.stdout;
681
707
  }
682
708
  throw new Error(`claude exited ${retry.exitCode}: ${annotateExitError(retry.exitCode, retry.stderr || "(no output)", false, modelId)}`);
@@ -687,9 +713,7 @@ export async function runClaude(
687
713
  await refreshClaudeToken();
688
714
  const retry = await runCli("claude", args, effectivePrompt, timeoutMs, { cwd, log: opts?.log });
689
715
  if (retry.exitCode === 0 || retry.stdout.length > 0) {
690
- session.requestCount++;
691
- session.lastUsedAt = Date.now();
692
- saveClaudeSessions();
716
+ recordSessionSuccess(model);
693
717
  return retry.stdout;
694
718
  }
695
719
  const retryStderr = retry.stderr || "(no output)";
@@ -729,7 +753,13 @@ export async function runCodex(
729
753
  opts?: { tools?: ToolDefinition[]; mediaFiles?: MediaFile[]; log?: (msg: string) => void }
730
754
  ): Promise<string> {
731
755
  const model = stripPrefix(modelId);
732
- const args = ["exec", "--model", model, "--full-auto"];
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"];
733
763
 
734
764
  // Codex supports native image input via -i flag
735
765
  if (opts?.mediaFiles?.length) {
@@ -741,23 +771,24 @@ export async function runCodex(
741
771
  }
742
772
 
743
773
  const cwd = workdir ?? homedir();
744
-
745
- // Codex requires a git repo in the working directory
746
774
  ensureGitRepo(cwd);
747
775
 
748
- // When tools are present, sandwich the conversation between tool instructions.
749
- // The reminder at the end ensures models (especially Haiku) remember the JSON format
750
- // after processing a long conversation history.
751
776
  const effectivePrompt = opts?.tools?.length
752
777
  ? buildToolPromptBlock(opts.tools) + "\n\n" + prompt + "\n\nREMINDER: You MUST respond with ONLY valid JSON — either {\"tool_calls\":[...]} or {\"content\":\"...\"}. Nothing else."
753
778
  : prompt;
754
779
 
780
+ debugLog("CODEX", `${isResume ? "resume" : "new"} ${model} session=${session.sessionId.slice(0, 8)}`, {
781
+ promptLen: effectivePrompt.length, requestCount: session.requestCount,
782
+ });
783
+
755
784
  const result = await runCli("codex", args, effectivePrompt, timeoutMs, { cwd, log: opts?.log });
756
785
 
757
786
  if (result.exitCode !== 0 && result.stdout.length === 0) {
787
+ if (isResume) invalidateSession(model); // session might be stale
758
788
  throw new Error(`codex exited ${result.exitCode}: ${annotateExitError(result.exitCode, result.stderr, result.timedOut, modelId)}`);
759
789
  }
760
790
 
791
+ recordSessionSuccess(model);
761
792
  return result.stdout || result.stderr;
762
793
  }
763
794