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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "surf-cli",
3
- "version": "2.10.0",
3
+ "version": "2.12.0",
4
4
  "description": "CLI for AI agents to control Chrome. Zero config, agent-agnostic, battle-tested.",
5
5
  "keywords": [
6
6
  "chrome",
@@ -10,7 +10,8 @@
10
10
  "agent",
11
11
  "cli",
12
12
  "cdp",
13
- "devtools"
13
+ "devtools",
14
+ "pi-package"
14
15
  ],
15
16
  "author": "Nico Bailon",
16
17
  "license": "MIT",
@@ -28,6 +29,7 @@
28
29
  },
29
30
  "files": [
30
31
  "native/",
32
+ "pi-extension/",
31
33
  "playbooks/",
32
34
  "scripts/",
33
35
  "dist/",
@@ -38,7 +40,7 @@
38
40
  "scripts": {
39
41
  "dev": "vite build --watch --mode development",
40
42
  "build": "vite build",
41
- "check": "tsc --noEmit",
43
+ "check": "tsc --noEmit && tsc --noEmit -p tsconfig.pi-extension.json",
42
44
  "lint": "biome check .",
43
45
  "lint:fix": "biome check --write .",
44
46
  "lint:test": "biome check test/",
@@ -64,11 +66,24 @@
64
66
  "devDependencies": {
65
67
  "@biomejs/biome": "^2.5.4",
66
68
  "@types/chrome": "^0.2.2",
69
+ "@types/node": "^26.1.2",
67
70
  "@vitest/coverage-v8": "^4.1.9",
68
71
  "@vitest/ui": "^4.1.9",
69
- "puppeteer": "25.3.0",
72
+ "puppeteer": "25.4.0",
70
73
  "typescript": "^7.0.2",
71
74
  "vite": "^8.1.4",
72
- "vitest": "^4.1.9"
75
+ "vitest": "^4.1.9",
76
+ "typebox": "^1.3.11"
77
+ },
78
+ "pi": {
79
+ "extensions": [
80
+ "./pi-extension/surf.ts"
81
+ ],
82
+ "skills": [
83
+ "./skills"
84
+ ]
85
+ },
86
+ "peerDependencies": {
87
+ "typebox": "*"
73
88
  }
74
89
  }
@@ -0,0 +1,230 @@
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 BACKGROUND_WORK_PROTOCOL_VERSION = 1;
25
+ const BACKGROUND_WORK_REGISTRY_KEY = "pi-subagents.background-work.v1";
26
+
27
+ type Pi = {
28
+ registerTool(tool: Record<string, unknown>): void;
29
+ on(event: "session_start" | "session_shutdown", handler: (event: unknown, ctx: unknown) => void | Promise<void>): void;
30
+ };
31
+
32
+ type SurfEndpoint = { kind?: string };
33
+
34
+ type ToolResult = { content: Array<{ type: "text" | "image"; text?: string; data?: string; mimeType?: string }>; details?: unknown; isError?: boolean };
35
+
36
+ type BackgroundWorkProvider = {
37
+ name: string;
38
+ wakeChannels: string[];
39
+ listActiveWork(): Array<{ id: string; sessionId: string }>;
40
+ };
41
+
42
+ type BackgroundWorkRegistry = {
43
+ version: typeof BACKGROUND_WORK_PROTOCOL_VERSION;
44
+ providers: Map<string, BackgroundWorkProvider>;
45
+ };
46
+
47
+ function textResult(value: unknown, isError = false): ToolResult {
48
+ const text = typeof value === "string" ? value : JSON.stringify(value, null, 2);
49
+ const bounded = text.length > MAX_OUTPUT_CHARS
50
+ ? `${text.slice(0, MAX_OUTPUT_CHARS)}\n\n[Surf output truncated at ${MAX_OUTPUT_CHARS} characters]`
51
+ : text;
52
+ return { content: [{ type: "text", text: bounded }], ...(isError ? { isError: true } : {}) };
53
+ }
54
+
55
+ function resultFromHost(response: Record<string, unknown>): ToolResult {
56
+ const error = response.error as { content?: Array<{ text?: string }> } | undefined;
57
+ if (error) return textResult(error.content?.map((item) => item.text ?? "").join("\n") || "Surf request failed", true);
58
+ const result = response.result as { content?: ToolResult["content"] } | undefined;
59
+ if (!result?.content) return textResult(result ?? "OK");
60
+ const content = result.content.map((item) => item.type === "text" && item.text && item.text.length > MAX_OUTPUT_CHARS
61
+ ? { ...item, text: `${item.text.slice(0, MAX_OUTPUT_CHARS)}\n\n[Surf output truncated at ${MAX_OUTPUT_CHARS} characters]` }
62
+ : item);
63
+ const text = content.find((item) => item.type === "text")?.text;
64
+ let details: unknown;
65
+ try {
66
+ details = text ? JSON.parse(text) : undefined;
67
+ } catch {
68
+ details = undefined;
69
+ }
70
+ return { content, details };
71
+ }
72
+
73
+ export function createToolRequest(tool: string, args: Record<string, unknown>, tabId?: number) {
74
+ return {
75
+ type: "tool_request",
76
+ method: "execute_tool",
77
+ params: { tool, args, ...(tabId === undefined ? {} : { tabId }) },
78
+ id: `pi-surf-${Date.now()}-${Math.random().toString(36).slice(2)}`,
79
+ };
80
+ }
81
+
82
+ function prepareRequest(endpoint: SurfEndpoint, tool: string, args: Record<string, unknown>) {
83
+ if (endpoint.kind === "remote") return prepareRemoteTool(tool, args);
84
+ return { args: validateLocalToolPaths(tool, args), uploads: [], downloads: [], pathRefs: [] };
85
+ }
86
+
87
+ export async function requestSurf(tool: string, args: Record<string, unknown>, tabId?: number): Promise<ToolResult> {
88
+ const { endpoint } = selectEndpoint([], process.env);
89
+ const timeoutMs = resolveRequestDeadlineMs(tool, args);
90
+ const transport = await openClientTransport(endpoint, { requestTimeoutMs: timeoutMs });
91
+ try {
92
+ const prepared = prepareRequest(endpoint, tool, args);
93
+ const response = await transport.request(createToolRequest(tool, prepared.args, tabId), timeoutMs, prepared);
94
+ return resultFromHost(response);
95
+ } finally {
96
+ await transport.close();
97
+ }
98
+ }
99
+
100
+ export function surfRequest(tool: string, args: Record<string, unknown>, tabId?: number) {
101
+ return requestSurf(tool, args, tabId);
102
+ }
103
+
104
+ export function registerGlobalBackgroundProvider(provider: BackgroundWorkProvider): () => void {
105
+ const key = Symbol.for(BACKGROUND_WORK_REGISTRY_KEY);
106
+ const globalObject = globalThis as Record<PropertyKey, unknown>;
107
+ const existing = globalObject[key];
108
+ let registry: BackgroundWorkRegistry;
109
+
110
+ if (existing === undefined) {
111
+ registry = { version: BACKGROUND_WORK_PROTOCOL_VERSION, providers: new Map() };
112
+ globalObject[key] = registry;
113
+ } else if (
114
+ existing &&
115
+ typeof existing === "object" &&
116
+ !Array.isArray(existing) &&
117
+ (existing as Partial<BackgroundWorkRegistry>).version === BACKGROUND_WORK_PROTOCOL_VERSION &&
118
+ (existing as Partial<BackgroundWorkRegistry>).providers instanceof Map
119
+ ) {
120
+ registry = existing as BackgroundWorkRegistry;
121
+ } else {
122
+ throw new Error(`Unsupported background-work registry at Symbol.for("${BACKGROUND_WORK_REGISTRY_KEY}").`);
123
+ }
124
+
125
+ registry.providers.set(provider.name, provider);
126
+ return () => {
127
+ if (registry.providers.get(provider.name) === provider) registry.providers.delete(provider.name);
128
+ };
129
+ }
130
+
131
+ export function registerOptionalBackgroundProvider(sessionId: string, jobIds: Set<string>, listJobs: () => Array<{ id: string; state: string }>, register: (provider: BackgroundWorkProvider) => () => void) {
132
+ return register({
133
+ name: "surf-oracle",
134
+ wakeChannels: ["surf-oracle:finished"],
135
+ listActiveWork: () => listJobs()
136
+ .filter((job) => jobIds.has(job.id) && ORACLE_ACTIVE_STATES.has(job.state))
137
+ .map((job) => ({ id: job.id, sessionId })),
138
+ });
139
+ }
140
+
141
+ export function rememberOracleJobForSession(jobIds: Set<string>, jobId: unknown, requestGeneration: number, currentGeneration: number, sessionActive: boolean): boolean {
142
+ if (typeof jobId !== "string" || !sessionActive || requestGeneration !== currentGeneration) return false;
143
+ jobIds.add(jobId);
144
+ return true;
145
+ }
146
+
147
+ function registerTool(pi: Pi, name: string, description: string, parameters: unknown, map: (args: Record<string, unknown>) => [string, Record<string, unknown>, number | undefined]) {
148
+ pi.registerTool({
149
+ name,
150
+ label: name,
151
+ description,
152
+ parameters,
153
+ async execute(_id: string, args: Record<string, unknown>) {
154
+ try {
155
+ const [tool, toolArgs, tabId] = map(args);
156
+ return await requestSurf(tool, toolArgs, tabId);
157
+ } catch (error) {
158
+ return textResult(error instanceof Error ? error.message : String(error), true);
159
+ }
160
+ },
161
+ });
162
+ }
163
+
164
+ export default function surfExtension(pi: Pi) {
165
+ registerTool(pi, "surf_read", "Read the current Surf browser page. Read tools are safer for parallel scouts than browser actions.", Type.Object({
166
+ 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()),
167
+ }), (args) => ["page.read", { filter: args.filter, depth: args.depth, ref: args.ref, compact: args.compact, "max-bytes": args.maxBytes }, args.tabId as number | undefined]);
168
+ registerTool(pi, "surf_screenshot", "Capture a bounded Surf browser screenshot.", Type.Object({
169
+ 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()),
170
+ }), (args) => ["screenshot", { output: args.output, fullpage: args.fullpage, annotate: args.annotate, "max-size": args.maxSize }, args.tabId as number | undefined]);
171
+ 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({
172
+ 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" })),
173
+ }), (args) => [args.button === "right" ? "right_click" : args.button === "double" ? "double_click" : "click", args, args.tabId as number | undefined]);
174
+ registerTool(pi, "surf_type", "Type text in the Surf browser. This can interfere with other agents in the shared browser session.", Type.Object({
175
+ 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()),
176
+ }), (args) => ["type", args, args.tabId as number | undefined]);
177
+ 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({
178
+ tool: Type.String(), args: Type.Optional(Type.Record(Type.String(), Type.Unknown())), tabId: Type.Optional(Type.Number()),
179
+ }), (args) => [args.tool as string, (args.args as Record<string, unknown>) ?? {}, args.tabId as number | undefined]);
180
+ 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]);
181
+ registerTool(pi, "surf_oracle_result", "Capture the result of a Surf oracle job.", Type.Object({ id: Type.String(), timeout: Type.Optional(Type.Number()) }), (args) => ["oracle.result", args, undefined]);
182
+
183
+ const oracleJobIds = new Set<string>();
184
+ let sessionGeneration = 0;
185
+ let sessionActive = false;
186
+ pi.registerTool({
187
+ name: "surf_oracle_ask",
188
+ label: "surf_oracle_ask",
189
+ description: "Start a durable local Surf ChatGPT oracle job.",
190
+ parameters: Type.Object({ prompt: Type.String(), model: Type.Optional(Type.String()), effort: Type.Optional(Type.String()), follow: Type.Optional(Type.String()) }),
191
+ async execute(_id: string, args: Record<string, unknown>) {
192
+ const requestGeneration = sessionGeneration;
193
+ try {
194
+ const result = await requestSurf("oracle.ask", args);
195
+ const job = result.details as { id?: string } | undefined;
196
+ rememberOracleJobForSession(oracleJobIds, job?.id, requestGeneration, sessionGeneration, sessionActive);
197
+ return result;
198
+ } catch (error) {
199
+ return textResult(error instanceof Error ? error.message : String(error), true);
200
+ }
201
+ },
202
+ });
203
+
204
+ let dispose: (() => void) | undefined;
205
+ pi.on("session_start", (_event, ctx) => {
206
+ sessionGeneration++;
207
+ sessionActive = false;
208
+ dispose?.();
209
+ dispose = undefined;
210
+ oracleJobIds.clear();
211
+
212
+ const session = ctx as { sessionManager?: { getSessionId?: () => string }; sessionId?: string };
213
+ const sessionId = session.sessionId ?? session.sessionManager?.getSessionId?.();
214
+ if (!sessionId) return;
215
+ try {
216
+ const jobs = require("../native/oracle-jobs.cjs") as { listJobs(): Array<{ id: string; state: string }> };
217
+ dispose = registerOptionalBackgroundProvider(sessionId, oracleJobIds, jobs.listJobs, registerGlobalBackgroundProvider);
218
+ sessionActive = true;
219
+ } catch {
220
+ // pi-subagents is optional. Browser tools work without it.
221
+ }
222
+ });
223
+ pi.on("session_shutdown", () => {
224
+ sessionGeneration++;
225
+ sessionActive = false;
226
+ dispose?.();
227
+ dispose = undefined;
228
+ oracleJobIds.clear();
229
+ });
230
+ }
@@ -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
@@ -82,6 +82,37 @@ surf chatgpt "review" --model gpt-4o # Specify model
82
82
  surf chatgpt "analyze" --file document.pdf # With file attachment
