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.
- package/README.md +208 -13
- package/dist/content/index.js +116 -0
- package/dist/content/index.js.map +1 -0
- package/dist/manifest.json +2 -11
- package/dist/options/options.js +3 -3
- package/dist/options/options.js.map +1 -1
- package/dist/service-worker/index.js +261 -61
- package/dist/service-worker/index.js.map +1 -1
- package/native/abort.cjs +65 -0
- package/native/ai-queue.cjs +64 -0
- package/native/aistudio-build.cjs +21 -13
- package/native/aistudio-client.cjs +40 -20
- package/native/browser-lock.cjs +169 -0
- package/native/chatgpt-client.cjs +63 -30
- package/native/cli.cjs +947 -460
- package/native/client-transport.cjs +168 -0
- package/native/config.cjs +2 -2
- package/native/do-executor.cjs +25 -51
- package/native/do-parser.cjs +12 -0
- package/native/doctor.cjs +633 -0
- package/native/endpoint.cjs +174 -0
- package/native/file-transfer.cjs +734 -0
- package/native/gemini-client.cjs +244 -88
- package/native/grok-client.cjs +321 -212
- package/native/host-helpers.cjs +88 -16
- package/native/host-sessions.cjs +283 -0
- package/native/host.cjs +811 -616
- package/native/listener.cjs +20 -0
- package/native/mcp-server.cjs +60 -62
- package/native/network-export.cjs +113 -0
- package/native/perplexity-client.cjs +46 -17
- package/native/remote-auth.cjs +279 -0
- package/native/remote-transport.cjs +337 -0
- package/native/request-pending.cjs +148 -0
- package/native/socket-path.cjs +46 -0
- package/package.json +11 -9
- package/scripts/install-native-host.cjs +184 -51
- package/scripts/uninstall-native-host.cjs +93 -15
- package/skills/README.md +11 -5
- package/skills/deep-x-research/SKILL.md +106 -0
- package/skills/surf/SKILL.md +77 -22
- package/dist/content/accessibility-tree.js +0 -11
- package/dist/content/accessibility-tree.js.map +0 -1
- package/dist/content/visual-indicator.js +0 -111
- package/dist/content/visual-indicator.js.map +0 -1
package/native/grok-client.cjs
CHANGED
|
@@ -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 = "
|
|
12
|
+
const DEFAULT_MODEL = "fast";
|
|
12
13
|
|
|
13
|
-
// Default models (as of
|
|
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
|
-
"
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
209
|
-
const normalize = (text) => (text || '').toLowerCase().replace(/[^a-z0-9
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
318
|
+
// Items found but no match - close menu and surface the failure to callers.
|
|
249
319
|
await evaluate(cdp, `document.body.click()`);
|
|
250
|
-
|
|
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
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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, //
|
|
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 (
|
|
448
|
-
return
|
|
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
|
|
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
|
-
|
|
504
|
-
responseText =
|
|
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
|
-
|
|
519
|
-
responseText =
|
|
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 >
|
|
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 >
|
|
594
|
-
|
|
686
|
+
const hasResponseNoStop = currentResponseText.trim().length > 0 && noStopButton && responseStableCycles >= 3;
|
|
687
|
+
|
|
595
688
|
// Response is complete when:
|
|
596
|
-
// 1. Has
|
|
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 >
|
|
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 >
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
903
|
-
const foundNames = result.models.map(m => m
|
|
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
|
-
|
|
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,
|