surf-cli 2.11.0 → 2.13.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.
@@ -0,0 +1,292 @@
1
+ import { createRequire } from "node:module";
2
+ import { Type } from "typebox";
3
+
4
+ const require = createRequire(import.meta.url);
5
+ const { openClientTransport } = require("../native/client-transport.cjs") as {
6
+ openClientTransport(endpoint: SurfEndpoint, options?: { requestTimeoutMs?: number }): Promise<{
7
+ request(message: Record<string, unknown>, timeoutMs?: number, transferPlan?: Record<string, unknown>): Promise<Record<string, unknown>>;
8
+ close(): Promise<void>;
9
+ }>;
10
+ };
11
+ const { selectEndpoint } = require("../native/endpoint.cjs") as {
12
+ selectEndpoint(args: string[], env?: Record<string, string | undefined>): { endpoint: SurfEndpoint };
13
+ };
14
+ const { resolveRequestDeadlineMs } = require("../native/host-sessions.cjs") as {
15
+ resolveRequestDeadlineMs(tool: string, args: Record<string, unknown>): number;
16
+ };
17
+ const { prepareRemoteTool, validateLocalToolPaths } = require("../native/file-transfer.cjs") as {
18
+ prepareRemoteTool(tool: string, args: Record<string, unknown>): { args: Record<string, unknown>; uploads?: unknown[]; downloads?: unknown[]; pathRefs?: unknown[] };
19
+ validateLocalToolPaths(tool: string, args: Record<string, unknown>): Record<string, unknown>;
20
+ };
21
+
22
+ const MAX_OUTPUT_CHARS = 20_000;
23
+ const ORACLE_ACTIVE_STATES = new Set(["created", "dispatched", "awaiting"]);
24
+ const ORACLE_TERMINAL_STATES = new Set(["captured", "failed"]);
25
+ const ORACLE_FINISHED_CHANNEL = "surf-oracle:finished";
26
+ const BACKGROUND_WORK_PROTOCOL_VERSION = 1;
27
+ const BACKGROUND_WORK_REGISTRY_KEY = "pi-subagents.background-work.v1";
28
+ const BACKGROUND_WORK_MODULE_SPECIFIER = "pi-subagents/background-work";
29
+
30
+ type Pi = {
31
+ registerTool(tool: Record<string, unknown>): void;
32
+ on(event: "session_start" | "session_shutdown", handler: (event: unknown, ctx: unknown) => void | Promise<void>): void;
33
+ events?: { emit(event: string, data: unknown): void };
34
+ };
35
+
36
+ type SurfEndpoint = { kind?: string };
37
+
38
+ type ToolResult = { content: Array<{ type: "text" | "image"; text?: string; data?: string; mimeType?: string }>; details?: unknown; isError?: boolean };
39
+
40
+ type BackgroundWorkProvider = {
41
+ name: string;
42
+ wakeChannels: string[];
43
+ listActiveWork(): Array<{ id: string; sessionId: string }>;
44
+ };
45
+
46
+ type RegisterBackgroundWorkProvider = (provider: BackgroundWorkProvider) => () => void;
47
+
48
+ type BackgroundWorkModule = {
49
+ registerBackgroundWorkProvider?: unknown;
50
+ };
51
+
52
+ type BackgroundWorkRegistry = {
53
+ version: typeof BACKGROUND_WORK_PROTOCOL_VERSION;
54
+ providers: Map<string, BackgroundWorkProvider>;
55
+ };
56
+
57
+ function textResult(value: unknown, isError = false): ToolResult {
58
+ const text = typeof value === "string" ? value : JSON.stringify(value, null, 2);
59
+ const bounded = text.length > MAX_OUTPUT_CHARS
60
+ ? `${text.slice(0, MAX_OUTPUT_CHARS)}\n\n[Surf output truncated at ${MAX_OUTPUT_CHARS} characters]`
61
+ : text;
62
+ return { content: [{ type: "text", text: bounded }], ...(isError ? { isError: true } : {}) };
63
+ }
64
+
65
+ export function resultFromHost(response: Record<string, unknown>): ToolResult {
66
+ const error = response.error as { content?: Array<{ text?: string }> } | undefined;
67
+ if (error) return textResult(error.content?.map((item) => item.text ?? "").join("\n") || "Surf request failed", true);
68
+ const result = response.result as { content?: ToolResult["content"] } | undefined;
69
+ if (!result?.content) return textResult(result ?? "OK");
70
+ const text = result.content.find((item) => item.type === "text")?.text;
71
+ let details: unknown;
72
+ try {
73
+ details = text ? JSON.parse(text) : undefined;
74
+ } catch {
75
+ details = undefined;
76
+ }
77
+ const content = result.content.map((item) => item.type === "text" && item.text && item.text.length > MAX_OUTPUT_CHARS
78
+ ? { ...item, text: `${item.text.slice(0, MAX_OUTPUT_CHARS)}\n\n[Surf output truncated at ${MAX_OUTPUT_CHARS} characters]` }
79
+ : item);
80
+ return { content, details };
81
+ }
82
+
83
+ export function createToolRequest(tool: string, args: Record<string, unknown>, tabId?: number) {
84
+ return {
85
+ type: "tool_request",
86
+ method: "execute_tool",
87
+ params: { tool, args, ...(tabId === undefined ? {} : { tabId }) },
88
+ id: `pi-surf-${Date.now()}-${Math.random().toString(36).slice(2)}`,
89
+ };
90
+ }
91
+
92
+ function prepareRequest(endpoint: SurfEndpoint, tool: string, args: Record<string, unknown>) {
93
+ if (endpoint.kind === "remote") return prepareRemoteTool(tool, args);
94
+ return { args: validateLocalToolPaths(tool, args), uploads: [], downloads: [], pathRefs: [] };
95
+ }
96
+
97
+ export async function requestSurf(tool: string, args: Record<string, unknown>, tabId?: number): Promise<ToolResult> {
98
+ const { endpoint } = selectEndpoint([], process.env);
99
+ const timeoutMs = resolveRequestDeadlineMs(tool, args);
100
+ const transport = await openClientTransport(endpoint, { requestTimeoutMs: timeoutMs });
101
+ try {
102
+ const prepared = prepareRequest(endpoint, tool, args);
103
+ const response = await transport.request(createToolRequest(tool, prepared.args, tabId), timeoutMs, prepared);
104
+ return resultFromHost(response);
105
+ } finally {
106
+ await transport.close();
107
+ }
108
+ }
109
+
110
+ export function surfRequest(tool: string, args: Record<string, unknown>, tabId?: number) {
111
+ return requestSurf(tool, args, tabId);
112
+ }
113
+
114
+ export function registerGlobalBackgroundProvider(provider: BackgroundWorkProvider): () => void {
115
+ const key = Symbol.for(BACKGROUND_WORK_REGISTRY_KEY);
116
+ const globalObject = globalThis as Record<PropertyKey, unknown>;
117
+ const existing = globalObject[key];
118
+ let registry: BackgroundWorkRegistry;
119
+
120
+ if (existing === undefined) {
121
+ registry = { version: BACKGROUND_WORK_PROTOCOL_VERSION, providers: new Map() };
122
+ globalObject[key] = registry;
123
+ } else if (
124
+ existing &&
125
+ typeof existing === "object" &&
126
+ !Array.isArray(existing) &&
127
+ (existing as Partial<BackgroundWorkRegistry>).version === BACKGROUND_WORK_PROTOCOL_VERSION &&
128
+ (existing as Partial<BackgroundWorkRegistry>).providers instanceof Map
129
+ ) {
130
+ registry = existing as BackgroundWorkRegistry;
131
+ } else {
132
+ throw new Error(`Unsupported background-work registry at Symbol.for("${BACKGROUND_WORK_REGISTRY_KEY}").`);
133
+ }
134
+
135
+ registry.providers.set(provider.name, provider);
136
+ return () => {
137
+ if (registry.providers.get(provider.name) === provider) registry.providers.delete(provider.name);
138
+ };
139
+ }
140
+
141
+ export async function resolveBackgroundWorkRegister(
142
+ loadModule: () => Promise<BackgroundWorkModule> = () => import(BACKGROUND_WORK_MODULE_SPECIFIER) as Promise<BackgroundWorkModule>,
143
+ ): Promise<RegisterBackgroundWorkProvider> {
144
+ try {
145
+ const module = await loadModule();
146
+ if (typeof module.registerBackgroundWorkProvider === "function") {
147
+ return module.registerBackgroundWorkProvider as RegisterBackgroundWorkProvider;
148
+ }
149
+ } catch {
150
+ // The Pi bridge is optional. Surf also runs in other coding-agent harnesses and as a direct CLI.
151
+ }
152
+ return registerGlobalBackgroundProvider;
153
+ }
154
+
155
+ export function registerOptionalBackgroundProvider(sessionId: string, jobIds: Set<string>, listJobs: () => Array<{ id: string; state: string }>, register: RegisterBackgroundWorkProvider) {
156
+ return register({
157
+ name: "surf-oracle",
158
+ wakeChannels: [ORACLE_FINISHED_CHANNEL],
159
+ listActiveWork: () => listJobs()
160
+ .filter((job) => jobIds.has(job.id) && ORACLE_ACTIVE_STATES.has(job.state))
161
+ .map((job) => ({ id: job.id, sessionId })),
162
+ });
163
+ }
164
+
165
+ export function rememberOracleJobForSession(jobIds: Set<string>, jobId: unknown, requestGeneration: number, currentGeneration: number, sessionActive: boolean): boolean {
166
+ if (typeof jobId !== "string" || !sessionActive || requestGeneration !== currentGeneration) return false;
167
+ jobIds.add(jobId);
168
+ return true;
169
+ }
170
+
171
+ export function emitOracleFinished(pi: Pi, job: unknown): boolean {
172
+ if (!job || typeof job !== "object" || Array.isArray(job)) return false;
173
+ const { id, state } = job as { id?: unknown; state?: unknown };
174
+ if (typeof id !== "string" || typeof state !== "string" || !ORACLE_TERMINAL_STATES.has(state)) return false;
175
+ if (!pi.events) return false;
176
+ pi.events.emit(ORACLE_FINISHED_CHANNEL, { id, state });
177
+ return true;
178
+ }
179
+
180
+ function registerTool(pi: Pi, name: string, description: string, parameters: unknown, map: (args: Record<string, unknown>) => [string, Record<string, unknown>, number | undefined]) {
181
+ pi.registerTool({
182
+ name,
183
+ label: name,
184
+ description,
185
+ parameters,
186
+ async execute(_id: string, args: Record<string, unknown>) {
187
+ try {
188
+ const [tool, toolArgs, tabId] = map(args);
189
+ return await requestSurf(tool, toolArgs, tabId);
190
+ } catch (error) {
191
+ return textResult(error instanceof Error ? error.message : String(error), true);
192
+ }
193
+ },
194
+ });
195
+ }
196
+
197
+ export default function surfExtension(pi: Pi) {
198
+ registerTool(pi, "surf_read", "Read the current Surf browser page. Read tools are safer for parallel scouts than browser actions.", Type.Object({
199
+ tabId: Type.Optional(Type.Number()), filter: Type.Optional(Type.String()), depth: Type.Optional(Type.Number()), ref: Type.Optional(Type.String()), compact: Type.Optional(Type.Boolean()), maxBytes: Type.Optional(Type.Number()),
200
+ }), (args) => ["page.read", { filter: args.filter, depth: args.depth, ref: args.ref, compact: args.compact, "max-bytes": args.maxBytes }, args.tabId as number | undefined]);
201
+ registerTool(pi, "surf_screenshot", "Capture a bounded Surf browser screenshot.", Type.Object({
202
+ tabId: Type.Optional(Type.Number()), output: Type.Optional(Type.String()), fullpage: Type.Optional(Type.Boolean()), annotate: Type.Optional(Type.Boolean()), maxSize: Type.Optional(Type.Number()),
203
+ }), (args) => ["screenshot", { output: args.output, fullpage: args.fullpage, annotate: args.annotate, "max-size": args.maxSize }, args.tabId as number | undefined]);
204
+ registerTool(pi, "surf_click", "Click a Surf browser element by ref, selector, or coordinates. This can interfere with other agents in the shared browser session.", Type.Object({
205
+ tabId: Type.Optional(Type.Number()), ref: Type.Optional(Type.String()), selector: Type.Optional(Type.String()), x: Type.Optional(Type.Number()), y: Type.Optional(Type.Number()), button: Type.Optional(Type.String({ description: "left, right, or double" })),
206
+ }), (args) => [args.button === "right" ? "right_click" : args.button === "double" ? "double_click" : "click", args, args.tabId as number | undefined]);
207
+ registerTool(pi, "surf_type", "Type text in the Surf browser. This can interfere with other agents in the shared browser session.", Type.Object({
208
+ tabId: Type.Optional(Type.Number()), text: Type.String(), ref: Type.Optional(Type.String()), selector: Type.Optional(Type.String()), clear: Type.Optional(Type.Boolean()), submit: Type.Optional(Type.Boolean()),
209
+ }), (args) => ["type", args, args.tabId as number | undefined]);
210
+ registerTool(pi, "surf_tool", "Run one existing Surf browser tool through the native host. Prefer the dedicated read, screenshot, click, and type tools when they fit.", Type.Object({
211
+ tool: Type.String(), args: Type.Optional(Type.Record(Type.String(), Type.Unknown())), tabId: Type.Optional(Type.Number()),
212
+ }), (args) => [args.tool as string, (args.args as Record<string, unknown>) ?? {}, args.tabId as number | undefined]);
213
+ registerTool(pi, "surf_oracle_status", "Get the status of a Surf oracle job, or the newest job.", Type.Object({ id: Type.Optional(Type.String()) }), (args) => ["oracle.status", args, undefined]);
214
+ pi.registerTool({
215
+ name: "surf_oracle_result",
216
+ label: "surf_oracle_result",
217
+ description: "Capture the result of a Surf oracle job.",
218
+ parameters: Type.Object({ id: Type.String(), timeout: Type.Optional(Type.Number()) }),
219
+ async execute(_id: string, args: Record<string, unknown>) {
220
+ try {
221
+ const result = await requestSurf("oracle.result", args);
222
+ emitOracleFinished(pi, result.details ?? { id: args.id, state: result.isError ? "failed" : undefined });
223
+ return result;
224
+ } catch (error) {
225
+ return textResult(error instanceof Error ? error.message : String(error), true);
226
+ }
227
+ },
228
+ });
229
+
230
+ const oracleJobIds = new Set<string>();
231
+ let sessionGeneration = 0;
232
+ let sessionActive = false;
233
+ pi.registerTool({
234
+ name: "surf_oracle_ask",
235
+ label: "surf_oracle_ask",
236
+ description: "Start a durable local Surf ChatGPT oracle job.",
237
+ parameters: Type.Object({ prompt: Type.String(), model: Type.Optional(Type.String()), effort: Type.Optional(Type.String()), follow: Type.Optional(Type.String()) }),
238
+ async execute(_id: string, args: Record<string, unknown>) {
239
+ const requestGeneration = sessionGeneration;
240
+ try {
241
+ const result = await requestSurf("oracle.ask", args);
242
+ const job = result.details as { id?: string } | undefined;
243
+ rememberOracleJobForSession(oracleJobIds, job?.id, requestGeneration, sessionGeneration, sessionActive);
244
+ return result;
245
+ } catch (error) {
246
+ return textResult(error instanceof Error ? error.message : String(error), true);
247
+ }
248
+ },
249
+ });
250
+
251
+ let dispose: (() => void) | undefined;
252
+ pi.on("session_start", (_event, ctx) => {
253
+ sessionGeneration++;
254
+ const generation = sessionGeneration;
255
+ sessionActive = false;
256
+ dispose?.();
257
+ dispose = undefined;
258
+ oracleJobIds.clear();
259
+
260
+ const session = ctx as { sessionManager?: { getSessionId?: () => string }; sessionId?: string };
261
+ const sessionId = session.sessionId ?? session.sessionManager?.getSessionId?.();
262
+ if (!sessionId) return;
263
+ try {
264
+ const jobs = require("../native/oracle-jobs.cjs") as { listJobs(): Array<{ id: string; state: string }> };
265
+ dispose = registerOptionalBackgroundProvider(sessionId, oracleJobIds, jobs.listJobs, registerGlobalBackgroundProvider);
266
+ sessionActive = true;
267
+ void resolveBackgroundWorkRegister().then((register) => {
268
+ try {
269
+ if (register === registerGlobalBackgroundProvider || generation !== sessionGeneration) return;
270
+ const nextDispose = registerOptionalBackgroundProvider(sessionId, oracleJobIds, jobs.listJobs, register);
271
+ if (generation !== sessionGeneration) {
272
+ nextDispose();
273
+ return;
274
+ }
275
+ dispose?.();
276
+ dispose = nextDispose;
277
+ } catch {
278
+ // Keep the already-registered fallback provider.
279
+ }
280
+ });
281
+ } catch {
282
+ // The Pi bridge is optional. Browser tools work without pi-subagents.
283
+ }
284
+ });
285
+ pi.on("session_shutdown", () => {
286
+ sessionGeneration++;
287
+ sessionActive = false;
288
+ dispose?.();
289
+ dispose = undefined;
290
+ oracleJobIds.clear();
291
+ });
292
+ }
package/skills/README.md CHANGED
@@ -1,3 +1,9 @@
1
+ ---
2
+ name: surf-skills
3
+ description: Package index for Surf skills. Use when you need install or layout notes for the Surf skill bundle.
4
+ disable-model-invocation: true
5
+ ---
6
+
1
7
  # Surf Skills
