surf-cli 2.10.0 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -230,9 +230,14 @@ surf read --compact # Remove empty structural elements
230
230
  surf read --depth 3 --compact # Both (60% smaller output)
231
231
  surf read --max-bytes 2000 # Cap visible text on a UTF-8 byte boundary
232
232
  surf page.text # Raw text content only
233
+ surf page.html # Rendered document HTML
234
+ surf page.html --strip-scripts > artifact.html # Save a safe static Claude artifact
235
+ surf page.save --selector "#artifact" --strip-scripts --output artifact.html # Save one rendered element
233
236
  surf page.state # Modals, loading state, scroll position
234
237
  ```
235
238
 
239
+ Use `surf page.html --strip-scripts` after the page loads when you need a static export of a Claude artifact or other rendered DOM. Use `--selector <css>` to export one element. Both commands target the active frame when `frame.switch` is active.
240
+
236
241
  Element refs (`e1`, `e2`, `e3`...) are stable identifiers from the accessibility tree - semantic, predictable, and resilient to DOM changes.
237
242
 
238
243
  ### Semantic Locators
@@ -492,6 +497,19 @@ surf aistudio.build "crm dashboard" --output ./out # Extract zip to d
492
497
  surf aistudio.build "game" --keep-open --timeout 600 # Keep tab open, 10min timeout
493
498
  ```
494
499
 
500
+ #### Oracle
501
+
502
+ Use `surf oracle` for a durable, local ChatGPT consult instead of a quick `surf chatgpt` one-shot. It persists jobs by conversation URL, supports repeatable file-context globs, and verifies requested model and reasoning effort before submission.
503
+
504
+ ```bash
505
+ surf oracle ask "review this change" --files "src/**/*.ts" --model pro --effort extended --detach --json
506
+ surf oracle status <job-id> --json
507
+ surf oracle result <job-id> --wait --json
508
+ surf oracle follow <job-id> "challenge that recommendation" --detach --json
509
+ ```
510
+
511
+ Only one oracle job can be in flight. Sensitive filename patterns and gitignored context are blocked unless `--allow-sensitive` is explicit.
512
+
495
513
  Each AI tool uses your existing browser login - no API keys needed. Just be logged into the respective service in Chrome (chatgpt.com, gemini.google.com, perplexity.ai, x.com, or aistudio.google.com).
496
514
 
497
515
  **Grok troubleshooting:** If queries fail, run `surf grok --validate` to check if the UI structure changed. Use `--save-models` to update the model cache in `surf.json`. Default model is `fast`.
@@ -694,6 +712,21 @@ surf use page read --json
694
712
  surf use <site> <write-op> --write --resource-id 123
695
713
  ```
696
714
 
715
+ Playbook read ops can also use a trusted `script` strategy when fixed JSON steps are too rigid. Scripts require `--allow-script` at run time. Only run scripts from playbooks you trust. This is not a security sandbox.
716
+
717
+ The script gets `input`, `tools.run`, `tools.all`, `tools.ref`/`refs`, `emit`, and `console`. Tool calls still use Surf workflow step behavior, including auto-waits unless `autoWait` is `false`.
718
+
719
+ ```json
720
+ {
721
+ "using": "script",
722
+ "script": [
723
+ "const page = await tools.run('page', { tool: 'page.text', args: {} });",
724
+ "const clicked = await tools.all(input.selectors.map((selector) => ({ key: selector.slice(1), tool: 'click', args: { selector } })));",
725
+ "return { page: page.output, clicked: clicked.map((link) => link.output) };"
726
+ ]
727
+ }
728
+ ```
729
+
697
730
  Project playbooks in `./.surf/playbooks/` override user playbooks in `~/.surf/playbooks/`; built-ins are the final fallback. `show` reports the selected source. Provider compatibility commands continue to use their validated command paths until provider playbooks have real login-flow validation.
698
731
 
699
732
  Author a playbook from redacted recent activity or an explicit evidence record:
@@ -919,6 +952,19 @@ cp -r skills/surf ~/.pi/agent/skills/
919
952
 
920
953
  See [`skills/README.md`](skills/README.md) for details.
921
954
 
955
+ ### Pi extension
956
+
957
+ Surf also includes an optional Pi extension. Install or load Surf as a Pi package, or load it from a checkout:
958
+
959
+ ```bash
960
+ pi install npm:surf-cli
961
+ pi -e /path/to/surf-cli/pi-extension/surf.ts
962
+ ```
963
+
964
+ It registers `surf_read`, `surf_screenshot`, `surf_click`, `surf_type`, `surf_tool`, and the `surf_oracle_*` tools. Browser calls use Surf's native-host socket, not shell commands. If `pi-subagents/background-work` is installed, the extension also reports active oracle jobs started by that Pi session. Pi still loads the browser tools when pi-subagents is not installed.
965
+
966
+ Surf agents share one browser session. Use read tools for parallel scouts when possible. `surf_click` and `surf_type` can interfere with another agent's browser actions. Browser leases are not available yet.
967
+
922
968
  ## Development
923
969
 
924
970
  ```bash
