@elvatis_com/openclaw-cli-bridge-elvatis 0.2.23 → 0.2.26

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/index.ts CHANGED
@@ -48,6 +48,16 @@ import {
48
48
  } from "./src/codex-auth.js";
49
49
  import { startProxyServer } from "./src/proxy-server.js";
50
50
  import { patchOpencllawConfig } from "./src/config-patcher.js";
51
+ import {
52
+ loadSession,
53
+ deleteSession,
54
+ isSessionExpiredByAge,
55
+ verifySession,
56
+ runInteractiveLogin,
57
+ createContextFromSession,
58
+ DEFAULT_SESSION_PATH,
59
+ } from "./src/grok-session.js";
60
+ import type { BrowserContext, Browser } from "playwright";
51
61
 
52
62
  // ──────────────────────────────────────────────────────────────────────────────
53
63
  // Types derived from SDK (not re-exported by the package)
@@ -66,6 +76,46 @@ interface CliPluginConfig {
66
76
  proxyPort?: number;
67
77
  proxyApiKey?: string;
68
78
  proxyTimeoutMs?: number;
79
+ grokSessionPath?: string;
80
+ }
81
+
82
+ // ──────────────────────────────────────────────────────────────────────────────
83
+ // Grok web-session state (module-level, persists across commands)
84
+ // ──────────────────────────────────────────────────────────────────────────────
85
+
86
+ let grokBrowser: Browser | null = null;
87
+ let grokContext: BrowserContext | null = null;
88
+
89
+ async function launchGrokBrowser(): Promise<Browser> {
90
+ const { chromium } = await import("playwright");
91
+ return chromium.launch({ headless: false });
92
+ }
93
+
94
+ async function tryRestoreGrokSession(
95
+ sessionPath: string,
96
+ log: (msg: string) => void
97
+ ): Promise<boolean> {
98
+ const saved = loadSession(sessionPath);
99
+ if (!saved || isSessionExpiredByAge(saved)) {
100
+ log("[cli-bridge:grok] no valid saved session");
101
+ return false;
102
+ }
103
+ try {
104
+ if (!grokBrowser) grokBrowser = await launchGrokBrowser();
105
+ const ctx = await createContextFromSession(grokBrowser, saved);
106
+ const check = await verifySession(ctx, log);
107
+ if (!check.valid) {
108
+ log(`[cli-bridge:grok] saved session invalid: ${check.reason}`);
109
+ await ctx.close().catch(() => {});
110
+ return false;
111
+ }
112
+ grokContext = ctx;
113
+ log("[cli-bridge:grok] session restored ✅");
114
+ return true;
115
+ } catch (err) {
116
+ log(`[cli-bridge:grok] session restore error: ${(err as Error).message}`);
117
+ return false;
118
+ }
69
119
  }
70
120
 
71
121
  const DEFAULT_PROXY_PORT = 31337;
