surf-cli 2.7.2 → 2.8.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.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Grok Web Client for surf-cli
3
- *
3
+ *
4
4
  * CDP-based client for X.com's Grok AI using browser automation.
5
5
  * Provides access to Grok's unique real-time X/Twitter data capabilities.
6
6
  */
@@ -8,14 +8,14 @@
8
8
  const { loadConfig, getConfigPath, clearCache } = require("./config.cjs");
9
9
 
10
10
  const GROK_URL = "https://x.com/i/grok";
11
- const DEFAULT_MODEL = "thinking";
11
+ const DEFAULT_MODEL = "fast";
12
12
 
13
- // Default models (as of Jan 2026)
13
+ // Default models (as of Jun 2026)
14
14
  const DEFAULT_GROK_MODELS = {
15
15
  "auto": { id: "auto", name: "Auto", desc: "Chooses Fast or Expert" },
16
16
  "fast": { id: "fast", name: "Fast", desc: "Quick responses" },
17
17
  "expert": { id: "expert", name: "Expert", desc: "Thinks hard" },
18
- "thinking": { id: "thinking", name: "Grok 4.1 Thinking", desc: "Thinks fast" },
18
+ "grok-4.20-beta": { id: "grok-4.20-beta", name: "Grok 4.20 Beta", desc: "4 Agents" },
19
19
  };
20
20
 
21
21
  // Load models from surf.json config or use defaults
@@ -60,6 +60,70 @@ function buildClickDispatcher() {
60
60
  }`;
61
61
  }
62
62
 
63
+ function normalizeGrokModelLabel(text) {
64
+ return String(text || "").toLowerCase().replace(/[^a-z0-9]/g, "");
65
+ }
66
+
67
+ function getGrokModelMatchLabels(desiredModel) {
68
+ const labels = new Set([desiredModel]);
69
+ const models = getGrokModels();
70
+ const configured = models[desiredModel];
71
+ if (configured) {
72
+ labels.add(configured.id);
73
+ labels.add(configured.name);
74
+ } else {
75
+ for (const model of Object.values(models)) {
76
+ if (model?.id === desiredModel) {
77
+ labels.add(model.id);
78
+ labels.add(model.name);
79
+ }
80
+ }
81
+ }
82
+ return Array.from(labels).filter(Boolean).map(normalizeGrokModelLabel);
83
+ }
84
+
85
+ function grokModelLabelsMatch(foundLabel, requestedLabels) {
86
+ const found = normalizeGrokModelLabel(foundLabel);
87
+ return requestedLabels.some(label => label && (found.includes(label) || label.includes(found)));
88
+ }
89
+
90
+ function grokSendButtonFinderScript() {
91
+ return `
92
+ const isVisible = (el) => {
93
+ const style = window.getComputedStyle?.(el);
94
+ const rect = el.getBoundingClientRect?.();
95
+ return el.offsetParent !== null &&
96
+ (!style || (style.visibility !== 'hidden' && style.display !== 'none')) &&
97
+ (!rect || (rect.width > 0 && rect.height > 0));
98
+ };
99
+ const isEnabled = (el) => !el.disabled && el.getAttribute('aria-disabled') !== 'true';
100
+ const matchesSendButton = (button) => {
101
+ const label = (button.getAttribute('aria-label') || '').trim().toLowerCase();
102
+ const testId = (button.getAttribute('data-testid') || '').trim().toLowerCase();
103
+ return label === 'send' || label === 'submit' ||
104
+ label.startsWith('send ') || label.startsWith('submit ') ||
105
+ testId === 'groksend' || testId === 'send-button' ||
106
+ testId.includes('composer-send') || testId.includes('grok-send');
107
+ };
108
+ const input = document.querySelector('textarea, [contenteditable="true"][role="textbox"], [data-testid="grokComposerInput"]');
109
+ const scopes = [];
110
+ if (input) {
111
+ const form = input.closest('form');
112
+ const composer = input.closest('[data-testid*="composer" i], [aria-label*="composer" i], [role="form"]');
113
+ if (form) scopes.push(form);
114
+ if (composer && composer !== form) scopes.push(composer);
115
+ }
116
+ scopes.push(document);
117
+ let sendBtn = null;
118
+ for (const scope of scopes) {
119
+ sendBtn = Array.from(scope.querySelectorAll('button')).find(b =>
120
+ matchesSendButton(b) && isVisible(b) && isEnabled(b)
121
+ );
122
+ if (sendBtn) break;
123
+ }
124
+ `;
125
+ }
126
+
63
127
  function hasRequiredCookies(cookies) {
64
128
  if (!cookies || !Array.isArray(cookies)) return false;
65
129
  // auth_token is the primary session cookie for X.com
@@ -71,8 +135,8 @@ function hasRequiredCookies(cookies) {
71
135
  async function evaluate(cdp, expression) {
72
136
  const result = await cdp(expression);
73
137
  if (result.exceptionDetails) {
74
- const desc = result.exceptionDetails.exception?.description ||
75
- result.exceptionDetails.text ||
138
+ const desc = result.exceptionDetails.exception?.description ||
139
+ result.exceptionDetails.text ||
76
140
  "Evaluation failed";
77
141
  throw new Error(desc);
78
142
  }
@@ -106,30 +170,30 @@ async function checkLoginStatus(cdp) {
106
170
  const hasLoginButton = !!document.querySelector('a[href*="/login"], [data-testid="loginButton"]');
107
171
  const hasGrokUI = body.includes('ask anything') || body.includes('grok');
108
172
  const hasPremiumPrompt = body.includes('subscribe') || body.includes('premium required');
109
-
173
+
110
174
  return {
111
175
  loggedIn: !hasLoginButton && hasGrokUI,
112
176
  hasPremium: hasGrokUI && !hasPremiumPrompt,
113
177
  url: location.href
114
178
  };
115
179
  })()`);
