@elvatis_com/openclaw-cli-bridge-elvatis 1.1.0 → 1.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) as model providers — with slash commands for instant model switching, restore, health testing, and model listing.
4
4
 
5
- **Current version:** `1.1.0`
5
+ **Current version:** `1.3.0`
6
6
 
7
7
  ---
8
8
 
@@ -287,6 +287,16 @@ npm test # vitest run (45 tests)
287
287
 
288
288
  ## Changelog
289
289
 
290
+ ### v1.3.0
291
+ - **fix:** Browser persistence after gateway restart — each provider launches its own persistent Chromium if OpenClaw browser is unavailable
292
+ - **feat:** `ensureAllProviderContexts()` — unified startup connect for all 4 providers
293
+ - **feat:** Lazy-connect fallback to persistent context when CDP unavailable
294
+
295
+ ### v1.2.0
296
+ - **fix:** Fresh page per request — no more message accumulation across calls
297
+ - **feat:** ChatGPT model switching via URL param (?model=gpt-4o, o3, etc.)
298
+ - **chore:** Gemini model switching: TODO (requires UI interaction)
299
+
290
300
  ### v1.1.0
291
301
  - **feat:** Auto-connect all providers on startup (no manual login after restart if browser is open)
292
302
  - **feat:** `/bridge-status` — all 4 providers at a glance with expiry info
package/SKILL.md CHANGED
@@ -53,4 +53,4 @@ Each command runs `openclaw models set <model>` atomically and replies with a co
53
53
 
54
54
  See `README.md` for full configuration reference and architecture diagram.
55
55
 
56
- **Version:** 1.1.0
56
+ **Version:** 1.3.0
package/index.ts CHANGED
@@ -322,6 +322,101 @@ async function cleanupBrowsers(log: (msg: string) => void): Promise<void> {
322
322
  log("[cli-bridge] browser resources cleaned up");
323
323
  }
324
324
 