83
83
  ```
84
84
 
85
+ ### Oracle
86
+
87
+ Use `surf chatgpt` for quick one-shot questions. Use `surf oracle` for long-running or Pro coding consults that need a durable job, explicit model and effort selection, file context, recovery, or follow-up turns. Oracle is local-only.
88
+
89
+ For agent workflows, detach after dispatch and keep the returned `.id`:
90
+
91
+ ```bash
92
+ surf oracle ask "Review this change and identify release risks" \
93
+ --files "src/**/*.ts" --files "package.json" \
94
+ --model pro --effort extended --detach --json
95
+
96
+ surf oracle status <job-id> --json
97
+ surf oracle result <job-id> --json
98
+ # Or let Surf keep polling until capture:
99
+ surf oracle result <job-id> --wait --json
100
+ ```
101
+
102
+ `status` reads persisted state without touching Chrome. `result` attempts to harvest the answer and returns the job object with `response` once its state is `captured`. A Ctrl-C during waiting exits with status 130 and prints `Recover with: surf oracle result <id>`. Once the job is `awaiting`, the persisted ChatGPT conversation URL is its durable key, so `surf oracle result <id>` can recover after CLI exit, native-host restart, or Chrome restart by reopening that conversation.
103
+
104
+ Treat Pro quota as scarce. Oracle never selects Pro implicitly; request it with `--model pro`. Accepted `--effort` values are `light`, `standard`, `extended`, and `heavy`. Requested model and effort selections are read back before submission, and an unverifiable selection fails with `model_verification_failed` instead of silently continuing. Capacity is one non-terminal oracle job. A `capacity` error includes the in-flight job ID; poll that job or wait for it to finish rather than submitting the same consult again.
105
+
106
+ Context comes from repeatable `--files` globs. Surf fails closed when a glob matches nothing or a matched file is unreadable, binary, or invalid UTF-8. It also blocks gitignored files and basenames matching `.env*`, `*.pem`, `*.key`, `id_rsa*`, `id_ed25519*`, `*.p12`, `*.pfx`, `credentials*`, or `secrets*`. Use `--allow-sensitive` only after intentionally reviewing those files; it overrides the block rather than redacting content. Context up to 60,000 evidence characters is inserted inline, while larger context becomes one private text attachment. The assembly manifest records each path, byte count, SHA-256, inline or bundle disposition, and deny-list outcome.
107
+
108
+ Continue a captured consult with `follow`. Use the ID returned by each turn for the next turn:
109
+
110
+ ```bash
111
+ surf oracle follow <job-id> "Challenge your recommendation. What could invalidate it?" --detach --json
112
+ surf oracle result <follow-job-id> --wait --json
113
+ surf oracle follow <follow-job-id> "Give the final decision and concrete next steps." --detach --json
114
+ ```
115
+
85
116
  ### Gemini
86
117
  ```bash
87
118
  surf gemini "explain quantum computing"
@@ -261,9 +292,26 @@ surf page.read --depth 3 # Limit tree depth
261
292
  surf page.read --compact # Minimal output for LLM efficiency
262
293
  surf page.read --max-bytes 2000 # Cap visible text at a UTF-8 byte boundary
263
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
264
297
  surf page.state # Modals, loading state, scroll info
265
298
  ```
266
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
+
267
315
  ## Semantic Element Location
268
316
 
269
317
  Find and act on elements by role, text, or label instead of refs:
@@ -656,10 +704,11 @@ surf wait.element ".missing" --auto-capture --timeout 2000
656
704
  12. **Window isolation** - Use `window.new` + `--window-id` or `--tab-id` to keep agent work separate from your browsing
657
705
  13. **Request lock** - Non-streaming browser CLI requests serialize per socket; use `--no-lock` only when you intentionally want to bypass it
658
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
659
- 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
660
- 16. **Hard isolation** - Use separate browser/profile instances plus separate `SURF_SOCKET` values when agents must not share a host or target
661
- 17. **Semantic locators** - `locate.role`, `locate.text`, `locate.label` for more robust element finding
662
- 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
663
712
 
664
713
  ## Socket API
665
714