surf-cli 2.9.0 → 2.11.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 (44) hide show
  1. package/README.md +61 -4
  2. package/dist/content/accessibility-tree.js +11 -0
  3. package/dist/content/accessibility-tree.js.map +1 -0
  4. package/dist/content/visual-indicator.js +111 -0
  5. package/dist/content/visual-indicator.js.map +1 -0
  6. package/dist/manifest.json +11 -2
  7. package/dist/options/options.js +3 -3
  8. package/dist/options/options.js.map +1 -1
  9. package/dist/service-worker/index.js +61 -261
  10. package/dist/service-worker/index.js.map +1 -1
  11. package/native/activity-journal.cjs +55 -0
  12. package/native/chatgpt-client-response.cjs +336 -0
  13. package/native/chatgpt-client-selection.cjs +119 -0
  14. package/native/chatgpt-client-ui.cjs +481 -0
  15. package/native/chatgpt-client.cjs +254 -664
  16. package/native/cli.cjs +100 -273
  17. package/native/do-executor.cjs +52 -475
  18. package/native/do-parser.cjs +8 -249
  19. package/native/host-helpers.cjs +32 -15
  20. package/native/host-sessions.cjs +6 -1
  21. package/native/host.cjs +228 -6
  22. package/native/network-export.cjs +20 -17
  23. package/native/network-store.cjs +38 -58
  24. package/native/oracle-cli.cjs +434 -0
  25. package/native/oracle-context.cjs +311 -0
  26. package/native/oracle-host.cjs +301 -0
  27. package/native/oracle-jobs.cjs +253 -0
  28. package/native/playbook-authoring.cjs +44 -0
  29. package/native/playbook-cli.cjs +157 -0
  30. package/native/playbook-client.cjs +259 -0
  31. package/native/playbook-receipts.cjs +109 -0
  32. package/native/playbook-records.cjs +208 -0
  33. package/native/playbook-runtime.cjs +177 -0
  34. package/native/playbooks.cjs +235 -0
  35. package/native/private-state.cjs +156 -0
  36. package/native/redaction.cjs +104 -0
  37. package/native/workflow-definition.cjs +369 -0
  38. package/native/workflow-runtime.cjs +225 -0
  39. package/package.json +2 -1
  40. package/playbooks/page/ops/read.json +22 -0
  41. package/playbooks/page/playbook.json +7 -0
  42. package/skills/surf/SKILL.md +72 -1
  43. package/dist/content/index.js +0 -116
  44. package/dist/content/index.js.map +0 -1
@@ -1,703 +1,144 @@
1
1
  const path = require("path");
2
- const { abortableDelay, raceAbort, throwIfAborted } = require("./abort.cjs");
2
+ const { raceAbort, throwIfAborted } = require("./abort.cjs");
3
+ const {
4
+ checkLoginStatus,
5
+ clickSend,
6
+ delay,
7
+ evaluate,
8
+ isCloudflareBlocked,
9
+ normalizeChatGPTEffortChoice,
10
+ normalizeChatGPTModelChoice,
11
+ resolveChatGPTEffortMenuOption,
12
+ resolveChatGPTModelMenuOption,
13
+ selectEffort,
14
+ selectModel,
15
+ typePrompt,
16
+ verifyChatGPTEffortSelection,
17
+ verifyChatGPTModelSelection,
18
+ waitForPageLoad,
19
+ waitForPromptReady,
20
+ } = require("./chatgpt-client-ui.cjs");
21
+ const {
22
+ cleanChatGPTResponseText,
23
+ extractLatestAssistantSnapshot,
24
+ isChatGPTResponseComplete,
25
+ isNewAssistantContent,
26
+ matchesPromptEcho,
27
+ normalizePromptEcho,
28
+ normalizeResponseSnapshot,
29
+ readChatGPTResponseSnapshot,
30
+ waitForResponse,
31
+ } = require("./chatgpt-client-response.cjs");
3
32
 
4
33
  const CHATGPT_URL = "https://chatgpt.com/";
5
-
6
- const SELECTORS = {
7
- promptTextarea: '#prompt-textarea, [data-testid="composer-textarea"], textarea[name="prompt-textarea"], .ProseMirror, [contenteditable="true"][data-virtualkeyboard="true"]',
8
- sendButton: 'button[data-testid="send-button"], button[data-testid*="composer-send"], form button[type="submit"]',
9
- modelButton: '[data-testid="model-switcher-dropdown-button"]',
10
- assistantMessage: '[data-message-author-role="assistant"], [data-turn="assistant"], [data-testid*="assistant-message"], [data-testid*="assistant-turn"], [data-testid*="assistant-response"]',
11
- assistantContent: '.markdown, [data-message-content], .prose, [class*="markdown"], [dir="auto"]',
12
- stopButton: '[data-testid="stop-button"], [data-testid*="stop"], button[aria-label*="Stop"], button[aria-label*="stop"]',
13
- finishedActions: 'button[data-testid="copy-turn-action-button"], button[data-testid="good-response-turn-action-button"], button[data-testid*="turn-action"], button[aria-label*="Copy"], button[aria-label*="copy"], button[aria-label*="Read aloud"], button[aria-label*="read aloud"]',
14
- conversationTurn: '[data-testid^="conversation-turn"], [data-testid*="conversation-turn"]',
15
- cloudflareScript: 'script[src*="/challenge-platform/"]',
16
- };
17
-
18
- function delay(ms, signal) {
19
- return abortableDelay(ms, signal);
20
- }
21
-
22
- function buildClickDispatcher() {
23
- return `function dispatchClickSequence(target){
24
- if(!target || !(target instanceof EventTarget)) return false;
25
- const types = ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click'];
26
- for (const type of types) {
27
- const common = { bubbles: true, cancelable: true, view: window };
28
- let event;
29
- if (type.startsWith('pointer') && 'PointerEvent' in window) {
30
- event = new PointerEvent(type, { ...common, pointerId: 1, pointerType: 'mouse' });
31
- } else {
32
- event = new MouseEvent(type, common);
33
- }
34
- target.dispatchEvent(event);
35
- }
36
- return true;
37
- }`;
38
- }
34
+ const RESPONSE_STARTED_AT = Symbol("responseStartedAt");
39
35
 