@@ -201,14 +251,53 @@ const CLI_MODEL_COMMANDS = [
201
251
  const CLI_TEST_DEFAULT_MODEL = "cli-claude/claude-sonnet-4-6";
202
252
 
203
253
  // ──────────────────────────────────────────────────────────────────────────────
204
- // Helper: switch global model, saving previous for /cli-back
254
+ // Staged-switch state file
255
+ // Stores a pending model switch that has not yet been applied.
256
+ // Written by /cli-* (default), applied by /cli-apply or /cli-* --now.
257
+ // Located at ~/.openclaw/cli-bridge-pending.json
205
258
  // ──────────────────────────────────────────────────────────────────────────────
206
- async function switchModel(
259
+ const PENDING_FILE = join(homedir(), ".openclaw", "cli-bridge-pending.json");
260
+
261
+ interface CliBridgePending {
262
+ model: string;
263
+ label: string;
264
+ requestedAt: string;
265
+ }
266
+
267
+ function readPending(): CliBridgePending | null {
268
+ try {
269
+ return JSON.parse(readFileSync(PENDING_FILE, "utf8")) as CliBridgePending;
270
+ } catch {
271
+ return null;
272
+ }
273
+ }
274
+
275
+ function writePending(pending: CliBridgePending): void {
276
+ try {
277
+ mkdirSync(join(homedir(), ".openclaw"), { recursive: true });
278
+ writeFileSync(PENDING_FILE, JSON.stringify(pending, null, 2) + "\n", "utf8");
279
+ } catch {
280
+ // non-fatal
281
+ }
282
+ }
283
+
284
+ function clearPending(): void {
285
+ try {
286
+ const { unlinkSync } = require("node:fs");
287
+ unlinkSync(PENDING_FILE);
288
+ } catch {
289
+ // non-fatal — file may not exist
290
+ }
291
+ }
292
+
293
+ // ──────────────────────────────────────────────────────────────────────────────
294
+ // Helper: immediately apply the model switch (no safety checks)
295
+ // ──────────────────────────────────────────────────────────────────────────────
296
+ async function applyModelSwitch(
207
297
  api: OpenClawPluginApi,
208
298
  model: string,
209
299
  label: string,
210
300
  ): Promise<PluginCommandResult> {
211
- // Save current model BEFORE switching so /cli-back can restore it
212
301
  const current = readCurrentModel();
213
302
  if (current && current !== model) {
214
303
  writeState({ previousModel: current });
@@ -227,9 +316,13 @@ async function switchModel(
227
316
  return { text: `❌ Failed to switch to ${label}: ${err}` };
228
317
  }
229
318
 
319
+ clearPending();
230
320
  api.logger.info(`[cli-bridge] switched model → ${model}`);
231
321
  return {
232
- text: `✅ Switched to **${label}**\n\`${model}\`\n\nUse \`/cli-back\` to restore previous model.`,
322
+ text:
323
+ `✅ Switched to **${label}**\n` +
324
+ `\`${model}\`\n\n` +
325
+ `Use \`/cli-back\` to restore previous model.`,
233
326
  };
234
327
  } catch (err) {
235
328
  const msg = (err as Error).message;
@@ -238,6 +331,53 @@ async function switchModel(
238
331
  }
239
332
  }
240
333
 
334
+ // ──────────────────────────────────────────────────────────────────────────────
335
+ // Helper: staged switch (default behavior)
336
+ //
337
+ // ⚠️ SAFETY: /cli-* mid-session bricht den aktiven Agenten.
338
+ //
339
+ // `openclaw models set` ist ein **sofortiger, globaler Switch**.
340
+ // Der laufende Agent verliert seinen Kontext — Tool-Calls werden nicht
341
+ // ausgeführt, Planfiles werden nicht geschrieben, keine Rückmeldung.
342
+ //
343
+ // Default: Switch wird nur gespeichert (nicht angewendet).
344
+ // Mit --now: sofortiger Switch (nur zwischen Sessions verwenden!).
345
+ // Mit /cli-apply: gespeicherten Switch anwenden (nach Session-Ende).
346
+ // ──────────────────────────────────────────────────────────────────────────────
347
+ async function switchModel(
348
+ api: OpenClawPluginApi,
349
+ model: string,
350
+ label: string,
351
+ forceNow: boolean,
352
+ ): Promise<PluginCommandResult> {
353
+ // --now: sofortiger Switch, volle Verantwortung beim User
354
+ if (forceNow) {
355
+ api.logger.warn(`[cli-bridge] --now switch to ${model} (immediate, session may break)`);
356
+ return applyModelSwitch(api, model, label);
357
+ }
358
+
359
+ // Default: staged switch — speichern, warnen, nicht anwenden
360
+ const current = readCurrentModel();
361
+
362
+ if (current === model) {
363
+ return { text: `ℹ️ Already on **${label}**\n\`${model}\`` };
364
+ }
365
+
366
+ writePending({ model, label, requestedAt: new Date().toISOString() });
367
+ api.logger.info(`[cli-bridge] staged switch → ${model} (pending, not applied yet)`);
368
+
369
+ return {
370
+ text:
371
+ `📋 **Model switch staged: ${label}**\n` +
372
+ `\`${model}\`\n\n` +
373
+ `⚠️ **NOT applied yet** — switching mid-session breaks the active agent:\n` +
374
+ `tool calls fail silently, plan files don't get written, no feedback.\n\n` +
375
+ `**To apply:**\n` +
376
+ `• \`/cli-apply\` — apply after finishing your current task\n` +
377
+ `• \`/cli-* --now\` — force immediate switch (only between sessions!)`,
378
+ };
379
+ }
380
+
241
381
  // ──────────────────────────────────────────────────────────────────────────────
242
382
  // Helper: fire a one-shot test request directly at the proxy (no global switch)
243
383
  // ──────────────────────────────────────────────────────────────────────────────
@@ -303,7 +443,7 @@ function proxyTestRequest(
303
443
  const plugin = {
304
444
  id: "openclaw-cli-bridge-elvatis",
305
445
  name: "OpenClaw CLI Bridge",
306
- version: "0.2.15",
446
+ version: "0.2.26",
307
447
  description:
308
448
  "Phase 1: openai-codex auth bridge. " +
309
449
  "Phase 2: HTTP proxy for gemini/claude CLIs. " +
@@ -317,6 +457,10 @@ const plugin = {
317
457
  const apiKey = cfg.proxyApiKey ?? DEFAULT_PROXY_API_KEY;
318
458
  const timeoutMs = cfg.proxyTimeoutMs ?? 120_000;
319
459
  const codexAuthPath = cfg.codexAuthPath ?? DEFAULT_CODEX_AUTH_PATH;
460
+ const grokSessionPath = cfg.grokSessionPath ?? DEFAULT_SESSION_PATH;
461
+
462
+ // ── Grok session restore (non-blocking) ───────────────────────────────────
463
+ void tryRestoreGrokSession(grokSessionPath, (msg) => api.logger.info(msg));
320
464
 
321
465
  // ── Phase 1: openai-codex auth bridge ─────────────────────────────────────
322
466
  if (enableCodex) {
@@ -413,6 +557,7 @@ const plugin = {
413
557
  timeoutMs,
414
558
  log: (msg) => api.logger.info(msg),
415
559
  warn: (msg) => api.logger.warn(msg),
560
+ getGrokContext: () => grokContext,
416
561
  });
417
562
  proxyServer = server;
418
563
  api.logger.info(
@@ -436,6 +581,7 @@ const plugin = {
436
581
  port, apiKey, timeoutMs,
437
582
  log: (msg) => api.logger.info(msg),
438
583
  warn: (msg) => api.logger.warn(msg),
584
+ getGrokContext: () => grokContext,
439
585
  });
440
586
  proxyServer = server;
441
587
  api.logger.info(`[cli-bridge] proxy ready on :${port} (retry)`);
@@ -483,11 +629,13 @@ const plugin = {
483
629
  const { name, model, description, label } = entry;
484
630
  api.registerCommand({
485
631
  name,
486
- description,
632
+ description: `${description}. Pass --now to apply immediately (only between sessions!).`,
633
+ acceptsArgs: true,
487
634
  requireAuth: false,
488
635
  handler: async (ctx: PluginCommandContext): Promise<PluginCommandResult> => {
489
- api.logger.info(`[cli-bridge] /${name} by ${ctx.senderId ?? "?"}`);
490
- return switchModel(api, model, label);
636
+ const forceNow = (ctx.args ?? "").trim().toLowerCase() === "--now";
637
+ api.logger.info(`[cli-bridge] /${name} by ${ctx.senderId ?? "?"} forceNow=${forceNow}`);
638
+ return switchModel(api, model, label, forceNow);
491
639
  },
492
640
  } satisfies OpenClawPluginCommandDefinition);
493
641
  }
@@ -495,11 +643,14 @@ const plugin = {
495
643
  // ── Phase 3b: /cli-back — restore previous model ──────────────────────────
496
644
  api.registerCommand({
497
645
  name: "cli-back",
498
- description: "Restore the model that was active before the last /cli-* switch",
646
+ description: "Restore the model active before the last /cli-* switch. Clears any pending staged switch.",
499
647
  requireAuth: false,
500
648
  handler: async (ctx: PluginCommandContext): Promise<PluginCommandResult> => {
501
649
  api.logger.info(`[cli-bridge] /cli-back by ${ctx.senderId ?? "?"}`);
502
650
 
651
+ // Clear any pending staged switch
652
+ clearPending();
653
+
503
654
  const state = readState();
504
655
  if (!state?.previousModel) {
505
656
  return { text: "ℹ️ No previous model saved. Use `/cli-sonnet` etc. to switch first." };
@@ -529,6 +680,57 @@ const plugin = {
529
680
  },
530
681
  } satisfies OpenClawPluginCommandDefinition);
531
682
 
683
+ // ── Phase 3b2: /cli-apply — apply staged model switch ─────────────────────
684
+ api.registerCommand({
685
+ name: "cli-apply",
686
+ description: "Apply a staged /cli-* model switch. Use this AFTER finishing your current task.",
687
+ requireAuth: false,
688
+ handler: async (ctx: PluginCommandContext): Promise<PluginCommandResult> => {
689
+ api.logger.info(`[cli-bridge] /cli-apply by ${ctx.senderId ?? "?"}`);
690
+
691
+ const pending = readPending();
692
+ if (!pending) {
693
+ const current = readCurrentModel();
694
+ return {
695
+ text:
696
+ `ℹ️ No staged switch pending.\n` +
697
+ `Current model: \`${current ?? "unknown"}\`\n\n` +
698
+ `Use \`/cli-sonnet\`, \`/cli-opus\` etc. to stage a switch.`,
699
+ };
700
+ }
701
+
702
+ api.logger.info(`[cli-bridge] applying staged switch → ${pending.model}`);
703
+ return applyModelSwitch(api, pending.model, pending.label);
704
+ },
705
+ } satisfies OpenClawPluginCommandDefinition);
706
+
707
+ // ── Phase 3b3: /cli-pending — show staged switch ───────────────────────────
708
+ api.registerCommand({
709
+ name: "cli-pending",
710
+ description: "Show the currently staged model switch (if any).",
711
+ requireAuth: false,
712
+ handler: async (): Promise<PluginCommandResult> => {
713
+ const pending = readPending();
714
+ const current = readCurrentModel();
715
+ if (!pending) {
716
+ return {
717
+ text:
718
+ `✅ No pending switch.\n` +
719
+ `Current model: \`${current ?? "unknown"}\``,
720
+ };
721
+ }
722
+ return {
723
+ text:
724
+ `📋 **Staged switch pending:**\n` +
725
+ `→ \`${pending.model}\` (${pending.label})\n` +
726
+ `Requested: ${pending.requestedAt}\n\n` +
727
+ `Current: \`${current ?? "unknown"}\`\n\n` +
728
+ `Run \`/cli-apply\` to apply after finishing your current task.\n` +
729
+ `Run \`/cli-sonnet --now\` etc. to discard and switch immediately.`,
730
+ };
731
+ },
732
+ } satisfies OpenClawPluginCommandDefinition);
733
+
532
734
  // ── Phase 3c: /cli-test — one-shot proxy ping, no global model switch ──────
533
735
  api.registerCommand({
534
736
  name: "cli-test",
@@ -605,22 +807,84 @@ const plugin = {
605
807
  }
606
808
  lines.push("");
607
809
  }
810
+ const pending = readPending();
811
+ const pendingNote = pending ? ` ← pending: ${pending.label}` : "";
812
+
608
813
  lines.push("*Utility*");
609
- lines.push(" /cli-back Restore previous model");
814
+ lines.push(` /cli-apply Apply staged switch${pendingNote}`);
815
+ lines.push(" /cli-pending Show staged switch (if any)");
816
+ lines.push(" /cli-back Restore previous model + clear staged");
610
817
  lines.push(" /cli-test [model] Health check (no model switch)");
611
818
  lines.push(" /cli-list This overview");
612
819
  lines.push("");
820
+ lines.push("*Switching safely:*");
821
+ lines.push(" /cli-sonnet → stages switch (safe, apply later)");
822
+ lines.push(" /cli-sonnet --now → immediate switch (only between sessions!)");
823
+ lines.push("");
613
824
  lines.push(`Proxy: \`127.0.0.1:${port}\``);
614
825
 
615
826
  return { text: lines.join("\n") };
616
827
  },
617
828
  } satisfies OpenClawPluginCommandDefinition);
618
829
 
830
+ // ── Phase 4: Grok web-session commands ────────────────────────────────────
831
+
832
+ api.registerCommand({
833
+ name: "grok-login",
834
+ description: "Open browser to log in to grok.com (X/Twitter account)",
835
+ handler: async (): Promise<PluginCommandResult> => {
836
+ if (grokContext) {
837
+ return { text: "✅ Already logged in to grok.com. Use /grok-logout first to re-authenticate." };
838
+ }
839
+ api.logger.info("[cli-bridge:grok] starting interactive login...");
840
+ try {
841
+ if (!grokBrowser) grokBrowser = await launchGrokBrowser();
842
+ const session = await runInteractiveLogin(grokBrowser, grokSessionPath, (msg) => api.logger.info(msg));
843
+ grokContext = await createContextFromSession(grokBrowser, session);
844
+ return { text: "✅ Logged in to grok.com!\n\nGrok models available:\n• `vllm/web-grok/grok-3`\n• `vllm/web-grok/grok-3-fast`\n• `vllm/web-grok/grok-3-mini`\n\nUse `/cli-grok` to switch." };
845
+ } catch (err) {
846
+ return { text: `❌ Login failed: ${(err as Error).message}` };
847
+ }
848
+ },
849
+ } satisfies OpenClawPluginCommandDefinition);
850
+
851
+ api.registerCommand({
852
+ name: "grok-status",
853
+ description: "Check grok.com session status",
854
+ handler: async (): Promise<PluginCommandResult> => {
855
+ if (!grokContext) {
856
+ return { text: "❌ No active grok.com session\nRun `/grok-login` to authenticate." };
857
+ }
858
+ const check = await verifySession(grokContext, (msg) => api.logger.info(msg));
859
+ if (check.valid) {
860
+ return { text: `✅ grok.com session active\nProxy: \`127.0.0.1:${port}\`\nModels: web-grok/grok-3, web-grok/grok-3-fast, web-grok/grok-3-mini, web-grok/grok-3-mini-fast` };
861
+ }
862
+ grokContext = null;
863
+ return { text: `❌ Session expired: ${check.reason}\nRun \`/grok-login\` to re-authenticate.` };
864
+ },
865
+ } satisfies OpenClawPluginCommandDefinition);
866
+
867
+ api.registerCommand({
868
+ name: "grok-logout",
869
+ description: "Clear saved grok.com session",
870
+ handler: async (): Promise<PluginCommandResult> => {
871
+ if (grokContext) {
872
+ await grokContext.close().catch(() => {});
873
+ grokContext = null;
874
+ }
875
+ deleteSession(grokSessionPath);
876
+ return { text: "✅ Logged out from grok.com. Session file deleted." };
877
+ },
878
+ } satisfies OpenClawPluginCommandDefinition);
879
+
619
880
  const allCommands = [
620
881
  ...CLI_MODEL_COMMANDS.map((c) => `/${c.name}`),
621
882
  "/cli-back",
622
883
  "/cli-test",
623
884
  "/cli-list",
885
+ "/grok-login",
886
+ "/grok-status",
887
+ "/grok-logout",
624
888
  ];
625
889
  api.logger.info(`[cli-bridge] registered ${allCommands.length} commands: ${allCommands.join(", ")}`);
626
890
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "openclaw-cli-bridge-elvatis",
3
3
  "name": "OpenClaw CLI Bridge",
4
- "version": "0.2.23",
4
+ "version": "0.2.26",
5
5
  "description": "Phase 1: openai-codex auth bridge. Phase 2: local HTTP proxy routing model calls through gemini/claude CLIs (vllm provider).",
6
6
  "providers": [
7
7
  "openai-codex"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elvatis_com/openclaw-cli-bridge-elvatis",
3
- "version": "0.2.23",
3
+ "version": "0.2.26",
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": {
@@ -18,5 +18,8 @@
18
18
  "@types/node": "^25.3.2",
19
19
  "typescript": "^5.9.3",
20
20
  "vitest": "^4.0.18"
21
+ },
22
+ "dependencies": {
23
+ "playwright": "^1.58.2"
21
24
  }
22
- }
25
+ }
@@ -47,7 +47,8 @@ const CREDENTIALS_PATH = join(homedir(), ".claude", ".credentials.json");
47
47
  // State
48
48
  // ──────────────────────────────────────────────────────────────────────────────
49
49
 
50
- let refreshTimer: ReturnType<typeof setTimeout> | null = null;
50
+ let refreshTimer: ReturnType<typeof setInterval> | null = null;
51
+ let nextRefreshAt = 0; // epoch ms when the next refresh is due
51
52
  let refreshInProgress: Promise<void> | null = null;
52
53
  let log: (msg: string) => void = () => {};
53
54
 
@@ -60,6 +61,19 @@ export function setAuthLogger(logger: (msg: string) => void): void {
60
61
  log = logger;
61
62
  }
62
63
 
64
+ /**
65
+ * Stop the background token refresh interval.
66
+ * Call in plugin deactivate / proxy server close to avoid timer leaks.
67
+ */
68
+ export function stopTokenRefresh(): void {
69
+ if (refreshTimer) {
70
+ clearInterval(refreshTimer);
71
+ refreshTimer = null;
72
+ nextRefreshAt = 0;
73
+ log("[cli-bridge:auth] Token refresh scheduler stopped");
74
+ }
75
+ }
76
+
63
77
  /**
64
78
  * Read the current token expiry from ~/.claude/.credentials.json.
65
79
  * Returns null if the file doesn't exist or has no OAuth credentials
@@ -80,13 +94,14 @@ export async function readTokenExpiry(): Promise<number | null> {
80
94
 
81
95
  /**
82
96
  * Schedule a proactive token refresh 30 minutes before expiry.
83
- * Call once at proxy startup. Safe to call multiple times (clears old timer).
97
+ * Call once at proxy startup. Safe to call multiple times (restarts the interval).
98
+ *
99
+ * Uses a 10-minute polling interval instead of a single long setTimeout so that
100
+ * the scheduler survives system sleep/resume without missing its window.
84
101
  */
85
102
  export async function scheduleTokenRefresh(): Promise<void> {
86
- if (refreshTimer) {
87
- clearTimeout(refreshTimer);
88
- refreshTimer = null;
89
- }
103
+ // Clear any existing interval before (re-)starting
104
+ stopTokenRefresh();
90
105
 
91
106
  const expiresAt = await readTokenExpiry();
92
107
  if (expiresAt === null) {
@@ -96,7 +111,6 @@ export async function scheduleTokenRefresh(): Promise<void> {
96
111
 
97
112
  const now = Date.now();
98
113
  const msUntilExpiry = expiresAt - now;
99
- const msUntilRefresh = msUntilExpiry - REFRESH_BEFORE_EXPIRY_MS;
100
114
 
101
115
  if (msUntilExpiry <= 0) {
102
116
  log("[cli-bridge:auth] Token already expired — refreshing now");
@@ -104,22 +118,33 @@ export async function scheduleTokenRefresh(): Promise<void> {
104
118
  return;
105
119
  }
106
120
 
107
- if (msUntilRefresh <= 0) {
121
+ if (msUntilExpiry <= REFRESH_BEFORE_EXPIRY_MS) {
108
122
  // Expires within the next 30 min — refresh immediately
109
123
  log(`[cli-bridge:auth] Token expires in ${Math.round(msUntilExpiry / 60000)}min — refreshing now`);
110
124
  await refreshClaudeToken();
111
125
  return;
112
126
  }
113
127
 
114
- const refreshInMin = Math.round(msUntilRefresh / 60000);
128
+ // Set the target time for the first scheduled refresh (30 min before expiry)
129
+ nextRefreshAt = expiresAt - REFRESH_BEFORE_EXPIRY_MS;
130
+ const refreshInMin = Math.round((nextRefreshAt - now) / 60000);
115
131
  log(`[cli-bridge:auth] Token valid for ${Math.round(msUntilExpiry / 60000)}min — refresh scheduled in ${refreshInMin}min`);
116
132
 
117
- refreshTimer = setTimeout(async () => {
133
+ // Poll every 10 minutes instead of a single long setTimeout.
134
+ // This survives laptop sleep/resume without missing the refresh window.
135
+ const POLL_INTERVAL_MS = 10 * 60 * 1000;
136
+ refreshTimer = setInterval(async () => {
137
+ if (Date.now() < nextRefreshAt) return; // not yet due
118
138
  log("[cli-bridge:auth] Scheduled token refresh triggered");
119
139
  await refreshClaudeToken();
120
- // Re-schedule for the next cycle after refresh
121
- await scheduleTokenRefresh();
122
- }, msUntilRefresh);
140
+ // Recompute next refresh target from the freshly written credentials
141
+ const newExpiry = await readTokenExpiry();
142
+ if (newExpiry) {
143
+ nextRefreshAt = newExpiry - REFRESH_BEFORE_EXPIRY_MS;
144
+ const nextInMin = Math.round((nextRefreshAt - Date.now()) / 60000);
145
+ log(`[cli-bridge:auth] Next refresh in ${nextInMin}min`);
146
+ }
147
+ }, POLL_INTERVAL_MS);
123
148
 
124
149
  // Don't block process exit
125
150
  if (refreshTimer.unref) refreshTimer.unref();
@@ -189,13 +214,12 @@ async function doRefresh(): Promise<void> {
189
214
  return;
190
215
  }
191
216
 
192
- // Re-read expiry and reschedule timer
217
+ // Re-read expiry and update the next refresh target for the running interval
193
218
  const newExpiry = await readTokenExpiry();
194
219
  if (newExpiry) {
195
220
  const validForMin = Math.round((newExpiry - Date.now()) / 60000);
196
221
  log(`[cli-bridge:auth] Token refreshed — valid for ${validForMin}min`);
197
- // Reschedule the proactive timer for the new expiry
198
- void scheduleTokenRefresh();
222
+ nextRefreshAt = newExpiry - REFRESH_BEFORE_EXPIRY_MS;
199
223
  }
200
224
  }
201
225