surf-cli 2.11.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
@@ -707,6 +712,21 @@ surf use page read --json
707
712
  surf use <site> <write-op> --write --resource-id 123
708
713
  ```
709
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
+
710
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.
711
731
 
712
732
  Author a playbook from redacted recent activity or an explicit evidence record:
@@ -932,6 +952,19 @@ cp -r skills/surf ~/.pi/agent/skills/
932
952
 
933
953
  See [`skills/README.md`](skills/README.md) for details.
934
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
+
935
968
  ## Development
936
969
 
937
970
  ```bash
package/native/cli.cjs CHANGED
@@ -533,7 +533,7 @@ const TOOLS = {
533
533
  "scroll": {
534
534
  desc: "Scroll in direction",
535
535
  args: ["direction", "pixels"],
536
- opts: { direction: "up|down|left|right", amount: "Scroll amount (1-10)" },
536
+ opts: { direction: "up|down|left|right", amount: "Scroll amount in 100 px steps (1-10)" },
537
537
  examples: [
538
538
  { cmd: "scroll down 800", desc: "Scroll down 800px" },
539
539
  { cmd: "scroll --direction down --amount 3", desc: "Scroll down" },
@@ -576,6 +576,18 @@ const TOOLS = {
576
576
  },
577
577
  "read": { desc: "Alias for page.read", args: [], alias: "page.read" },
578
578
  "page.text": { desc: "Extract all text from page", args: [] },
579
+ "page.html": {
580
+ desc: "Print rendered document HTML",
581
+ args: [],
582
+ opts: { selector: "Export matching CSS selector", "strip-scripts": "Remove script elements" },
583
+ examples: [{ cmd: "page.html", desc: "Print current document HTML" }],
584
+ },
585
+ "page.save": {
586
+ desc: "Save rendered document HTML",
587
+ args: [],
588
+ opts: { output: "File path", selector: "Export matching CSS selector", "strip-scripts": "Remove script elements" },
589
+ examples: [{ cmd: "page.save --output page.html", desc: "Save current document HTML" }],
590
+ },
579
591
  "page.state": { desc: "Get page state (modals, loading, etc.)", args: [] },
580
592
  }
581
593
  },
@@ -1464,7 +1476,7 @@ const ALL_SOCKET_TOOLS = [
1464
1476
  "click_type", "click_type_submit", "type", "key", "type_submit",
1465
1477
  "scroll", "scroll_to", "hover", "left_click_drag", "drag", "wait",
1466
1478
  "computer",
1467
- "page.read", "page.text", "page.state",
1479
+ "page.read", "page.text", "page.html", "page.save", "page.state",
1468
1480
  "locate.role", "locate.text", "locate.label",
1469
1481
  "tab.list", "tab.new", "tab.switch", "tab.close", "tab.move", "tab.name", "tab.unname", "tab.named",
1470
1482
  "tab.group", "tab.ungroup", "tab.groups", "tab.reload",
@@ -2818,11 +2830,16 @@ if (tool === "network.export" && outputPath !== undefined) {
2818
2830
  toolArgs.output = outputPath;
2819
2831
  }
2820
2832
 
2821
- if ((tool === "screenshot" || tool === "record" || tool === "perf-audit") && outputPath && typeof outputPath !== "string") {
2833
+ if ((tool === "screenshot" || tool === "record" || tool === "perf-audit" || tool === "page.save") && outputPath && typeof outputPath !== "string") {
2822
2834
  console.error("Error: --output requires a file path");
2823
2835
  process.exit(1);
2824
2836
  }
2825
2837
 
2838
+ if (tool === "page.save" && !outputPath) {
2839
+ console.error("Error: page.save requires --output <path>");
2840
+ process.exit(1);
2841
+ }
2842
+
2826
2843
  if (tool === "screenshot" && outputPath) {
2827
2844
  toolArgs.savePath = outputPath;
2828
2845
  if (options.full) toolArgs.full = true;
@@ -3294,6 +3311,17 @@ async function handleResponse(response) {
3294
3311
  data = { response: data };
3295
3312
  }
3296
3313
 
3314
+ if (tool === "page.save" && typeof data?.html === "string") {
3315
+ const saveTo = path.resolve(outputPath);
3316
+ fs.mkdirSync(path.dirname(saveTo), { recursive: true });
3317
+ fs.writeFileSync(saveTo, data.html);
3318
+ if (!wantJson) {
3319
+ console.log(`Saved rendered page HTML to ${saveTo}`);
3320
+ socket.end();
3321
+ process.exit(0);
3322
+ }
3323
+ }
3324
+
3297
3325
  if (tool === "perf-audit" && outputPath) {
3298
3326
  const saveTo = path.resolve(outputPath);
3299
3327
  fs.mkdirSync(path.dirname(saveTo), { recursive: true });
@@ -3375,6 +3403,8 @@ async function handleResponse(response) {
3375
3403
  console.log(data.pageContent);
3376
3404
  } else if (tool === "page.text" && data?.text) {
3377
3405
  console.log(data.text);
3406
+ } else if (tool === "page.html" && typeof data?.html === "string") {
3407
+ console.log(data.html);
3378
3408
  } else if (tool === "emulate.device" && data?.devices) {
3379
3409
  console.log("Available devices:\n");
3380
3410
  const devices = data.devices;
@@ -936,6 +936,18 @@ function mapToolToMessage(tool, args, tabId) {
936
936
  }
937
937
  case "page.text":
938
938
  return { type: "GET_PAGE_TEXT", ...baseMsg };
939
+ case "page.html":
940
+ case "page.save": {
941
+ if (a.selector !== undefined && (typeof a.selector !== "string" || a.selector.length === 0)) {
942
+ throw new Error("selector must be a non-empty string");
943
+ }
944
+ return {
945
+ type: "GET_PAGE_HTML",
946
+ selector: a.selector,
947
+ stripScripts: a["strip-scripts"] === true,
948
+ ...baseMsg,
949
+ };
950
+ }
939
951
  case "page.state":
940
952
  return { type: "PAGE_STATE", ...baseMsg };
941
953
  case "locate.role":
package/native/host.cjs CHANGED
@@ -523,6 +523,7 @@ async function runHostPlaybook(msg, request) {
523
523
  onEvent: report,
524
524
  beforeDispatch: async () => updateReceipt(receipt, "dispatched"),
525
525
  afterDispatch: async ({ status, error }) => updateReceipt(receipt, status, { error }),
526
+ allowScript: params.allowScript === true,
526
527
  });
527
528
  }
528
529
 
@@ -48,7 +48,7 @@ function runSpec(argv) {
48
48
  const parsed = parseCommandArgs(argv.slice(offset));
49
49
  const [playbook, op] = parsed.positional;
50
50
  if (!playbook || !op) throw new Error(direct ? "Usage: surf use <playbook> <op> [--arg value]" : "Usage: surf pb run <playbook> <op> [--arg value]");
51
- const reserved = new Set(["json", "no-lock", "tab-id", "write", "repeat", "retry-attempt", "override-in-doubt", "pin-built-in"]);
51
+ const reserved = new Set(["json", "no-lock", "tab-id", "write", "repeat", "retry-attempt", "override-in-doubt", "pin-built-in", "allow-script"]);
52
52
  const args = Object.fromEntries(Object.entries(parsed.options).filter(([name]) => !reserved.has(name)));
53
53
  return { playbook, op, args, options: parsed.options };
54
54
  }
@@ -89,6 +89,7 @@ async function handlePlaybookCli(argv, { endpoint, cwd = process.cwd() }) {
89
89
  retryAttempt: spec.options["retry-attempt"],
90
90
  overrideInDoubt: spec.options["override-in-doubt"] === true,
91
91
  pinBuiltIn: spec.options["pin-built-in"] === true,
92
+ allowScript: spec.options["allow-script"] === true,
92
93
  };
93
94
  const value = await requestHost(endpoint, "playbook.run", args, {
94
95
  tabId: spec.options["tab-id"],
@@ -98,7 +99,7 @@ async function handlePlaybookCli(argv, { endpoint, cwd = process.cwd() }) {
98
99
  }
99
100
  const command = argv[1];
100
101
  const parsed = parseCommandArgs(argv.slice(2));
101
- if (!command || command === "help") return { handled: true, value: "Usage: surf playbook|pb <list|show|ops|run|record|suggest|save|client|trace|export|import>" };
102
+ if (!command || command === "help") return { handled: true, value: "Usage: surf playbook|pb <list|show|ops|run|record|suggest|save|client|trace|export|import>\nRun trusted script strategies with: surf use <playbook> <op> --allow-script" };
102
103
  if (endpoint?.kind === "remote" && ["list", "show", "ops"].includes(command)) throw new Error(`playbook ${command} is local-only with --remote because runs resolve on the browser host`);
103
104
  if (command === "list") return { handled: true, value: listPlaybooks({ cwd }), json: parsed.options.json === true };
104
105
  if (command === "show") {
@@ -1,4 +1,5 @@
1
- const { executeWorkflow } = require("./workflow-runtime.cjs");
1
+ const { executeSingleStep, executeWorkflow } = require("./workflow-runtime.cjs");
2
+ const { runWorkflowScript } = require("./workflow-script-runtime.cjs");
2
3
 
3
4
  function applyTemplate(value, args) {
4
5
  if (typeof value === "string") {
@@ -93,6 +94,31 @@ return { status: response.status, ok: response.ok, url: response.url, headers: O
93
94
  }
94
95
 
95
96
  async function runStrategy(strategy, context) {
97
+ if (strategy.using === "script") {
98
+ if (context.allowScript !== true) throw new Error("script strategy requires --allow-script");
99
+ const vars = { ...context.args };
100
+ const result = await runWorkflowScript({
101
+ script: strategy.script,
102
+ input: context.args,
103
+ timeoutMs: strategy.timeoutMs ?? 10 * 60 * 1000,
104
+ signal: context.signal,
105
+ onEvent: context.onEvent,
106
+ executeTool: async (tool, args, options = {}) => {
107
+ await context.markDispatched?.();
108
+ const step = await executeSingleStep({ cmd: tool, args, as: "value" }, vars, {
109
+ autoWait: strategy.autoWait !== false,
110
+ executeTool: context.executeTool,
111
+ onEvent: context.onEvent,
112
+ signal: options.signal || context.signal,
113
+ sleep: context.sleep,
114
+ stepDelay: strategy.stepDelay ?? 100,
115
+ });
116
+ if (!step.success) throw new Error(step.error || `tool ${tool} failed`);
117
+ return step.output;
118
+ },
119
+ });
120
+ return result.value;
121
+ }
96
122
  if (strategy.using === "workflow") {
97
123
  const result = await executeWorkflow(applyTemplate(strategy.steps, context.args), {
98
124
  autoWait: strategy.autoWait !== false,
@@ -130,7 +156,7 @@ async function runStrategy(strategy, context) {
130
156
  throw new Error(`unsupported strategy: ${strategy.using}`);
131
157
  }
132
158
 
133
- async function runPlaybookOp({ playbook, op, args: providedArgs = {}, attemptId, executeTool, executeNative, signal, sleep, onEvent = () => {}, beforeDispatch = async () => {}, afterDispatch = async () => {} }) {
159
+ async function runPlaybookOp({ playbook, op, args: providedArgs = {}, attemptId, executeTool, executeNative, signal, sleep, onEvent = () => {}, beforeDispatch = async () => {}, afterDispatch = async () => {}, allowScript = false }) {
134
160
  const args = resolveArgs(op, providedArgs);
135
161
  const attempts = [];
136
162
  for (let index = 0; index < op.run.length; index++) {
@@ -154,6 +180,7 @@ async function runPlaybookOp({ playbook, op, args: providedArgs = {}, attemptId,
154
180
  allowedOrigins: op.origins || playbook.origins || [],
155
181
  markDispatched: op.effect === "write" ? markDispatched : undefined,
156
182
  serverIdempotency: op.effect === "write" ? op.safety?.serverIdempotency : undefined,
183
+ allowScript,
157
184
  });
158
185
  const value = extractResult(raw, strategy.extract);
159
186
  verifyResult(value, strategy.verify || strategy.expect || op.on?.success?.expect, raw);
@@ -75,8 +75,21 @@ function validateWriteWorkflowSteps(steps, opId) {
75
75
  }
76
76
  }
77
77
 
78
+ function normalizeScript(value) {
79
+ if (typeof value === "string") return value;
80
+ if (Array.isArray(value) && value.every((line) => typeof line === "string")) return value.join("\n");
81
+ return null;
82
+ }
83
+
78
84
  function validateStrategy(strategy, effect, opId) {
79
85
  if (!strategy || typeof strategy !== "object" || Array.isArray(strategy)) throw new Error("playbook strategy must be an object");
86
+ if (strategy.using === "script") {
87
+ if (effect === "write") throw new Error(`write op ${opId} script strategy is not supported`);
88
+ const script = normalizeScript(strategy.script);
89
+ if (!script?.trim()) throw new Error("script strategy requires script");
90
+ if (strategy.timeoutMs !== undefined && (!Number.isInteger(strategy.timeoutMs) || strategy.timeoutMs < 1)) throw new Error("script strategy timeoutMs must be a positive integer");
91
+ return { ...strategy, script };
92
+ }
80
93
  if (strategy.using === "workflow") {
81
94
  if (!Array.isArray(strategy.steps) || strategy.steps.length === 0) throw new Error("workflow strategy requires steps");
82
95
  const steps = strategy.steps.map(normalizeStep);
@@ -0,0 +1,294 @@
1
+ const { Worker } = require("node:worker_threads");
2
+
3
+ const KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
4
+
5
+ const WORKER_SOURCE = String.raw`
6
+ const { parentPort } = require("node:worker_threads");
7
+ const vm = require("node:vm");
8
+ const { inspect } = require("node:util");
9
+
10
+ let nextCallId = 0;
11
+ const pending = new Map();
12
+ const keyPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
13
+ const fingerprints = new Map();
14
+ let contextObjectPrototype;
15
+
16
+ function stableJson(value) {
17
+ if (Array.isArray(value)) return "[" + value.map(stableJson).join(",") + "]";
18
+ if (value && typeof value === "object") return "{" + Object.keys(value).sort().map((key) => JSON.stringify(key) + ":" + stableJson(value[key])).join(",") + "}";
19
+ return JSON.stringify(value) ?? "undefined";
20
+ }
21
+
22
+ function assertJsonValue(value, path = "value", seen = new Set()) {
23
+ if (value === null || typeof value === "string" || typeof value === "boolean") return;
24
+ if (typeof value === "number") {
25
+ if (!Number.isFinite(value)) throw new Error(path + " must contain only finite JSON numbers.");
26
+ return;
27
+ }
28
+ if (typeof value !== "object") throw new Error(path + " must be a JSON value; received " + typeof value + ".");
29
+ if (seen.has(value)) throw new Error(path + " must not contain cycles.");
30
+ seen.add(value);
31
+ if (Array.isArray(value)) {
32
+ for (let index = 0; index < value.length; index++) {
33
+ if (!Object.prototype.hasOwnProperty.call(value, index)) throw new Error(path + " must not contain sparse array entries.");
34
+ assertJsonValue(value[index], path + "[" + index + "]", seen);
35
+ }
36
+ } else {
37
+ const prototype = Object.getPrototypeOf(value);
38
+ if (prototype !== null && prototype !== Object.prototype && prototype !== contextObjectPrototype) throw new Error(path + " must contain only plain JSON objects.");
39
+ if (Object.getOwnPropertySymbols(value).length > 0) throw new Error(path + " must not contain symbol keys.");
40
+ for (const [key, entry] of Object.entries(value)) assertJsonValue(entry, path + "." + key, seen);
41
+ }
42
+ seen.delete(value);
43
+ }
44
+
45
+ function hostCall(method, args) {
46
+ return new Promise((resolve, reject) => {
47
+ const callId = ++nextCallId;
48
+ pending.set(callId, { resolve, reject });
49
+ parentPort.postMessage({ type: "call", callId, method, args });
50
+ });
51
+ }
52
+
53
+ function validateRunCall(key, params, label, nextFingerprints = fingerprints) {
54
+ if (typeof key !== "string" || !keyPattern.test(key)) throw new Error(label + " has an invalid key.");
55
+ if (!params || typeof params !== "object" || Array.isArray(params)) throw new Error(label + " requires a params object.");
56
+ const tool = params.tool ?? params.cmd;
57
+ if (typeof tool !== "string" || !tool) throw new Error(label + " requires a tool string.");
58
+ if (params.args !== undefined && (!params.args || typeof params.args !== "object" || Array.isArray(params.args))) throw new Error(label + " args must be an object.");
59
+ assertJsonValue(params, label + " params");
60
+ const fingerprint = stableJson(params);
61
+ const existing = nextFingerprints.get(key);
62
+ if (existing !== undefined && existing !== fingerprint) throw new Error("Duplicate script key '" + key + "' used with incompatible tool params.");
63
+ nextFingerprints.set(key, fingerprint);
64
+ }
65
+
66
+ const tools = Object.freeze({
67
+ run(key, params) {
68
+ validateRunCall(key, params, "tools.run");
69
+ return hostCall("run", { key, params });
70
+ },
71
+ all(items) {
72
+ if (!Array.isArray(items)) throw new Error("tools.all(items) requires an array.");
73
+ const nextFingerprints = new Map(fingerprints);
74
+ const calls = [];
75
+ for (let index = 0; index < items.length; index++) {
76
+ if (!Object.prototype.hasOwnProperty.call(items, index)) throw new Error("tools.all items must not contain sparse entries.");
77
+ const item = items[index];
78
+ if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error("tools.all item " + index + " must be an object.");
79
+ const { key, ...params } = item;
80
+ validateRunCall(key, params, "tools.all item " + index, nextFingerprints);
81
+ calls.push({ key, params });
82
+ }
83
+ for (const { key, params } of calls) fingerprints.set(key, stableJson(params));
84
+ return Promise.all(calls.map(({ key, params }) => hostCall("run", { key, params, collectFailure: true })));
85
+ },
86
+ ref(result) {
87
+ if (!result || typeof result !== "object") throw new Error("tools.ref(result) requires a tool result object.");
88
+ return "[tool " + (result.key || "unknown") + "]";
89
+ },
90
+ refs(results) {
91
+ if (!Array.isArray(results)) throw new Error("tools.refs(results) requires an array.");
92
+ return results.map(tools.ref).join("\n");
93
+ },
94
+ });
95
+
96
+ const capturedConsole = Object.freeze(Object.fromEntries(
97
+ ["log", "info", "warn", "error"].map((level) => [level, (...args) => {
98
+ parentPort.postMessage({ type: "console", level, text: args.map((value) => typeof value === "string" ? value : inspect(value, { depth: 4, breakLength: 120 })).join(" ") });
99
+ }]),
100
+ ));
101
+
102
+ parentPort.on("message", async (message) => {
103
+ if (message.type === "response") {
104
+ const entry = pending.get(message.callId);
105
+ if (!entry) return;
106
+ pending.delete(message.callId);
107
+ if (message.ok) entry.resolve(message.value);
108
+ else entry.reject(new Error(message.error));
109
+ return;
110
+ }
111
+ if (message.type !== "start") return;
112
+ try {
113
+ const sandbox = {
114
+ input: Object.freeze(message.input ?? {}),
115
+ tools,
116
+ surf: tools,
117
+ emit(value) { assertJsonValue(value, "emit"); parentPort.postMessage({ type: "emit", value }); },
118
+ console: capturedConsole,
119
+ };
120
+ const context = vm.createContext(sandbox, { codeGeneration: { strings: false, wasm: false } });
121
+ contextObjectPrototype = vm.runInContext("Object.prototype", context);
122
+ const compiled = new vm.Script("(async () => {\n" + message.script + "\n})()", { filename: "surf-workflow-script.js" });
123
+ const value = await compiled.runInContext(context);
124
+ const persistedValue = value === undefined ? null : value;
125
+ assertJsonValue(persistedValue, "return");
126
+ parentPort.postMessage({ type: "complete", value: persistedValue });
127
+ } catch (error) {
128
+ parentPort.postMessage({ type: "error", error: error && error.stack ? error.stack : String(error) });
129
+ }
130
+ });
131
+ `;
132
+
133
+ function isRecord(value) {
134
+ return !!value && typeof value === "object" && !Array.isArray(value);
135
+ }
136
+
137
+ function assertJsonValue(value, path = "value", seen = new Set()) {
138
+ if (value === null || typeof value === "string" || typeof value === "boolean") return;
139
+ if (typeof value === "number") {
140
+ if (!Number.isFinite(value)) throw new Error(`${path} must contain only finite JSON numbers.`);
141
+ return;
142
+ }
143
+ if (typeof value !== "object") throw new Error(`${path} must be a JSON value; received ${typeof value}.`);
144
+ if (seen.has(value)) throw new Error(`${path} must not contain cycles.`);
145
+ seen.add(value);
146
+ if (Array.isArray(value)) {
147
+ for (let index = 0; index < value.length; index++) {
148
+ if (!Object.hasOwn(value, index)) throw new Error(`${path} must not contain sparse array entries.`);
149
+ assertJsonValue(value[index], `${path}[${index}]`, seen);
150
+ }
151
+ } else {
152
+ const prototype = Object.getPrototypeOf(value);
153
+ if (prototype !== null && prototype !== Object.prototype) throw new Error(`${path} must contain only plain JSON objects.`);
154
+ if (Object.getOwnPropertySymbols(value).length > 0) throw new Error(`${path} must not contain symbol keys.`);
155
+ for (const [key, entry] of Object.entries(value)) assertJsonValue(entry, `${path}.${key}`, seen);
156
+ }
157
+ seen.delete(value);
158
+ }
159
+
160
+ function omitUndefined(value, seen = new Set()) {
161
+ if (value === null || typeof value !== "object") return value;
162
+ if (seen.has(value)) return value;
163
+ seen.add(value);
164
+ const next = Array.isArray(value)
165
+ ? value.map((entry) => entry === undefined ? null : omitUndefined(entry, seen))
166
+ : Object.fromEntries(Object.entries(value).flatMap(([key, entry]) => entry === undefined ? [] : [[key, omitUndefined(entry, seen)]]));
167
+ seen.delete(value);
168
+ return next;
169
+ }
170
+
171
+ function validateKey(value) {
172
+ if (typeof value !== "string" || !KEY_PATTERN.test(value)) {
173
+ throw new Error("tool key must be 1-128 characters using letters, numbers, '.', '_' or '-', and start with a letter or number.");
174
+ }
175
+ return value;
176
+ }
177
+
178
+ async function runWorkflowScript({ script, input = {}, timeoutMs = 10 * 60 * 1000, signal, executeTool, onEvent = () => {}, onEmit = () => {}, onConsole = () => {} }) {
179
+ if (typeof script !== "string" || !script.trim()) throw new Error("script strategy requires a non-empty script string");
180
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1) throw new Error("script timeoutMs must be a positive integer");
181
+ if (typeof executeTool !== "function") throw new Error("script strategy requires executeTool");
182
+ assertJsonValue(input, "input");
183
+
184
+ const worker = new Worker(WORKER_SOURCE, { eval: true });
185
+ const emits = [];
186
+ const consoleEntries = [];
187
+ const trace = [];
188
+ const childController = new AbortController();
189
+ let settled = false;
190
+
191
+ return await new Promise((resolve, reject) => {
192
+ const finish = (outcome) => {
193
+ if (settled) return;
194
+ settled = true;
195
+ clearTimeout(timer);
196
+ signal?.removeEventListener("abort", onAbort);
197
+ void worker.terminate();
198
+ childController.abort(outcome.error || new Error("Script strategy completed."));
199
+ if (outcome.error) reject(outcome.error);
200
+ else resolve({ value: outcome.value, emits, console: consoleEntries, trace });
201
+ };
202
+ const onAbort = () => finish({ error: new Error(signal.reason instanceof Error ? signal.reason.message : String(signal.reason || "Script strategy aborted")) });
203
+ const timer = setTimeout(() => finish({ error: new Error(`Script strategy timed out after ${timeoutMs}ms.`) }), timeoutMs);
204
+ signal?.addEventListener("abort", onAbort, { once: true });
205
+ if (signal?.aborted) return onAbort();
206
+
207
+ const respond = (callId, promise) => {
208
+ void promise.then(
209
+ (value) => {
210
+ try {
211
+ const clean = omitUndefined(value);
212
+ assertJsonValue(clean, "tool result");
213
+ worker.postMessage({ type: "response", callId, ok: true, value: clean });
214
+ } catch (error) {
215
+ worker.postMessage({ type: "response", callId, ok: false, error: error instanceof Error ? error.message : String(error) });
216
+ }
217
+ },
218
+ (error) => worker.postMessage({ type: "response", callId, ok: false, error: error instanceof Error ? error.message : String(error) }),
219
+ );
220
+ };
221
+
222
+ worker.on("error", (error) => finish({ error: new Error(`Script worker failed: ${error.message}`) }));
223
+ worker.on("exit", (code) => {
224
+ if (!settled && code !== 0) finish({ error: new Error(`Script worker exited with code ${code}.`) });
225
+ });
226
+ worker.on("message", (message) => {
227
+ if (message.type === "emit") {
228
+ try {
229
+ assertJsonValue(message.value, "emit");
230
+ emits.push(message.value);
231
+ onEmit([...emits]);
232
+ } catch (error) {
233
+ finish({ error: new Error(`Script emit could not be persisted: ${error.message}`) });
234
+ }
235
+ return;
236
+ }
237
+ if (message.type === "console") {
238
+ if (["log", "info", "warn", "error"].includes(message.level) && typeof message.text === "string") {
239
+ const entry = { level: message.level, text: message.text };
240
+ consoleEntries.push(entry);
241
+ onConsole(entry);
242
+ }
243
+ return;
244
+ }
245
+ if (message.type === "complete") {
246
+ try {
247
+ assertJsonValue(message.value, "return");
248
+ finish({ value: message.value });
249
+ } catch (error) {
250
+ finish({ error: new Error(`Script return could not be persisted: ${error.message}`) });
251
+ }
252
+ return;
253
+ }
254
+ if (message.type === "error") return finish({ error: new Error(typeof message.error === "string" ? message.error : "Script strategy failed.") });
255
+ if (message.type !== "call" || typeof message.callId !== "number" || message.method !== "run" || !isRecord(message.args)) return;
256
+ let key;
257
+ try {
258
+ key = validateKey(message.args.key);
259
+ } catch (error) {
260
+ return respond(message.callId, Promise.reject(error));
261
+ }
262
+ const params = message.args.params;
263
+ if (!isRecord(params)) return respond(message.callId, Promise.reject(new Error(`tools.run('${key}', params) requires a params object.`)));
264
+ const tool = params.tool ?? params.cmd;
265
+ if (typeof tool !== "string" || !tool) return respond(message.callId, Promise.reject(new Error(`tools.run('${key}') requires a tool string.`)));
266
+ const args = params.args === undefined ? {} : params.args;
267
+ if (!isRecord(args)) return respond(message.callId, Promise.reject(new Error(`tools.run('${key}') args must be an object.`)));
268
+ const collectFailure = message.args.collectFailure === true;
269
+ const startedAt = Date.now();
270
+ trace.push({ key, tool, state: "started" });
271
+ onEvent({ type: "script.tool.started", key, tool, startedAt: new Date().toISOString() });
272
+ respond(message.callId, Promise.resolve().then(async () => {
273
+ try {
274
+ const output = await executeTool(tool, args, { signal: childController.signal });
275
+ const result = { key, tool, ok: true, output };
276
+ trace.push({ key, tool, state: "completed", durationMs: Date.now() - startedAt });
277
+ onEvent({ type: "script.tool.completed", key, tool, endedAt: new Date().toISOString() });
278
+ return result;
279
+ } catch (error) {
280
+ const text = error instanceof Error ? error.message : String(error);
281
+ const result = { key, tool, ok: false, error: text };
282
+ trace.push({ key, tool, state: "failed", durationMs: Date.now() - startedAt, error: text });
283
+ onEvent({ type: "script.tool.failed", key, tool, error: text, endedAt: new Date().toISOString() });
284
+ if (!collectFailure) throw new Error(`Tool '${key}' failed: ${text}`);
285
+ return result;
286
+ }
287
+ }));
288
+ });
289
+
290
+ worker.postMessage({ type: "start", script, input });
291
+ });
292
+ }
293
+
294
+ module.exports = { runWorkflowScript };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "surf-cli",
3
- "version": "2.11.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
@@ -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