@@ -0,0 +1,336 @@
1
+ const { throwIfAborted } = require("./abort.cjs");
2
+ const { SELECTORS, delay, evaluate } = require("./chatgpt-client-ui.cjs");
3
+
4
+ function normalizePromptEcho(prompt) {
5
+ return String(prompt || "")
6
+ .replace(/\s+/g, " ")
7
+ .trim()
8
+ .slice(0, 200);
9
+ }
10
+
11
+ function matchesPromptEcho(text, promptEcho) {
12
+ const expected = normalizePromptEcho(promptEcho);
13
+ return expected.length > 0 && normalizePromptEcho(text) === expected;
14
+ }
15
+
16
+ function cleanChatGPTResponseText(rawText) {
17
+ if (!rawText) return "";
18
+
19
+ const chromeLines = new Set([
20
+ "copy",
21
+ "good response",
22
+ "bad response",
23
+ "read aloud",
24
+ "edit",
25
+ "retry",
26
+ "continue generating",
27
+ "share",
28
+ ]);
29
+
30
+ const lines = [];
31
+ let inCodeFence = false;
32
+
33
+ for (const line of String(rawText).replace(/\r\n?/g, "\n").split("\n")) {
34
+ const trimmed = line.trim();
35
+ const isFenceLine = trimmed.startsWith("```");
36
+ const normalizedLine = inCodeFence || isFenceLine ? line.replace(/[\t ]+$/g, "") : line;
37
+
38
+ lines.push({
39
+ text: normalizedLine,
40
+ trimmed,
41
+ isChrome: trimmed.length > 0 && chromeLines.has(trimmed.toLowerCase()),
42
+ inCodeFence,
43
+ isFenceLine,
44
+ });
45
+
46
+ if (isFenceLine) {
47
+ inCodeFence = !inCodeFence;
48
+ }
49
+ }
50
+
51
+ while (lines.length > 0 && lines[0].trimmed.length === 0) {
52
+ lines.shift();
53
+ }
54
+ while (lines.length > 0 && lines[lines.length - 1].trimmed.length === 0) {
55
+ lines.pop();
56
+ }
57
+
58
+ let trailingChromeStart = lines.length;
59
+ while (trailingChromeStart > 0) {
60
+ const line = lines[trailingChromeStart - 1];
61
+ if (line.inCodeFence || line.isFenceLine || !line.isChrome) break;
62
+ trailingChromeStart--;
63
+ }
64
+
65
+ const trailingChromeCount = lines.length - trailingChromeStart;
66
+ if (trailingChromeCount >= 2) {
67
+ lines.splice(trailingChromeStart);
68
+ }
69
+
70
+ while (lines.length > 0 && lines[0].trimmed.length === 0) {
71
+ lines.shift();
72
+ }
73
+ while (lines.length > 0 && lines[lines.length - 1].trimmed.length === 0) {
74
+ lines.pop();
75
+ }
76
+
77
+ return lines.map((line) => line.text).join("\n");
78
+ }
79
+
80
+ function extractLatestAssistantSnapshot(candidates) {
81
+ if (!Array.isArray(candidates)) return null;
82
+
83
+ let latestEmptyAssistant = null;
84
+
85
+ for (let i = candidates.length - 1; i >= 0; i--) {
86
+ const candidate = candidates[i];
87
+ if (!candidate?.isAssistant) continue;
88
+
89
+ const snapshot = {
90
+ ...candidate,
91
+ text: cleanChatGPTResponseText(candidate?.text || ""),
92
+ turnIndex: i,
93
+ };
94
+
95
+ if (snapshot.text) {
96
+ return snapshot;
97
+ }
98
+
99
+ if (!latestEmptyAssistant) {
100
+ latestEmptyAssistant = snapshot;
101
+ }
102
+ }
103
+
104
+ return latestEmptyAssistant;
105
+ }
106
+
107
+ function normalizeResponseSnapshot(rawSnapshot) {
108
+ const candidates = rawSnapshot?.candidates;
109
+ return {
110
+ latestAssistant: extractLatestAssistantSnapshot(candidates),
111
+ assistantCount: Array.isArray(candidates)
112
+ ? candidates.filter((candidate) => candidate?.isAssistant).length
113
+ : 0,
114
+ stopVisible: Boolean(rawSnapshot?.stopVisible),
115
+ };
116
+ }
117
+
118
+ function isNewAssistantContent(
119
+ latestAssistant,
120
+ baselineAssistant,
121
+ assistantCount = 0,
122
+ baselineAssistantCount = 0,
123
+ ) {
124
+ if (!latestAssistant) return false;
125
+ if (!baselineAssistant) return true;
126
+ if (
127
+ latestAssistant.messageId &&
128
+ baselineAssistant.messageId &&
129
+ latestAssistant.messageId !== baselineAssistant.messageId
130
+ ) {
131
+ return true;
132
+ }
133
+
134
+ const currentText = latestAssistant.text || "";
135
+ const baselineText = baselineAssistant.text || "";
136
+
137
+ if (assistantCount > baselineAssistantCount) {
138
+ if (latestAssistant.turnIndex !== baselineAssistant.turnIndex) {
139
+ return true;
140
+ }
141
+ if (currentText !== baselineText) {
142
+ return true;
143
+ }
144
+ return false;
145
+ }
146
+
147
+ return currentText !== baselineText;
148
+ }
149
+
150
+ function isChatGPTResponseComplete(snapshot, stableCycles, stableMs) {
151
+ if (!snapshot?.text) return false;
152
+ if (snapshot.stopVisible) return false;
153
+ if (snapshot.hasFinishedActions) return true;
154
+ return stableCycles >= 6 && stableMs >= 1200;
155
+ }
156
+
157
+ function scopeSnapshotToPrompt(rawSnapshot, promptEcho) {
158
+ const candidates = Array.isArray(rawSnapshot?.candidates) ? rawSnapshot.candidates : [];
159
+ let userIndex = -1;
160
+ for (let index = candidates.length - 1; index >= 0; index--) {
161
+ const candidate = candidates[index];
162
+ if (candidate?.isUser && matchesPromptEcho(candidate.text, promptEcho)) {
163
+ userIndex = index;
164
+ break;
165
+ }
166
+ }
167
+ if (userIndex === -1) return { ...rawSnapshot, candidates: [] };
168
+
169
+ const following = candidates.slice(userIndex + 1);
170
+ const nextUserIndex = following.findIndex((candidate) => candidate?.isUser);
171
+ return {
172
+ ...rawSnapshot,
173
+ candidates: nextUserIndex === -1 ? following : following.slice(0, nextUserIndex),
174
+ };
175
+ }
176
+
177
+ async function readChatGPTResponseSnapshot(cdp) {
178
+ return evaluate(
179
+ cdp,
180
+ `(() => {
181
+ const scope = document.querySelector('main') || document;
182
+ const CONVERSATION_SELECTOR = ${JSON.stringify(SELECTORS.conversationTurn)};
183
+ const ASSISTANT_SELECTOR = ${JSON.stringify(SELECTORS.assistantMessage)};
184
+ const CONTENT_SELECTORS = ${JSON.stringify(SELECTORS.assistantContent.split(", "))};
185
+ const STOP_SELECTOR = ${JSON.stringify(SELECTORS.stopButton)};
186
+ const FINISHED_SELECTOR = ${JSON.stringify(SELECTORS.finishedActions)};
187
+
188
+ const toCandidate = (turnNode, messageRoot = null) => {
189
+ const resolvedMessageRoot = messageRoot || (turnNode.matches?.(ASSISTANT_SELECTOR)
190
+ ? turnNode
191
+ : turnNode.querySelector(ASSISTANT_SELECTOR));
192
+ const authorRoot = turnNode.matches?.('[data-message-author-role], [data-turn]')
193
+ ? turnNode
194
+ : turnNode.querySelector('[data-message-author-role], [data-turn]');
195
+ const searchRoot = resolvedMessageRoot || authorRoot || turnNode;
196
+ let contentRoot = null;
197
+
198
+ for (const selector of CONTENT_SELECTORS) {
199
+ const match = selector === '[dir="auto"]'
200
+ ? (searchRoot.matches?.(selector) ? searchRoot : null)
201
+ : (searchRoot.matches?.(selector) ? searchRoot : searchRoot.querySelector(selector));
202
+ if (match) {
203
+ contentRoot = match;
204
+ break;
205
+ }
206
+ }
207
+
208
+ const role =
209
+ resolvedMessageRoot?.getAttribute('data-message-author-role') ||
210
+ authorRoot?.getAttribute('data-message-author-role') ||
211
+ turnNode.getAttribute('data-message-author-role') ||
212
+ null;
213
+ const turn =
214
+ resolvedMessageRoot?.getAttribute('data-turn') ||
215
+ authorRoot?.getAttribute('data-turn') ||
216
+ turnNode.getAttribute('data-turn') ||
217
+ null;
218
+ const isAssistant =
219
+ role === 'assistant' ||
220
+ turn === 'assistant' ||
221
+ resolvedMessageRoot !== null;
222
+ const isUser = role === 'user' || turn === 'user';
223
+ const text = (contentRoot || turnNode).innerText || (contentRoot || turnNode).textContent || '';
224
+ const messageId =
225
+ resolvedMessageRoot?.getAttribute('data-message-id') ||
226
+ turnNode.getAttribute('data-message-id') ||
227
+ null;
228
+ const hasFinishedActions = Boolean(turnNode.querySelector(FINISHED_SELECTOR));
229
+
230
+ return {
231
+ role,
232
+ turn,
233
+ isAssistant,
234
+ isUser,
235
+ text,
236
+ messageId,
237
+ hasFinishedActions,
238
+ };
239
+ };
240
+
241
+ let candidates = Array.from(scope.querySelectorAll(CONVERSATION_SELECTOR)).map((turnNode) =>
242
+ toCandidate(turnNode)
243
+ );
244
+
245
+ if (candidates.length === 0) {
246
+ candidates = Array.from(scope.querySelectorAll(ASSISTANT_SELECTOR)).map((messageRoot) =>
247
+ toCandidate(messageRoot, messageRoot)
248
+ );
249
+ }
250
+
251
+ return {
252
+ candidates,
253
+ stopVisible: Boolean(scope.querySelector(STOP_SELECTOR)),
254
+ };
255
+ })()`,
256
+ );
257
+ }
258
+
259
+ async function waitForResponse(
260
+ cdp,
261
+ timeoutMs = 2700000,
262
+ baselineAssistant,
263
+ baselineAssistantCount,
264
+ signal,
265
+ promptEcho,
266
+ ) {
267
+ throwIfAborted(signal);
268
+ const deadline = Date.now() + timeoutMs;
269
+ let previousText = baselineAssistant?.text || "";
270
+ let stableCycles = 0;
271
+ let lastChangeAt = Date.now();
272
+
273
+ while (Date.now() < deadline) {
274
+ const rawSnapshot = await readChatGPTResponseSnapshot(cdp);
275
+ const snapshot = promptEcho ? scopeSnapshotToPrompt(rawSnapshot, promptEcho) : rawSnapshot;
276
+
277
+ if (!snapshot) {
278
+ await delay(400, signal);
279
+ continue;
280
+ }
281
+
282
+ const { latestAssistant, assistantCount, stopVisible } = normalizeResponseSnapshot(snapshot);
283
+ const currentText = latestAssistant?.text || "";
284
+ const hasNewAssistantContent = isNewAssistantContent(
285
+ latestAssistant,
286
+ baselineAssistant,
287
+ assistantCount,
288
+ baselineAssistantCount,
289
+ );
290
+
291
+ if (!hasNewAssistantContent) {
292
+ await delay(400, signal);
293
+ continue;
294
+ }
295
+
296
+ if (currentText !== previousText) {
297
+ previousText = currentText;
298
+ stableCycles = 0;
299
+ lastChangeAt = Date.now();
300
+ } else if (currentText) {
301
+ stableCycles++;
302
+ } else {
303
+ stableCycles = 0;
304
+ lastChangeAt = Date.now();
305
+ }
306
+
307
+ const stableMs = Date.now() - lastChangeAt;
308
+ const completionSnapshot = latestAssistant
309
+ ? { ...latestAssistant, stopVisible }
310
+ : { text: "", stopVisible, hasFinishedActions: false };
311
+
312
+ if (isChatGPTResponseComplete(completionSnapshot, stableCycles, stableMs)) {
313
+ return {
314
+ text: latestAssistant.text,
315
+ messageId: latestAssistant.messageId,
316
+ turnIndex: latestAssistant.turnIndex,
317
+ };
318
+ }
319
+
320
+ await delay(400, signal);
321
+ }
322
+
323
+ throw new Error("Response timeout");
324
+ }
325
+
326
+ module.exports = {
327
+ cleanChatGPTResponseText,
328
+ extractLatestAssistantSnapshot,
329
+ isChatGPTResponseComplete,
330
+ isNewAssistantContent,
331
+ matchesPromptEcho,
332
+ normalizePromptEcho,
333
+ normalizeResponseSnapshot,
334
+ readChatGPTResponseSnapshot,
335
+ waitForResponse,
336
+ };
@@ -0,0 +1,119 @@
1
+ const CHATGPT_EFFORT_CHOICES = ["light", "standard", "extended", "heavy"];
2
+
3
+ function normalizeChatGPTModelChoice(desiredModel) {
4
+ const normalized = String(desiredModel || "")
5
+ .toLowerCase()
6
+ .replace(/[^a-z0-9]/g, "");
7
+
8
+ if (["instant", "gpt53"].includes(normalized)) return "instant";
9
+ if (["thinking", "gpt54thinking"].includes(normalized)) return "thinking";
10
+ if (["pro", "gpt54pro"].includes(normalized)) return "pro";
11
+
12
+ return normalized;
13
+ }
14
+
15
+ function normalizeChatGPTEffortChoice(desiredEffort) {
16
+ const normalized = String(desiredEffort || "").toLowerCase().trim();
17
+ return CHATGPT_EFFORT_CHOICES.includes(normalized) ? normalized : null;
18
+ }
19
+
20
+ function normalizedWords(value) {
21
+ return String(value || "")
22
+ .toLowerCase()
23
+ .replace(/[^a-z0-9]+/g, " ")
24
+ .trim()
25
+ .split(/\s+/)
26
+ .filter(Boolean);
27
+ }
28
+
29
+ function modelCandidateMatches(item, targetModel) {
30
+ const values = [item?.label, item?.testId?.replace(/^model-switcher-/, "")].filter(Boolean);
31
+ return values.some((value) => {
32
+ if (normalizeChatGPTModelChoice(value) === targetModel) return true;
33
+ const variants = ["instant", "thinking", "pro"].filter((variant) =>
34
+ normalizedWords(value).includes(variant),
35
+ );
36
+ return variants.length === 1 && variants[0] === targetModel;
37
+ });
38
+ }
39
+
40
+ function effortCandidateMatches(item, targetEffort) {
41
+ const variants = new Set(
42
+ [item?.label, item?.testId]
43
+ .flatMap((value) => normalizedWords(value))
44
+ .filter((word) => CHATGPT_EFFORT_CHOICES.includes(word)),
45
+ );
46
+ return variants.size === 1 && variants.has(targetEffort);
47
+ }
48
+
49
+ function uniqueMatch(items, matches) {
50
+ if (!Array.isArray(items)) return null;
51
+ const candidates = items.filter(matches);
52
+ return candidates.length === 1 ? candidates[0] : null;
53
+ }
54
+
55
+ function resolveChatGPTModelMenuOption(items, desiredModel) {
56
+ const targetModel = normalizeChatGPTModelChoice(desiredModel);
57
+ if (!targetModel) return null;
58
+ return uniqueMatch(
59
+ items,
60
+ (item) =>
61
+ item?.role === "menuitemradio" &&
62
+ typeof item?.testId === "string" &&
63
+ item.testId.startsWith("model-switcher-") &&
64
+ modelCandidateMatches(item, targetModel),
65
+ );
66
+ }
67
+
68
+ function verifyChatGPTModelSelection(items, desiredModel) {
69
+ const targetModel = normalizeChatGPTModelChoice(desiredModel);
70
+ if (!targetModel) return null;
71
+ return uniqueMatch(
72
+ items,
73
+ (item) =>
74
+ (typeof item?.label === "string" || typeof item?.testId === "string") &&
75
+ modelCandidateMatches(item, targetModel),
76
+ );
77
+ }
78
+
79
+ function resolveChatGPTEffortMenuOption(items, desiredEffort) {
80
+ const targetEffort = normalizeChatGPTEffortChoice(desiredEffort);
81
+ if (!targetEffort) return null;
82
+ return uniqueMatch(
83
+ items,
84
+ (item) =>
85
+ ["button", "menuitem", "menuitemradio"].includes(item?.role) &&
86
+ effortCandidateMatches(item, targetEffort),
87
+ );
88
+ }
89
+
90
+ function verifyChatGPTEffortSelection(items, desiredEffort) {
91
+ const targetEffort = normalizeChatGPTEffortChoice(desiredEffort);
92
+ if (!targetEffort) return null;
93
+ return uniqueMatch(
94
+ items,
95
+ (item) =>
96
+ (typeof item?.label === "string" || typeof item?.testId === "string") &&
97
+ effortCandidateMatches(item, targetEffort),
98
+ );
99
+ }
100
+
101
+ function boundedOptionLabels(items) {
102
+ if (!Array.isArray(items)) return [];
103
+ return items
104
+ .map((item) => String(item?.label || "").replace(/\s+/g, " ").trim().slice(0, 80))
105
+ .filter(Boolean)
106
+ .filter((label, index, labels) => labels.indexOf(label) === index)
107
+ .slice(0, 10);
108
+ }
109
+
110
+ module.exports = {
111
+ CHATGPT_EFFORT_CHOICES,
112
+ boundedOptionLabels,
113
+ normalizeChatGPTEffortChoice,
114
+ normalizeChatGPTModelChoice,
115
+ resolveChatGPTEffortMenuOption,
116
+ resolveChatGPTModelMenuOption,
117
+ verifyChatGPTEffortSelection,
118
+ verifyChatGPTModelSelection,
119
+ };