2
8
 
3
9
  This directory contains skill files for AI coding agents:
@@ -63,8 +63,8 @@ surf click --x 100 --y 200
63
63
  # 4. Type text
64
64
  surf type --text "hello"
65
65
 
66
- # 5. Screenshot
67
- surf screenshot --output /tmp/shot.png
66
+ # 5. Full-page screenshot
67
+ surf screenshot --full-page --output /tmp/shot.png
68
68
 
69
69
  # Inspect animation/style changes as JSON
70
70
  surf animate-audit --selector ".thing" --duration 2000 --fps 10
@@ -292,9 +292,26 @@ surf page.read --depth 3 # Limit tree depth
292
292
  surf page.read --compact # Minimal output for LLM efficiency
293
293
  surf page.read --max-bytes 2000 # Cap visible text at a UTF-8 byte boundary
294
294
  surf page.text # Plain text content only
295
+ surf page.html --strip-scripts # Rendered HTML without scripts
296
+ surf page.save --selector "#artifact" --strip-scripts --output page.html # Save one static element
295
297
  surf page.state # Modals, loading state, scroll info
296
298
  ```
297
299
 
300
+ ### Export Rendered HTML
301
+
302
+ Use `page.html` when the user wants a static copy of the current rendered DOM. This works for Claude artifact pages and ordinary web pages.
303
+
304
+ ```bash
305
+ # Save the active page as HTML.
306
+ surf page.save --output page.html
307
+
308
+ # Save a Claude artifact or other preview page after it loads, without scripts.
309
+ surf wait.dom --stable 500
310
+ surf page.html --selector "#artifact" --strip-scripts > artifact.html
311
+ ```
312
+
313
+ Use `--selector <css>` to export its matching element only. A selector miss fails with an error. `--strip-scripts` removes scripts from exported markup without changing the page. Without `--selector`, `page.html` exports the whole document with its doctype. `page.html` exports the selected frame when `frame.switch` is active. Use `page.read` first when you need refs or visible text.
314
+
298
315
  ## Semantic Element Location
299
316
 
300
317
  Find and act on elements by role, text, or label instead of refs:
@@ -687,10 +704,11 @@ surf wait.element ".missing" --auto-capture --timeout 2000
687
704
  12. **Window isolation** - Use `window.new` + `--window-id` or `--tab-id` to keep agent work separate from your browsing
688
705
  13. **Request lock** - Non-streaming browser CLI requests serialize per socket; use `--no-lock` only when you intentionally want to bypass it
689
706
  14. **Native host diagnostics** - If commands fail with socket/native-host errors, run `surf doctor` or `surf doctor --browser all` before guessing at reinstall steps
690
- 15. **Animation capture** - Use `surf record --duration 2000 --fps 10 --output /tmp/anim.gif` when the agent needs to see motion; use `animate-audit` for numeric timelines and `perf-audit` for jank/layout-shift snapshots
691
- 16. **Hard isolation** - Use separate browser/profile instances plus separate `SURF_SOCKET` values when agents must not share a host or target
692
- 17. **Semantic locators** - `locate.role`, `locate.text`, `locate.label` for more robust element finding
693
- 18. **Frame context** - Use `frame.switch` before interacting with iframe content
707
+ 15. **HTML export** - Use `surf page.html > artifact.html` to save Claude artifacts or any rendered page as static HTML
708
+ 16. **Animation capture** - Use `surf record --duration 2000 --fps 10 --output /tmp/anim.gif` when the agent needs to see motion; use `animate-audit` for numeric timelines and `perf-audit` for jank/layout-shift snapshots
709
+ 17. **Hard isolation** - Use separate browser/profile instances plus separate `SURF_SOCKET` values when agents must not share a host or target
710
+ 18. **Semantic locators** - `locate.role`, `locate.text`, `locate.label` for more robust element finding
711
+ 19. **Frame context** - Use `frame.switch` before interacting with iframe content
694
712
 
695
713
  ## Socket API
696
714