40
36
  function hasRequiredCookies(cookies) {
41
37
  if (!cookies || !Array.isArray(cookies)) return false;
42
38
  return cookies.some(
43
- (c) =>
44
- typeof c?.name === "string" &&
45
- Boolean(c.value) &&
46
- (c.name === "__Secure-next-auth.session-token" ||
47
- /^__Secure-next-auth\.session-token\.\d+$/.test(c.name))
39
+ (cookie) =>
40
+ typeof cookie?.name === "string" &&
41
+ Boolean(cookie.value) &&
42
+ (cookie.name === "__Secure-next-auth.session-token" ||
43
+ /^__Secure-next-auth\.session-token\.\d+$/.test(cookie.name)),
48
44
  );
49
45
  }
50
46
 
51
- function cleanChatGPTResponseText(rawText) {
52
- if (!rawText) return "";
53
-
54
- const chromeLines = new Set([
55
- "copy",
56
- "good response",
57
- "bad response",
58
- "read aloud",
59
- "edit",
60
- "retry",
61
- "continue generating",
62
- "share",
63
- ]);
64
-
65
- const lines = [];
66
- let inCodeFence = false;
67
-
68
- for (const line of String(rawText).replace(/\r\n?/g, "\n").split("\n")) {
69
- const trimmed = line.trim();
70
- const isFenceLine = trimmed.startsWith("```");
71
- const normalizedLine = inCodeFence || isFenceLine ? line.replace(/[\t ]+$/g, "") : line;
72
-
73
- lines.push({
74
- text: normalizedLine,
75
- trimmed,
76
- isChrome: trimmed.length > 0 && chromeLines.has(trimmed.toLowerCase()),
77
- inCodeFence,
78
- isFenceLine,
79
- });
80
-
81
- if (isFenceLine) {
82
- inCodeFence = !inCodeFence;
83
- }
84
- }
85
-
86
- while (lines.length > 0 && lines[0].trimmed.length === 0) {
87
- lines.shift();
88
- }
89
- while (lines.length > 0 && lines[lines.length - 1].trimmed.length === 0) {
90
- lines.pop();
91
- }
92
-
93
- let trailingChromeStart = lines.length;
94
- while (trailingChromeStart > 0) {
95
- const line = lines[trailingChromeStart - 1];
96
- if (line.inCodeFence || line.isFenceLine || !line.isChrome) break;
97
- trailingChromeStart--;
98
- }
99
-
100
- const trailingChromeCount = lines.length - trailingChromeStart;
101
- if (trailingChromeCount >= 2) {
102
- lines.splice(trailingChromeStart);
103
- }
104
-
105
- while (lines.length > 0 && lines[0].trimmed.length === 0) {
106
- lines.shift();
107
- }
108
- while (lines.length > 0 && lines[lines.length - 1].trimmed.length === 0) {
109
- lines.pop();
110
- }
111
-
112
- return lines.map((line) => line.text).join("\n");
113
- }
114
-
115
- function extractLatestAssistantSnapshot(candidates) {
116
- if (!Array.isArray(candidates)) return null;
117
-
118
- let latestEmptyAssistant = null;
119
-
120
- for (let i = candidates.length - 1; i >= 0; i--) {
121
- const candidate = candidates[i];
122
- if (!candidate?.isAssistant) continue;
123
-
124
- const snapshot = {
125
- ...candidate,
126
- text: cleanChatGPTResponseText(candidate?.text || ""),
127
- turnIndex: i,
128
- };
129
-
130
- if (snapshot.text) {
131
- return snapshot;
132
- }
133
-
134
- if (!latestEmptyAssistant) {
135
- latestEmptyAssistant = snapshot;
136
- }
47
+ function extractConversationUrl(value) {
48
+ try {
49
+ const url = new URL(String(value));
50
+ const match = url.pathname.match(/^\/c\/([^/]+)\/?$/);
51
+ if (url.protocol !== "https:" || url.hostname !== "chatgpt.com" || !match) return null;
52
+ return `${url.origin}/c/${match[1]}`;
53
+ } catch {
54
+ return null;
137
55
  }
138
-
139
- return latestEmptyAssistant;
140
56
  }
141
57
 
142
- function normalizeResponseSnapshot(rawSnapshot) {
143
- const candidates = rawSnapshot?.candidates;
144
- return {
145
- latestAssistant: extractLatestAssistantSnapshot(candidates),
146
- assistantCount: Array.isArray(candidates)
147
- ? candidates.filter((candidate) => candidate?.isAssistant).length
148
- : 0,
149
- stopVisible: Boolean(rawSnapshot?.stopVisible),
150
- };
58
+ function codedError(message, code) {
59
+ const error = new Error(message);
60
+ error.code = code;
61
+ return error;
151
62
  }
152
63
 
153
- function isNewAssistantContent(
154
- latestAssistant,
155
- baselineAssistant,
156
- assistantCount = 0,
157
- baselineAssistantCount = 0
158
- ) {
159
- if (!latestAssistant) return false;
160
- if (!baselineAssistant) return true;
161
- if (latestAssistant.messageId && baselineAssistant.messageId) {
162
- if (latestAssistant.messageId !== baselineAssistant.messageId) {
163
- return true;
164
- }
165
- }
166
-
167
- const currentText = latestAssistant.text || "";
168
- const baselineText = baselineAssistant.text || "";
169
-
170
- if (assistantCount > baselineAssistantCount) {
171
- if (latestAssistant.turnIndex !== baselineAssistant.turnIndex) {
172
- return true;
173
- }
174
- if (currentText !== baselineText) {
175
- return true;
176
- }
177
- return false;
178
- }
179
-
180
- if (currentText !== baselineText) {
181
- return true;
64
+ function classifyError(error, fallbackCode, preservedCodes = []) {
65
+ const classified = error instanceof Error ? error : new Error(String(error));
66
+ if (
67
+ classified.code !== "SURF_REQUEST_ABORTED" &&
68
+ !preservedCodes.includes(classified.code)
69
+ ) {
70
+ classified.code = fallbackCode;
182
71
  }
183
- return false;
184
- }
185
-
186
- function isChatGPTResponseComplete(snapshot, stableCycles, stableMs) {
187
- if (!snapshot?.text) return false;
188
- if (snapshot.stopVisible) return false;
189
- if (snapshot.hasFinishedActions) return true;
190
- return stableCycles >= 6 && stableMs >= 1200;
72
+ return classified;
191
73
  }
192
74
 
193
- async function evaluate(cdp, expression, signal) {
194
- throwIfAborted(signal);
195
- const result = await cdp(expression);
196
- throwIfAborted(signal);
197
- if (result.exceptionDetails) {
198
- const desc = result.exceptionDetails.exception?.description ||
199
- result.exceptionDetails.text ||
200
- "Evaluation failed";
201
- throw new Error(desc);
202
- }
203
- if (result.error) {
204
- throw new Error(result.error);
205
- }
206
- return result.result?.value;
207
- }
208
-
209
- async function waitForPageLoad(cdp, timeoutMs = 45000, signal) {
210
- throwIfAborted(signal);
75
+ async function waitForConversationUrl(cdp, timeoutMs = 30000, signal) {
211
76
  const deadline = Date.now() + timeoutMs;
212
77
  while (Date.now() < deadline) {
213
- const ready = await evaluate(cdp, "document.readyState");
214
- if (ready === "complete" || ready === "interactive") {
215
- return;
216
- }
217
- await delay(100, signal);
218
- }
219
- throw new Error("Page did not load in time");
220
- }
221
-
222
- async function isCloudflareBlocked(cdp) {
223
- const title = await evaluate(cdp, "document.title.toLowerCase()");
224
- if (title && title.includes("just a moment")) return true;
225
- const hasScript = await evaluate(
226
- cdp,
227
- `Boolean(document.querySelector('${SELECTORS.cloudflareScript}'))`
228
- );
229
- return hasScript;
230
- }
231
-
232
- async function checkLoginStatus(cdp) {
233
- const result = await evaluate(
234
- cdp,
235
- `(async () => {
236
- try {
237
- const response = await fetch('/backend-api/me', {
238
- cache: 'no-store',
239
- credentials: 'include'
240
- });
241
- const hasLoginCta = Array.from(document.querySelectorAll('a[href*="/auth/login"], button'))
242
- .some(el => {
243
- const text = (el.textContent || '').toLowerCase().trim();
244
- return text.startsWith('log in') || text.startsWith('sign in');
245
- });
246
- return {
247
- status: response.status,
248
- hasLoginCta,
249
- url: location.href
250
- };
251
- } catch (e) {
252
- return { status: 0, error: e.message, url: location.href };
253
- }
254
- })()`
255
- );
256
- return result || { status: 0 };
257
- }
258
-
259
- async function waitForPromptReady(cdp, timeoutMs = 30000, signal) {
260
- throwIfAborted(signal);
261
- const deadline = Date.now() + timeoutMs;
262
- const selectors = JSON.stringify(SELECTORS.promptTextarea.split(", "));
263
- while (Date.now() < deadline) {
264
- const found = await evaluate(
265
- cdp,
266
- `(() => {
267
- const selectors = ${selectors};
268
- for (const selector of selectors) {
269
- const node = document.querySelector(selector);
270
- if (node && !node.hasAttribute('disabled')) {
271
- return true;
272
- }
273
- }
274
- return false;
275
- })()`
276
- );
277
- if (found) return true;
78
+ const conversationUrl = extractConversationUrl(await evaluate(cdp, "location.href", signal));
79
+ if (conversationUrl) return conversationUrl;
278
80
  await delay(200, signal);
279
81
  }
280
- return false;
281
- }
282
-
283
- function normalizeChatGPTModelChoice(desiredModel) {
284
- const normalized = String(desiredModel || "")
285
- .toLowerCase()
286
- .replace(/[^a-z0-9]/g, "");
287
-
288
- if (["instant", "gpt53"].includes(normalized)) return "instant";
289
- if (["thinking", "gpt54thinking"].includes(normalized)) return "thinking";
290
- if (["pro", "gpt54pro"].includes(normalized)) return "pro";
291
-
292
- return normalized;
82
+ return null;
293
83
  }
294
84
 
295
- function resolveChatGPTModelMenuOption(items, desiredModel) {
296
- if (!Array.isArray(items)) return null;
297
-
298
- const targetModel = normalizeChatGPTModelChoice(desiredModel);
299
-
300
- return items.find((item) => {
301
- if (item?.role !== "menuitemradio") return false;
302
- if (typeof item?.testId !== "string" || !item.testId.startsWith("model-switcher-")) return false;
303
-
304
- const label = normalizeChatGPTModelChoice(item.label || "");
305
- const testId = normalizeChatGPTModelChoice(item.testId.replace(/^model-switcher-/, ""));
306
- return label === targetModel || testId === targetModel;
307
- }) || null;
308
- }
309
-
310
- async function selectModel(cdp, desiredModel, timeoutMs = 8000, signal) {
311
- throwIfAborted(signal);
312
- const modelButton = await evaluate(
313
- cdp,
314
- `(() => {
315
- const btn = document.querySelector('${SELECTORS.modelButton}');
316
- return btn ? true : false;
317
- })()`
318
- );
319
- if (!modelButton) {
320
- throw new Error("Model selector button not found");
321
- }
322
- await evaluate(
323
- cdp,
324
- `(() => {
325
- ${buildClickDispatcher()}
326
- const btn = document.querySelector('${SELECTORS.modelButton}');
327
- if (btn) dispatchClickSequence(btn);
328
- })()`
329
- );
330
- await delay(300, signal);
331
-
332
- const normalizedModel = normalizeChatGPTModelChoice(desiredModel);
333
- const deadline = Date.now() + timeoutMs;
334
-
335
- while (Date.now() < deadline) {
336
- const result = await evaluate(
337
- cdp,
338
- `(() => {
339
- const menu = document.querySelector('[role="menu"][data-radix-menu-content]');
340
- if (!menu) {
341
- return { found: false, waiting: true };
342
- }
343
-
344
- return {
345
- found: true,
346
- items: Array.from(menu.children).map((item) => {
347
- const primary = item.querySelector?.('.min-w-0 > span');
348
- return {
349
- role: item.getAttribute?.('role') || null,
350
- label: (primary?.textContent || item.getAttribute?.('aria-label') || item.textContent || '').trim(),
351
- testId: item.getAttribute?.('data-testid') || null,
352
- };
353
- }),
354
- };
355
- })()`
356
- );
357
-
358
- if (result && result.found) {
359
- const match = resolveChatGPTModelMenuOption(result.items, normalizedModel);
360
- if (match) {
361
- await evaluate(
362
- cdp,
363
- `(() => {
364
- ${buildClickDispatcher()}
365
- const menu = document.querySelector('[role="menu"][data-radix-menu-content]');
366
- const item = menu?.querySelector('[data-testid="${match.testId}"]');
367
- if (item) dispatchClickSequence(item);
368
- })()`
369
- );
370
- await delay(200, signal);
371
- return match.label;
372
- }
373
-
374
- const available = Array.isArray(result.items)
375
- ? result.items
376
- .filter((item) => item?.role === "menuitemradio" && typeof item?.testId === "string" && item.testId.startsWith("model-switcher-"))
377
- .map((item) => item.label)
378
- .filter(Boolean)
379
- .join(", ")
380
- : "";
381
- throw new Error(
382
- available
383
- ? `Model not found: ${desiredModel}. Available: ${available}`
384
- : `Model not found: ${desiredModel}`
385
- );
386
- }
387
-
388
- await delay(100, signal);
389
- }
390
-
391
- throw new Error(`Model not found: ${desiredModel} (timeout)`);
392
- }
393
-
394
- async function typePrompt(cdp, inputCdp, prompt, signal) {
395
- throwIfAborted(signal);
396
- const selectors = JSON.stringify(SELECTORS.promptTextarea.split(", "));
397
- const encodedPrompt = JSON.stringify(prompt);
398
- const focused = await evaluate(
399
- cdp,
400
- `(() => {
401
- ${buildClickDispatcher()}
402
- const selectors = ${selectors};
403
- for (const selector of selectors) {
404
- const node = document.querySelector(selector);
405
- if (!node) continue;
406
- dispatchClickSequence(node);
407
- if (typeof node.focus === 'function') node.focus();
408
- const doc = node.ownerDocument;
409
- const selection = doc?.getSelection?.();
410
- if (selection) {
411
- const range = doc.createRange();
412
- range.selectNodeContents(node);
413
- range.collapse(false);
414
- selection.removeAllRanges();
415
- selection.addRange(range);
416
- }
417
- return true;
418
- }
419
- return false;
420
- })()`
421
- );
422
- if (!focused) {
423
- throw new Error("Failed to focus prompt textarea");
424
- }
425
- await inputCdp("Input.insertText", { text: prompt });
426
- await delay(300, signal);
427
- const verified = await evaluate(
428
- cdp,
429
- `(() => {
430
- const selectors = ${selectors};
431
- for (const selector of selectors) {
432
- const node = document.querySelector(selector);
433
- if (!node) continue;
434
- const text = node.innerText || node.value || node.textContent || '';
435
- if (text.trim().length > 0) return true;
436
- }
437
- return false;
438
- })()`
439
- );
440
- if (!verified) {
441
- await evaluate(
442
- cdp,
443
- `(() => {
444
- const editor = document.querySelector('#prompt-textarea');
445
- const fallback = document.querySelector('textarea[name="prompt-textarea"]');
446
- if (fallback) {
447
- fallback.value = ${encodedPrompt};
448
- fallback.dispatchEvent(new InputEvent('input', { bubbles: true, data: ${encodedPrompt}, inputType: 'insertFromPaste' }));
449
- }
450
- if (editor) {
451
- editor.textContent = ${encodedPrompt};
452
- editor.dispatchEvent(new InputEvent('input', { bubbles: true, data: ${encodedPrompt}, inputType: 'insertFromPaste' }));
453
- }
454
- })()`
455
- );
456
- }
457
- }
458
-
459
- async function clickSend(cdp, inputCdp, signal) {
460
- throwIfAborted(signal);
461
- const selectors = SELECTORS.sendButton.split(", ");
462
- const selectorsJson = JSON.stringify(selectors);
463
- const deadline = Date.now() + 8000;
464
- while (Date.now() < deadline) {
465
- const result = await evaluate(
466
- cdp,
467
- `(() => {
468
- ${buildClickDispatcher()}
469
- const selectors = ${selectorsJson};
470
- let button = null;
471
- for (const selector of selectors) {
472
- button = document.querySelector(selector);
473
- if (button) break;
474
- }
475
- if (!button) return 'missing';
476
- const disabled = button.hasAttribute('disabled') ||
477
- button.getAttribute('aria-disabled') === 'true' ||
478
- button.getAttribute('data-disabled') === 'true';
479
- if (disabled) return 'disabled';
480
- dispatchClickSequence(button);
481
- return 'clicked';
482
- })()`
483
- );
484
- if (result === "clicked") return true;
485
- if (result === "missing") break;
486
- await delay(100, signal);
487
- }
488
- await inputCdp("Input.dispatchKeyEvent", {
489
- type: "keyDown",
490
- key: "Enter",
491
- code: "Enter",
492
- windowsVirtualKeyCode: 13,
493
- nativeVirtualKeyCode: 13,
494
- text: "\r",
495
- });
496
- await inputCdp("Input.dispatchKeyEvent", {
497
- type: "keyUp",
498
- key: "Enter",
499
- code: "Enter",
500
- windowsVirtualKeyCode: 13,
501
- nativeVirtualKeyCode: 13,
502
- });
503
- return true;
504
- }
505
-
506
- async function readChatGPTResponseSnapshot(cdp) {
507
- return evaluate(
508
- cdp,
509
- `(() => {
510
- const scope = document.querySelector('main') || document;
511
- const CONVERSATION_SELECTOR = ${JSON.stringify(SELECTORS.conversationTurn)};
512
- const ASSISTANT_SELECTOR = ${JSON.stringify(SELECTORS.assistantMessage)};
513
- const CONTENT_SELECTORS = ${JSON.stringify(SELECTORS.assistantContent.split(", "))};
514
- const STOP_SELECTOR = ${JSON.stringify(SELECTORS.stopButton)};
515
- const FINISHED_SELECTOR = ${JSON.stringify(SELECTORS.finishedActions)};
516
-
517
- const toCandidate = (turnNode, messageRoot = null) => {
518
- const resolvedMessageRoot = messageRoot || (turnNode.matches?.(ASSISTANT_SELECTOR)
519
- ? turnNode
520
- : turnNode.querySelector(ASSISTANT_SELECTOR));
521
- const searchRoot = resolvedMessageRoot || turnNode;
522
- let contentRoot = null;
523
-
524
- for (const selector of CONTENT_SELECTORS) {
525
- const match = selector === '[dir="auto"]'
526
- ? (searchRoot.matches?.(selector) ? searchRoot : null)
527
- : (searchRoot.matches?.(selector) ? searchRoot : searchRoot.querySelector(selector));
528
- if (match) {
529
- contentRoot = match;
530
- break;
531
- }
532
- }
533
-
534
- const role =
535
- resolvedMessageRoot?.getAttribute('data-message-author-role') ||
536
- turnNode.getAttribute('data-message-author-role') ||
537
- null;
538
- const turn =
539
- resolvedMessageRoot?.getAttribute('data-turn') ||
540
- turnNode.getAttribute('data-turn') ||
541
- null;
542
- const isAssistant =
543
- role === 'assistant' ||
544
- turn === 'assistant' ||
545
- resolvedMessageRoot !== null;
546
- const text = (contentRoot || turnNode).innerText || (contentRoot || turnNode).textContent || '';
547
- const messageId =
548
- resolvedMessageRoot?.getAttribute('data-message-id') ||
549
- turnNode.getAttribute('data-message-id') ||
550
- null;
551
- const hasFinishedActions = Boolean(turnNode.querySelector(FINISHED_SELECTOR));
552
-
553
- return {
554
- role,
555
- turn,
556
- isAssistant,
557
- text,
558
- messageId,
559
- hasFinishedActions,
560
- };
561
- };
562
-
563
- let candidates = Array.from(scope.querySelectorAll(CONVERSATION_SELECTOR)).map((turnNode) =>
564
- toCandidate(turnNode)
565
- );
566
-
567
- if (candidates.length === 0) {
568
- candidates = Array.from(scope.querySelectorAll(ASSISTANT_SELECTOR)).map((messageRoot) =>
569
- toCandidate(messageRoot, messageRoot)
570
- );
571
- }
572
-
573
- return {
574
- candidates,
575
- stopVisible: Boolean(scope.querySelector(STOP_SELECTOR)),
576
- };
577
- })()`
578
- );
579
- }
580
-
581
- async function waitForResponse(
582
- cdp,
583
- timeoutMs = 2700000,
584
- baselineAssistant,
585
- baselineAssistantCount,
586
- signal
587
- ) {
588
- throwIfAborted(signal);
589
- const deadline = Date.now() + timeoutMs;
590
- let previousText = "";
591
- let stableCycles = 0;
592
- let lastChangeAt = Date.now();
593
-
594
- previousText = baselineAssistant?.text || "";
595
- lastChangeAt = Date.now();
596
-
597
- while (Date.now() < deadline) {
598
- const snapshot = await readChatGPTResponseSnapshot(cdp);
599
-
600
- if (!snapshot) {
601
- await delay(400, signal);
602
- continue;
603
- }
604
-
605
- const { latestAssistant, assistantCount, stopVisible } = normalizeResponseSnapshot(snapshot);
606
- const currentText = latestAssistant?.text || "";
607
- const hasNewAssistantContent = isNewAssistantContent(
608
- latestAssistant,
609
- baselineAssistant,
610
- assistantCount,
611
- baselineAssistantCount
612
- );
613
-
614
- if (!hasNewAssistantContent) {
615
- await delay(400, signal);
616
- continue;
617
- }
618
-
619
- if (currentText !== previousText) {
620
- previousText = currentText;
621
- stableCycles = 0;
622
- lastChangeAt = Date.now();
623
- } else if (currentText) {
624
- stableCycles++;
625
- } else {
626
- stableCycles = 0;
627
- lastChangeAt = Date.now();
628
- }
629
-
630
- const stableMs = Date.now() - lastChangeAt;
631
- const completionSnapshot = latestAssistant
632
- ? { ...latestAssistant, stopVisible }
633
- : { text: "", stopVisible, hasFinishedActions: false };
634
-
635
- if (isChatGPTResponseComplete(completionSnapshot, stableCycles, stableMs)) {
636
- return {
637
- text: latestAssistant.text,
638
- messageId: latestAssistant.messageId,
639
- turnIndex: latestAssistant.turnIndex,
640
- };
641
- }
642
-
643
- await delay(400, signal);
644
- }
645
-
646
- throw new Error("Response timeout");
647
- }
648
-
649
- async function query(options) {
85
+ async function dispatch(options) {
650
86
  const {
651
87
  prompt,
652
88
  model,
89
+ effort,
653
90
  file,
654
- timeout = 2700000,
655
91
  getCookies,
656
92
  createTab,
657
- closeTab,
658
93
  cdpEvaluate,
659
94
  cdpCommand,
660
95
  uploadFile,
96
+ beforeSubmit,
97
+ afterSubmit,
98
+ startUrl,
661
99
  log = () => {},
662
100
  signal,
663
101
  } = options;
664
- throwIfAborted(signal);
665
- const guardedUploadFile = uploadFile
666
- ? (...args) => raceAbort(() => uploadFile(...args), signal)
667
- : uploadFile;
668
- const startTime = Date.now();
669
- log("Starting ChatGPT query");
670
- const { cookies } = await raceAbort(getCookies, signal);
671
- if (!hasRequiredCookies(cookies)) {
672
- throw new Error("ChatGPT login required");
673
- }
674
- log(`Got ${cookies.length} cookies`);
675
- const tabInfo = await raceAbort(createTab, signal);
676
- const { tabId } = tabInfo;
677
- if (!tabId) {
678
- throw new Error("Failed to create ChatGPT tab");
679
- }
680
- log(`Created tab ${tabId}`);
681
-
682
- const cdp = (expr) => raceAbort(() => cdpEvaluate(tabId, expr), signal);
683
- const inputCdp = (method, params) => raceAbort(() => cdpCommand(tabId, method, params), signal);
684
-
102
+
685
103
  try {
104
+ throwIfAborted(signal);
105
+ const guardedUploadFile = uploadFile
106
+ ? (...args) => raceAbort(() => uploadFile(...args), signal)
107
+ : uploadFile;
108
+ log("Starting ChatGPT query");
109
+ const { cookies } = await raceAbort(getCookies, signal);
110
+ if (!hasRequiredCookies(cookies)) {
111
+ throw codedError("ChatGPT login required", "auth");
112
+ }
113
+ log(`Got ${cookies.length} cookies`);
114
+ const tabInfo = await raceAbort(createTab, signal);
115
+ const { tabId } = tabInfo;
116
+ if (!tabId) {
117
+ throw new Error("Failed to create ChatGPT tab");
118
+ }
119
+ log(`Created tab ${tabId}`);
120
+
121
+ const cdp = (expression) => raceAbort(() => cdpEvaluate(tabId, expression), signal);
122
+ const inputCdp = (method, params) =>
123
+ raceAbort(() => cdpCommand(tabId, method, params), signal);
124
+
125
+ if (startUrl) await inputCdp("Page.navigate", { url: startUrl });
686
126
  await waitForPageLoad(cdp, 45000, signal);
687
127
  log("Page loaded");
688
128
  if (await isCloudflareBlocked(cdp)) {
689
- throw new Error("Cloudflare challenge detected - complete in browser");
129
+ throw codedError("Cloudflare challenge detected - complete in browser", "cloudflare");
690
130
  }
691
131
  const loginStatus = await checkLoginStatus(cdp);
692
132
  if (loginStatus.status === 0) {
693
- throw new Error(
133
+ throw codedError(
694
134
  loginStatus.error
695
135
  ? `ChatGPT login check failed: ${loginStatus.error}`
696
- : "ChatGPT login check failed"
136
+ : "ChatGPT login check failed",
137
+ "auth",
697
138
  );
698
139
  }
699
140
  if (loginStatus.status !== 200 || loginStatus.hasLoginCta) {
700
- throw new Error("ChatGPT login required");
141
+ throw codedError("ChatGPT login required", "auth");
701
142
  }
702
143
  log("Login verified");
703
144
  const promptReady = await waitForPromptReady(cdp, 30000, signal);
@@ -705,13 +146,21 @@ async function query(options) {
705
146
  throw new Error("Prompt textarea not ready");
706
147
  }
707
148
  log("Prompt ready");
149
+ let modelVerified = null;
150
+ let effortVerified = null;
708
151
  if (model) {
709
- const selectedLabel = await selectModel(cdp, model, 8000, signal);
710
- log(`Selected model: ${selectedLabel}`);
152
+ modelVerified = await selectModel(cdp, model, 8000, signal);
153
+ log(`Verified model: ${modelVerified}`);
154
+ }
155
+ if (effort) {
156
+ effortVerified = await selectEffort(cdp, effort, 8000, signal);
157
+ log(`Verified effort: ${effortVerified}`);
711
158
  }
712
159
  if (file) {
713
160
  if (!uploadFile) {
714
- throw new Error("ChatGPT file upload unavailable: native host did not provide upload callback");
161
+ throw new Error(
162
+ "ChatGPT file upload unavailable: native host did not provide upload callback",
163
+ );
715
164
  }
716
165
  const files = Array.isArray(file) ? file : [file];
717
166
  const absFiles = files.map((filePath) => path.resolve(process.cwd(), filePath));
@@ -729,39 +178,180 @@ async function query(options) {
729
178
  await typePrompt(cdp, inputCdp, prompt, signal);
730
179
  log("Prompt typed");
731
180
  const baseline = normalizeResponseSnapshot(await readChatGPTResponseSnapshot(cdp));
181
+ if (beforeSubmit) await raceAbort(beforeSubmit, signal);
732
182
  await clickSend(cdp, inputCdp, signal);
183
+ baseline[RESPONSE_STARTED_AT] = Date.now();
184
+ const promptEcho = normalizePromptEcho(prompt);
185
+ if (afterSubmit) {
186
+ await afterSubmit({ tabId, promptEcho, modelVerified, effortVerified });
187
+ }
733
188
  log("Prompt sent, waiting for response...");
189
+ const conversationUrl = await waitForConversationUrl(cdp, 30000, signal);
190
+
191
+ return {
192
+ tabId,
193
+ conversationUrl,
194
+ promptEcho,
195
+ model: model || "current",
196
+ modelVerified,
197
+ effortVerified,
198
+ baseline,
199
+ };
200
+ } catch (error) {
201
+ throw classifyError(error, "dispatch_failed", [
202
+ "auth",
203
+ "cloudflare",
204
+ "model_verification_failed",
205
+ ]);
206
+ }
207
+ }
208
+
209
+ async function harvest(options) {
210
+ const {
211
+ tabId: liveTabId,
212
+ conversationUrl,
213
+ promptEcho,
214
+ baseline,
215
+ timeout = 2700000,
216
+ createTab,
217
+ closeTab,
218
+ cdpEvaluate,
219
+ cdpCommand,
220
+ keepCreatedTabOpen = false,
221
+ log = () => {},
222
+ signal,
223
+ } = options;
224
+ const startTime = Date.now();
225
+ let tabId = liveTabId;
226
+ let ownsTab = false;
227
+
228
+ try {
229
+ throwIfAborted(signal);
230
+ if (!tabId) {
231
+ if (!conversationUrl) {
232
+ throw new Error("ChatGPT conversation URL required for fresh-tab harvest");
233
+ }
234
+ const tabInfo = await raceAbort(createTab, signal);
235
+ tabId = tabInfo?.tabId;
236
+ if (!tabId) {
237
+ throw new Error("Failed to create ChatGPT tab");
238
+ }
239
+ ownsTab = true;
240
+ }
241
+
242
+ const cdp = (expression) => raceAbort(() => cdpEvaluate(tabId, expression), signal);
243
+ const inputCdp = (method, params) =>
244
+ raceAbort(() => cdpCommand(tabId, method, params), signal);
245
+
246
+ if (ownsTab) {
247
+ await inputCdp("Page.navigate", { url: conversationUrl });
248
+ await waitForPageLoad(cdp, 45000, signal);
249
+ if (await isCloudflareBlocked(cdp)) {
250
+ throw codedError("Cloudflare challenge detected - complete in browser", "cloudflare");
251
+ }
252
+ const loginStatus = await checkLoginStatus(cdp);
253
+ if (loginStatus.status === 0) {
254
+ throw codedError(
255
+ loginStatus.error
256
+ ? `ChatGPT login check failed: ${loginStatus.error}`
257
+ : "ChatGPT login check failed",
258
+ "auth",
259
+ );
260
+ }
261
+ if (loginStatus.status !== 200 || loginStatus.hasLoginCta) {
262
+ throw codedError("ChatGPT login required", "auth");
263
+ }
264
+ }
265
+
266
+ const elapsedSinceSend = baseline?.[RESPONSE_STARTED_AT]
267
+ ? Date.now() - baseline[RESPONSE_STARTED_AT]
268
+ : 0;
734
269
  const response = await waitForResponse(
735
270
  cdp,
736
- timeout,
737
- baseline.latestAssistant,
738
- baseline.assistantCount,
739
- signal
271
+ Math.max(0, timeout - elapsedSinceSend),
272
+ baseline?.latestAssistant,
273
+ baseline?.assistantCount,
274
+ signal,
275
+ baseline ? undefined : promptEcho,
740
276
  );
741
277
  log(`Response received (${response.text.length} chars)`);
742
278
  return {
743
279
  response: response.text,
744
- model: model || "current",
745
280
  messageId: response.messageId,
746
281
  tookMs: Date.now() - startTime,
747
282
  };
283
+ } catch (error) {
284
+ const fallbackCode = error?.message === "Response timeout" ? "timeout" : "harvest_failed";
285
+ throw classifyError(error, fallbackCode, ["auth", "cloudflare", "timeout"]);
286
+ } finally {
287
+ if (ownsTab && !keepCreatedTabOpen) {
288
+ try {
289
+ await closeTab(tabId);
290
+ } catch (error) {
291
+ log(`Failed to close ChatGPT tab ${tabId}: ${error?.message || error}`);
292
+ }
293
+ }
294
+ }
295
+ }
296
+
297
+ async function query(options) {
298
+ const { closeTab, createTab, log = () => {}, signal, timeout = 2700000 } = options;
299
+ throwIfAborted(signal);
300
+ const startTime = Date.now();
301
+ let tabId = null;
302
+
303
+ try {
304
+ const dispatched = await dispatch({
305
+ ...options,
306
+ createTab: async () => {
307
+ const tabInfo = await createTab();
308
+ tabId = tabInfo?.tabId || null;
309
+ return tabInfo;
310
+ },
311
+ });
312
+ const result = await harvest({
313
+ ...options,
314
+ tabId: dispatched.tabId,
315
+ conversationUrl: dispatched.conversationUrl,
316
+ promptEcho: dispatched.promptEcho,
317
+ baseline: dispatched.baseline,
318
+ timeout,
319
+ });
320
+ return {
321
+ response: result.response,
322
+ model: dispatched.model,
323
+ messageId: result.messageId,
324
+ tookMs: Date.now() - startTime,
325
+ };
748
326
  } finally {
749
- try {
750
- await closeTab(tabId);
751
- } catch (error) {
752
- log(`Failed to close ChatGPT tab ${tabId}: ${error?.message || error}`);
327
+ if (tabId) {
328
+ try {
329
+ await closeTab(tabId);
330
+ } catch (error) {
331
+ log(`Failed to close ChatGPT tab ${tabId}: ${error?.message || error}`);
332
+ }
753
333
  }
754
334
  }
755
335
  }
756
336
 
757
337
  module.exports = {
758
338
  query,
339
+ dispatch,
340
+ harvest,
759
341
  hasRequiredCookies,
760
342
  cleanChatGPTResponseText,
761
343
  extractLatestAssistantSnapshot,
344
+ normalizeChatGPTEffortChoice,
762
345
  normalizeChatGPTModelChoice,
346
+ resolveChatGPTEffortMenuOption,
763
347
  resolveChatGPTModelMenuOption,
764
348
  isNewAssistantContent,
765
349
  isChatGPTResponseComplete,
350
+ isCloudflareBlocked,
351
+ normalizePromptEcho,
352
+ matchesPromptEcho,
353
+ extractConversationUrl,
354
+ verifyChatGPTEffortSelection,
355
+ verifyChatGPTModelSelection,
766
356
  CHATGPT_URL,
767
357
  };