116
-
180
+
117
181
  return result || { loggedIn: false, hasPremium: false };
118
182
  }
119
183
 
120
184
  async function waitForGrokReady(cdp, timeoutMs = 20000) {
121
185
  const deadline = Date.now() + timeoutMs;
122
186
  let lastState = null;
123
-
187
+
124
188
  while (Date.now() < deadline) {
125
189
  const state = await evaluate(cdp, `(() => {
126
190
  // Check for Grok-specific elements
127
191
  const hasInput = !!document.querySelector('textarea, [contenteditable="true"][role="textbox"], [data-testid="grokComposerInput"]');
128
- const hasGrokBranding = document.body.innerText.includes('Grok') ||
192
+ const hasGrokBranding = document.body.innerText.includes('Grok') ||
129
193
  !!document.querySelector('[data-testid*="grok"]');
130
194
  const isGrokPage = location.pathname.includes('/grok');
131
195
  const isLoginPage = location.pathname.includes('/login') || location.pathname.includes('/i/flow');
132
-
196
+
133
197
  return {
134
198
  ready: isGrokPage && (hasInput || hasGrokBranding),
135
199
  hasInput,
@@ -138,26 +202,26 @@ async function waitForGrokReady(cdp, timeoutMs = 20000) {
138
202
  url: location.href
139
203
  };
140
204
  })()`);
141
-
205
+
142
206
  lastState = state;
143
-
207
+
144
208
  if (state && state.ready) {
145
209
  return state;
146
210
  }
147
-
211
+
148
212
  // If redirected to login, fail fast
149
213
  if (state && state.isLoginPage) {
150
214
  throw new Error("Redirected to login page - X.com login required");
151
215
  }
152
-
216
+
153
217
  await delay(200);
154
218
  }
155
-
219
+
156
220
  // Timeout - provide helpful error based on last state
157
221
  if (lastState && !lastState.isGrokPage) {
158
222
  throw new Error(`Not on Grok page (current: ${lastState.url}) - may need to log in`);
159
223
  }
160
-
224
+
161
225
  // Return fallback for edge cases where we're on Grok page but UI isn't detected
162
226
  return { ready: true, fallback: true };
163
227
  }