325
+ /**
326
+ * Ensure all browser provider contexts are connected.
327
+ * 1. Try the shared OpenClaw browser (CDP 18800)
328
+ * 2. Fallback: launch a persistent headless Chromium per provider (saved profile with cookies)
329
+ */
330
+ async function ensureAllProviderContexts(log: (msg: string) => void): Promise<void> {
331
+ const { chromium } = await import("playwright");
332
+
333
+ // Try CDP first (OpenClaw browser)
334
+ let sharedCtx: BrowserContext | null = null;
335
+ try {
336
+ const b = await chromium.connectOverCDP("http://127.0.0.1:18800", { timeout: 2000 });
337
+ sharedCtx = b.contexts()[0] ?? null;
338
+ if (sharedCtx) log("[cli-bridge] using OpenClaw browser for all providers");
339
+ } catch { /* not available */ }
340
+
341
+ // For each provider: if no context yet, try shared ctx or launch own persistent context
342
+ const providerConfigs = [
343
+ {
344
+ name: "claude",
345
+ profileDir: join(homedir(), ".openclaw", "claude-profile"),
346
+ getCtx: () => claudeContext,
347
+ setCtx: (c: BrowserContext) => { claudeContext = c; },
348
+ homeUrl: "https://claude.ai/new",
349
+ verifySelector: ".ProseMirror",
350
+ },
351
+ {
352
+ name: "gemini",
353
+ profileDir: join(homedir(), ".openclaw", "gemini-profile"),
354
+ getCtx: () => geminiContext,
355
+ setCtx: (c: BrowserContext) => { geminiContext = c; },
356
+ homeUrl: "https://gemini.google.com/app",
357
+ verifySelector: ".ql-editor",
358
+ },
359
+ {
360
+ name: "chatgpt",
361
+ profileDir: join(homedir(), ".openclaw", "chatgpt-profile"),
362
+ getCtx: () => chatgptContext,
363
+ setCtx: (c: BrowserContext) => { chatgptContext = c; },
364
+ homeUrl: "https://chatgpt.com",
365
+ verifySelector: "#prompt-textarea",
366
+ },
367
+ ];
368
+
369
+ for (const cfg of providerConfigs) {
370
+ if (cfg.getCtx()) continue; // already connected
371
+
372
+ let ctx: BrowserContext | null = null;
373
+
374
+ // 1. Try shared OpenClaw browser context
375
+ if (sharedCtx) {
376
+ try {
377
+ const pages = sharedCtx.pages();
378
+ const existing = pages.find(p => p.url().includes(new URL(cfg.homeUrl).hostname));
379
+ const page = existing ?? await sharedCtx.newPage();
380
+ if (!existing) {
381
+ await page.goto(cfg.homeUrl, { waitUntil: "domcontentloaded", timeout: 10_000 });
382
+ await new Promise(r => setTimeout(r, 2000));
383
+ }
384
+ const visible = await page.locator(cfg.verifySelector).isVisible().catch(() => false);
385
+ if (visible) {
386
+ ctx = sharedCtx;
387
+ log(`[cli-bridge:${cfg.name}] connected via OpenClaw browser ✅`);
388
+ }
389
+ } catch { /* fall through */ }
390
+ }
391
+
392
+ // 2. Launch own persistent context (has saved cookies)
393
+ if (!ctx) {
394
+ try {
395
+ mkdirSync(cfg.profileDir, { recursive: true });
396
+ const pCtx = await chromium.launchPersistentContext(cfg.profileDir, {
397
+ headless: true,
398
+ args: ["--no-sandbox", "--disable-setuid-sandbox"],
399
+ });
400
+ const page = await pCtx.newPage();
401
+ await page.goto(cfg.homeUrl, { waitUntil: "domcontentloaded", timeout: 15_000 });
402
+ await new Promise(r => setTimeout(r, 3000));
403
+ const visible = await page.locator(cfg.verifySelector).isVisible().catch(() => false);
404
+ if (visible) {
405
+ ctx = pCtx;
406
+ log(`[cli-bridge:${cfg.name}] launched persistent context ✅`);
407
+ } else {
408
+ await pCtx.close().catch(() => {});
409
+ log(`[cli-bridge:${cfg.name}] persistent context: editor not visible (not logged in?)`);
410
+ }
411
+ } catch (err) {
412
+ log(`[cli-bridge:${cfg.name}] could not launch browser: ${(err as Error).message}`);
413
+ }
414
+ }
415
+
416
+ if (ctx) cfg.setCtx(ctx);
417
+ }
418
+ }
419
+
325
420
  async function tryRestoreGrokSession(
326
421
  _sessionPath: string,
327
422
  log: (msg: string) => void
@@ -613,7 +708,7 @@ function proxyTestRequest(
613
708
  const plugin = {
614
709
  id: "openclaw-cli-bridge-elvatis",
615
710
  name: "OpenClaw CLI Bridge",
616
- version: "1.1.0",
711
+ version: "1.3.0",
617
712
  description:
618
713
  "Phase 1: openai-codex auth bridge. " +
619
714
  "Phase 2: HTTP proxy for gemini/claude CLIs. " +
@@ -634,49 +729,8 @@ const plugin = {
634
729
 
635
730
  // ── Auto-connect all browser providers on startup (non-blocking) ──────────
636
731
  void (async () => {
637
- // Wait for proxy to start first
638
- await new Promise(r => setTimeout(r, 3000));
639
- const log = (msg: string) => api.logger.info(msg);
640
- const ctx = await connectToOpenClawBrowser(log);
641
- if (!ctx) { log("[cli-bridge] startup auto-connect: OpenClaw browser not available"); return; }
642
- const pages = ctx.pages().map(p => p.url());
643
- log(`[cli-bridge] startup auto-connect: ${pages.length} pages open`);
644
-
645
- // Claude
646
- if (pages.some(u => u.includes("claude.ai")) && !claudeContext) {
647
- try {
648
- const { getOrCreateClaudePage } = await import("./src/claude-browser.js");
649
- const { page } = await getOrCreateClaudePage(ctx);
650
- if (await page.locator(".ProseMirror").isVisible().catch(() => false)) {
651
- claudeContext = ctx;
652
- log("[cli-bridge:claude] auto-connected ✅");
653
- }
654
- } catch { /* not available */ }
655
- }
656
-
657
- // Gemini
658
- if (pages.some(u => u.includes("gemini.google.com")) && !geminiContext) {
659
- try {
660
- const { getOrCreateGeminiPage } = await import("./src/gemini-browser.js");
661
- const { page } = await getOrCreateGeminiPage(ctx);
662
- if (await page.locator(".ql-editor").isVisible().catch(() => false)) {
663
- geminiContext = ctx;
664
- log("[cli-bridge:gemini] auto-connected ✅");
665
- }
666
- } catch { /* not available */ }
667
- }
668
-
669
- // ChatGPT
670
- if (pages.some(u => u.includes("chatgpt.com")) && !chatgptContext) {
671
- try {
672
- const { getOrCreateChatGPTPage } = await import("./src/chatgpt-browser.js");
673
- const { page } = await getOrCreateChatGPTPage(ctx);
674
- if (await page.locator("#prompt-textarea").isVisible().catch(() => false)) {
675
- chatgptContext = ctx;
676
- log("[cli-bridge:chatgpt] auto-connected ✅");
677
- }
678
- } catch { /* not available */ }
679
- }
732
+ await new Promise(r => setTimeout(r, 3000)); // wait for proxy to start
733
+ await ensureAllProviderContexts((msg) => api.logger.info(msg));
680
734
  })();
681
735
 
682
736
  // ── Phase 1: openai-codex auth bridge ─────────────────────────────────────
@@ -792,7 +846,9 @@ const plugin = {
792
846
  const editor = await page.locator(".ProseMirror").isVisible().catch(() => false);
793
847
  if (editor) { claudeContext = ctx; return ctx; }
794
848
  }
795
- return null;
849
+ // Fallback: try persistent context
850
+ await ensureAllProviderContexts((msg) => api.logger.info(msg));
851
+ return claudeContext;
796
852
  },
797
853
  getGeminiContext: () => geminiContext,
798
854
  connectGeminiContext: async () => {
@@ -803,7 +859,9 @@ const plugin = {
803
859
  const editor = await page.locator(".ql-editor").isVisible().catch(() => false);
804
860
  if (editor) { geminiContext = ctx; return ctx; }
805
861
  }
806
- return null;
862
+ // Fallback: try persistent context
863
+ await ensureAllProviderContexts((msg) => api.logger.info(msg));
864
+ return geminiContext;
807
865
  },
808
866
  getChatGPTContext: () => chatgptContext,
809
867
  connectChatGPTContext: async () => {
@@ -814,7 +872,9 @@ const plugin = {
814
872
  const editor = await page.locator("#prompt-textarea").isVisible().catch(() => false);
815
873
  if (editor) { chatgptContext = ctx; return ctx; }
816
874
  }
817
- return null;
875
+ // Fallback: try persistent context
876
+ await ensureAllProviderContexts((msg) => api.logger.info(msg));
877
+ return chatgptContext;
818
878
  },
819
879
  });
820
880
  proxyServer = server;
@@ -857,7 +917,9 @@ const plugin = {
857
917
  const editor = await page.locator(".ProseMirror").isVisible().catch(() => false);
858
918
  if (editor) { claudeContext = ctx; return ctx; }
859
919
  }
860
- return null;
920
+ // Fallback: try persistent context
921
+ await ensureAllProviderContexts((msg) => api.logger.info(msg));
922
+ return claudeContext;
861
923
  },
