surf-cli 2.14.0 → 2.15.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.
@@ -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, { tabId: parsed.options["tab-id"] });
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.14.0",
3
+ "version": "2.15.0",
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"
@@ -81,7 +82,12 @@
81
82
  ],
82
83
  "skills": [
83
84
  "./skills"
84
- ]
85
+ ],
86
+ "subagents": {
87
+ "agents": [
88
+ "./agents"
89
+ ]
90
+ }
85
91
  },
86
92
  "peerDependencies": {
87
93
  "typebox": "*"
@@ -308,6 +308,16 @@ function emitFailedOracleJob(error: unknown, emitTerminal: EmitOracleJob) {
308
308
  emitTerminal({ id: error.jobId, state: "failed" });
309
309
  }
310
310
 
311
+ function oracleOption(input: Record<string, unknown>, key: "model" | "effort"): string | undefined {
312
+ const options = input.options;
313
+ if (options && typeof options === "object" && !Array.isArray(options)) {
314
+ const value = (options as Record<string, unknown>)[key];
315
+ if (typeof value === "string") return value;
316
+ }
317
+ const direct = input[key];
318
+ return typeof direct === "string" ? direct : undefined;
319
+ }
320
+
311
321
  export function createOracleExternalJobProvider(
312
322
  sessionId: string,
313
323
  jobIds: Set<string>,
@@ -325,10 +335,12 @@ export function createOracleExternalJobProvider(
325
335
  async start(input) {
326
336
  const prompt = typeof input.prompt === "string" ? input.prompt : "";
327
337
  if (!prompt.trim()) throw new Error("prompt required");
338
+ const model = oracleOption(input, "model");
339
+ const effort = oracleOption(input, "effort");
328
340
  const job = await requestOracleJob(request, "oracle.ask", {
329
341
  prompt,
330
- ...(typeof input.model === "string" ? { model: input.model } : {}),
331
- ...(typeof input.effort === "string" ? { effort: input.effort } : {}),
342
+ ...(model !== undefined ? { model } : {}),
343
+ ...(effort !== undefined ? { effort } : {}),
332
344
  });
333
345
  rememberJob(job.id);
334
346
  return { ...job, promptDigest: job.promptDigest ?? digestPrompt(prompt) };
@@ -361,11 +373,13 @@ export function createOracleExternalJobProvider(
361
373
  },
362
374
  async follow(id, message, input = {}) {
363
375
  if (!message.trim()) throw new Error("message required");
376
+ const model = oracleOption(input, "model");
377
+ const effort = oracleOption(input, "effort");
364
378
  const job = await requestOracleJob(request, "oracle.ask", {
365
379
  follow: id,
366
380
  prompt: message,
367
- ...(typeof input.model === "string" ? { model: input.model } : {}),
368
- ...(typeof input.effort === "string" ? { effort: input.effort } : {}),
381
+ ...(model !== undefined ? { model } : {}),
382
+ ...(effort !== undefined ? { effort } : {}),
369
383
  });
370
384
  rememberJob(job.id);
371
385
  return { ...job, promptDigest: job.promptDigest ?? digestPrompt(message) };
@@ -47,6 +47,17 @@ 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`, `reattach`, and `follow` to durable Surf Oracle jobs. It returns the conversation URL, requested and verified model and effort, prompt digest, result text, and failure details. `reattach` only harvests an existing job by ID; it never submits the prompt again.
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`, `reattach`, and `follow` to durable Surf Oracle jobs. It honors `options.model` and `options.effort` for starts and follows, so `model: pro` selects ChatGPT GPT-5.6 Sol Pro web mode through the browser. It returns the conversation URL, requested and verified model and effort, prompt digest, result text, and failure details. `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
- **Multi-agent isolation:**
263
+ **Concurrent agent sessions:**
264
+
251
265
  ```bash
252
- # Create a separate window for one agent and keep using its ID
253
- surf window.new "https://example.com"
254
- surf --window-id 123 tab.list
255
- surf --window-id 123 go "https://other.com"
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
- # Pin work to a specific tab, or name it for easier handoff
258
- surf read --tab-id 456
259
- surf tab.name agent-a --tab-id 456
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
- Use `window.new`, `--window-id`, `--tab-id`, and named tabs to keep parallel agents on separate targets. Surf serializes non-streaming browser CLI requests per socket with a file-based lock, so agents sharing one native host wait instead of interleaving commands. Use `--no-lock` only for intentional bypasses. For hard isolation, run separate browser/profile instances with separate native hosts and `SURF_SOCKET` values; each socket gets its own lock. Surf does not yet have `session.new`, session IDs, or independent per-agent CDP sessions.
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 "FRAME_ID" # Switch to iframe context
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 --id "FRAME_ID" --code "return document.title"
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-1"
469
+ surf frame.switch --selector "#payment-iframe"
448
470
  surf page.read # Reads iframe content
449
- surf click e5 # Clicks in iframe
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
- --tab-id <id> # Target specific tab
687
- --window-id <id> # Target specific window
688
- --json # Raw JSON output
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. **Window isolation** - Use `window.new` + `--window-id` or `--tab-id` to keep agent work separate from your browsing
707
- 13. **Request lock** - Non-streaming browser CLI requests serialize per socket; use `--no-lock` only when you intentionally want to bypass it
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** - Use separate browser/profile instances plus separate `SURF_SOCKET` values when agents must not share a host or target
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