@@ -167,13 +231,13 @@ async function waitForGrokReady(cdp, timeoutMs = 20000) {
167
231
  // ============================================================================
168
232
 
169
233
  async function selectModel(cdp, desiredModel, timeoutMs = 8000) {
170
- const normalizedModel = desiredModel.toLowerCase().replace(/[^a-z0-9.-]/g, "");
171
-
234
+ const requestedLabels = getGrokModelMatchLabels(desiredModel);
235
+
172
236
  // First, find and click the model selector button
173
237
  const buttonClicked = await evaluate(cdp, `(() => {
174
238
  ${buildClickDispatcher()}
175
-
176
- // Look for model selector button (shows current model: Auto, Fast, Expert, or Grok 4.1 Thinking)
239
+
240
+ // Look for model selector button (shows current model: Auto, Fast, Expert, or Grok 4.x)
177
241
  const buttons = Array.from(document.querySelectorAll('button'));
178
242
  const modelBtn = buttons.find(b => {
179
243
  const text = (b.textContent || '').toLowerCase();
@@ -184,78 +248,81 @@ async function selectModel(cdp, desiredModel, timeoutMs = 8000) {
184
248
  const hasModelLabel = label.includes('model') || testId.includes('model');
185
249
  return hasModelName || hasModelLabel;
186
250
  });
187
-
251
+
188
252
  if (!modelBtn) return { success: false, error: 'Model selector not found' };
189
-
253
+
190
254
  dispatchClickSequence(modelBtn);
191
255
  return { success: true };
192
256
  })()`);
193
-
257
+
194
258
  if (!buttonClicked || !buttonClicked.success) {
195
259
  // Model selector might not exist (single model), continue anyway
196
260
  return desiredModel;
197
261
  }
198
-
262
+
199
263
  await delay(400);
200
-
264
+
201
265
  // Select from menu - loop in Node.js to avoid CDP timeout issues
202
266
  const deadline = Date.now() + timeoutMs;
203
-
267
+
204
268
  while (Date.now() < deadline) {
205
269
  const result = await evaluate(cdp, `(() => {
206
270
  ${buildClickDispatcher()}
207
-
208
- const targetModel = ${JSON.stringify(normalizedModel)};
209
- const normalize = (text) => (text || '').toLowerCase().replace(/[^a-z0-9.-]/g, '');
210
-
271
+
272
+ const requestedLabels = ${JSON.stringify(requestedLabels)};
273
+ const normalize = (text) => (text || '').toLowerCase().replace(/[^a-z0-9]/g, '');
274
+
211
275
  // Look for menu items
212
276
  const items = document.querySelectorAll('[role="menuitem"], [role="menuitemradio"], [role="option"]');
213
-
277
+
214
278
  if (items.length === 0) {
215
279
  return { found: false, waiting: true };
216
280
  }
217
-
281
+
218
282
  let bestMatch = null;
219
283
  let bestScore = 0;
220
-
284
+
221
285
  for (const item of items) {
222
286
  const text = normalize(item.textContent || '');
223
287
  let score = 0;
224
-
225
- if (text.includes(targetModel)) score = 100;
226
- else if (targetModel.includes(text) && text.length > 3) score = 50;
227
- else if (text.includes('thinking') && targetModel.includes('thinking')) score = 75;
228
-
288
+
289
+ for (const label of requestedLabels) {
290
+ if (!label) continue;
291
+ if (text === label) score = Math.max(score, 100);
292
+ else if (text.includes(label)) score = Math.max(score, 90);
293
+ else if (label.includes(text) && text.length > 3) score = Math.max(score, 50);
294
+ }
295
+
229
296
  if (score > bestScore) {
230
297
  bestScore = score;
231
298
  bestMatch = item;
232
299
  }
233
300
  }
234
-
301
+
235
302
  if (bestMatch) {
236
303
  dispatchClickSequence(bestMatch);
237
304
  return { found: true, success: true, model: bestMatch.textContent?.trim() };
238
305
  }
239
-
306
+
240
307
  return { found: true, success: false, error: 'No matching model in menu' };
241
308
  })()`);
242
-
309
+
243
310
  if (result && result.found) {
244
311
  if (result.success) {
245
312
  await delay(200);
246
313
  return result.model;
247
314
  }
248
- // Items found but no match - close menu and return default
315
+ // Items found but no match - close menu and surface the failure to callers.
249
316
  await evaluate(cdp, `document.body.click()`);
250
- return desiredModel;
317
+ throw new Error(result.error ? `${result.error} for "${desiredModel}"` : `No matching model in menu for "${desiredModel}"`);
251
318
  }
252
-
319
+
253
320
  await delay(100);
254
321
  }
255
-
322
+
256
323
  // Timeout - close menu
257
324
  await evaluate(cdp, `document.body.click()`);
258
- return desiredModel;
325
+ throw new Error(`Timed out waiting for model menu to show "${desiredModel}"`);
259
326
  }
260
327
 
261
328
  // ============================================================================
@@ -265,7 +332,7 @@ async function selectModel(cdp, desiredModel, timeoutMs = 8000) {
265
332
  async function enableDeepSearch(cdp) {
266
333
  const result = await evaluate(cdp, `(() => {
267
334
  ${buildClickDispatcher()}
268
-
335
+
269
336
  // Look for DeepSearch toggle or button
270
337
  const buttons = Array.from(document.querySelectorAll('button, [role="switch"]'));
271
338
  const deepSearchBtn = buttons.find(b => {
@@ -275,27 +342,27 @@ async function enableDeepSearch(cdp) {
275
342
  return text.includes('deepsearch') || text.includes('deep search') ||
276
343
  label.includes('deepsearch') || label.includes('deep search');
277
344
  });
278
-
345
+
279
346
  if (!deepSearchBtn) {
280
347
  return { success: false, error: 'DeepSearch toggle not found' };
281
348
  }
282
-
349
+
283
350
  // Check if already enabled
284
351
  const isEnabled = deepSearchBtn.getAttribute('aria-checked') === 'true' ||
285
352
  deepSearchBtn.classList.contains('active');
286
-
353
+
287
354
  if (isEnabled) {
288
355
  return { success: true, alreadyEnabled: true };
289
356
  }
290
-
357
+
291
358
  dispatchClickSequence(deepSearchBtn);
292
359
  return { success: true };
293
360
  })()`);
294
-
361
+
295
362
  if (result && result.success) {
296
363
  await delay(300);
297
364
  }
298
-
365
+
299
366
  return result || { success: false };
300
367
  }
301
368
 
@@ -307,7 +374,7 @@ async function typePrompt(cdp, inputCdp, prompt) {
307
374
  // Focus the input area
308
375
  const focused = await evaluate(cdp, `(() => {
309
376
  ${buildClickDispatcher()}
310
-
377
+
311
378
  // Strategy 1: Find textarea or contenteditable
312
379
  const inputs = document.querySelectorAll('textarea, [contenteditable="true"][role="textbox"], [data-testid="grokComposerInput"]');
313
380
  for (const el of inputs) {
@@ -317,7 +384,7 @@ async function typePrompt(cdp, inputCdp, prompt) {
317
384
  return { success: true, method: 'input' };
318
385
  }
319
386
  }
320
-
387
+
321
388
  // Strategy 2: Look for elements with "Ask" placeholder (more targeted selector)
322
389
  const placeholderEls = document.querySelectorAll('[placeholder*="Ask"], [placeholder*="ask"], [aria-placeholder*="Ask"]');
323
390
  for (const el of placeholderEls) {
@@ -327,16 +394,16 @@ async function typePrompt(cdp, inputCdp, prompt) {
327
394
  return { success: true, method: 'placeholder' };
328
395
  }
329
396
  }
330
-
397
+
331
398
  return { success: false, error: 'Input not found' };
332
399
  })()`);
333
-
400
+
334
401
  if (!focused || !focused.success) {
335
402
  throw new Error(`Could not focus input: ${focused?.error || 'unknown'}`);
336
403
  }
337
-
404
+
338
405
  await delay(300);
339
-
406
+
340
407
  // Type using CDP Input API
341
408
  await inputCdp("Input.insertText", { text: prompt });
342
409
  await delay(200);
@@ -346,24 +413,17 @@ async function submitPrompt(cdp, inputCdp) {
346
413
  // Try to click send button
347
414
  const clicked = await evaluate(cdp, `(() => {
348
415
  ${buildClickDispatcher()}
349
-
350
- // Look for send button
351
- const buttons = Array.from(document.querySelectorAll('button'));
352
- const sendBtn = buttons.find(b => {
353
- const label = (b.getAttribute('aria-label') || '').toLowerCase();
354
- const testId = b.getAttribute('data-testid') || '';
355
- return label.includes('send') || testId.includes('send') ||
356
- testId.includes('submit') || testId.includes('grokSend');
357
- });
358
-
359
- if (sendBtn && !sendBtn.disabled) {
416
+
417
+ ${grokSendButtonFinderScript()}
418
+
419
+ if (sendBtn) {
360
420
  dispatchClickSequence(sendBtn);
361
421
  return { success: true, method: 'button' };
362
422
  }
363
-
423
+
364
424
  return { success: false };
365
425
  })()`);
366
-
426
+
367
427
  if (!clicked || !clicked.success) {
368
428
  // Fallback: press Enter
369
429
  await inputCdp("Input.dispatchKeyEvent", {
@@ -382,7 +442,7 @@ async function submitPrompt(cdp, inputCdp) {
382
442
  nativeVirtualKeyCode: 13,
383
443
  });
384
444
  }
385
-
445
+
386
446
  await delay(500);
387
447
  }
388
448
 
@@ -390,13 +450,56 @@ async function submitPrompt(cdp, inputCdp) {
390
450
  // Response Handling
391
451
  // ============================================================================
392
452
 
453
+ function looksLikeTrailingSuggestion(line) {
454
+ if (!line || line.length < 4 || line.length > 90) return false;
455
+ if (/[.!:;)]$/.test(line)) return false;
456
+ const words = line.split(/\s+/).filter(Boolean);
457
+ if (words.length < 2 || words.length > 9) return false;
458
+ if (/^[-*•\d]/.test(line)) return false;
459
+ if (/\b(https?:\/\/|www\.)\b/i.test(line)) return false;
460
+ return true;
461
+ }
462
+
463
+ function trimTrailingSuggestionLines(lines) {
464
+ let end = lines.length;
465
+ while (end > 0 && looksLikeTrailingSuggestion(lines[end - 1])) {
466
+ end--;
467
+ }
468
+
469
+ const trimmedCount = lines.length - end;
470
+ if (trimmedCount >= 2 && end > 0) {
471
+ return lines.slice(0, end);
472
+ }
473
+
474
+ const last = lines[lines.length - 1];
475
+ if (
476
+ trimmedCount === 1 &&
477
+ lines.length > 1 &&
478
+ /^(explain|tell|share|compare|derive|make|show|summari[sz]e|expand|rewrite)\b/i.test(last)
479
+ ) {
480
+ return lines.slice(0, -1);
481
+ }
482
+
483
+ const previous = lines[lines.length - 2];
484
+ if (
485
+ last &&
486
+ previous &&
487
+ /^[A-Z][a-z]{3,}$/.test(last) &&
488
+ /(?:[.!?)]|^\d+(?:\.\d+)?$)/.test(previous)
489
+ ) {
490
+ return lines.slice(0, -1);
491
+ }
492
+
493
+ return lines;
494
+ }
495
+
393
496
  // Extract Grok's response from the full page body text
394
497
  function extractGrokResponse(bodyText, userPrompt = '') {
395
498
  if (!bodyText) return null;
396
-
499
+
397
500
  // Split into lines and filter out navigation/UI elements
398
501
  const lines = bodyText.split('\n').map(l => l.trim()).filter(l => l);
399
-
502
+
400
503
  // Known UI elements to skip
401
504
  const uiPatterns = [
402
505
  /^(Home|Explore|Notifications|Messages|Chat|Grok|Premium|Bookmarks|Communities|Profile|More|Post)$/i,
@@ -405,15 +508,15 @@ function extractGrokResponse(bodyText, userPrompt = '') {
405
508
  /^(Create recurring tasks|Get access to|Explore)$/i,
406
509
  /^(Think Harder)$/i,
407
510
  /^(Auto|Fast|Expert)$/i, // Model names
408
- /^Grok\s*\d/i, // "Grok 4.1 Thinking" etc
511
+ /^Grok\s*\d/i, // Grok 4.x model names
409
512
  /^@\w+$/, // Username mentions alone
410
513
  /^[A-Z][a-z]+ \d+$/, // Dates like "Jan 20"
411
514
  /^(See new posts|Talk to Grok|Get access to)/, // Sidebar promos
412
515
  ];
413
-
516
+
414
517
  // Normalize prompt for comparison (first 30 chars to handle truncation)
415
518
  const promptNorm = userPrompt.toLowerCase().replace(/[^a-z0-9]/g, '').substring(0, 30);
416
-
519
+
417
520
  // Find the LAST occurrence of the user's question to get the most recent conversation
418
521
  let lastQuestionIndex = -1;
419
522
  for (let i = lines.length - 1; i >= 0; i--) {
@@ -423,31 +526,30 @@ function extractGrokResponse(bodyText, userPrompt = '') {
423
526
  break;
424
527
  }
425
528
  }
426
-
529
+
427
530
  // Extract content after the last question
428
531
  const contentLines = [];
429
532
  const startIndex = lastQuestionIndex >= 0 ? lastQuestionIndex + 1 : 0;
430
-
533
+
431
534
  for (let i = startIndex; i < lines.length; i++) {
432
535
  const line = lines[i];
433
-
536
+
434
537
  // Skip empty and UI lines
435
538
  if (!line || uiPatterns.some(p => p.test(line))) continue;
436
-
539
+
437
540
  // Skip very short lines that are likely icons/buttons (but keep numbers)
438
541
  if (line.length <= 2 && !/^\d+$/.test(line)) continue;
439
-
440
- // Stop at follow-up suggestions (they mark the end of the response)
441
- if (/^(Explain|Tell me|Learn more|Show me|Multiplication)/i.test(line)) break;
442
-
542
+
443
543
  contentLines.push(line);
444
544
  }
445
-
545
+
546
+ const responseLines = trimTrailingSuggestionLines(contentLines);
547
+
446
548
  // If we found content after the question, return the response
447
- if (contentLines.length > 0) {
448
- return contentLines.join('\n').trim();
549
+ if (responseLines.length > 0) {
550
+ return responseLines.join('\n').trim();
449
551
  }
450
-
552
+
451
553
  // Fallback: look for the LAST standalone numeric answer
452
554
  for (let i = lines.length - 1; i >= 0; i--) {
453
555
  const line = lines[i];
@@ -455,15 +557,15 @@ function extractGrokResponse(bodyText, userPrompt = '') {
455
557
  return line;
456
558
  }
457
559
  }
458
-
560
+
459
561
  return null;
460
562
  }
461
563
 
462
564
  async function waitForResponse(cdp, timeoutMs = 300000, userPrompt = '') {
463
565
  // Grok can take a long time:
464
- // - Thinking models (Grok 4.1 Thinking): 40-60+ seconds to think, then streams
566
+ // - Thinking models: 40-60+ seconds to think, then streams
465
567
  // - Fast/Auto models: No thinking phase, just streams directly
466
-
568
+
467
569
  const deadline = Date.now() + timeoutMs;
468
570
  let previousText = '';
469
571
  let previousLength = 0;
@@ -472,30 +574,30 @@ async function waitForResponse(cdp, timeoutMs = 300000, userPrompt = '') {
472
574
  let thinkingComplete = false;
473
575
  let lastResponseText = '';
474
576
  let responseStableCycles = 0;
475
-
577
+
476
578
  while (Date.now() < deadline) {
477
579
  // Get page state with multiple completion indicators
478
580
  const snapshot = await evaluate(cdp, `(function() {
479
581
  const bodyText = document.body.innerText || '';
480
-
582
+
481
583
  // Check for stop/cancel button (indicates still generating)
482
584
  const hasStopBtn = !!document.querySelector('button[aria-label*="Stop"], button[aria-label*="stop"], button[aria-label*="Cancel"]');
483
-
585
+
484
586
  // Check for "Thought for Xs" which indicates thinking model completed thinking
485
587
  const thinkMatch = bodyText.match(/Thought for (\\d+)s/i);
486
588
  const thinkingDone = !!thinkMatch;
487
589
  const thinkingSecs = thinkMatch ? parseInt(thinkMatch[1], 10) : null;
488
-
590
+
489
591
  // Check if actively showing "thinking..." or similar loading state
490
- const isThinking = /\\bthinking\\.\\.\\./i.test(bodyText) ||
592
+ const isThinking = /\\bthinking\\.\\.\\./i.test(bodyText) ||
491
593
  /\\bSearching\\.\\.\\./i.test(bodyText) ||
492
594
  bodyText.includes('Grok is thinking') ||
493
595
  bodyText.includes('is thinking...');
494
-
596
+
495
597
  // Try to find the actual Grok response in the DOM
496
598
  // Look for the main content area - Grok responses appear in the conversation area
497
599
  let responseText = '';
498
-
600
+
499
601
  // Strategy 1: Look for article elements or main content containers
500
602
  const articles = document.querySelectorAll('article');
501
603
  if (articles.length > 0) {
@@ -503,7 +605,7 @@ async function waitForResponse(cdp, timeoutMs = 300000, userPrompt = '') {
503
605
  const lastArticle = articles[articles.length - 1];
504
606
  responseText = lastArticle.innerText || '';
505
607
  }
506
-
608
+
507
609
  // Strategy 2: If no articles, look for the conversation container
508
610
  if (!responseText) {
509
611
  const convArea = document.querySelector('[data-testid="conversation"], [role="main"] > div > div');
@@ -511,14 +613,14 @@ async function waitForResponse(cdp, timeoutMs = 300000, userPrompt = '') {
511
613
  responseText = convArea.innerText || '';
512
614
  }
513
615
  }
514
-
616
+
515
617
  // Strategy 3: Fallback to looking for text after common Grok UI patterns
516
618
  if (!responseText || responseText.length < 10) {
517
619
  // Find content between user question and follow-up suggestions
518
620
  const mainArea = document.querySelector('main') || document.body;
519
621
  responseText = mainArea.innerText || bodyText;
520
622
  }
521
-
623
+
522
624
  return {
523
625
  bodyText: bodyText,
524
626
  responseText: responseText,
@@ -530,22 +632,22 @@ async function waitForResponse(cdp, timeoutMs = 300000, userPrompt = '') {
530
632
  url: location.href
531
633
  };
532
634
  })()`);
533
-
635
+
534
636
  if (!snapshot || !snapshot.bodyText) {
535
637
  await delay(300);
536
638
  continue;
537
639
  }
538
-
640
+
539
641
  const bodyText = snapshot.bodyText;
540
642
  const bodyLength = snapshot.bodyLength;
541
-
643
+
542
644
  // Track thinking time (for thinking models)
543
645
  if (snapshot.thinkingSecs) {
544
646
  if (!thinkingTime || snapshot.thinkingSecs > thinkingTime) {
545
647
  thinkingTime = snapshot.thinkingSecs;
546
648
  }
547
649
  }
548
-
650
+
549
651
  // Detect when thinking completes (thinking models only)
550
652
  // "Thought for Xs" is a DEFINITIVE signal that thinking AND response generation is done
551
653
  if (snapshot.thinkingDone && !thinkingComplete) {
@@ -553,7 +655,7 @@ async function waitForResponse(cdp, timeoutMs = 300000, userPrompt = '') {
553
655
  // Give a brief moment for final render, then we're done
554
656
  await delay(500);
555
657
  }
556
-
658
+
557
659
  // Extract the actual response text - try DOM-extracted first, fall back to body parsing
558
660
  let currentResponseText = '';
559
661
  if (snapshot.responseText && snapshot.responseText.length > 10) {
@@ -562,7 +664,7 @@ async function waitForResponse(cdp, timeoutMs = 300000, userPrompt = '') {
562
664
  if (!currentResponseText || currentResponseText.length < 5) {
563
665
  currentResponseText = extractGrokResponse(bodyText, userPrompt) || '';
564
666
  }
565
-
667
+
566
668
  // Track RESPONSE text stability (more reliable than body text)
567
669
  if (currentResponseText !== lastResponseText) {
568
670
  lastResponseText = currentResponseText;
@@ -571,34 +673,34 @@ async function waitForResponse(cdp, timeoutMs = 300000, userPrompt = '') {
571
673
  } else if (currentResponseText.length > 0) {
572
674
  responseStableCycles++;
573
675
  }
574
-
676
+
575
677
  // Track body text for timeout fallback
576
678
  if (bodyLength !== previousLength) {
577
679
  previousText = bodyText;
578
680
  previousLength = bodyLength;
579
681
  }
580
-
682
+
581
683
  const stableMs = Date.now() - lastChangeAt;
582
684
  const noStopButton = !snapshot.hasStopBtn;
583
-
685
+
584
686
  // Response is stable if the extracted response text hasn't changed
585
687
  // Use shorter thresholds since we're checking actual content, not noisy body text
586
688
  // 4 cycles (1.2s) + 1.5s minimum is enough for response stability
587
689
  const responseIsStable = responseStableCycles >= 4 && stableMs >= 1500 && currentResponseText.length > 10;
588
-
690
+
589
691
  // "Thought for Xs" is the strongest completion signal - response is definitely done
590
692
  const thinkingModelDone = snapshot.thinkingDone && noStopButton;
591
-
693
+
592
694
  // SIMPLE CHECK: If we have response content, no stop button, and stable for 3+ cycles
593
695
  const hasResponseNoStop = currentResponseText.length > 5 && noStopButton && responseStableCycles >= 3;
594
-
696
+
595
697
  // Response is complete when:
596
698
  // 1. Has meaningful response content (> 5 chars)
597
699
  // 2. No stop button
598
700
  // 3. Either: thinking done, response stable for 3+ cycles, OR stable for 4+ cycles with 1.5s
599
701
  const isDone = currentResponseText.length > 5 && noStopButton &&
600
702
  (thinkingModelDone || hasResponseNoStop || responseIsStable);
601
-
703
+
602
704
  if (isDone) {
603
705
  return {
604
706
  text: currentResponseText,
@@ -606,10 +708,10 @@ async function waitForResponse(cdp, timeoutMs = 300000, userPrompt = '') {
606
708
  url: snapshot.url,
607
709
  };
608
710
  }
609
-
711
+
610
712
  await delay(300);
611
713
  }
612
-
714
+
613
715
  // Timeout - return whatever we have (partial response is better than nothing)
614
716
  const finalText = extractGrokResponse(previousText, userPrompt);
615
717
  if (finalText && finalText.length > 10) {
@@ -619,7 +721,7 @@ async function waitForResponse(cdp, timeoutMs = 300000, userPrompt = '') {
619
721
  partial: true,
620
722
  };
621
723
  }
622
-
724
+
623
725
  throw new Error("Response timeout - Grok did not complete in time");
624
726
  }
625
727
 
@@ -640,34 +742,34 @@ async function query(options) {
640
742
  cdpCommand,
641
743
  log = () => {},
642
744
  } = options;
643
-
745
+
644
746
  const startTime = Date.now();
645
747
  log("Starting Grok query");
646
-
748
+
647
749
  // Check cookies for X.com authentication
648
750
  const { cookies } = await getCookies();
649
751
  if (!hasRequiredCookies(cookies)) {
650
752
  throw new Error("X.com login required - log in to x.com in Chrome first");
651
753
  }
652
754
  log(`Got ${cookies.length} cookies`);
653
-
755
+
654
756
  // Create tab
655
757
  const tabInfo = await createTab();
656
758
  const { tabId } = tabInfo || {};
657
-
759
+
658
760
  if (!tabId) {
659
761
  throw new Error(`Failed to create Grok tab: ${JSON.stringify(tabInfo)}`);
660
762
  }
661
763
  log(`Created tab ${tabId}`);
662
-
764
+
663
765
  const cdp = (expr) => cdpEvaluate(tabId, expr);
664
766
  const inputCdp = (method, params) => cdpCommand(tabId, method, params);
665
-
767
+
666
768
  try {
667
769
  // Wait for page load
668
770
  await waitForPageLoad(cdp);
669
771
  log("Page loaded");
670
-
772
+
671
773
  // Check login status
672
774
  const loginStatus = await checkLoginStatus(cdp);
673
775
  if (!loginStatus.loggedIn) {
@@ -677,14 +779,14 @@ async function query(options) {
677
779
  log("Warning: X Premium may be required for some Grok features");
678
780
  }
679
781
  log(`Login: yes${loginStatus.hasPremium ? ' (Premium)' : ''}`);
680
-
782
+
681
783
  // Track warnings for agent feedback
682
784
  const warnings = [];
683
-
785
+
684
786
  // Wait for Grok UI
685
787
  await waitForGrokReady(cdp);
686
788
  log("Grok ready");
687
-
789
+
688
790
  // Select model (use default if not specified)
689
791
  const targetModel = model || DEFAULT_MODEL;
690
792
  let selectedModel = targetModel;
@@ -703,7 +805,7 @@ async function query(options) {
703
805
  warnings.push(`Model selection failed: ${e.message}. Run 'surf grok --validate' to check available models.`);
704
806
  log(`Model selection failed: ${e.message}`);
705
807
  }
706
-
808
+
707
809
  // Enable DeepSearch if requested
708
810
  let deepSearchEnabled = false;
709
811
  if (deepSearch) {
@@ -720,20 +822,20 @@ async function query(options) {
720
822
  log(`DeepSearch toggle failed: ${e.message}`);
721
823
  }
722
824
  }
723
-
825
+
724
826
  // Type prompt
725
827
  await typePrompt(cdp, inputCdp, prompt);
726
828
  log("Prompt typed");
727
-
829
+
728
830
  // Submit
729
831
  await submitPrompt(cdp, inputCdp);
730
832
  log("Submitted, waiting for response...");
731
-
833
+
732
834
  // Wait for response
733
835
  const response = await waitForResponse(cdp, timeout, prompt);
734
836
  const thinkingInfo = response.thinkingTime ? ` (thought for ${response.thinkingTime}s)` : '';
735
837
  log(`Response: ${response.text.length} chars${thinkingInfo}${response.partial ? ' (partial)' : ''}`);
736
-
838
+
737
839
  return {
738
840
  response: response.text,
739
841
  model: selectedModel,
@@ -764,10 +866,10 @@ async function validate(options) {
764
866
  cdpEvaluate,
765
867
  log = () => {},
766
868
  } = options;
767
-
869
+
768
870
  const startTime = Date.now();
769
871
  log("Starting Grok validation");
770
-
872
+
771
873
  const result = {
772
874
  authenticated: false,
773
875
  premium: false,
@@ -779,7 +881,7 @@ async function validate(options) {
779
881
  errors: [],
780
882
  configPath: getConfigPath() || "~/surf.json",
781
883
  };
782
-
884
+
783
885
  // Check cookies
784
886
  try {
785
887
  const { cookies } = await getCookies();
@@ -793,7 +895,7 @@ async function validate(options) {
793
895
  result.errors.push(`Cookie check failed: ${e.message}`);
794
896
  return { ...result, tookMs: Date.now() - startTime };
795
897
  }
796
-
898
+
797
899
  // Create tab
798
900
  let tabId;
799
901
  try {
@@ -808,29 +910,29 @@ async function validate(options) {
808
910
  result.errors.push(`Tab creation failed: ${e.message}`);
809
911
  return { ...result, tookMs: Date.now() - startTime };
810
912
  }
811
-
913
+
812
914
  const cdp = (expr) => cdpEvaluate(tabId, expr);
813
-
915
+
814
916
  try {
815
917
  // Wait for page load
816
918
  await waitForPageLoad(cdp);
817
919
  log("Page loaded");
818
-
920
+
819
921
  // Check login status
820
922
  const loginStatus = await checkLoginStatus(cdp);
821
923
  result.authenticated = loginStatus.loggedIn;
822
924
  result.premium = loginStatus.hasPremium;
823
-
925
+
824
926
  if (!loginStatus.loggedIn) {
825
927
  result.errors.push("Page shows logged out state");
826
928
  return { ...result, tookMs: Date.now() - startTime };
827
929
  }
828
930
  log(`Login: yes${result.premium ? ' (Premium)' : ''}`);
829
-
931
+
830
932
  // Wait for Grok UI
831
933
  await waitForGrokReady(cdp);
832
934
  log("Grok ready");
833
-
935
+
834
936
  // Check for input field
835
937
  const inputCheck = await evaluate(cdp, `(() => {
836
938
  const input = document.querySelector('textarea, [contenteditable="true"][role="textbox"], [data-testid="grokComposerInput"]');
@@ -838,20 +940,15 @@ async function validate(options) {
838
940
  })()`);
839
941
  result.inputFound = inputCheck?.found || false;
840
942
  log(`Input field: ${result.inputFound ? 'found' : 'NOT FOUND'}`);
841
-
943
+
842
944
  // Check for send button
843
945
  const sendCheck = await evaluate(cdp, `(() => {
844
- const buttons = Array.from(document.querySelectorAll('button'));
845
- const sendBtn = buttons.find(b => {
846
- const label = (b.getAttribute('aria-label') || '').toLowerCase();
847
- const testId = b.getAttribute('data-testid') || '';
848
- return label.includes('send') || testId.includes('send') || testId.includes('submit');
849
- });
946
+ ${grokSendButtonFinderScript()}
850
947
  return { found: !!sendBtn };
851
948
  })()`);
852
949
  result.sendButtonFound = sendCheck?.found || false;
853
950
  log(`Send button: ${result.sendButtonFound ? 'found' : 'NOT FOUND'}`);
854
-
951
+
855
952
  // Click model selector and scrape models
856
953
  const modelButtonClicked = await evaluate(cdp, `(() => {
857
954
  ${buildClickDispatcher()}
@@ -868,10 +965,10 @@ async function validate(options) {
868
965
  dispatchClickSequence(modelBtn);
869
966
  return { success: true };
870
967
  })()`);
871
-
968
+
872
969
  if (modelButtonClicked?.success) {
873
970
  await delay(500);
874
-
971
+
875
972
  // Scrape model options
876
973
  const modelScrape = await evaluate(cdp, `(() => {
877
974
  const items = document.querySelectorAll('[role="menuitem"], [role="menuitemradio"], [role="option"]');
@@ -887,24 +984,24 @@ async function validate(options) {
887
984
  }
888
985
  return { models };
889
986
  })()`);
890
-
987
+
891
988
  result.models = modelScrape?.models || [];
892
989
  log(`Found models: ${result.models.join(', ')}`);
893
-
990
+
894
991
  // Close the menu
895
992
  await evaluate(cdp, `document.body.click()`);
896
993
  } else {
897
994
  log("Could not open model selector");
898
995
  result.errors.push("Model selector button not found");
899
996
  }
900
-
997
+
901
998
  // Check for model mismatch
902
- const expectedNames = Object.values(getGrokModels()).map(m => m.name.toLowerCase());
903
- const foundNames = result.models.map(m => m.toLowerCase());
904
-
999
+ const expectedNames = Object.values(getGrokModels()).map(m => normalizeGrokModelLabel(m.name));
1000
+ const foundNames = result.models.map(m => normalizeGrokModelLabel(m));
1001
+
905
1002
  const missing = expectedNames.filter(e => !foundNames.some(f => f.includes(e) || e.includes(f)));
906
1003
  const extra = foundNames.filter(f => !expectedNames.some(e => f.includes(e) || e.includes(f)));
907
-
1004
+
908
1005
  if (missing.length > 0 || extra.length > 0) {
909
1006
  result.modelMismatch = true;
910
1007
  if (missing.length > 0) {
@@ -914,13 +1011,13 @@ async function validate(options) {
914
1011
  result.errors.push(`Unexpected models found: ${extra.join(', ')}`);
915
1012
  }
916
1013
  }
917
-
1014
+
918
1015
  } catch (e) {
919
1016
  result.errors.push(`Validation error: ${e.message}`);
920
1017
  } finally {
921
1018
  await closeTab(tabId).catch(() => {});
922
1019
  }
923
-
1020
+
924
1021
  result.tookMs = Date.now() - startTime;
925
1022
  return result;
926
1023
  }
@@ -930,14 +1027,14 @@ function saveModels(models) {
930
1027
  const fs = require("fs");
931
1028
  const path = require("path");
932
1029
  const os = require("os");
933
-
1030
+
934
1031
  try {
935
1032
  // Use existing config path or default to ~/surf.json
936
1033
  let configPath = getConfigPath();
937
1034
  if (!configPath) {
938
1035
  configPath = path.join(os.homedir(), "surf.json");
939
1036
  }
940
-
1037
+
941
1038
  // Load existing config or start fresh
942
1039
  let config = {};
943
1040
  if (fs.existsSync(configPath)) {
@@ -947,11 +1044,11 @@ function saveModels(models) {
947
1044
  // Start fresh if parse fails
948
1045
  }
949
1046
  }
950
-
1047
+
951
1048
  // Update grok.models
952
1049
  config.grok = config.grok || {};
953
1050
  config.grok.models = models;
954
-
1051
+
955
1052
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
956
1053
  clearCache(); // Clear config cache so subsequent reads see new values
957
1054
  return { success: true, path: configPath };
@@ -960,13 +1057,16 @@ function saveModels(models) {
960
1057
  }
961
1058
  }
962
1059
 
963
- module.exports = {
1060
+ module.exports = {
964
1061
  query,
965
1062
  validate,
966
- hasRequiredCookies,
1063
+ hasRequiredCookies,
967
1064
  getGrokModels,
968
1065
  saveModels,
969
1066
  extractGrokResponse,
1067
+ normalizeGrokModelLabel,
1068
+ getGrokModelMatchLabels,
1069
+ grokModelLabelsMatch,
970
1070
  GROK_URL,
971
1071
  GROK_MODELS,
972
1072
  DEFAULT_GROK_MODELS,