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/native/cli.cjs CHANGED
@@ -24,7 +24,7 @@ const {
24
24
  formatOracleOutput,
25
25
  handleOracleCli,
26
26
  } = require("./oracle-cli.cjs");
27
- const { formatPlaybookOutput, handlePlaybookCli, playbookCommandNeedsBrowser } = require("./playbook-cli.cjs");
27
+ const { formatPlaybookOutput, handlePlaybookCli } = require("./playbook-cli.cjs");
28
28
 
29
29
  const IS_WIN = process.platform === "win32";
30
30
  const { SURF_TMP, formatSocketError } = require("./socket-path.cjs");
@@ -32,6 +32,7 @@ const { acquireBrowserLock } = require("./browser-lock.cjs");
32
32
  const { selectEndpoint, connectEndpoint, formatEndpointError } = require("./endpoint.cjs");
33
33
  const { createFrameParser, createSocketWriter, writeFrame } = require("./remote-transport.cjs");
34
34
  const { resolveRequestDeadlineMs } = require("./host-sessions.cjs");
35
+ const { classifyTool } = require("./tool-scope.cjs");
35
36
  const { AUTO_SCREENSHOT_TOOLS, prepareRemoteTool, validateLocalToolPaths } = require("./file-transfer.cjs");
36
37
  const { authorizeClient, listClients, revokeClient, getStateDir } = require("./remote-auth.cjs");
37
38
  if (IS_WIN) { try { fs.mkdirSync(SURF_TMP, { recursive: true }); } catch {} }
@@ -49,6 +50,46 @@ function parseBrowserLockOptions(noLockFlag) {
49
50
  return { noLock, timeoutMs };
50
51
  }
51
52
 
53
+ function flagValue(argv, flag) {
54
+ const index = argv.indexOf(flag);
55
+ if (index === -1) return undefined;
56
+ const value = argv[index + 1];
57
+ if (!value || value.startsWith("--")) {
58
+ console.error(`Error: ${flag} requires a value`);
59
+ process.exit(1);
60
+ }
61
+ return value;
62
+ }
63
+
64
+ function positiveIdFlag(argv, flag) {
65
+ const value = flagValue(argv, flag);
66
+ if (value === undefined) return undefined;
67
+ const parsed = Number(value);
68
+ if (!Number.isInteger(parsed) || parsed <= 0) {
69
+ console.error(`Error: ${flag} must be a positive number`);
70
+ process.exit(1);
71
+ }
72
+ return parsed;
73
+ }
74
+
75
+ function resolveEarlyTargetOptions(argv, { allowWindow = true } = {}) {
76
+ const explicitSession = flagValue(argv, "--session");
77
+ const tabId = positiveIdFlag(argv, "--tab-id");
78
+ const windowId = allowWindow ? positiveIdFlag(argv, "--window-id") : undefined;
79
+ if (explicitSession && (tabId || windowId)) {
80
+ console.error("Error: use either --session or --tab-id/--window-id, not both");
81
+ process.exit(1);
82
+ }
83
+ const environmentSession = process.env.SURF_SESSION;
84
+ const session = explicitSession || (!tabId && !windowId ? environmentSession : undefined);
85
+ return {
86
+ ...(session ? { session, sessionSource: explicitSession ? "explicit" : "environment" } : {}),
87
+ ...(tabId ? { tabId } : {}),
88
+ ...(windowId ? { windowId } : {}),
89
+ ...(argv.includes("--no-wait") ? { admission: { wait: false } } : {}),
90
+ };
91
+ }
92
+
52
93
  function installBrowserLock({ noLock, timeoutMs }, endpoint) {
53
94
  let releaseBrowserLock = () => {};
54
95
  if (!noLock) {
@@ -78,28 +119,6 @@ function installBrowserLock({ noLock, timeoutMs }, endpoint) {
78
119
  });
79
120
  }
80
121
 
81
- async function runWithBrowserLock(lockOptions, endpoint, operation) {
82
- let releaseBrowserLock = () => {};
83
- if (!lockOptions.noLock) {
84
- const lock = acquireBrowserLock(endpoint.key, SURF_TMP, {
85
- timeoutMs: lockOptions.timeoutMs,
86
- });
87
- releaseBrowserLock = lock.release;
88
- }
89
- const release = () => {
90
- const releaseCurrent = releaseBrowserLock;
91
- releaseBrowserLock = () => {};
92
- releaseCurrent();
93
- };
94
- process.once("exit", release);
95
- try {
96
- return await operation();
97
- } finally {
98
- process.removeListener("exit", release);
99
- release();
100
- }
101
- }
102
-
103
122
  // Cross-platform image resize (macOS: sips, Linux: ImageMagick)
