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 +33 -0
- package/native/cli.cjs +33 -3
- package/native/host-helpers.cjs +12 -0
- package/native/host.cjs +1 -0
- package/native/playbook-cli.cjs +3 -2
- package/native/playbook-runtime.cjs +29 -2
- package/native/playbooks.cjs +13 -0
- package/native/workflow-script-runtime.cjs +294 -0
- package/package.json +20 -5
- package/pi-extension/surf.ts +230 -0
- package/skills/surf/SKILL.md +24 -6
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;
|
package/native/host-helpers.cjs
CHANGED
|
@@ -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
|
|
package/native/playbook-cli.cjs
CHANGED
|
@@ -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);
|
package/native/playbooks.cjs
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
+
}
|
package/skills/surf/SKILL.md
CHANGED
|
@@ -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.
|
|
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. **
|
|
691
|
-
16. **
|
|
692
|
-
17. **
|
|
693
|
-
18. **
|
|
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
|
|