862
924
  getGeminiContext: () => geminiContext,
863
925
  connectGeminiContext: async () => {
@@ -868,7 +930,9 @@ const plugin = {
868
930
  const editor = await page.locator(".ql-editor").isVisible().catch(() => false);
869
931
  if (editor) { geminiContext = ctx; return ctx; }
870
932
  }
871
- return null;
933
+ // Fallback: try persistent context
934
+ await ensureAllProviderContexts((msg) => api.logger.info(msg));
935
+ return geminiContext;
872
936
  },
873
937
  getChatGPTContext: () => chatgptContext,
874
938
  connectChatGPTContext: async () => {
@@ -879,7 +943,9 @@ const plugin = {
879
943
  const editor = await page.locator("#prompt-textarea").isVisible().catch(() => false);
880
944
  if (editor) { chatgptContext = ctx; return ctx; }
881
945
  }
882
- return null;
946
+ // Fallback: try persistent context
947
+ await ensureAllProviderContexts((msg) => api.logger.info(msg));
948
+ return chatgptContext;
883
949
  },
884
950
  });
885
951
  proxyServer = server;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "openclaw-cli-bridge-elvatis",
3
3
  "name": "OpenClaw CLI Bridge",
4
- "version": "1.1.0",
4
+ "version": "1.3.0",
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": "1.1.0",
3
+ "version": "1.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": {
@@ -35,6 +35,14 @@ const STABLE_CHECKS = 3;
35
35
  const STABLE_INTERVAL_MS = 500;
36
36
  const CHATGPT_HOME = "https://chatgpt.com";
37
37
 
38
+ const MODEL_URLS: Record<string, string> = {
39
+ "gpt-4o": "https://chatgpt.com/?model=gpt-4o",
40
+ "gpt-4o-mini": "https://chatgpt.com/?model=gpt-4o-mini",
41
+ "o3": "https://chatgpt.com/?model=o3",
42
+ "o4-mini": "https://chatgpt.com/?model=o4-mini",
43
+ "gpt-5": "https://chatgpt.com/?model=gpt-5",
44
+ };
45
+
38
46
  const MODEL_MAP: Record<string, string> = {
39
47
  "gpt-4o": "gpt-4o",
40
48
  "gpt-4o-mini": "gpt-4o-mini",
@@ -173,18 +181,21 @@ export async function chatgptComplete(
173
181
  opts: ChatGPTBrowserOptions,
174
182
  log: (msg: string) => void
175
183
  ): Promise<ChatGPTBrowserResult> {
176
- const { page, owned } = await getOrCreateChatGPTPage(context);
177
184
  const model = resolveModel(opts.model);
178
185
  const prompt = flattenMessages(opts.messages);
179
186
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
187
+ const navUrl = MODEL_URLS[model] ?? CHATGPT_HOME;
180
188
 
181
189
  log(`chatgpt-browser: complete model=${model}`);
182
190
 
191
+ const page = await context.newPage();
183
192
  try {
193
+ await page.goto(navUrl, { waitUntil: "domcontentloaded", timeout: 15_000 });
194
+ await new Promise((r) => setTimeout(r, 2_000));
184
195
  const content = await sendAndWait(page, prompt, timeoutMs, log);
185
196
  return { content, model, finishReason: "stop" };
186
197
  } finally {
187
- if (owned) await page.close().catch(() => {});
198
+ await page.close().catch(() => {});
188
199
  }
189
200
  }
190
201
 
@@ -194,60 +205,66 @@ export async function chatgptCompleteStream(
194
205
  onToken: (token: string) => void,
195
206
  log: (msg: string) => void
196
207
  ): Promise<ChatGPTBrowserResult> {
197
- const { page, owned } = await getOrCreateChatGPTPage(context);
198
208
  const model = resolveModel(opts.model);
199
209
  const prompt = flattenMessages(opts.messages);
200
210
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
211
+ const navUrl = MODEL_URLS[model] ?? CHATGPT_HOME;
201
212
 
202
213
  log(`chatgpt-browser: stream model=${model}`);
203
214
 
204
- const countBefore = await countAssistantMessages(page);
205
-
206
- await page.evaluate((msg: string) => {
207
- const ed = document.querySelector("#prompt-textarea") as HTMLElement | null;
208
- if (!ed) throw new Error("ChatGPT editor not found");
209
- ed.focus();
210
- document.execCommand("insertText", false, msg);
211
- }, prompt);
212
- await new Promise((r) => setTimeout(r, 300));
213
- const sendBtn = page.locator('button[data-testid="send-button"]').first();
214
- if (await sendBtn.isVisible().catch(() => false)) await sendBtn.click();
215
- else await page.keyboard.press("Enter");
216
-
217
- const deadline = Date.now() + timeoutMs;
218
- let emittedLength = 0;
219
- let lastText = "";
220
- let stableCount = 0;
221
-
222
- while (Date.now() < deadline) {
223
- await new Promise((r) => setTimeout(r, STABLE_INTERVAL_MS));
224
-
225
- const currentCount = await countAssistantMessages(page);
226
- if (currentCount <= countBefore) continue;
227
-
228
- const text = await getLastAssistantText(page);
229
-
230
- if (text.length > emittedLength) {
231
- onToken(text.slice(emittedLength));
232
- emittedLength = text.length;
233
- }
234
-
235
- const streaming = await isStreaming(page);
236
- if (streaming) { stableCount = 0; continue; }
215
+ const page = await context.newPage();
216
+ try {
217
+ await page.goto(navUrl, { waitUntil: "domcontentloaded", timeout: 15_000 });
218
+ await new Promise((r) => setTimeout(r, 2_000));
219
+
220
+ const countBefore = await countAssistantMessages(page);
221
+
222
+ await page.evaluate((msg: string) => {
223
+ const ed = document.querySelector("#prompt-textarea") as HTMLElement | null;
224
+ if (!ed) throw new Error("ChatGPT editor not found");
225
+ ed.focus();
226
+ document.execCommand("insertText", false, msg);
227
+ }, prompt);
228
+ await new Promise((r) => setTimeout(r, 300));
229
+ const sendBtn = page.locator('button[data-testid="send-button"]').first();
230
+ if (await sendBtn.isVisible().catch(() => false)) await sendBtn.click();
231
+ else await page.keyboard.press("Enter");
232
+
233
+ const deadline = Date.now() + timeoutMs;
234
+ let emittedLength = 0;
235
+ let lastText = "";
236
+ let stableCount = 0;
237
+
238
+ while (Date.now() < deadline) {
239
+ await new Promise((r) => setTimeout(r, STABLE_INTERVAL_MS));
240
+
241
+ const currentCount = await countAssistantMessages(page);
242
+ if (currentCount <= countBefore) continue;
243
+
244
+ const text = await getLastAssistantText(page);
245
+
246
+ if (text.length > emittedLength) {
247
+ onToken(text.slice(emittedLength));
248
+ emittedLength = text.length;
249
+ }
237
250
 
238
- if (text && text === lastText) {
239
- stableCount++;
240
- if (stableCount >= STABLE_CHECKS) {
241
- log(`chatgpt-browser: stream done (${text.length} chars)`);
242
- if (owned) await page.close().catch(() => {});
243
- return { content: text, model, finishReason: "stop" };
251
+ const streaming = await isStreaming(page);
252
+ if (streaming) { stableCount = 0; continue; }
253
+
254
+ if (text && text === lastText) {
255
+ stableCount++;
256
+ if (stableCount >= STABLE_CHECKS) {
257
+ log(`chatgpt-browser: stream done (${text.length} chars)`);
258
+ return { content: text, model, finishReason: "stop" };
259
+ }
260
+ } else {
261
+ stableCount = 0;
262
+ lastText = text;
244
263
  }
245
- } else {
246
- stableCount = 0;
247
- lastText = text;
248
264
  }
249
- }
250
265
 
251
- if (owned) await page.close().catch(() => {});
252
- throw new Error(`chatgpt.com stream timeout after ${timeoutMs}ms`);
266
+ throw new Error(`chatgpt.com stream timeout after ${timeoutMs}ms`);
267
+ } finally {
268
+ await page.close().catch(() => {});
269
+ }
253
270
  }
@@ -160,18 +160,20 @@ export async function claudeComplete(
160
160
  opts: ClaudeBrowserOptions,
161
161
  log: (msg: string) => void
162
162
  ): Promise<ClaudeBrowserResult> {
163
- const { page, owned } = await getOrCreateClaudePage(context);
164
163
  const model = resolveModel(opts.model);
165
164
  const prompt = flattenMessages(opts.messages);
166
165
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
167
166
 
168
167
  log(`claude-browser: complete model=${model}`);
169
168
 
169
+ const page = await context.newPage();
170
170
  try {
171
+ await page.goto(CLAUDE_HOME, { waitUntil: "domcontentloaded", timeout: 15_000 });
172
+ await new Promise((r) => setTimeout(r, 2_000));
171
173
  const content = await sendAndWait(page, prompt, timeoutMs, log);
172
174
  return { content, model, finishReason: "stop" };
173
175
  } finally {
174
- if (owned) await page.close().catch(() => {});
176
+ await page.close().catch(() => {});
175
177
  }
176
178
  }
177
179
 
@@ -181,53 +183,60 @@ export async function claudeCompleteStream(
181
183
  onToken: (token: string) => void,
182
184
  log: (msg: string) => void
183
185
  ): Promise<ClaudeBrowserResult> {
184
- const { page, owned } = await getOrCreateClaudePage(context);
185
186
  const model = resolveModel(opts.model);
186
187
  const prompt = flattenMessages(opts.messages);
187
188
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
188
189
 
189
190
  log(`claude-browser: stream model=${model}`);
190
191
 
191
- const countBefore = await countAssistantMessages(page);
192
+ const page = await context.newPage();
193
+ try {
194
+ await page.goto(CLAUDE_HOME, { waitUntil: "domcontentloaded", timeout: 15_000 });
195
+ await new Promise((r) => setTimeout(r, 2_000));
192
196
 
193
- await page.evaluate((msg: string) => {
194
- const ed = document.querySelector(".ProseMirror") as HTMLElement | null;
195
- if (!ed) throw new Error("Claude editor not found");
196
- ed.focus();
197
- document.execCommand("insertText", false, msg);
198
- }, prompt);
199
- await new Promise((r) => setTimeout(r, 300));
200
- await page.keyboard.press("Enter");
197
+ const countBefore = await countAssistantMessages(page);
201
198
 
202
- const deadline = Date.now() + timeoutMs;
203
- let emittedLength = 0;
204
- let lastText = "";
205
- let stableCount = 0;
199
+ await page.evaluate((msg: string) => {
200
+ const ed = document.querySelector(".ProseMirror") as HTMLElement | null;
201
+ if (!ed) throw new Error("Claude editor not found");
202
+ ed.focus();
203
+ document.execCommand("insertText", false, msg);
204
+ }, prompt);
205
+ await new Promise((r) => setTimeout(r, 300));
206
+ await page.keyboard.press("Enter");
206
207
 
207
- while (Date.now() < deadline) {
208
- await new Promise((r) => setTimeout(r, STABLE_INTERVAL_MS));
208
+ const deadline = Date.now() + timeoutMs;
209
+ let emittedLength = 0;
210
+ let lastText = "";
211
+ let stableCount = 0;
209
212
 
210
- const currentCount = await countAssistantMessages(page);
211
- if (currentCount <= countBefore) continue;
213
+ while (Date.now() < deadline) {
214
+ await new Promise((r) => setTimeout(r, STABLE_INTERVAL_MS));
212
215
 
213
- const text = await getLastAssistantText(page);
216
+ const currentCount = await countAssistantMessages(page);
217
+ if (currentCount <= countBefore) continue;
214
218
 
215
- if (text.length > emittedLength) {
216
- onToken(text.slice(emittedLength));
217
- emittedLength = text.length;
218
- }
219
+ const text = await getLastAssistantText(page);
219
220
 
220
- if (text && text === lastText) {
221
- stableCount++;
222
- if (stableCount >= STABLE_CHECKS) {
223
- log(`claude-browser: stream done (${text.length} chars)`);
224
- return { content: text, model, finishReason: "stop" };
221
+ if (text.length > emittedLength) {
222
+ onToken(text.slice(emittedLength));
223
+ emittedLength = text.length;
224
+ }
225
+
226
+ if (text && text === lastText) {
227
+ stableCount++;
228
+ if (stableCount >= STABLE_CHECKS) {
229
+ log(`claude-browser: stream done (${text.length} chars)`);
230
+ return { content: text, model, finishReason: "stop" };
231
+ }
232
+ } else {
233
+ stableCount = 0;
234
+ lastText = text;
225
235
  }
226
- } else {
227
- stableCount = 0;
228
- lastText = text;
229
236
  }
230
- }
231
237
 
232
- throw new Error(`claude.ai stream timeout after ${timeoutMs}ms`);
238
+ throw new Error(`claude.ai stream timeout after ${timeoutMs}ms`);
239
+ } finally {
240
+ await page.close().catch(() => {});
241
+ }
233
242
  }
@@ -167,18 +167,21 @@ export async function geminiComplete(
167
167
  opts: GeminiBrowserOptions,
168
168
  log: (msg: string) => void
169
169
  ): Promise<GeminiBrowserResult> {
170
- const { page, owned } = await getOrCreateGeminiPage(context);
170
+ // TODO: Gemini model switching requires UI interaction (model picker dropdown), not yet implemented
171
171
  const model = resolveModel(opts.model);
172
172
  const prompt = flattenMessages(opts.messages);
173
173
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
174
174
 
175
175
  log(`gemini-browser: complete model=${model}`);
176
176
 
177
+ const page = await context.newPage();
177
178
  try {
179
+ await page.goto(GEMINI_HOME, { waitUntil: "domcontentloaded", timeout: 15_000 });
180
+ await new Promise((r) => setTimeout(r, 2_000));
178
181
  const content = await sendAndWait(page, prompt, timeoutMs, log);
179
182
  return { content, model, finishReason: "stop" };
180
183
  } finally {
181
- if (owned) await page.close().catch(() => {});
184
+ await page.close().catch(() => {});
182
185
  }
183
186
  }
184
187
 
@@ -188,55 +191,60 @@ export async function geminiCompleteStream(
188
191
  onToken: (token: string) => void,
189
192
  log: (msg: string) => void
190
193
  ): Promise<GeminiBrowserResult> {
191
- const { page, owned } = await getOrCreateGeminiPage(context);
192
194
  const model = resolveModel(opts.model);
193
195
  const prompt = flattenMessages(opts.messages);
194
196
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
195
197
 
196
198
  log(`gemini-browser: stream model=${model}`);
197
199
 
198
- const countBefore = await countResponses(page);
200
+ const page = await context.newPage();
201
+ try {
202
+ await page.goto(GEMINI_HOME, { waitUntil: "domcontentloaded", timeout: 15_000 });
203
+ await new Promise((r) => setTimeout(r, 2_000));
199
204
 
200
- const editor = page.locator(".ql-editor");
201
- await editor.click();
202
- await editor.type(prompt, { delay: 10 });
203
- await new Promise((r) => setTimeout(r, 300));
204
- await page.keyboard.press("Enter");
205
+ const countBefore = await countResponses(page);
205
206
 
206
- const deadline = Date.now() + timeoutMs;
207
- let emittedLength = 0;
208
- let lastText = "";
209
- let stableCount = 0;
207
+ const editor = page.locator(".ql-editor");
208
+ await editor.click();
209
+ await editor.type(prompt, { delay: 10 });
210
+ await new Promise((r) => setTimeout(r, 300));
211
+ await page.keyboard.press("Enter");
210
212
 
211
- while (Date.now() < deadline) {
212
- await new Promise((r) => setTimeout(r, STABLE_INTERVAL_MS));
213
+ const deadline = Date.now() + timeoutMs;
214
+ let emittedLength = 0;
215
+ let lastText = "";
216
+ let stableCount = 0;
213
217
 
214
- const currentCount = await countResponses(page);
215
- if (currentCount <= countBefore) continue;
218
+ while (Date.now() < deadline) {
219
+ await new Promise((r) => setTimeout(r, STABLE_INTERVAL_MS));
216
220
 
217
- const text = await getLastResponseText(page);
221
+ const currentCount = await countResponses(page);
222
+ if (currentCount <= countBefore) continue;
218
223
 
219
- if (text.length > emittedLength) {
220
- onToken(text.slice(emittedLength));
221
- emittedLength = text.length;
222
- }
224
+ const text = await getLastResponseText(page);
223
225
 
224
- const streaming = await isStreaming(page);
225
- if (streaming) { stableCount = 0; continue; }
226
+ if (text.length > emittedLength) {
227
+ onToken(text.slice(emittedLength));
228
+ emittedLength = text.length;
229
+ }
226
230
 
227
- if (text && text === lastText) {
228
- stableCount++;
229
- if (stableCount >= STABLE_CHECKS) {
230
- log(`gemini-browser: stream done (${text.length} chars)`);
231
- if (owned) await page.close().catch(() => {});
232
- return { content: text, model, finishReason: "stop" };
231
+ const streaming = await isStreaming(page);
232
+ if (streaming) { stableCount = 0; continue; }
233
+
234
+ if (text && text === lastText) {
235
+ stableCount++;
236
+ if (stableCount >= STABLE_CHECKS) {
237
+ log(`gemini-browser: stream done (${text.length} chars)`);
238
+ return { content: text, model, finishReason: "stop" };
239
+ }
240
+ } else {
241
+ stableCount = 0;
242
+ lastText = text;
233
243
  }
234
- } else {
235
- stableCount = 0;
236
- lastText = text;
237
244
  }
238
- }
239
245
 
240
- if (owned) await page.close().catch(() => {});
241
- throw new Error(`gemini.google.com stream timeout after ${timeoutMs}ms`);
246
+ throw new Error(`gemini.google.com stream timeout after ${timeoutMs}ms`);
247
+ } finally {
248
+ await page.close().catch(() => {});
249
+ }
242
250
  }
@@ -160,18 +160,20 @@ export async function grokComplete(
160
160
  opts: GrokCompleteOptions,
161
161
  log: (msg: string) => void
162
162
  ): Promise<GrokCompleteResult> {
163
- const { page, owned } = await getOrCreateGrokPage(context);
164
163
  const model = resolveModel(opts.model);
165
164
  const prompt = flattenMessages(opts.messages);
166
165
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
167
166
 
168
167
  log(`grok-client: complete model=${model}`);
169
168
 
169
+ const page = await context.newPage();
170
170
  try {
171
+ await page.goto("https://grok.com", { waitUntil: "domcontentloaded", timeout: 15_000 });
172
+ await new Promise((r) => setTimeout(r, 2_000));
171
173
  const content = await sendAndWait(page, prompt, timeoutMs, log);
172
174
  return { content, model, finishReason: "stop" };
173
175
  } finally {
174
- if (owned) await page.close().catch(() => {});
176
+ await page.close().catch(() => {});
175
177
  }
176
178
  }
177
179
 
@@ -184,66 +186,73 @@ export async function grokCompleteStream(
184
186
  onToken: (token: string) => void,
185
187
  log: (msg: string) => void
186
188
  ): Promise<GrokCompleteResult> {
187
- const { page, owned } = await getOrCreateGrokPage(context);
188
189
  const model = resolveModel(opts.model);
189
190
  const prompt = flattenMessages(opts.messages);
190
191
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
191
192
 
192
193
  log(`grok-client: stream model=${model}`);
193
194
 
194
- const countBefore = await page.evaluate(
195
- () => document.querySelectorAll(".message-bubble").length
196
- );
197
-
198
- // Send message
199
- await page.evaluate((msg: string) => {
200
- const ed =
201
- document.querySelector(".ProseMirror") ||
202
- document.querySelector('[contenteditable="true"]');
203
- if (!ed) throw new Error("Grok editor not found");
204
- (ed as HTMLElement).focus();
205
- document.execCommand("insertText", false, msg);
206
- }, prompt);
207
- await new Promise((r) => setTimeout(r, 300));
208
- await page.keyboard.press("Enter");
209
-
210
- log(`grok-client: message sent, streaming…`);
211
-
212
- // Stream: poll DOM, emit new chars as tokens
213
- const deadline = Date.now() + timeoutMs;
214
- let emittedLength = 0;
215
- let lastText = "";
216
- let stableCount = 0;
217
-
218
- while (Date.now() < deadline) {
219
- await new Promise((r) => setTimeout(r, STABLE_INTERVAL_MS));
195
+ const page = await context.newPage();
196
+ try {
197
+ await page.goto("https://grok.com", { waitUntil: "domcontentloaded", timeout: 15_000 });
198
+ await new Promise((r) => setTimeout(r, 2_000));
220
199
 
221
- const text = await page.evaluate(
222
- (before: number) => {
223
- const bubbles = [...document.querySelectorAll(".message-bubble")];
224
- if (bubbles.length <= before) return "";
225
- return bubbles[bubbles.length - 1].textContent?.trim() ?? "";
226
- },
227
- countBefore
200
+ const countBefore = await page.evaluate(
201
+ () => document.querySelectorAll(".message-bubble").length
228
202
  );
229
203
 
230
- if (text && text.length > emittedLength) {
231
- const newChars = text.slice(emittedLength);
232
- onToken(newChars);
233
- emittedLength = text.length;
234
- }
204
+ // Send message
205
+ await page.evaluate((msg: string) => {
206
+ const ed =
207
+ document.querySelector(".ProseMirror") ||
208
+ document.querySelector('[contenteditable="true"]');
209
+ if (!ed) throw new Error("Grok editor not found");
210
+ (ed as HTMLElement).focus();
211
+ document.execCommand("insertText", false, msg);
212
+ }, prompt);
213
+ await new Promise((r) => setTimeout(r, 300));
214
+ await page.keyboard.press("Enter");
215
+
216
+ log(`grok-client: message sent, streaming…`);
217
+
218
+ // Stream: poll DOM, emit new chars as tokens
219
+ const deadline = Date.now() + timeoutMs;
220
+ let emittedLength = 0;
221
+ let lastText = "";
222
+ let stableCount = 0;
223
+
224
+ while (Date.now() < deadline) {
225
+ await new Promise((r) => setTimeout(r, STABLE_INTERVAL_MS));
226
+
227
+ const text = await page.evaluate(
228
+ (before: number) => {
229
+ const bubbles = [...document.querySelectorAll(".message-bubble")];
230
+ if (bubbles.length <= before) return "";
231
+ return bubbles[bubbles.length - 1].textContent?.trim() ?? "";
232
+ },
233
+ countBefore
234
+ );
235
+
236
+ if (text && text.length > emittedLength) {
237
+ const newChars = text.slice(emittedLength);
238
+ onToken(newChars);
239
+ emittedLength = text.length;
240
+ }
235
241
 
236
- if (text && text === lastText) {
237
- stableCount++;
238
- if (stableCount >= STABLE_CHECKS) {
239
- log(`grok-client: stream done (${text.length} chars)`);
240
- return { content: text, model, finishReason: "stop" };
242
+ if (text && text === lastText) {
243
+ stableCount++;
244
+ if (stableCount >= STABLE_CHECKS) {
245
+ log(`grok-client: stream done (${text.length} chars)`);
246
+ return { content: text, model, finishReason: "stop" };
247
+ }
248
+ } else {
249
+ stableCount = 0;
250
+ lastText = text;
241
251
  }
242
- } else {
243
- stableCount = 0;
244
- lastText = text;
245
252
  }
246
- }
247
253
 
248
- throw new Error(`grok.com stream timeout after ${timeoutMs}ms`);
254
+ throw new Error(`grok.com stream timeout after ${timeoutMs}ms`);
255
+ } finally {
256
+ await page.close().catch(() => {});
257
+ }
249
258
  }