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