surf-cli 2.14.0 → 2.15.1
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 +63 -34
- package/agents/gpt-pro.md +19 -0
- package/dist/service-worker/index.js +14 -14
- package/dist/service-worker/index.js.map +1 -1
- package/native/aistudio-parser.cjs +5 -2
- package/native/browser-scheduler.cjs +348 -0
- package/native/browser-session-store.cjs +271 -0
- package/native/cli.cjs +331 -60
- package/native/do-executor.cjs +5 -0
- package/native/host-helpers.cjs +19 -3
- package/native/host-sessions.cjs +8 -1
- package/native/host.cjs +766 -19
- package/native/playbook-cli.cjs +16 -3
- package/native/surf-error.cjs +47 -0
- package/native/tool-scope.cjs +107 -0
- package/native/workflow-definition.cjs +7 -0
- package/package.json +12 -5
- package/pi-extension/surf.ts +80 -52
- package/skills/surf/SKILL.md +49 -22
package/native/playbook-cli.cjs
CHANGED
|
@@ -36,6 +36,11 @@ async function requestHost(endpoint, tool, args, options = {}) {
|
|
|
36
36
|
try {
|
|
37
37
|
const request = { type: "tool_request", method: "execute_tool", params: { tool, args }, id: `playbook-${Date.now()}-${Math.random()}` };
|
|
38
38
|
if (options.tabId) request.tabId = options.tabId;
|
|
39
|
+
if (options.session) {
|
|
40
|
+
request.session = options.session;
|
|
41
|
+
request.sessionSource = options.sessionSource || "environment";
|
|
42
|
+
}
|
|
43
|
+
if (options.admission) request.admission = options.admission;
|
|
39
44
|
return unwrapResponse(await transport.request(request, options.timeoutMs || 11 * 60 * 1000));
|
|
40
45
|
} finally {
|
|
41
46
|
await transport.close();
|
|
@@ -48,7 +53,7 @@ function runSpec(argv) {
|
|
|
48
53
|
const parsed = parseCommandArgs(argv.slice(offset));
|
|
49
54
|
const [playbook, op] = parsed.positional;
|
|
50
55
|
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", "allow-script"]);
|
|
56
|
+
const reserved = new Set(["json", "no-lock", "no-wait", "session", "tab-id", "write", "repeat", "retry-attempt", "override-in-doubt", "pin-built-in", "allow-script"]);
|
|
52
57
|
const args = Object.fromEntries(Object.entries(parsed.options).filter(([name]) => !reserved.has(name)));
|
|
53
58
|
return { playbook, op, args, options: parsed.options };
|
|
54
59
|
}
|
|
@@ -73,7 +78,7 @@ function playbookCommandNeedsBrowser(argv) {
|
|
|
73
78
|
return subcommand === "record" && ["start", "stop", "discard"].includes(argv[2]);
|
|
74
79
|
}
|
|
75
80
|
|
|
76
|
-
async function handlePlaybookCli(argv, { endpoint, cwd = process.cwd() }) {
|
|
81
|
+
async function handlePlaybookCli(argv, { endpoint, cwd = process.cwd(), session, sessionSource, admission } = {}) {
|
|
77
82
|
if (!["playbook", "pb", "use"].includes(argv[0])) return { handled: false };
|
|
78
83
|
if (argv[0] === "use" || argv[1] === "run") {
|
|
79
84
|
const spec = runSpec(argv);
|
|
@@ -93,6 +98,9 @@ async function handlePlaybookCli(argv, { endpoint, cwd = process.cwd() }) {
|
|
|
93
98
|
};
|
|
94
99
|
const value = await requestHost(endpoint, "playbook.run", args, {
|
|
95
100
|
tabId: spec.options["tab-id"],
|
|
101
|
+
session: spec.options.session || session,
|
|
102
|
+
sessionSource: spec.options.session ? "explicit" : sessionSource,
|
|
103
|
+
admission: spec.options["no-wait"] === true ? { wait: false } : admission,
|
|
96
104
|
timeoutMs: resolveRequestDeadlineMs("playbook.run", args),
|
|
97
105
|
});
|
|
98
106
|
return { handled: true, value, json: spec.options.json === true };
|
|
@@ -118,7 +126,12 @@ async function handlePlaybookCli(argv, { endpoint, cwd = process.cwd() }) {
|
|
|
118
126
|
else if (action === "mark") args = { label: parsed.positional.slice(1).join(" ") };
|
|
119
127
|
else if (action === "stop") args = { draft: parsed.options.draft === true };
|
|
120
128
|
else if (!["status", "pause", "resume", "discard"].includes(action)) throw new Error("Unknown record command");
|
|
121
|
-
const value = await requestHost(endpoint, tool, args, {
|
|
129
|
+
const value = await requestHost(endpoint, tool, args, {
|
|
130
|
+
tabId: parsed.options["tab-id"],
|
|
131
|
+
session: parsed.options.session || session,
|
|
132
|
+
sessionSource: parsed.options.session ? "explicit" : sessionSource,
|
|
133
|
+
admission: parsed.options["no-wait"] === true ? { wait: false } : admission,
|
|
134
|
+
});
|
|
122
135
|
return { handled: true, value, json: parsed.options.json === true };
|
|
123
136
|
}
|
|
124
137
|
if (command === "suggest") return { handled: true, value: suggestions({ since: parsed.options.since || "1h" }), json: parsed.options.json === true };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
class SurfError extends Error {
|
|
2
|
+
constructor(code, message, details = {}) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = "SurfError";
|
|
5
|
+
this.code = code;
|
|
6
|
+
Object.assign(this, details);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
toJSON() {
|
|
10
|
+
const value = { code: this.code, message: this.message };
|
|
11
|
+
for (const key of [
|
|
12
|
+
"session", "target", "lastUrl", "laneKey", "resourceKeys", "retryable", "recoveryCommand",
|
|
13
|
+
"queue", "reason", "browserEpoch", "expectedBrowserEpoch",
|
|
14
|
+
]) {
|
|
15
|
+
if (this[key] !== undefined) value[key] = this[key];
|
|
16
|
+
}
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function surfError(code, message, details = {}) {
|
|
22
|
+
return new SurfError(code, message, details);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isSurfError(error) {
|
|
26
|
+
return Boolean(error && typeof error === "object" && typeof error.code === "string");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function fromExtensionError(result, fallbackCode = "browser_error") {
|
|
30
|
+
if (!result?.error) return null;
|
|
31
|
+
return surfError(result.errorCode || fallbackCode, result.error, result.errorDetails || {});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function recoveryFor(error) {
|
|
35
|
+
if (!error || typeof error !== "object") return null;
|
|
36
|
+
if (typeof error.recoveryCommand === "string" && error.recoveryCommand) return error.recoveryCommand;
|
|
37
|
+
if ((error.code === "tab_gone" || error.code === "session_epoch_stale") && error.session) {
|
|
38
|
+
return `surf session.reopen ${error.session}`;
|
|
39
|
+
}
|
|
40
|
+
if ((error.code === "tab_busy" || error.code === "browser_busy" || error.code === "resource_busy") && error.session) {
|
|
41
|
+
return `surf session.info ${error.session}`;
|
|
42
|
+
}
|
|
43
|
+
if (error.code === "browser_busy") return "surf session.list --refresh";
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { SurfError, surfError, isSurfError, fromExtensionError, recoveryFor };
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
const path = require("path");
|
|
2
|
+
|
|
3
|
+
const PROVIDER_TOOLS = new Set([
|
|
4
|
+
"chatgpt", "gemini", "perplexity", "grok", "kimi", "aistudio", "aistudio.build",
|
|
5
|
+
"oracle.ask", "oracle.result", "oracle.cancel",
|
|
6
|
+
]);
|
|
7
|
+
|
|
8
|
+
const HOST_TOOLS = new Set([
|
|
9
|
+
"wait",
|
|
10
|
+
"session.list",
|
|
11
|
+
"session.info",
|
|
12
|
+
"tab.unname", "tabs_unregister", "tab.named", "tabs_list_named",
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
const BROWSER_READ_TOOLS = new Set([
|
|
16
|
+
"tab.list", "tabs_context", "list_tabs",
|
|
17
|
+
"window.list",
|
|
18
|
+
"history.list", "history.search",
|
|
19
|
+
"bookmark.list",
|
|
20
|
+
"downloads.search",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
const BROWSER_WRITE_TOOLS = new Set([
|
|
24
|
+
"session.new", "session.ensure", "session.close", "session.rebind", "session.reopen",
|
|
25
|
+
"tab.new", "new_tab", "tabs_create",
|
|
26
|
+
"tab.move", "tab.switch", "switch_tab",
|
|
27
|
+
"tab.group", "tab.ungroup",
|
|
28
|
+
"window.new", "window.close", "window.focus", "window.resize",
|
|
29
|
+
"smoke",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
const BROWSER_WRITE_TARGETED_TOOLS = new Set([
|
|
33
|
+
"cookie.set", "cookie.clear", "cookie.clear-all",
|
|
34
|
+
"bookmark.add", "bookmark.remove",
|
|
35
|
+
"playbook.run",
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const TAB_TOOLS = new Set([
|
|
39
|
+
"ai", "computer", "batch", "record", "animate-audit", "perf-audit",
|
|
40
|
+
"navigate", "go", "back", "forward", "reload", "tab.reload",
|
|
41
|
+
"screenshot", "snap", "resize",
|
|
42
|
+
"page.read", "read_page", "page.text", "get_page_text", "page.html", "page.save", "page.state",
|
|
43
|
+
"click", "left_click", "right_click", "double_click", "triple_click", "drag", "hover", "key", "submit",
|
|
44
|
+
"type", "smart_type", "find_and_type", "form_input", "form.fill", "select", "upload", "upload_image",
|
|
45
|
+
"scroll", "scroll.top", "scroll.bottom", "scroll.to", "scroll.info", "scroll_to_position",
|
|
46
|
+
"search", "locate.role", "locate.text", "locate.label", "element.styles",
|
|
47
|
+
"js", "javascript_tool", "eval",
|
|
48
|
+
"wait.element", "wait.url", "wait.network", "wait.dom", "wait.load", "health",
|
|
49
|
+
"frame.list", "frame.switch", "frame.main", "frame.js",
|
|
50
|
+
"dialog.accept", "dialog.dismiss", "dialog.info",
|
|
51
|
+
"console", "network", "network.get", "network.body", "network.curl", "network.path",
|
|
52
|
+
"network.origins", "network.clear", "network.stats", "network.export",
|
|
53
|
+
"emulate.network", "emulate.cpu", "emulate.geo", "emulate.device", "emulate.viewport", "emulate.touch",
|
|
54
|
+
"perf.start", "perf.stop", "perf.metrics",
|
|
55
|
+
"zoom", "cookie.list", "cookie.get",
|
|
56
|
+
"tab.name", "tabs_register",
|
|
57
|
+
"playbook.record.start", "playbook.record.stop", "playbook.record.status", "playbook.record.mark",
|
|
58
|
+
"playbook.record.pause", "playbook.record.resume", "playbook.record.discard",
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
function hasExplicitTabCloseTarget(args = {}) {
|
|
62
|
+
return [args.id, args.tab_id, args.tabId, args.ids, args.tab_ids, args.tabIds]
|
|
63
|
+
.some((value) => value !== undefined && value !== null && value !== "");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function classifyTool(tool, args = {}) {
|
|
67
|
+
if (PROVIDER_TOOLS.has(tool) || tool.startsWith("oracle.")) {
|
|
68
|
+
const hostOnly = tool === "oracle.result" || tool === "oracle.cancel" || tool === "oracle.status" || tool === "oracle.list";
|
|
69
|
+
return hostOnly
|
|
70
|
+
? { scope: "host", targetUse: "host" }
|
|
71
|
+
: { scope: "provider", targetUse: "default-tab" };
|
|
72
|
+
}
|
|
73
|
+
if (tool === "session.list") {
|
|
74
|
+
return { scope: args.refresh ? "browser-read" : "host", targetUse: "host" };
|
|
75
|
+
}
|
|
76
|
+
if (tool === "session.info") {
|
|
77
|
+
return { scope: args.refresh ? "browser-read" : "host", targetUse: "host" };
|
|
78
|
+
}
|
|
79
|
+
if (tool === "tab.close" || tool === "close_tab") {
|
|
80
|
+
return hasExplicitTabCloseTarget(args)
|
|
81
|
+
? { scope: "browser-write", targetUse: "browser" }
|
|
82
|
+
: { scope: "browser-write", targetUse: "default-tab" };
|
|
83
|
+
}
|
|
84
|
+
if (HOST_TOOLS.has(tool)) return { scope: "host", targetUse: "host" };
|
|
85
|
+
if (BROWSER_READ_TOOLS.has(tool)) return { scope: "browser-read", targetUse: "browser" };
|
|
86
|
+
if (BROWSER_WRITE_TARGETED_TOOLS.has(tool)) {
|
|
87
|
+
return { scope: "browser-write", targetUse: "default-tab" };
|
|
88
|
+
}
|
|
89
|
+
if (BROWSER_WRITE_TOOLS.has(tool)) return { scope: "browser-write", targetUse: "browser" };
|
|
90
|
+
if (TAB_TOOLS.has(tool)) {
|
|
91
|
+
const resourceKeys = [];
|
|
92
|
+
if (tool === "network.export" && typeof args.output === "string") resourceKeys.push(`file:${path.resolve(args.output)}`);
|
|
93
|
+
if (tool.startsWith("playbook.record.")) resourceKeys.push("playbook-recorder");
|
|
94
|
+
return { scope: "tab", targetUse: "default-tab", resourceKeys };
|
|
95
|
+
}
|
|
96
|
+
return { scope: "browser-write", targetUse: "browser", conservative: true };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = {
|
|
100
|
+
BROWSER_READ_TOOLS,
|
|
101
|
+
BROWSER_WRITE_TARGETED_TOOLS,
|
|
102
|
+
BROWSER_WRITE_TOOLS,
|
|
103
|
+
HOST_TOOLS,
|
|
104
|
+
PROVIDER_TOOLS,
|
|
105
|
+
TAB_TOOLS,
|
|
106
|
+
classifyTool,
|
|
107
|
+
};
|
|
@@ -36,6 +36,13 @@ const COMMANDS = {
|
|
|
36
36
|
"tab.close": { primaryArg: "id", effect: "page-write", argKinds: { id: "tab-id" } },
|
|
37
37
|
"tab.name": { primaryArg: "name", effect: "page-write", argKinds: { name: "name" } },
|
|
38
38
|
"tab.unname": { primaryArg: "name", effect: "page-write", argKinds: { name: "name" } },
|
|
39
|
+
"session.new": { primaryArg: "name", effect: "navigation", recordable: false, argKinds: { name: "name", url: "url" } },
|
|
40
|
+
"session.ensure": { primaryArg: "name", effect: "navigation", recordable: false, argKinds: { name: "name", url: "url" } },
|
|
41
|
+
"session.list": { effect: "read", recordable: false },
|
|
42
|
+
"session.info": { primaryArg: "name", effect: "read", recordable: false, argKinds: { name: "name" } },
|
|
43
|
+
"session.close": { primaryArg: "name", effect: "page-write", recordable: false, argKinds: { name: "name" } },
|
|
44
|
+
"session.rebind": { primaryArg: "name", effect: "page-write", recordable: false, argKinds: { name: "name", tabId: "tab-id" } },
|
|
45
|
+
"session.reopen": { primaryArg: "name", effect: "navigation", recordable: false, argKinds: { name: "name", url: "url" } },
|
|
39
46
|
scroll_to_position: { primaryArg: "position", effect: "page-write", argKinds: { position: "position" } },
|
|
40
47
|
type: { primaryArg: "text", effect: "page-write", argKinds: { selector: "selector", text: "user-input" }, sensitiveArgs: ["text"] },
|
|
41
48
|
smart_type: { primaryArg: "text", effect: "page-write", argKinds: { selector: "selector", text: "user-input" }, sensitiveArgs: ["text"] },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "surf-cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.15.1",
|
|
4
4
|
"description": "CLI for AI agents to control Chrome. Zero config, agent-agnostic, battle-tested.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"chrome",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"playbooks/",
|
|
34
34
|
"scripts/",
|
|
35
35
|
"dist/",
|
|
36
|
+
"agents/",
|
|
36
37
|
"skills/",
|
|
37
38
|
"README.md",
|
|
38
39
|
"LICENSE"
|
|
@@ -69,11 +70,12 @@
|
|
|
69
70
|
"@types/node": "^26.1.2",
|
|
70
71
|
"@vitest/coverage-v8": "^4.1.9",
|
|
71
72
|
"@vitest/ui": "^4.1.9",
|
|
72
|
-
"
|
|
73
|
+
"pi-subagents": "^0.52.0",
|
|
74
|
+
"puppeteer": "25.6.0",
|
|
75
|
+
"typebox": "^1.3.11",
|
|
73
76
|
"typescript": "^7.0.2",
|
|
74
77
|
"vite": "^8.1.4",
|
|
75
|
-
"vitest": "^4.1.9"
|
|
76
|
-
"typebox": "^1.3.11"
|
|
78
|
+
"vitest": "^4.1.9"
|
|
77
79
|
},
|
|
78
80
|
"pi": {
|
|
79
81
|
"extensions": [
|
|
@@ -81,7 +83,12 @@
|
|
|
81
83
|
],
|
|
82
84
|
"skills": [
|
|
83
85
|
"./skills"
|
|
84
|
-
]
|
|
86
|
+
],
|
|
87
|
+
"subagents": {
|
|
88
|
+
"agents": [
|
|
89
|
+
"./agents"
|
|
90
|
+
]
|
|
91
|
+
}
|
|
85
92
|
},
|
|
86
93
|
"peerDependencies": {
|
|
87
94
|
"typebox": "*"
|
package/pi-extension/surf.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
-
import { createHash } from "node:crypto";
|
|
3
2
|
import { Type } from "typebox";
|
|
4
3
|
|
|
5
4
|
const require = createRequire(import.meta.url);
|
|
@@ -73,29 +72,31 @@ type BackgroundWorkRegistry = {
|
|
|
73
72
|
};
|
|
74
73
|
|
|
75
74
|
type OracleExternalJob = {
|
|
76
|
-
provider: "surf-oracle";
|
|
77
75
|
id: string;
|
|
78
76
|
state: string;
|
|
79
77
|
conversationUrl: string | null;
|
|
80
|
-
requestedModel: string | null;
|
|
81
|
-
verifiedModel: string | null;
|
|
82
|
-
requestedEffort: string | null;
|
|
83
|
-
verifiedEffort: string | null;
|
|
84
|
-
promptDigest: string | null;
|
|
85
78
|
resultText?: string;
|
|
86
|
-
resultArtifact?: { kind: "inline-text"; bytes: number };
|
|
87
79
|
failure?: { code: string; message: string };
|
|
88
80
|
};
|
|
89
81
|
|
|
82
|
+
type PiExternalJobState = "queued" | "running" | "completed" | "failed";
|
|
83
|
+
|
|
84
|
+
type PiExternalJobHandle = {
|
|
85
|
+
providerJobId: string;
|
|
86
|
+
state: PiExternalJobState;
|
|
87
|
+
conversationUrl?: string;
|
|
88
|
+
failureCode?: string;
|
|
89
|
+
failureMessage?: string;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
type PiExternalJobResult = PiExternalJobHandle & { output?: string };
|
|
93
|
+
|
|
90
94
|
type OracleExternalJobProvider = {
|
|
91
95
|
name: "surf-oracle";
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
result(id: string, input?: Record<string, unknown>): Promise<OracleExternalJob>;
|
|
97
|
-
reattach(id: string, input?: Record<string, unknown>): Promise<OracleExternalJob>;
|
|
98
|
-
follow(id: string, message: string, input?: Record<string, unknown>): Promise<OracleExternalJob>;
|
|
96
|
+
start(input: Record<string, unknown>): Promise<PiExternalJobHandle>;
|
|
97
|
+
status(id: string): Promise<PiExternalJobHandle>;
|
|
98
|
+
result(id: string): Promise<PiExternalJobResult>;
|
|
99
|
+
reattach(id: string): Promise<PiExternalJobHandle>;
|
|
99
100
|
};
|
|
100
101
|
|
|
101
102
|
type RegisterExternalJobProvider = (provider: OracleExternalJobProvider) => () => void;
|
|
@@ -251,15 +252,11 @@ export async function resolveExternalJobProviderRegister(
|
|
|
251
252
|
return module.registerExternalJobProvider as RegisterExternalJobProvider;
|
|
252
253
|
}
|
|
253
254
|
} catch {
|
|
254
|
-
// The
|
|
255
|
+
// The Pi bridge is optional. Surf also runs in other coding-agent harnesses and as a direct CLI.
|
|
255
256
|
}
|
|
256
257
|
return registerGlobalExternalJobProvider;
|
|
257
258
|
}
|
|
258
259
|
|
|
259
|
-
function digestPrompt(prompt: string) {
|
|
260
|
-
return `sha256:${createHash("sha256").update(prompt).digest("hex")}`;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
260
|
function asOracleJob(value: unknown): OracleJob {
|
|
264
261
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Surf oracle response did not include job metadata");
|
|
265
262
|
const job = value as Partial<OracleJob>;
|
|
@@ -272,22 +269,53 @@ function oracleExternalJob(job: OracleJob): OracleExternalJob {
|
|
|
272
269
|
const failure = job.error
|
|
273
270
|
? { code: job.error.code || "failed", message: job.error.message || "Surf oracle job failed" }
|
|
274
271
|
: undefined;
|
|
275
|
-
const requestedModel = Object.hasOwn(job, "modelRequested") ? job.modelRequested ?? null : job.model ?? null;
|
|
276
272
|
return {
|
|
277
|
-
provider: "surf-oracle",
|
|
278
273
|
id: job.id,
|
|
279
274
|
state: job.state,
|
|
280
275
|
conversationUrl: job.conversationUrl ?? null,
|
|
281
|
-
|
|
282
|
-
verifiedModel: job.modelVerified ?? null,
|
|
283
|
-
requestedEffort: job.effortRequested ?? null,
|
|
284
|
-
verifiedEffort: job.effortVerified ?? null,
|
|
285
|
-
promptDigest: job.promptDigest ?? null,
|
|
286
|
-
...(resultText === undefined ? {} : { resultText, resultArtifact: { kind: "inline-text", bytes: Buffer.byteLength(resultText, "utf8") } }),
|
|
276
|
+
...(resultText === undefined ? {} : { resultText }),
|
|
287
277
|
...(failure ? { failure } : {}),
|
|
288
278
|
};
|
|
289
279
|
}
|
|
290
280
|
|
|
281
|
+
// pi-subagents' external-job contract rejects unknown fields, null values,
|
|
282
|
+
// untrimmed strings, and non-contract states, so map oracle payloads at this
|
|
283
|
+
// boundary instead of passing them through.
|
|
284
|
+
const PI_STATE_BY_ORACLE_STATE: Record<string, PiExternalJobState> = {
|
|
285
|
+
created: "queued",
|
|
286
|
+
dispatched: "running",
|
|
287
|
+
awaiting: "running",
|
|
288
|
+
captured: "completed",
|
|
289
|
+
failed: "failed",
|
|
290
|
+
};
|
|
291
|
+
const PI_MAX_FAILURE_CODE_CHARS = 128;
|
|
292
|
+
const PI_MAX_FAILURE_MESSAGE_CHARS = 4_096;
|
|
293
|
+
const PI_MAX_OUTPUT_CHARS = 1024 * 1024;
|
|
294
|
+
|
|
295
|
+
function piBounded(value: string, maxChars: number): string {
|
|
296
|
+
return value.slice(0, maxChars).trim();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function piExternalJobHandle(job: OracleExternalJob): PiExternalJobHandle {
|
|
300
|
+
const state = PI_STATE_BY_ORACLE_STATE[job.state];
|
|
301
|
+
if (!state) throw new Error(`Surf oracle job ${job.id} reported unknown state '${job.state}'`);
|
|
302
|
+
const conversationUrl = job.conversationUrl ?? undefined;
|
|
303
|
+
const failureCode = job.failure ? piBounded(job.failure.code, PI_MAX_FAILURE_CODE_CHARS) : "";
|
|
304
|
+
const failureMessage = job.failure ? piBounded(job.failure.message, PI_MAX_FAILURE_MESSAGE_CHARS) : "";
|
|
305
|
+
return {
|
|
306
|
+
providerJobId: job.id,
|
|
307
|
+
state,
|
|
308
|
+
...(conversationUrl ? { conversationUrl } : {}),
|
|
309
|
+
...(failureCode ? { failureCode } : {}),
|
|
310
|
+
...(failureMessage ? { failureMessage } : {}),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function piExternalJobResult(job: OracleExternalJob): PiExternalJobResult {
|
|
315
|
+
const output = job.resultText === undefined ? "" : piBounded(job.resultText, PI_MAX_OUTPUT_CHARS);
|
|
316
|
+
return { ...piExternalJobHandle(job), ...(output ? { output } : {}) };
|
|
317
|
+
}
|
|
318
|
+
|
|
291
319
|
async function requestOracleJob(request: typeof requestSurf, tool: string, args: Record<string, unknown>) {
|
|
292
320
|
const result = await request(tool, args);
|
|
293
321
|
if (result.isError) {
|
|
@@ -298,6 +326,7 @@ async function requestOracleJob(request: typeof requestSurf, tool: string, args:
|
|
|
298
326
|
const error = new Error(message);
|
|
299
327
|
if (typeof details?.code === "string") Object.assign(error, { code: details.code });
|
|
300
328
|
if (typeof details?.jobId === "string") Object.assign(error, { jobId: details.jobId });
|
|
329
|
+
if (details?.code === "capacity" && typeof details?.jobId === "string") Object.assign(error, { blockingJobId: details.jobId });
|
|
301
330
|
throw error;
|
|
302
331
|
}
|
|
303
332
|
return oracleExternalJob(asOracleJob(result.details));
|
|
@@ -308,6 +337,16 @@ function emitFailedOracleJob(error: unknown, emitTerminal: EmitOracleJob) {
|
|
|
308
337
|
emitTerminal({ id: error.jobId, state: "failed" });
|
|
309
338
|
}
|
|
310
339
|
|
|
340
|
+
function oracleOption(input: Record<string, unknown>, key: "model" | "effort"): string | undefined {
|
|
341
|
+
const options = input.options;
|
|
342
|
+
if (options && typeof options === "object" && !Array.isArray(options)) {
|
|
343
|
+
const value = (options as Record<string, unknown>)[key];
|
|
344
|
+
if (typeof value === "string") return value;
|
|
345
|
+
}
|
|
346
|
+
const direct = input[key];
|
|
347
|
+
return typeof direct === "string" ? direct : undefined;
|
|
348
|
+
}
|
|
349
|
+
|
|
311
350
|
export function createOracleExternalJobProvider(
|
|
312
351
|
sessionId: string,
|
|
313
352
|
jobIds: Set<string>,
|
|
@@ -320,56 +359,45 @@ export function createOracleExternalJobProvider(
|
|
|
320
359
|
): OracleExternalJobProvider {
|
|
321
360
|
return {
|
|
322
361
|
name: "surf-oracle",
|
|
323
|
-
kind: "external-job",
|
|
324
|
-
wakeChannels: [ORACLE_FINISHED_CHANNEL],
|
|
325
362
|
async start(input) {
|
|
326
363
|
const prompt = typeof input.prompt === "string" ? input.prompt : "";
|
|
327
364
|
if (!prompt.trim()) throw new Error("prompt required");
|
|
365
|
+
const model = oracleOption(input, "model");
|
|
366
|
+
const effort = oracleOption(input, "effort");
|
|
328
367
|
const job = await requestOracleJob(request, "oracle.ask", {
|
|
329
368
|
prompt,
|
|
330
|
-
...(
|
|
331
|
-
...(
|
|
369
|
+
...(model !== undefined ? { model } : {}),
|
|
370
|
+
...(effort !== undefined ? { effort } : {}),
|
|
332
371
|
});
|
|
333
372
|
rememberJob(job.id);
|
|
334
|
-
return
|
|
373
|
+
return piExternalJobHandle(job);
|
|
335
374
|
},
|
|
336
|
-
status(id) {
|
|
337
|
-
return requestOracleJob(request, "oracle.status", { id });
|
|
375
|
+
async status(id) {
|
|
376
|
+
return piExternalJobHandle(await requestOracleJob(request, "oracle.status", { id }));
|
|
338
377
|
},
|
|
339
|
-
result(id
|
|
340
|
-
return requestOracleJob(request, "oracle.result", { id
|
|
378
|
+
result(id) {
|
|
379
|
+
return requestOracleJob(request, "oracle.result", { id })
|
|
341
380
|
.then((job) => {
|
|
342
381
|
emitTerminal({ id: job.id, state: job.state });
|
|
343
|
-
return job;
|
|
382
|
+
return piExternalJobResult(job);
|
|
344
383
|
})
|
|
345
384
|
.catch((error) => {
|
|
346
385
|
emitFailedOracleJob(error, emitTerminal);
|
|
347
386
|
throw error;
|
|
348
387
|
});
|
|
349
388
|
},
|
|
350
|
-
reattach(id
|
|
351
|
-
return requestOracleJob(request, "oracle.result", { id
|
|
389
|
+
reattach(id) {
|
|
390
|
+
return requestOracleJob(request, "oracle.result", { id })
|
|
352
391
|
.then((job) => {
|
|
353
392
|
rememberJob(job.id);
|
|
354
393
|
emitTerminal({ id: job.id, state: job.state });
|
|
355
|
-
return job;
|
|
394
|
+
return piExternalJobHandle(job);
|
|
356
395
|
})
|
|
357
396
|
.catch((error) => {
|
|
358
397
|
emitFailedOracleJob(error, emitTerminal);
|
|
359
398
|
throw error;
|
|
360
399
|
});
|
|
361
400
|
},
|
|
362
|
-
async follow(id, message, input = {}) {
|
|
363
|
-
if (!message.trim()) throw new Error("message required");
|
|
364
|
-
const job = await requestOracleJob(request, "oracle.ask", {
|
|
365
|
-
follow: id,
|
|
366
|
-
prompt: message,
|
|
367
|
-
...(typeof input.model === "string" ? { model: input.model } : {}),
|
|
368
|
-
...(typeof input.effort === "string" ? { effort: input.effort } : {}),
|
|
369
|
-
});
|
|
370
|
-
rememberJob(job.id);
|
|
371
|
-
return { ...job, promptDigest: job.promptDigest ?? digestPrompt(message) };
|
|
372
|
-
},
|
|
373
401
|
};
|
|
374
402
|
}
|
|
375
403
|
|
package/skills/surf/SKILL.md
CHANGED
|
@@ -40,13 +40,24 @@ Remote paths are client-local by default. `local:./file` is explicit client-loca
|
|
|
40
40
|
## CLI Quick Reference
|
|
41
41
|
|
|
42
42
|
```bash
|
|
43
|
-
surf --help #
|
|
43
|
+
surf --help # Basic help
|
|
44
44
|
surf <group> # Group help (tab, scroll, page, wait, dialog, emulate, form, perf, ai)
|
|
45
45
|
surf --help-full # All commands
|
|
46
46
|
surf --find <term> # Search tools
|
|
47
47
|
surf --help-topic <topic> # Topic guide (refs, semantic, frames, devices, windows)
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
+
## First Command for Independent Agents
|
|
51
|
+
|
|
52
|
+
Before the first browser command in each independent agent shell, choose a unique valid session name and ensure its target exists:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
export SURF_SESSION="$(basename "$PWD" | sed 's/[^A-Za-z0-9._-]/-/g')"
|
|
56
|
+
surf session.ensure "$SURF_SESSION" about:blank
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`session.ensure` is idempotent. It creates a missing session, reuses a live binding, and reopens a stale or closed tab. Keep `SURF_SESSION` set for every later tab-scoped command in that shell. Use a distinct worktree/directory name per agent; when agents share one directory, append a stable agent identifier. Use `surf session.info "$SURF_SESSION"` to inspect the target and queue state.
|
|
60
|
+
|
|
50
61
|
## Core Workflow
|
|
51
62
|
|
|
52
63
|
```bash
|
|
@@ -103,7 +114,9 @@ surf oracle result <job-id> --wait --json
|
|
|
103
114
|
|
|
104
115
|
Treat Pro quota as scarce. Oracle never selects Pro implicitly; request it with `--model pro` or `--effort pro`. ChatGPT model aliases include `instant`, `thinking`, `pro`, `gpt-5.5`, and `gpt-5.6-sol`. Accepted `--effort` values are `light`, `standard`, `extended`, `heavy`, and `pro`. Requested model and effort selections are read back before submission, and an unverifiable selection fails with `model_verification_failed` instead of silently continuing. Capacity is one non-terminal oracle job. A `capacity` error includes the in-flight job ID; poll that job or wait for it to finish rather than submitting the same consult again.
|
|
105
116
|
|
|
106
|
-
When loaded as a Pi extension, Surf also registers a `surf-oracle` external-job provider when the runtime exposes that bridge. The provider maps `start`, `status`, `result`,
|
|
117
|
+
When loaded as a Pi extension, Surf also registers a `surf-oracle` external-job provider when the runtime exposes that bridge. The provider maps `start`, `status`, `result`, and `reattach` to durable Surf Oracle jobs and returns pi-subagents' external-job contract shape: `providerJobId`, a contract state (`queued`, `running`, `completed`, `failed`), the conversation URL, the captured result text as `output`, and failure code and message. It honors `options.model` and `options.effort` for starts, so `model: pro` selects ChatGPT GPT-5.6 Sol Pro web mode through the browser. `reattach` only harvests an existing job by ID; it never submits the prompt again.
|
|
118
|
+
|
|
119
|
+
When Surf is installed as a Pi package, it exposes an optional `gpt-pro` package agent for `pi-subagents`. That profile uses `runner.type: external-job`, provider `surf-oracle`, and `options.model: pro`. Surf remains useful without Pi or `pi-subagents`.
|
|
107
120
|
|
|
108
121
|
Context comes from repeatable `--files` globs. Surf fails closed when a glob matches nothing or a matched file is unreadable, binary, or invalid UTF-8. It also blocks gitignored files and basenames matching `.env*`, `*.pem`, `*.key`, `id_rsa*`, `id_ed25519*`, `*.p12`, `*.pfx`, `credentials*`, or `secrets*`. Use `--allow-sensitive` only after intentionally reviewing those files; it overrides the block rather than redacting content. Context up to 60,000 evidence characters is inserted inline, while larger context becomes one private text attachment. The assembly manifest records each path, byte count, SHA-256, inline or bundle disposition, and deny-list outcome.
|
|
109
122
|
|
|
@@ -247,20 +260,27 @@ surf window.resize --id 123 --width 1920 --height 1080
|
|
|
247
260
|
surf window.resize --id 123 --state maximized # States: normal, minimized, maximized, fullscreen
|
|
248
261
|
```
|
|
249
262
|
|
|
250
|
-
**
|
|
263
|
+
**Concurrent agent sessions:**
|
|
264
|
+
|
|
251
265
|
```bash
|
|
252
|
-
#
|
|
253
|
-
|
|
254
|
-
surf
|
|
255
|
-
|
|
266
|
+
# Required first command rule for each independent agent shell
|
|
267
|
+
export SURF_SESSION="$(basename "$PWD" | sed 's/[^A-Za-z0-9._-]/-/g')"
|
|
268
|
+
surf session.ensure "$SURF_SESSION" about:blank
|
|
269
|
+
|
|
270
|
+
# Explicit form when an environment variable is inconvenient
|
|
271
|
+
surf --session research go "https://example.com"
|
|
272
|
+
surf --session research read
|
|
256
273
|
|
|
257
|
-
#
|
|
258
|
-
surf
|
|
259
|
-
surf
|
|
260
|
-
surf tab.switch agent-a
|
|
274
|
+
# Inspect bindings and scheduler state
|
|
275
|
+
surf session.list --refresh
|
|
276
|
+
surf session.info research --refresh
|
|
261
277
|
```
|
|
262
278
|
|
|
263
|
-
|
|
279
|
+
Each session owns one explicit tab and defaults to a separate unfocused window. Commands for the same tab are FIFO; different session tabs may run concurrently. Browser-wide writers wait for tab lanes to drain. `--no-wait` returns `tab_busy` or `browser_busy` immediately. On `tab_gone` or `session_epoch_stale`, run the exact command printed after `Recovery:`—normally `surf session.reopen <name>`.
|
|
280
|
+
|
|
281
|
+
Browser-login provider commands (`chatgpt`, `gemini`, `perplexity`, `grok`, `kimi`, `aistudio`, and `oracle ask`) take exclusive browser access and print a warning before dispatch. Do not assume Surf is hung while that warning is visible; inspect `surf session.info <name>` from another shell to see the active writer.
|
|
282
|
+
|
|
283
|
+
Sessions share cookies, authentication, same-origin storage, downloads, history, bookmarks, and other Chrome-profile state. Use separate browser/profile instances and `SURF_SOCKET` values only when hard isolation is required. Explicit `--tab-id`, `--window-id`, and named tabs remain available for one-off targeting.
|
|
264
284
|
|
|
265
285
|
## Input Methods
|
|
266
286
|
|
|
@@ -439,14 +459,16 @@ surf upload --ref e5 --files "/path/file1.txt,/path/file2.txt"
|
|
|
439
459
|
|
|
440
460
|
```bash
|
|
441
461
|
surf frame.list # List frames with IDs
|
|
442
|
-
surf frame.switch "
|
|
462
|
+
surf frame.switch --selector "#payment-iframe"
|
|
463
|
+
surf frame.switch --name "checkout"
|
|
464
|
+
surf frame.switch --index 0 # First iframe
|
|
443
465
|
surf frame.main # Return to main frame
|
|
444
|
-
surf frame.js
|
|
466
|
+
surf frame.js "return document.title" --id "FRAME_ID"
|
|
445
467
|
|
|
446
468
|
# After frame.switch, subsequent commands target that frame:
|
|
447
|
-
surf frame.switch "iframe
|
|
469
|
+
surf frame.switch --selector "#payment-iframe"
|
|
448
470
|
surf page.read # Reads iframe content
|
|
449
|
-
surf click
|
|
471
|
+
surf click --selector "#pay" # Clicks in iframe
|
|
450
472
|
surf frame.main # Back to main page
|
|
451
473
|
```
|
|
452
474
|
|
|
@@ -550,6 +572,9 @@ surf do 'go "https://example.com" | click e5 | screenshot'
|
|
|
550
572
|
# Multi-step login flow
|
|
551
573
|
surf do 'go "https://example.com/login" | type "user@example.com" --selector "#email" | type "pass" --selector "#password" | click --selector "button[type=submit]"'
|
|
552
574
|
|
|
575
|
+
# JSON action batch. Uses SURF_SESSION when it is set.
|
|
576
|
+
surf batch --actions '[{"type":"frame.switch","index":0},{"type":"click","selector":"#pay"}]'
|
|
577
|
+
|
|
553
578
|
# Validate without executing
|
|
554
579
|
surf do 'go "url" | click e5' --dry-run
|
|
555
580
|
```
|
|
@@ -683,9 +708,11 @@ surf wait.element ".missing" --auto-capture --timeout 2000
|
|
|
683
708
|
## Common Options
|
|
684
709
|
|
|
685
710
|
```bash
|
|
686
|
-
--
|
|
687
|
-
--
|
|
688
|
-
--
|
|
711
|
+
--session <name> # Target a durable named session (or set SURF_SESSION)
|
|
712
|
+
--tab-id <id> # Target a specific tab
|
|
713
|
+
--window-id <id> # Target a specific window
|
|
714
|
+
--no-wait # Return tab_busy/browser_busy instead of queueing
|
|
715
|
+
--json # Raw JSON including target metadata
|
|
689
716
|
--auto-capture # Screenshot + console on error
|
|
690
717
|
--timeout <ms> # Override default timeout
|
|
691
718
|
```
|
|
@@ -703,12 +730,12 @@ surf wait.element ".missing" --auto-capture --timeout 2000
|
|
|
703
730
|
9. **AI Studio for unrestricted Gemini** - `surf aistudio` gives less filtered responses than `surf gemini` for the same models
|
|
704
731
|
10. **Use `surf do` for multi-step tasks** - Reduces token overhead and improves reliability
|
|
705
732
|
11. **Dry-run workflows first** - `surf do '...' --dry-run` validates without executing
|
|
706
|
-
12. **
|
|
707
|
-
13. **
|
|
733
|
+
12. **Session first** - Set a unique `SURF_SESSION` and run `session.ensure` before the first browser command in every independent agent shell
|
|
734
|
+
13. **Queue diagnostics** - `session.info` distinguishes the session's own tab queue, other active tabs, and browser-wide writers; use `--no-wait` for immediate busy errors
|
|
708
735
|
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
|
|
709
736
|
15. **HTML export** - Use `surf page.html > artifact.html` to save Claude artifacts or any rendered page as static HTML
|
|
710
737
|
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
|
|
711
|
-
17. **Hard isolation** -
|
|
738
|
+
17. **Hard isolation** - Sessions share a Chrome profile; use separate browser/profile instances plus separate `SURF_SOCKET` values when profile state must not be shared
|
|
712
739
|
18. **Semantic locators** - `locate.role`, `locate.text`, `locate.label` for more robust element finding
|
|
713
740
|
19. **Frame context** - Use `frame.switch` before interacting with iframe content
|
|
714
741
|
|