104
123
  function resizeImage(filePath, maxSize) {
105
124
  const platform = process.platform;
@@ -181,14 +200,13 @@ try {
181
200
  }
182
201
 
183
202
  if (args[0] === "oracle") {
203
+ if (args[1] === "ask" || args[1] === "follow") {
204
+ console.error("[surf] Oracle requires exclusive browser access while dispatching; other sessions will queue.");
205
+ }
184
206
  handleOracleCli(args, {
185
207
  endpoint,
186
208
  cwd: process.cwd(),
187
- withBrowserLock: (operation) => runWithBrowserLock(
188
- parseBrowserLockOptions(args.includes("--no-lock")),
189
- endpoint,
190
- operation,
191
- ),
209
+ withBrowserLock: (operation) => operation(),
192
210
  })
193
211
  .then((result) => {
194
212
  if (!result.handled) throw new Error("Oracle command was not handled");
@@ -203,10 +221,8 @@ if (args[0] === "oracle") {
203
221
  }
204
222
 
205
223
  if (["playbook", "pb", "use"].includes(args[0])) {
206
- if (playbookCommandNeedsBrowser(args)) {
207
- installBrowserLock(parseBrowserLockOptions(args.includes("--no-lock")), endpoint);
208
- }
209
- handlePlaybookCli(args, { endpoint, cwd: process.cwd() })
224
+ const targetOptions = resolveEarlyTargetOptions(args, { allowWindow: false });
225
+ handlePlaybookCli(args, { endpoint, cwd: process.cwd(), ...targetOptions })
210
226
  .then((result) => {
211
227
  if (!result.handled) throw new Error("Playbook command was not handled");
212
228
  if (result.value !== undefined) console.log(formatPlaybookOutput(result.value, result.json));
@@ -262,6 +278,66 @@ const REMOVED_COMMANDS = {
262
278
  };
263
279
 
264
280
  const TOOLS = {
281
+ session: {
282
+ desc: "Durable tab-bound browser sessions",
283
+ commands: {
284
+ "session.new": {
285
+ desc: "Create a named session in a separate unfocused window by default",
286
+ args: ["name", "url"],
287
+ opts: {
288
+ window: "Create a separate window (default)",
289
+ tab: "Create an inactive tab instead of a window",
290
+ focused: "Focus the new target",
291
+ "window-id": "Window for --tab mode",
292
+ },
293
+ examples: [
294
+ { cmd: 'session.new research "https://example.com"', desc: "Create a window session" },
295
+ { cmd: 'session.new research about:blank --tab', desc: "Create an inactive tab session" },
296
+ ],
297
+ },
298
+ "session.ensure": {
299
+ desc: "Idempotently create, reuse, or reopen a named session",
300
+ args: ["name", "url"],
301
+ opts: {
302
+ window: "Use a separate window (default)",
303
+ tab: "Use an inactive tab",
304
+ focused: "Focus a newly created target",
305
+ "window-id": "Window for --tab mode",
306
+ },
307
+ examples: [
308
+ { cmd: 'session.ensure research about:blank', desc: "Safe first command for an agent" },
309
+ ],
310
+ },
311
+ "session.list": {
312
+ desc: "List sessions, status, and queue state",
313
+ args: [],
314
+ opts: { refresh: "Validate every binding against Chrome" },
315
+ },
316
+ "session.info": {
317
+ desc: "Show one session, target, and scheduler queue state",
318
+ args: ["name"],
319
+ opts: { refresh: "Validate the binding against Chrome" },
320
+ },
321
+ "session.close": {
322
+ desc: "Remove a session and close Surf-created targets by default",
323
+ args: ["name"],
324
+ opts: {
325
+ "keep-target": "Unbind without closing the target",
326
+ "close-target": "Close an adopted target too",
327
+ },
328
+ },
329
+ "session.rebind": {
330
+ desc: "Bind a stale or gone session to an explicit existing tab",
331
+ args: ["name"],
332
+ opts: { "tab-id": "Existing tab ID", replace: "Replace a live binding" },
333
+ },
334
+ "session.reopen": {
335
+ desc: "Create a replacement target using the stored or supplied URL",
336
+ args: ["name", "url"],
337
+ opts: { replace: "Replace a live target", tab: "Reopen as an inactive tab", window: "Reopen as a window" },
338
+ },
339
+ },
340
+ },
265
341
  ai: {
266
342
  desc: "AI assistants (ChatGPT, Gemini)",
267
343
  commands: {
@@ -1485,6 +1561,7 @@ Exclude text content:
1485
1561
  };
1486
1562
 
1487
1563
  const ALL_SOCKET_TOOLS = [
1564
+ "session.new", "session.ensure", "session.list", "session.info", "session.close", "session.rebind", "session.reopen",
1488
1565
  "ai", "screenshot", "record", "animate-audit", "perf-audit", "navigate",
1489
1566
  "form_input", "find_and_type", "autocomplete", "set_value", "smart_type",
1490
1567
  "scroll_to_position", "get_scroll_info", "close_dialogs", "page_state",
@@ -1553,6 +1630,8 @@ const SEE_ALSO = {
1553
1630
  "scroll.to": ["click", "page.read"],
1554
1631
  "console": ["network", "perf.metrics"],
1555
1632
  "network": ["console", "network.get"],
1633
+ "session.ensure": ["session.info", "session.list"],
1634
+ "session.info": ["session.reopen", "session.rebind", "session.list"],
1556
1635
  };
1557
1636
 
1558
1637
  const showBasicHelp = () => {
@@ -1561,6 +1640,7 @@ const showBasicHelp = () => {
1561
1640
  Usage: surf <command> [args] [options]
1562
1641
 
1563
1642
  Common Commands:
1643
+ session.ensure <name> [url] Idempotently create or reuse a tab-bound session
1564
1644
  navigate <url> Go to URL (alias: go)
1565
1645
  click <ref> Click element by ref or selector
1566
1646
  type <text> Type text at cursor or into element
@@ -1577,6 +1657,8 @@ Common Commands:
1577
1657
  wait <seconds> Wait N seconds
1578
1658
 
1579
1659
  Quick Examples:
1660
+ export SURF_SESSION="$(basename "$PWD" | sed 's/[^A-Za-z0-9._-]/-/g')"
1661
+ surf session.ensure "$SURF_SESSION" about:blank
1580
1662
  surf go "https://example.com"
1581
1663
  surf read
1582
1664
  surf click e5
@@ -1587,6 +1669,8 @@ Quick Examples:
1587
1669
  surf window.new "https://example.com" && surf --window-id 123 go "https://other.com"
1588
1670
 
1589
1671
  More Help:
1672
+ --session <name> Target a durable named browser session
1673
+ --no-wait Return tab_busy/browser_busy instead of queueing
1590
1674
  --remote <host>:<port> Route requests to a remote native host
1591
1675
  --remote-credential <path> Use a mode-0600 Ed25519 remote credential file
1592
1676
  surf remote authorize <label> --output <path>
@@ -1621,8 +1705,10 @@ Scroll: surf scroll down 800 | surf scroll up 400 | surf scroll bottom | surf sc
1621
1705
  Find by semantics: surf locate.role button --name "Submit" --action click
1622
1706
  Device/viewport: surf emulate.device "iPhone 14" | surf resize 375 812
1623
1707
  Cookies: surf cookie list | surf cookie get "name" | surf cookie delete "name"
1624
- Window isolation: surf window.new "https://example.com" then pass --window-id <id>
1625
- Concurrency: surf serializes commands per socket; use --no-lock only for intentional bypass
1708
+ Session targeting: surf --session research read | SURF_SESSION=research surf read
1709
+ Session status/queue: surf session.info research | surf session.list --refresh
1710
+ Recovery: run the exact command printed after Recovery: on tab_gone, session_epoch_stale, tab_busy, or browser_busy
1711
+ Concurrency: commands for different session tabs can overlap; each tab remains FIFO; provider flows are browser-exclusive
1626
1712
  Doctor: surf doctor --browser all # native host/socket diagnostics
1627
1713
  Workflow: surf do 'go "https://example.com" | wait 2 | read | click e5 | screenshot'
1628
1714
  More help: surf --help-full | surf <command> --help | surf --help-topic refs | surf --find <query>`);
@@ -1656,12 +1742,14 @@ Playbooks:
1656
1742
  Options:
1657
1743
  --remote <host>:<port> Route requests to a remote native host
1658
1744
  --remote-credential <path> Use a mode-0600 Ed25519 remote credential file
1745
+ --session <name> Target a durable named session (or set SURF_SESSION)
1659
1746
  --tab-id <id> Target specific tab
1660
- --window-id <id> Target specific window (isolate from your browsing)
1661
- --json Output raw JSON
1747
+ --window-id <id> Target specific window
1748
+ --no-wait Return immediately when the tab/browser is busy
1749
+ --json Output raw JSON including target metadata
1662
1750
  --auto-capture On error: capture screenshot + console to /tmp
1663
1751
  --soft-fail On error: warn and exit 0 (for non-critical commands)
1664
- --no-lock Bypass the per-socket browser request lock
1752
+ --no-lock Bypass the legacy lock for compound client-side commands
1665
1753
 
1666
1754
  Remote Credentials (run on the browser host):
1667
1755
  surf remote authorize <label> --output <credential-file>
@@ -2042,8 +2130,7 @@ if (args.includes("--script")) {
2042
2130
  const dryRun = args.includes("--dry-run");
2043
2131
  const stopOnError = args.includes("--stop-on-error");
2044
2132
 
2045
- const tabIdIdx = args.indexOf("--tab-id");
2046
- const scriptTabId = tabIdIdx !== -1 ? args[tabIdIdx + 1] : undefined;
2133
+ const scriptTarget = resolveEarlyTargetOptions(args);
2047
2134
 
2048
2135
  if (!scriptPath || scriptPath.startsWith("--")) {
2049
2136
  console.error("Error: --script requires a file path");
@@ -2077,7 +2164,13 @@ if (args.includes("--script")) {
2077
2164
  params: { tool: toolName, args: toolArgs },
2078
2165
  id: "cli-" + Date.now() + "-" + Math.random(),
2079
2166
  };
2080
- if (scriptTabId) req.tabId = parseInt(scriptTabId, 10);
2167
+ if (scriptTarget.tabId) req.tabId = scriptTarget.tabId;
2168
+ if (scriptTarget.windowId) req.windowId = scriptTarget.windowId;
2169
+ if (scriptTarget.session) {
2170
+ req.session = scriptTarget.session;
2171
+ req.sessionSource = scriptTarget.sessionSource;
2172
+ }
2173
+ if (scriptTarget.admission) req.admission = scriptTarget.admission;
2081
2174
  const prepared = endpoint.kind === "remote" ? prepareRemoteTool(toolName, toolArgs) : (() => { const args = validateLocalToolPaths(toolName, toolArgs); return { args, uploads: [], downloads: [] }; })();
2082
2175
  req.params.args = prepared.args;
2083
2176
  return scriptTransport.request(req, resolveRequestDeadlineMs(toolName, prepared.args), prepared);
@@ -2150,10 +2243,6 @@ if (args.includes("--script")) {
2150
2243
  }
2151
2244
  };
2152
2245
 
2153
- if (!dryRun) {
2154
- installBrowserLock(parseBrowserLockOptions(args.includes("--no-lock")), endpoint);
2155
- }
2156
-
2157
2246
  runScript()
2158
2247
  .then((code) => process.exit(code))
2159
2248
  .catch((error) => {
@@ -2176,9 +2265,11 @@ if (args[0] === "do") {
2176
2265
  let wantJson = false;
2177
2266
  let tabId = undefined;
2178
2267
  let windowId = undefined;
2268
+ let explicitSession = undefined;
2269
+ let noWait = false;
2179
2270
 
2180
2271
  // Reserved flags that aren't workflow args
2181
- const reservedFlags = ['file', 'f', 'dry-run', 'on-error', 'no-auto-wait', 'step-delay', 'json', 'tab-id', 'window-id', 'no-lock'];
2272
+ const reservedFlags = ['file', 'f', 'dry-run', 'on-error', 'no-auto-wait', 'step-delay', 'json', 'tab-id', 'window-id', 'session', 'no-lock', 'no-wait'];
2182
2273
 
2183
2274
  // Workflow-specific args (collected for variable substitution)
2184
2275
  const workflowArgs = {};
@@ -2208,6 +2299,16 @@ if (args[0] === "do") {
2208
2299
  } else if (arg === "--window-id") {
2209
2300
  windowId = parseInt(doArgs[i + 1], 10);
2210
2301
  i++;
2302
+ } else if (arg === "--session") {
2303
+ const value = doArgs[i + 1];
2304
+ if (!value || value.startsWith("--")) {
2305
+ console.error("Error: --session requires a value");
2306
+ process.exit(1);
2307
+ }
2308
+ explicitSession = value;
2309
+ i++;
2310
+ } else if (arg === "--no-wait") {
2311
+ noWait = true;
2211
2312
  } else if (arg.startsWith("--")) {
2212
2313
  // Workflow-specific arg (e.g., --email, --password)
2213
2314
  const key = arg.slice(2);
@@ -2231,6 +2332,22 @@ if (args[0] === "do") {
2231
2332
  }
2232
2333
  }
2233
2334
 
2335
+ if (tabId !== undefined && (!Number.isInteger(tabId) || tabId <= 0)) {
2336
+ console.error("Error: --tab-id must be a positive number");
2337
+ process.exit(1);
2338
+ }
2339
+ if (windowId !== undefined && (!Number.isInteger(windowId) || windowId <= 0)) {
2340
+ console.error("Error: --window-id must be a positive number");
2341
+ process.exit(1);
2342
+ }
2343
+ if (explicitSession && (tabId || windowId)) {
2344
+ console.error("Error: use either --session or --tab-id/--window-id, not both");
2345
+ process.exit(1);
2346
+ }
2347
+ const environmentSession = process.env.SURF_SESSION;
2348
+ const session = explicitSession || (!tabId && !windowId ? environmentSession : undefined);
2349
+ const sessionSource = explicitSession ? "explicit" : session ? "environment" : undefined;
2350
+
2234
2351
  if (!commandsInput && !fileInput) {
2235
2352
  console.error("Error: commands string, workflow name, or --file required");
2236
2353
  console.error('Usage: surf do \'go "url" | click e5\'');
@@ -2336,8 +2453,6 @@ if (args[0] === "do") {
2336
2453
  process.exit(0);
2337
2454
  }
2338
2455
 
2339
- installBrowserLock(parseBrowserLockOptions(doArgs.includes("--no-lock")), endpoint);
2340
-
2341
2456
  if (!wantJson) {
2342
2457
  if (workflowName) {
2343
2458
  console.log(`Running workflow: ${workflowName} (${steps.length} steps)...\n`);
@@ -2359,6 +2474,9 @@ if (args[0] === "do") {
2359
2474
  context: {
2360
2475
  tabId,
2361
2476
  windowId,
2477
+ session,
2478
+ sessionSource,
2479
+ admission: noWait ? { wait: false } : undefined,
2362
2480
  endpoint,
2363
2481
  transport,
2364
2482
  },
@@ -2517,7 +2635,7 @@ if (args[0] === "workflow.validate") {
2517
2635
  }
2518
2636
  }
2519
2637
 
2520
- const BOOLEAN_FLAGS = ["auto-capture", "json", "stream", "dry-run", "stop-on-error", "fail-fast", "clear", "submit", "all", "case-sensitive", "hard", "annotate", "fullpage", "full-page", "reset", "no-screenshot", "full", "soft-fail", "has-body", "exclude-static", "v", "vv", "request", "by-tab", "har", "jsonl", "no-save", "no-auto-wait", "no-lock"];
2638
+ const BOOLEAN_FLAGS = ["auto-capture", "json", "stream", "dry-run", "stop-on-error", "fail-fast", "clear", "submit", "all", "case-sensitive", "hard", "annotate", "fullpage", "full-page", "reset", "no-screenshot", "full", "soft-fail", "has-body", "exclude-static", "v", "vv", "request", "by-tab", "har", "jsonl", "no-save", "no-auto-wait", "no-lock", "no-wait", "window", "tab", "focused", "unfocused", "keep-target", "close-target", "replace", "refresh"];
2521
2639
 
2522
2640
  const parseArgs = (rawArgs) => {
2523
2641
  const result = { positional: [], options: {} };
@@ -2563,6 +2681,24 @@ let { positional, options } = parseArgs(args);
2563
2681
  let tool = positional[0];
2564
2682
  let firstArg = positional[1];
2565
2683
 
2684
+ if (tool === "session" && firstArg) {
2685
+ const sessionSubcommands = {
2686
+ new: "session.new",
2687
+ ensure: "session.ensure",
2688
+ list: "session.list",
2689
+ info: "session.info",
2690
+ close: "session.close",
2691
+ rebind: "session.rebind",
2692
+ reopen: "session.reopen",
2693
+ };
2694
+ const sessionTool = sessionSubcommands[firstArg];
2695
+ if (sessionTool) {
2696
+ tool = sessionTool;
2697
+ positional = [tool, ...positional.slice(2)];
2698
+ firstArg = positional[1];
2699
+ }
2700
+ }
2701
+
2566
2702
  if (tool === "cookie" && firstArg) {
2567
2703
  const cookieSubcommands = {
2568
2704
  list: "cookie.list",
@@ -2678,6 +2814,12 @@ const PRIMARY_ARG_MAP = {
2678
2814
  "window.new": "url",
2679
2815
  "window.focus": "id",
2680
2816
  "window.close": "id",
2817
+ "session.new": "name",
2818
+ "session.ensure": "name",
2819
+ "session.info": "name",
2820
+ "session.close": "name",
2821
+ "session.rebind": "name",
2822
+ "session.reopen": "name",
2681
2823
  "locate.role": "role",
2682
2824
  "locate.text": "text",
2683
2825
  "locate.label": "label",
@@ -2748,6 +2890,10 @@ if (firstArg !== undefined) {
2748
2890
  }
2749
2891
  }
2750
2892
 
2893
+ if (["session.new", "session.ensure", "session.reopen"].includes(tool) && positional[2] !== undefined && toolArgs.url === undefined) {
2894
+ toolArgs.url = positional[2];
2895
+ }
2896
+
2751
2897
  if ((tool === "js" || tool === "frame.js") && toolArgs.file) {
2752
2898
  try {
2753
2899
  toolArgs.code = fs.readFileSync(toolArgs.file, "utf8");
@@ -2767,6 +2913,13 @@ if (tool === "batch" && toolArgs.file) {
2767
2913
  console.error(`Error: Failed to read batch file: ${e.message}`);
2768
2914
  process.exit(1);
2769
2915
  }
2916
+ } else if (tool === "batch" && typeof toolArgs.actions === "string") {
2917
+ try {
2918
+ toolArgs.actions = JSON.parse(toolArgs.actions);
2919
+ } catch (e) {
2920
+ console.error(`Error: Failed to parse batch actions: ${e.message}`);
2921
+ process.exit(1);
2922
+ }
2770
2923
  }
2771
2924
 
2772
2925
  // Handle select command: capture multiple values after selector
@@ -2786,22 +2939,30 @@ if (toolArgs.into && !toolArgs.selector) {
2786
2939
  }
2787
2940
 
2788
2941
  const globalOpts = {};
2942
+ const explicitSession = toolArgs.session;
2943
+ delete toolArgs.session;
2944
+ const environmentSession = process.env.SURF_SESSION;
2945
+ const noWait = toolArgs["no-wait"] === true;
2946
+ delete toolArgs["no-wait"];
2947
+
2789
2948
  if (toolArgs["tab-id"] !== undefined) {
2790
2949
  const tid = parseInt(toolArgs["tab-id"], 10);
2791
- if (isNaN(tid)) {
2792
- console.error("Error: --tab-id must be a number");
2950
+ if (!Number.isInteger(tid) || tid <= 0) {
2951
+ console.error("Error: --tab-id must be a positive number");
2793
2952
  process.exit(1);
2794
2953
  }
2795
- globalOpts.tabId = tid;
2954
+ if (tool === "session.rebind") toolArgs.tabId = tid;
2955
+ else globalOpts.tabId = tid;
2796
2956
  delete toolArgs["tab-id"];
2797
2957
  }
2798
2958
  if (toolArgs["window-id"] !== undefined) {
2799
2959
  const wid = parseInt(toolArgs["window-id"], 10);
2800
- if (isNaN(wid)) {
2801
- console.error("Error: --window-id must be a number");
2960
+ if (!Number.isInteger(wid) || wid <= 0) {
2961
+ console.error("Error: --window-id must be a positive number");
2802
2962
  process.exit(1);
2803
2963
  }
2804
- globalOpts.windowId = wid;
2964
+ if (["session.new", "session.ensure", "session.reopen"].includes(tool)) toolArgs.windowId = wid;
2965
+ else globalOpts.windowId = wid;
2805
2966
  delete toolArgs["window-id"];
2806
2967
  }
2807
2968
  if (toolArgs["network-path"] !== undefined && typeof toolArgs["network-path"] !== "string") {
@@ -2912,6 +3073,39 @@ if (methodFlag === "js") {
2912
3073
  }
2913
3074
  }
2914
3075
 
3076
+ const finalClassification = classifyTool(finalTool, toolArgs);
3077
+ if (explicitSession !== undefined) {
3078
+ if (typeof explicitSession !== "string" || !explicitSession) {
3079
+ console.error("Error: --session requires a session name");
3080
+ process.exit(1);
3081
+ }
3082
+ if (finalClassification.targetUse !== "default-tab" || finalTool.startsWith("session.")) {
3083
+ console.error(`Error: --session does not apply to ${finalTool}`);
3084
+ process.exit(1);
3085
+ }
3086
+ if (globalOpts.tabId || globalOpts.windowId) {
3087
+ console.error("Error: use either --session or --tab-id/--window-id, not both");
3088
+ process.exit(1);
3089
+ }
3090
+ globalOpts.session = explicitSession;
3091
+ globalOpts.sessionSource = "explicit";
3092
+ } else if (
3093
+ environmentSession &&
3094
+ finalClassification.targetUse === "default-tab" &&
3095
+ !globalOpts.tabId &&
3096
+ !globalOpts.windowId &&
3097
+ !finalTool.startsWith("session.")
3098
+ ) {
3099
+ globalOpts.session = environmentSession;
3100
+ globalOpts.sessionSource = "environment";
3101
+ }
3102
+ if (noWait) globalOpts.admission = { wait: false };
3103
+
3104
+ if (finalClassification.scope === "provider") {
3105
+ const suffix = globalOpts.session ? ` Session ${globalOpts.session} remains selected for page context.` : "";
3106
+ console.error(`[surf] ${finalTool} requires exclusive browser access; other sessions will queue until it finishes.${suffix}`);
3107
+ }
3108
+
2915
3109
  if (streamMode && (tool === "console" || tool === "network")) {
2916
3110
  const streamType = tool === "console" ? "STREAM_CONSOLE" : "STREAM_NETWORK";
2917
3111
  const streamOpts = {
@@ -2967,7 +3161,8 @@ if (streamMode && (tool === "console" || tool === "network")) {
2967
3161
  }
2968
3162
  }
2969
3163
  if (msg.error) {
2970
- console.error("Error:", msg.error);
3164
+ const text = msg.error?.content?.[0]?.text || msg.error?.message || String(msg.error);
3165
+ console.error("Error:", text);
2971
3166
  sock.end();
2972
3167
  process.exit(1);
2973
3168
  }
@@ -2976,7 +3171,11 @@ if (streamMode && (tool === "console" || tool === "network")) {
2976
3171
  sock.end();
2977
3172
  process.exit(1);
2978
3173
  }
2979
- if (msg.type === "stream_started") return;
3174
+ if (msg.type === "stream_started") {
3175
+ const context = formatTargetContext(msg.target);
3176
+ if (context) console.error(context);
3177
+ return;
3178
+ }
2980
3179
  if (msg.type === "console_event") {
2981
3180
  const { level, text, timestamp } = msg;
2982
3181
  if (streamLevel && level !== streamLevel) return;
@@ -3227,7 +3426,6 @@ if (finalTool === "record") {
3227
3426
  return;
3228
3427
  }
3229
3428
 
3230
- installBrowserLock(lockOptions, endpoint);
3231
3429
  let socket;
3232
3430
  let timeout;
3233
3431
 
@@ -3254,7 +3452,7 @@ socket = connectEndpoint(endpoint, () => {
3254
3452
  writeFrame(socket, request).catch((error) => socket.destroy(error));
3255
3453
  });
3256
3454
 
3257
- const requestTimeout = resolveRequestDeadlineMs(tool, options);
3455
+ const requestTimeout = resolveRequestDeadlineMs(finalTool, toolArgs);
3258
3456
  timeout = setTimeout(() => {
3259
3457
  console.error(`Error: Request timed out (${requestTimeout / 1000}s)`);
3260
3458
  socket.destroy();
@@ -3295,8 +3493,45 @@ socket.on("close", () => {
3295
3493
  clearTimeout(timeout);
3296
3494
  });
3297
3495
 
3496
+ function formatTargetContext(target) {
3497
+ if (!target) return null;
3498
+ const fields = [];
3499
+ if (target.session) fields.push(`session=${target.session}`);
3500
+ if (target.tabId !== undefined) fields.push(`tab=${target.tabId}`);
3501
+ if (target.windowId !== undefined) fields.push(`window=${target.windowId}`);
3502
+ if (target.queuedMs > 0) fields.push(`queued=${target.queuedMs}ms`);
3503
+ return fields.length > 0 ? `[surf ${fields.join(" ")}]` : null;
3504
+ }
3505
+
3506
+ function printResponseContext(response) {
3507
+ const context = formatTargetContext(response.target);
3508
+ if (context) console.error(context);
3509
+ if (response.notice && finalClassification.scope !== "provider") {
3510
+ console.error(`[surf] ${response.notice}`);
3511
+ }
3512
+ }
3513
+
3514
+ function queueSummary(queue) {
3515
+ if (!queue) return "unknown";
3516
+ const pieces = [
3517
+ `own-tab=${queue.active ? "active" : "idle"}`,
3518
+ `own-queued=${queue.queued || 0}`,
3519
+ ];
3520
+ if (queue.blockedBy) pieces.push(`blocked-by=${queue.blockedBy}`);
3521
+ if (queue.browserWriter) {
3522
+ pieces.push(`writer=${queue.browserWriter.scope}${queue.browserWriter.session ? `:${queue.browserWriter.session}` : ""}`);
3523
+ } else if (queue.queuedBrowserWriters) {
3524
+ pieces.push(`writers-waiting=${queue.queuedBrowserWriters}`);
3525
+ }
3526
+ if (Array.isArray(queue.otherActiveTabLanes) && queue.otherActiveTabLanes.length > 0) {
3527
+ pieces.push(`other-tabs-active=${queue.otherActiveTabLanes.length}`);
3528
+ }
3529
+ return pieces.join(" ");
3530
+ }
3531
+
3298
3532
  async function handleResponse(response) {
3299
3533
  clearTimeout(timeout);
3534
+ printResponseContext(response);
3300
3535
 
3301
3536
  if (response.error) {
3302
3537
  const errContent = response.error.content?.[0]?.text || JSON.stringify(response.error);
@@ -3355,12 +3590,48 @@ async function handleResponse(response) {
3355
3590
  }
3356
3591
 
3357
3592
  if (wantJson) {
3358
- console.log(JSON.stringify(data ?? null, null, 2));
3593
+ const output = response.target || response.notice
3594
+ ? { result: data ?? null, target: response.target || null, notice: response.notice || null }
3595
+ : data ?? null;
3596
+ console.log(JSON.stringify(output, null, 2));
3359
3597
  socket.end();
3360
3598
  process.exit(0);
3361
3599
  }
3362
3600
 
3363
- if (tool === "screenshot" && data?.base64 && (outputPath || toolArgs.savePath)) {
3601
+ if (finalTool === "session.list") {
3602
+ const sessions = Array.isArray(data?.sessions) ? data.sessions : [];
3603
+ if (sessions.length === 0) {
3604
+ console.log("No browser sessions. Create one with: surf session.ensure <name> about:blank");
3605
+ } else {
3606
+ for (const entry of sessions) {
3607
+ console.log([
3608
+ entry.name,
3609
+ entry.status,
3610
+ `tab=${entry.tabId ?? "-"}`,
3611
+ `window=${entry.windowId ?? "-"}`,
3612
+ `mode=${entry.mode || "-"}`,
3613
+ queueSummary(entry.queue),
3614
+ entry.lastUrl || "",
3615
+ ].join("\t"));
3616
+ }
3617
+ }
3618
+ } else if (finalTool === "session.info" && data?.session) {
3619
+ const entry = data.session;
3620
+ console.log(`Session: ${entry.name}`);
3621
+ console.log(`Status: ${entry.status}`);
3622
+ console.log(`Target: tab=${entry.tabId ?? "-"} window=${entry.windowId ?? "-"} mode=${entry.mode || "-"}`);
3623
+ console.log(`Ownership: ${entry.ownership || "unknown"}`);
3624
+ console.log(`URL: ${entry.currentUrl || entry.lastUrl || "(unknown)"}`);
3625
+ console.log(`Queue: ${queueSummary(entry.queue)}`);
3626
+ if (data.sharedProfile) console.log(`Profile: ${data.sharedProfile}`);
3627
+ } else if (["session.new", "session.ensure", "session.rebind", "session.reopen"].includes(finalTool) && data?.session) {
3628
+ const entry = data.session;
3629
+ const action = data.created ? "created" : data.reopened ? "reopened" : data.rebound ? "rebound" : "ready";
3630
+ console.log(`Session ${entry.name} ${action}: tab=${entry.tabId} window=${entry.windowId} mode=${entry.mode} status=${entry.status}`);
3631
+ console.log(`Use: export SURF_SESSION=${entry.name}`);
3632
+ } else if (finalTool === "session.close" && data?.success) {
3633
+ console.log(`Session ${data.name} closed (${data.targetClosed ? "target closed" : "target kept"})`);
3634
+ } else if (tool === "screenshot" && data?.base64 && (outputPath || toolArgs.savePath)) {
3364
3635
  const saveTo = transferPlan.downloads?.[0]?.destination || toolArgs.savePath || outputPath;
3365
3636
  fs.writeFileSync(saveTo, Buffer.from(data.base64, "base64"));
3366
3637
 
@@ -13,6 +13,11 @@ function sendDoRequest(toolName, toolArgs, context = {}) {
13
13
  };
14
14
  if (context.tabId) request.tabId = context.tabId;
15
15
  if (context.windowId) request.windowId = context.windowId;
16
+ if (context.session) {
17
+ request.session = context.session;
18
+ request.sessionSource = context.sessionSource || "environment";
19
+ }
20
+ if (context.admission) request.admission = context.admission;
16
21
  const timeoutMs = context.timeoutMs || resolveRequestDeadlineMs(toolName, toolArgs);
17
22
  const endpoint = context.endpoint || selectEndpoint([]).endpoint;
18
23
  const prepared = endpoint.kind === "remote"