surf-cli 2.17.0 → 2.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/native/cli.cjs CHANGED
@@ -16,7 +16,9 @@ const {
16
16
  validateWorkflowArgs,
17
17
  validateWorkflowFile,
18
18
  } = require("./workflow-definition.cjs");
19
- const { executeDoSteps } = require("./do-executor.cjs");
19
+ const { executeDoSteps, sendDoRequest } = require("./do-executor.cjs");
20
+ const { runExtraction, renderExtractionMarkdown } = require("./extract.cjs");
21
+ const { applyOptionsPrelude, parseScriptOptions } = require("./script-options.cjs");
20
22
  const { openClientTransport } = require("./client-transport.cjs");
21
23
  const { version: VERSION } = require("../package.json");
22
24
  const {
@@ -33,6 +35,7 @@ const { selectEndpoint, connectEndpoint, formatEndpointError } = require("./endp
33
35
  const { createFrameParser, createSocketWriter, writeFrame } = require("./remote-transport.cjs");
34
36
  const { resolveRequestDeadlineMs } = require("./host-sessions.cjs");
35
37
  const { classifyTool } = require("./tool-scope.cjs");
38
+ const { parseVideoFps, validateVideoOutputPath } = require("./video-recorder.cjs");
36
39
  const { AUTO_SCREENSHOT_TOOLS, prepareRemoteTool, validateLocalToolPaths } = require("./file-transfer.cjs");
37
40
  const { authorizeClient, listClients, revokeClient, getStateDir } = require("./remote-auth.cjs");
38
41
  if (IS_WIN) { try { fs.mkdirSync(SURF_TMP, { recursive: true }); } catch {} }
@@ -72,7 +75,7 @@ function positiveIdFlag(argv, flag) {
72
75
  return parsed;
73
76
  }
74
77
 
75
- function resolveEarlyTargetOptions(argv, { allowWindow = true } = {}) {
78
+ function resolveEarlyTargetOptions(argv, { allowWindow = true, allowEnvironmentSession = true } = {}) {
76
79
  const explicitSession = flagValue(argv, "--session");
77
80
  const tabId = positiveIdFlag(argv, "--tab-id");
78
81
  const windowId = allowWindow ? positiveIdFlag(argv, "--window-id") : undefined;
@@ -81,7 +84,7 @@ function resolveEarlyTargetOptions(argv, { allowWindow = true } = {}) {
81
84
  process.exit(1);
82
85
  }
83
86
  const environmentSession = process.env.SURF_SESSION;
84
- const session = explicitSession || (!tabId && !windowId ? environmentSession : undefined);
87
+ const session = explicitSession || (allowEnvironmentSession && !tabId && !windowId ? environmentSession : undefined);
85
88
  return {
86
89
  ...(session ? { session, sessionSource: explicitSession ? "explicit" : "environment" } : {}),
87
90
  ...(tabId ? { tabId } : {}),
@@ -313,6 +316,14 @@ const TOOLS = {
313
316
  args: [],
314
317
  opts: { refresh: "Validate every binding against Chrome" },
315
318
  },
319
+ "session.cleanup": {
320
+ desc: "Remove idle session bindings and close only Surf-created targets",
321
+ args: [],
322
+ opts: {
323
+ "idle-after": "Required threshold such as 30s, 5m, 1h, or 1d",
324
+ "dry-run": "Report matches without removing bindings or closing targets",
325
+ },
326
+ },
316
327
  "session.info": {
317
328
  desc: "Show one session, target, and scheduler queue state",
318
329
  args: ["name"],
@@ -346,7 +357,7 @@ const TOOLS = {
346
357
  args: ["query"],
347
358
  opts: {
348
359
  "with-page": "Include current page context",
349
- model: "Model: instant, thinking, pro, gpt-5.5, gpt-5.6-sol, or a visible model label",
360
+ model: "Model: gpt-6-astra, latest, gpt-5.6-sol, gpt-5.5",
350
361
  file: "Attach file",
351
362
  timeout: "Timeout in seconds (default: 2700 = 45min)"
352
363
  },
@@ -619,6 +630,37 @@ const TOOLS = {
619
630
  "snap": { desc: "Alias for screenshot (auto-saves to /tmp)", args: [], alias: "screenshot" },
620
631
  }
621
632
  },
633
+ video: {
634
+ desc: "Local WebM recording",
635
+ commands: {
636
+ "video.start": {
637
+ desc: "Start local WebM recording",
638
+ args: ["output"],
639
+ opts: {
640
+ fps: "Frames per second (default: 30, max: 60)",
641
+ },
642
+ examples: [{ cmd: "video start ./demo.webm --fps 30", desc: "Start recording" }],
643
+ },
644
+ "video.stop": {
645
+ desc: "Stop local WebM recording",
646
+ args: [],
647
+ examples: [{ cmd: "video stop", desc: "Stop recording" }],
648
+ },
649
+ "video.status": {
650
+ desc: "Show local WebM recording status",
651
+ args: [],
652
+ examples: [{ cmd: "video status", desc: "Show status" }],
653
+ },
654
+ "video.restart": {
655
+ desc: "Restart local WebM recording",
656
+ args: ["output"],
657
+ opts: {
658
+ fps: "Frames per second (default: 30, max: 60)",
659
+ },
660
+ examples: [{ cmd: "video restart ./take2.webm --fps 60", desc: "Restart recording" }],
661
+ },
662
+ },
663
+ },
622
664
  scroll: {
623
665
  desc: "Scrolling",
624
666
  commands: {
@@ -681,6 +723,17 @@ const TOOLS = {
681
723
  examples: [{ cmd: "page.save --output page.html", desc: "Save current document HTML" }],
682
724
  },
683
725
  "page.state": { desc: "Get page state (modals, loading, etc.)", args: [] },
726
+ "page.readiness": {
727
+ desc: "Classify the page once: ready, empty, loading, login, challenge, not-found, error",
728
+ args: [],
729
+ opts: {
730
+ selector: "Visible CSS selector that marks a ready page",
731
+ text: "Page text that marks a ready page",
732
+ "url-prefix": "Expected URL prefix",
733
+ "empty-text": "Text of an explicit no-results render",
734
+ },
735
+ examples: [{ cmd: "page.readiness --json", desc: "State plus evidence as JSON" }]
736
+ },
684
737
  }
685
738
  },
686
739
  locate: {
@@ -784,6 +837,24 @@ const TOOLS = {
784
837
  },
785
838
  "wait.dom": { desc: "Wait for DOM to stabilize", args: [], opts: { stable: "Stability window in ms (default: 100)", timeout: "Max wait time in ms" } },
786
839
  "wait.load": { desc: "Wait for page to fully load", args: [], opts: { timeout: "Max wait time in ms (default: 30000)" } },
840
+ "wait.ready": {
841
+ desc: "Wait until the page is ready, or fail fast with a typed state (challenge, login, not-found, error)",
842
+ args: [],
843
+ opts: {
844
+ selector: "Visible CSS selector that marks a ready page",
845
+ text: "Page text that marks a ready page",
846
+ "url-prefix": "Expected URL prefix; anything else is a bounce",
847
+ "empty-text": "Text of an explicit no-results render (reports state 'empty')",
848
+ accept: "Negative states to return instead of fail (comma list)",
849
+ timeout: "Max wait time in ms (default: 20000, max: 120000)",
850
+ interval: "Poll interval in ms (default: 400)",
851
+ },
852
+ examples: [
853
+ { cmd: 'wait.ready --selector ".results"', desc: "Wait for content; fail fast on a login bounce" },
854
+ { cmd: 'wait.ready --url-prefix "https://app.example.com/" --empty-text "No results"', desc: "Distinguish empty from blocked" },
855
+ { cmd: "wait.ready --accept login --json", desc: "Return the login state to the caller" },
856
+ ]
857
+ },
787
858
  }
788
859
  },
789
860
  input: {
@@ -838,17 +909,52 @@ const TOOLS = {
838
909
  "drag": { desc: "Drag between points", args: [], opts: { from: "Start x,y", to: "End x,y" } },
839
910
  }
840
911
  },
912
+ extract: {
913
+ desc: "Scripted extraction in an owned tab",
914
+ commands: {
915
+ "extract": {
916
+ desc: "Open a URL in a fresh tab, wait until it is ready, run a page-side script that returns JSON, print rows",
917
+ args: ["url"],
918
+ opts: {
919
+ file: "Script file; must `return` JSON (an array, or an object with a rows/items/results array)",
920
+ code: "Inline script instead of --file",
921
+ options: "JSON object exposed to the script as SURF_OPTIONS",
922
+ "options-file": "Read the options object from a JSON file",
923
+ "ready-selector": "wait.ready --selector before extracting",
924
+ "ready-text": "wait.ready --text before extracting",
925
+ "ready-url-prefix": "wait.ready --url-prefix; a different URL is a bounce",
926
+ "empty-text": "wait.ready --empty-text; lets an explicit no-results page pass the zero-rows check",
927
+ "ready-timeout": "Readiness timeout in ms (default: 20000)",
928
+ "ready-interval": "Readiness polling interval in ms (default: 400)",
929
+ rows: "Key of the row array in the script result (default: auto)",
930
+ retry: "Fresh-tab retries on transient failures (default: 1, max: 5)",
931
+ "retry-delay-ms": "Delay between attempts (default: 500)",
932
+ "allow-empty": "Accept zero rows",
933
+ "keep-tab": "Leave the owned tab open on success and report its id",
934
+ "tab-id": "Extract from an existing tab instead (no fresh tab, no retry; navigates only if a URL is given)",
935
+ session: "Extract from a session's tab instead (same rules as --tab-id)",
936
+ json: "Print {data, rows, rowCount, attempts, readiness} as JSON",
937
+ },
938
+ examples: [
939
+ { cmd: 'extract "https://example.com/list" --file rows.js --ready-selector ".item"', desc: "Fresh tab, wait for items, print a Markdown table" },
940
+ { cmd: 'extract "https://example.com/search?q=x" --file rows.js --options \'{"limit": 20}\' --empty-text "No results" --json', desc: "Options prelude, explicit empty state, JSON output" },
941
+ { cmd: "extract --tab-id 42 --code 'return [...document.querySelectorAll(\"h2\")].map(h => ({ title: h.textContent }))'", desc: "Read an existing tab in place" },
942
+ ]
943
+ },
944
+ }
945
+ },
841
946
  js: {
842
947
  desc: "JavaScript execution",
843
948
  commands: {
844
949
  "js": {
845
950
  desc: "Execute JavaScript (use 'return' for values)",
846
951
  args: ["code"],
847
- opts: { file: "Run JS from file" },
952
+ opts: { file: "Run JS from file", options: "JSON object exposed to the script as a frozen SURF_OPTIONS constant" },
848
953
  examples: [
849
954
  { cmd: 'js "return document.title"', desc: "Get title" },
850
955
  { cmd: 'js "document.body.style.background = \'red\'"', desc: "Run code" },
851
956
  { cmd: "js --file script.js", desc: "Run file" },
957
+ { cmd: 'js --file script.js --options \'{"limit": 20}\'', desc: "Run file with SURF_OPTIONS.limit" },
852
958
  ]
853
959
  },
854
960
  }
@@ -1116,7 +1222,7 @@ const TOOLS = {
1116
1222
  "frame.js": {
1117
1223
  desc: "Execute JS in specific frame",
1118
1224
  args: ["code"],
1119
- opts: { id: "Frame ID from frame.list", file: "Run JS from file" },
1225
+ opts: { id: "Frame ID from frame.list", file: "Run JS from file", options: "JSON object exposed as SURF_OPTIONS" },
1120
1226
  examples: [
1121
1227
  { cmd: 'frame.js "return document.title" --id frame1', desc: "JS in specific frame" },
1122
1228
  ]
@@ -1561,8 +1667,8 @@ Exclude text content:
1561
1667
  };
1562
1668
 
1563
1669
  const ALL_SOCKET_TOOLS = [
1564
- "session.new", "session.ensure", "session.list", "session.info", "session.close", "session.rebind", "session.reopen",
1565
- "ai", "screenshot", "record", "animate-audit", "perf-audit", "navigate",
1670
+ "session.new", "session.ensure", "session.list", "session.cleanup", "session.info", "session.close", "session.rebind", "session.reopen",
1671
+ "ai", "screenshot", "record", "video.start", "video.stop", "video.status", "video.restart", "animate-audit", "perf-audit", "navigate",
1566
1672
  "form_input", "find_and_type", "autocomplete", "set_value", "smart_type",
1567
1673
  "scroll_to_position", "get_scroll_info", "close_dialogs", "page_state",
1568
1674
  "javascript_tool", "health", "smoke",
@@ -1574,7 +1680,8 @@ const ALL_SOCKET_TOOLS = [
1574
1680
  "tab.list", "tab.new", "tab.switch", "tab.close", "tab.move", "tab.name", "tab.unname", "tab.named",
1575
1681
  "tab.group", "tab.ungroup", "tab.groups", "tab.reload",
1576
1682
  "scroll.top", "scroll.bottom", "scroll.to", "scroll.info",
1577
- "wait.element", "wait.network", "wait.url", "wait.dom", "wait.load",
1683
+ "wait.element", "wait.network", "wait.url", "wait.dom", "wait.load", "wait.ready",
1684
+ "page.readiness",
1578
1685
  "click", "hover", "drag",
1579
1686
  "js", "console", "network",
1580
1687
  "network.get", "network.body", "network.curl", "network.origins",
@@ -1621,10 +1728,16 @@ const SEE_ALSO = {
1621
1728
  "navigate": ["wait.load", "page.read"],
1622
1729
  "screenshot": ["page.read", "scroll.bottom for fullpage"],
1623
1730
  "record": ["screenshot", "animate-audit", "perf-audit"],
1731
+ "video.start": ["video.stop", "video.status"],
1732
+ "video.stop": ["video.status", "video.restart"],
1733
+ "video.status": ["video.start", "video.stop"],
1734
+ "video.restart": ["video.stop", "video.status"],
1624
1735
  "animate-audit": ["screenshot", "record", "perf-audit", "js"],
1625
1736
  "perf-audit": ["record", "animate-audit", "perf.metrics", "console"],
1626
1737
  "search": ["locate.text", "page.read"],
1627
- "wait.element": ["wait.load", "wait.network"],
1738
+ "wait.element": ["wait.load", "wait.network", "wait.ready"],
1739
+ "wait.ready": ["page.readiness", "wait.element", "wait.url"],
1740
+ "page.readiness": ["wait.ready", "page.state"],
1628
1741
  "wait.load": ["wait.element", "wait.network"],
1629
1742
  "wait.network": ["wait.load", "wait.element"],
1630
1743
  "scroll.to": ["click", "page.read"],
@@ -1641,11 +1754,16 @@ Usage: surf <command> [args] [options]
1641
1754
 
1642
1755
  Common Commands:
1643
1756
  session.ensure <name> [url] Idempotently create or reuse a tab-bound session
1757
+ session.cleanup --idle-after <duration> Remove idle sessions (opt-in; use --dry-run first)
1644
1758
  navigate <url> Go to URL (alias: go)
1645
1759
  click <ref> Click element by ref or selector
1646
1760
  type <text> Type text at cursor or into element
1647
1761
  screenshot Capture screenshot (alias: snap)
1648
1762
  record Capture screenshot frames into an animated GIF
1763
+ video start <path> Start a long-running local WebM recording
1764
+ video stop Stop the active WebM recording
1765
+ video status Show WebM recording status
1766
+ video restart <path> Replace the active WebM recording
1649
1767
  animate-audit JSON timeline of element animation/style samples
1650
1768
  perf-audit PerformanceObserver snapshot for motion/jank debugging
1651
1769
  page.read Get page accessibility tree (alias: read)
@@ -1673,6 +1791,9 @@ More Help:
1673
1791
  --no-wait Return tab_busy/browser_busy instead of queueing
1674
1792
  --remote <host>:<port> Route requests to a remote native host
1675
1793
  --remote-credential <path> Use a mode-0600 Ed25519 remote credential file
1794
+ --remote-tls Use TLS through a TLS-terminating reverse proxy
1795
+ --remote-tls-ca <path> Replace system roots with a custom CA bundle
1796
+ --remote-tls-server-name <name> Override TLS SNI and certificate identity
1676
1797
  surf remote authorize <label> --output <path>
1677
1798
  surf remote list | surf remote revoke <label>
1678
1799
  surf --help-full All commands
@@ -1690,6 +1811,7 @@ Purpose: control Chrome from shell. Commands are \`surf <command> [args] [option
1690
1811
  Core loop: navigate -> wait/read -> act -> screenshot/read.
1691
1812
  Navigate: surf navigate "https://example.com" # alias: surf go "..."
1692
1813
  Wait after navigation: surf wait 2 # or wait.load for load complete
1814
+ Wait for real content: surf wait.ready --selector ".results" # fails fast with page_login / page_challenge / page_not_found; --accept login returns the state
1693
1815
  Read DOM/refs: surf page.read --depth 3 --compact # alias: surf read
1694
1816
  Refs: use e1/e2 refs from page.read; prefer refs over CSS when available.
1695
1817
  Click ref: surf click e5
@@ -1698,15 +1820,18 @@ Type: surf type "text" --submit # use --ref e5 to target a fiel
1698
1820
  Screenshot: surf screenshot /tmp/shot.png # auto-saves to /tmp if no path
1699
1821
  Full page screenshot: surf screenshot --full-page /tmp/full.png
1700
1822
  Record animation: surf record --duration 2000 --fps 10 --output /tmp/anim.gif
1823
+ Video recording: surf video start ./demo.webm --fps 30; surf video stop
1701
1824
  Animation audit: surf animate-audit --selector ".thing" --duration 2000 --fps 10
1702
1825
  Performance audit: surf perf-audit --duration 3000 --trigger "click:.cta" --output /tmp/perf.json
1703
1826
  JavaScript: surf js "return document.title"
1827
+ Frames: surf frame.list | surf frame.diagnose # diagnose explains why a selector misses inside iframes (shadow roots, srcdoc, out-of-process)
1704
1828
  Scroll: surf scroll down 800 | surf scroll up 400 | surf scroll bottom | surf scroll top
1705
1829
  Find by semantics: surf locate.role button --name "Submit" --action click
1706
1830
  Device/viewport: surf emulate.device "iPhone 14" | surf resize 375 812
1707
1831
  Cookies: surf cookie list | surf cookie get "name" | surf cookie delete "name"
1708
1832
  Session targeting: surf --session research read | SURF_SESSION=research surf read
1709
1833
  Session status/queue: surf session.info research | surf session.list --refresh
1834
+ Session cleanup: surf session.cleanup --idle-after 1h [--dry-run]
1710
1835
  Recovery: run the exact command printed after Recovery: on tab_gone, session_epoch_stale, tab_busy, or browser_busy
1711
1836
  Concurrency: commands for different session tabs can overlap; each tab remains FIFO; provider flows are browser-exclusive
1712
1837
  Doctor: surf doctor --browser all # native host/socket diagnostics
@@ -1742,15 +1867,25 @@ Playbooks:
1742
1867
  Options:
1743
1868
  --remote <host>:<port> Route requests to a remote native host
1744
1869
  --remote-credential <path> Use a mode-0600 Ed25519 remote credential file
1870
+ --remote-tls Use TLS through a TLS-terminating reverse proxy
1871
+ --remote-tls-ca <path> Replace system roots with a custom CA bundle
1872
+ --remote-tls-server-name <name> Override TLS SNI and certificate identity
1745
1873
  --session <name> Target a durable named session (or set SURF_SESSION)
1746
1874
  --tab-id <id> Target specific tab
1747
1875
  --window-id <id> Target specific window
1748
1876
  --no-wait Return immediately when the tab/browser is busy
1749
1877
  --json Output raw JSON including target metadata
1750
1878
  --auto-capture On error: capture screenshot + console to /tmp
1751
- --soft-fail On error: warn and exit 0 (for non-critical commands)
1879
+ --soft-fail Host tool errors: warn on stderr, exit 0, no JSON error output
1752
1880
  --no-lock Bypass the legacy lock for compound client-side commands
1753
1881
 
1882
+ Host tool-response errors: stderr includes [code] on the first line when supplied;
1883
+ --json also writes {"error":{"code":"...","message":"..."}} to stdout; exit 1.
1884
+ Host details, when present, are included without redundant code/message fields.
1885
+ Missing codes use "error" in JSON. --soft-fail keeps the original warning text.
1886
+ This is not a universal error format: local validation, transport and parser
1887
+ failures keep their existing output/status; --soft-fail does not mask them.
1888
+
1754
1889
  Remote Credentials (run on the browser host):
1755
1890
  surf remote authorize <label> --output <credential-file>
1756
1891
  surf remote list
@@ -2014,12 +2149,17 @@ Options:
2014
2149
  On WSL2, auto installs for Windows Chrome. Use linux for WSLg/Linux browsers.
2015
2150
  --listen <tailscale-ip>:<port>
2016
2151
  Requires surf remote authorize <label> --output <path> first.
2152
+ --socket-mode <600|660>
2153
+ Persist the local POSIX socket mode (default: 600); 660 requires --socket-group.
2154
+ --socket-group <group-or-gid>
2155
+ Persist the local POSIX socket group for mode 660.
2017
2156
 
2018
2157
  Examples:
2019
2158
  surf install hnfbepgmaoklhekckbpjnleifhahkcpl
2020
2159
  surf install hnfbepgmaoklhekckbpjnleifhahkcpl --browser brave
2021
2160
  surf install hnfbepgmaoklhekckbpjnleifhahkcpl --browser all
2022
2161
  surf install hnfbepgmaoklhekckbpjnleifhahkcpl --target linux
2162
+ surf install hnfbepgmaoklhekckbpjnleifhahkcpl --socket-mode 660 --socket-group surf
2023
2163
  `);
2024
2164
  process.exit(0);
2025
2165
  }
@@ -2514,6 +2654,150 @@ if (args[0] === "do") {
2514
2654
  return;
2515
2655
  }
2516
2656
 
2657
+ // Handle `surf extract`: a page-side script in an owned tab with a
2658
+ // readiness gate, bounded fresh-tab retry and the zero-rows invariant.
2659
+ if (args[0] === "extract") {
2660
+ const extractArgs = args.slice(1);
2661
+ const valueFlags = new Set([
2662
+ "file", "code", "options", "options-file", "ready-selector", "ready-text", "ready-url-prefix",
2663
+ "ready-timeout", "ready-interval", "empty-text", "rows", "retry", "retry-delay-ms",
2664
+ "tab-id", "session",
2665
+ ]);
2666
+ const boolFlags = new Set(["allow-empty", "keep-tab", "json", "no-wait", "help"]);
2667
+ const opts = {};
2668
+ let url = null;
2669
+ for (let i = 0; i < extractArgs.length; i++) {
2670
+ const arg = extractArgs[i];
2671
+ if (arg === "-f") {
2672
+ opts.file = flagValue(extractArgs, arg);
2673
+ i++;
2674
+ } else if (arg.startsWith("--")) {
2675
+ const key = arg.slice(2);
2676
+ if (boolFlags.has(key)) opts[key] = true;
2677
+ else if (valueFlags.has(key)) {
2678
+ opts[key] = key === "options" && extractArgs[i + 1] === ""
2679
+ ? ""
2680
+ : flagValue(extractArgs, arg);
2681
+ i++;
2682
+ } else {
2683
+ console.error(`Error: unknown extract option --${key}`);
2684
+ process.exit(1);
2685
+ }
2686
+ } else if (url === null) {
2687
+ url = arg;
2688
+ } else {
2689
+ console.error(`Error: unexpected argument ${arg}`);
2690
+ process.exit(1);
2691
+ }
2692
+ }
2693
+ if (opts.help) {
2694
+ showToolHelp("extract");
2695
+ process.exit(0);
2696
+ }
2697
+ const wantJson = opts.json === true;
2698
+ const fail = (code, message, details) => {
2699
+ if (wantJson) {
2700
+ console.log(JSON.stringify({ error: { code, message, ...(details ? { details } : {}) } }, null, 2));
2701
+ } else {
2702
+ console.error(`Error: ${message}${code ? ` [${code}]` : ""}`);
2703
+ }
2704
+ process.exit(1);
2705
+ };
2706
+ if (opts.options !== undefined && opts["options-file"] !== undefined) {
2707
+ fail("usage", "use either --options or --options-file, not both");
2708
+ }
2709
+
2710
+ let code = null;
2711
+ try {
2712
+ if (opts.file && opts.code) fail("usage", "use either --file or --code, not both");
2713
+ if (opts.file) code = fs.readFileSync(opts.file, "utf8");
2714
+ else if (typeof opts.code === "string") code = opts.code;
2715
+ else fail("usage", "an extraction script is required: --file script.js or --code 'return {...}'");
2716
+ } catch (error) {
2717
+ fail("usage", `Failed to read script: ${error.message}`);
2718
+ }
2719
+
2720
+ let scriptOptions = {};
2721
+ try {
2722
+ if (opts["options-file"]) scriptOptions = parseScriptOptions(fs.readFileSync(opts["options-file"], "utf8"));
2723
+ else scriptOptions = parseScriptOptions(opts.options);
2724
+ } catch (error) {
2725
+ fail("usage", error.message);
2726
+ }
2727
+
2728
+ const toInt = (key, fallback) => {
2729
+ if (opts[key] === undefined) return fallback;
2730
+ const parsed = Number(opts[key]);
2731
+ if (!/^\d+$/.test(opts[key]) || !Number.isSafeInteger(parsed)) {
2732
+ fail("usage", `--${key} must be a non-negative integer`);
2733
+ }
2734
+ return parsed;
2735
+ };
2736
+
2737
+ const targetOptions = resolveEarlyTargetOptions(extractArgs, {
2738
+ allowWindow: false,
2739
+ allowEnvironmentSession: false,
2740
+ });
2741
+ const hasTarget = Boolean(targetOptions.tabId || targetOptions.session);
2742
+ if (!hasTarget && !url) fail("usage", "a URL is required unless --tab-id or --session names the page to read");
2743
+
2744
+ const settings = {
2745
+ code,
2746
+ url: url ?? undefined,
2747
+ options: scriptOptions,
2748
+ ready: {
2749
+ selector: opts["ready-selector"],
2750
+ text: opts["ready-text"],
2751
+ urlPrefix: opts["ready-url-prefix"],
2752
+ emptyText: opts["empty-text"],
2753
+ timeout: toInt("ready-timeout", undefined),
2754
+ interval: toInt("ready-interval", undefined),
2755
+ },
2756
+ retry: { count: toInt("retry", undefined), delayMs: toInt("retry-delay-ms", undefined) },
2757
+ keepTab: opts["keep-tab"] === true,
2758
+ allowEmpty: opts["allow-empty"] === true,
2759
+ rowsKey: opts.rows,
2760
+ target: hasTarget,
2761
+ };
2762
+
2763
+ const runExtract = async () => {
2764
+ let transport;
2765
+ try {
2766
+ transport = await openClientTransport(endpoint);
2767
+ const baseContext = { ...targetOptions, endpoint, transport };
2768
+ const executeTool = (toolName, toolArgs, ownedTabId) => {
2769
+ const context = ownedTabId
2770
+ ? { tabId: ownedTabId, admission: targetOptions.admission, endpoint, transport }
2771
+ : baseContext;
2772
+ return sendDoRequest(toolName, toolArgs, context);
2773
+ };
2774
+ const result = await runExtraction({
2775
+ ...settings,
2776
+ executeTool,
2777
+ onEvent: (event) => {
2778
+ if (wantJson) return;
2779
+ if (event.type === "attempt" && event.of > 1) console.error(`[surf] extract attempt ${event.attempt}/${event.of}`);
2780
+ if (event.type === "attempt-failed" && event.retryable) console.error(`[surf] attempt ${event.attempt} failed (${event.error}); retrying with a fresh tab`);
2781
+ },
2782
+ });
2783
+ if (wantJson) {
2784
+ console.log(JSON.stringify(result, null, 2));
2785
+ } else {
2786
+ console.log(renderExtractionMarkdown(result.data, result.rows, { title: url ? `Extraction from ${url}` : "Extraction" }));
2787
+ if (result.tabId) console.error(`[surf] tab ${result.tabId} left open (--keep-tab)`);
2788
+ }
2789
+ return 0;
2790
+ } catch (error) {
2791
+ fail(error.code || "extraction_failed", error.message, error.details);
2792
+ } finally {
2793
+ await transport?.close();
2794
+ }
2795
+ };
2796
+
2797
+ runExtract().then((exitCode) => process.exit(exitCode));
2798
+ return;
2799
+ }
2800
+
2517
2801
  // Handle workflow management commands
2518
2802
  if (args[0] === "workflow.list") {
2519
2803
  const workflows = listWorkflows();
@@ -2686,6 +2970,7 @@ if (tool === "session" && firstArg) {
2686
2970
  new: "session.new",
2687
2971
  ensure: "session.ensure",
2688
2972
  list: "session.list",
2973
+ cleanup: "session.cleanup",
2689
2974
  info: "session.info",
2690
2975
  close: "session.close",
2691
2976
  rebind: "session.rebind",
@@ -2715,6 +3000,21 @@ if (tool === "cookie" && firstArg) {
2715
3000
  }
2716
3001
  }
2717
3002
 
3003
+ if (tool === "video" && firstArg) {
3004
+ const videoSubcommands = {
3005
+ start: "video.start",
3006
+ stop: "video.stop",
3007
+ status: "video.status",
3008
+ restart: "video.restart",
3009
+ };
3010
+ const videoTool = videoSubcommands[firstArg];
3011
+ if (videoTool) {
3012
+ tool = videoTool;
3013
+ positional = [tool, ...positional.slice(2)];
3014
+ firstArg = positional[1];
3015
+ }
3016
+ }
3017
+
2718
3018
  if (!tool) {
2719
3019
  console.error("Error: No command specified");
2720
3020
  process.exit(1);
@@ -2879,6 +3179,11 @@ if (tool === "record" && firstArg !== undefined && toolArgs.output === undefined
2879
3179
  firstArg = undefined;
2880
3180
  }
2881
3181
 
3182
+ if ((tool === "video.start" || tool === "video.restart") && firstArg !== undefined && toolArgs.output === undefined) {
3183
+ toolArgs.output = firstArg;
3184
+ firstArg = undefined;
3185
+ }
3186
+
2882
3187
  if (firstArg !== undefined) {
2883
3188
  const primaryKey = PRIMARY_ARG_MAP[tool];
2884
3189
  if (primaryKey && toolArgs[primaryKey] === undefined) {
@@ -2904,6 +3209,17 @@ if ((tool === "js" || tool === "frame.js") && toolArgs.file) {
2904
3209
  }
2905
3210
  }
2906
3211
 
3212
+ if ((tool === "js" || tool === "frame.js") && toolArgs.options !== undefined) {
3213
+ try {
3214
+ if (typeof toolArgs.code !== "string") throw new Error("--options needs code (inline or --file)");
3215
+ toolArgs.code = applyOptionsPrelude(toolArgs.code, toolArgs.options);
3216
+ delete toolArgs.options;
3217
+ } catch (e) {
3218
+ console.error(`Error: ${e.message}`);
3219
+ process.exit(1);
3220
+ }
3221
+ }
3222
+
2907
3223
  if (tool === "batch" && toolArgs.file) {
2908
3224
  try {
2909
3225
  const parsed = JSON.parse(fs.readFileSync(toolArgs.file, "utf8"));
@@ -3007,8 +3323,11 @@ if (tool === "gemini") {
3007
3323
  if (tool === "network.export" && outputPath !== undefined) {
3008
3324
  toolArgs.output = outputPath;
3009
3325
  }
3326
+ if ((tool === "video.start" || tool === "video.restart") && outputPath !== undefined) {
3327
+ toolArgs.output = outputPath;
3328
+ }
3010
3329
 
3011
- if ((tool === "screenshot" || tool === "record" || tool === "perf-audit" || tool === "page.save") && outputPath && typeof outputPath !== "string") {
3330
+ if ((tool === "screenshot" || tool === "record" || tool === "perf-audit" || tool === "page.save" || tool === "video.start" || tool === "video.restart") && outputPath && typeof outputPath !== "string") {
3012
3331
  console.error("Error: --output requires a file path");
3013
3332
  process.exit(1);
3014
3333
  }
@@ -3018,6 +3337,16 @@ if (tool === "page.save" && !outputPath) {
3018
3337
  process.exit(1);
3019
3338
  }
3020
3339
 
3340
+ if (tool === "video.start" || tool === "video.restart") {
3341
+ try {
3342
+ parseVideoFps(toolArgs.fps);
3343
+ validateVideoOutputPath(toolArgs.output, { createParent: false });
3344
+ } catch (error) {
3345
+ console.error(`Error: ${error.message}`);
3346
+ process.exit(1);
3347
+ }
3348
+ }
3349
+
3021
3350
  if (tool === "screenshot" && outputPath) {
3022
3351
  toolArgs.savePath = outputPath;
3023
3352
  if (options.full) toolArgs.full = true;
@@ -3540,7 +3869,25 @@ async function handleResponse(response) {
3540
3869
  socket.end();
3541
3870
  process.exit(0);
3542
3871
  }
3543
- console.error("Error:", errContent);
3872
+ // Host tool-response errors carry codes separately from their display text.
3873
+ const errorCode = typeof response.error.code === "string" ? response.error.code : null;
3874
+ const [firstLine, ...restLines] = errContent.split("\n");
3875
+ const display = errorCode && !firstLine.includes(`[${errorCode}]`)
3876
+ ? [`${firstLine} [${errorCode}]`, ...restLines].join("\n")
3877
+ : errContent;
3878
+ console.error("Error:", display);
3879
+ if (wantJson) {
3880
+ // details repeats code/message when the error serialises itself; keep the rest.
3881
+ const { code: _code, message: _message, ...details } =
3882
+ response.error.details && typeof response.error.details === "object" ? response.error.details : {};
3883
+ console.log(JSON.stringify({
3884
+ error: {
3885
+ code: errorCode || "error",
3886
+ message: typeof response.error.message === "string" ? response.error.message : firstLine,
3887
+ ...(Object.keys(details).length > 0 ? { details } : {}),
3888
+ },
3889
+ }, null, 2));
3890
+ }
3544
3891
 
3545
3892
  if (autoCapture) {
3546
3893
  await performAutoCapture();
@@ -3615,6 +3962,21 @@ async function handleResponse(response) {
3615
3962
  ].join("\t"));
3616
3963
  }
3617
3964
  }
3965
+ } else if (finalTool === "session.cleanup" && data?.success) {
3966
+ const removed = Array.isArray(data.removed) ? data.removed : [];
3967
+ if (removed.length === 0) {
3968
+ console.log("No browser sessions matched the idle cleanup threshold.");
3969
+ } else {
3970
+ const verb = data.dryRun ? "Would remove" : "Removed";
3971
+ for (const entry of removed) {
3972
+ const target = entry.targetAction === "close"
3973
+ ? "target closed"
3974
+ : entry.targetAction === "keep"
3975
+ ? "target kept"
3976
+ : "target already gone";
3977
+ console.log(`${verb} session ${entry.name} tab=${entry.tabId ?? "-"} (${target})`);
3978
+ }
3979
+ }
3618
3980
  } else if (finalTool === "session.info" && data?.session) {
3619
3981
  const entry = data.session;
3620
3982
  console.log(`Session: ${entry.name}`);
@@ -3631,6 +3993,18 @@ async function handleResponse(response) {
3631
3993
  console.log(`Use: export SURF_SESSION=${entry.name}`);
3632
3994
  } else if (finalTool === "session.close" && data?.success) {
3633
3995
  console.log(`Session ${data.name} closed (${data.targetClosed ? "target closed" : "target kept"})`);
3996
+ } else if (finalTool === "video.start" && data?.status === "active") {
3997
+ console.log(`Video recording started: ${data.path} (${data.fps}fps)`);
3998
+ } else if (finalTool === "video.restart" && data?.status === "active") {
3999
+ console.log(`Video recording restarted: ${data.path} (${data.fps}fps)`);
4000
+ } else if (finalTool === "video.stop" && data?.status === "stopped") {
4001
+ console.log(`Saved video recording to ${data.path} (${data.frames} frames, ${data.capturedFrames} captured frames @ ${data.fps}fps)`);
4002
+ } else if (finalTool === "video.status") {
4003
+ if (data?.status === "active") {
4004
+ console.log(`Video recording active: ${data.path} (${data.fps}fps, ${data.frames} frames, ${data.capturedFrames} captured frames)`);
4005
+ } else {
4006
+ console.log("No active video recording");
4007
+ }
3634
4008
  } else if (tool === "screenshot" && data?.base64 && (outputPath || toolArgs.savePath)) {
3635
4009
  const saveTo = transferPlan.downloads?.[0]?.destination || toolArgs.savePath || outputPath;
3636
4010
  fs.writeFileSync(saveTo, Buffer.from(data.base64, "base64"));
@@ -3705,6 +4079,62 @@ async function handleResponse(response) {
3705
4079
  }
3706
4080
  console.log("\nUsage: surf emulate.device \"<device name>\"");
3707
4081
  console.log('Reset: surf emulate.device "reset"');
4082
+ } else if (tool === "wait.ready" || tool === "page.readiness") {
4083
+ const lines = [`state: ${data?.state ?? "unknown"}`];
4084
+ if (data?.href) lines.push(`url: ${data.href}`);
4085
+ if (data?.title) lines.push(`title: ${data.title}`);
4086
+ if (typeof data?.waited === "number") lines.push(`waited: ${data.waited}ms (${data.polls} poll${data.polls === 1 ? "" : "s"})`);
4087
+ if (data?.accepted) lines.push("accepted: negative state returned because of --accept");
4088
+ for (const item of Array.isArray(data?.evidence) ? data.evidence : []) lines.push(`- ${item}`);
4089
+ console.log(lines.join("\n"));
4090
+ } else if (tool === "frame.diagnose" && data?.counts) {
4091
+ // Keep frame URLs readable: embed runners carry kilobyte-long state
4092
+ // parameters that bury the report (use --json for the full URLs).
4093
+ const abbreviateUrl = (url, max = 100) => {
4094
+ if (typeof url !== "string" || url.length <= max) return url;
4095
+ try {
4096
+ const parsed = new URL(url);
4097
+ const base = `${parsed.origin}${parsed.pathname}`;
4098
+ const trailing = url.length - base.length;
4099
+ if (trailing > 0 && base.length <= max - 12) return `${base}?...(+${trailing} chars)`;
4100
+ } catch {}
4101
+ return `${url.slice(0, max - 3)}...`;
4102
+ };
4103
+ const lines = [];
4104
+ lines.push(`Frame diagnosis for ${data.mainPage?.href ?? "?"}${data.mainPage?.title ? ` (${data.mainPage.title})` : ""}`);
4105
+ lines.push(`DOM iframes: ${data.counts.domIframes}, extension frames: ${data.counts.extensionFrames} (incl. main), CDP frames: ${data.counts.cdpFrames}`);
4106
+ if (Array.isArray(data.domIframes) && data.domIframes.length > 0) {
4107
+ lines.push("", "DOM iframes:");
4108
+ for (const f of data.domIframes) {
4109
+ const flags = [
4110
+ f.blank ? "blank" : null,
4111
+ f.crossOrigin ? "cross-origin" : null,
4112
+ f.scriptsBlocked ? "scripts-blocked" : null,
4113
+ f.zeroSize ? "0-size" : null,
4114
+ ].filter(Boolean).join(",");
4115
+ const links = [
4116
+ f.extensionFrameIds?.length ? `ext ${f.extensionFrameIds.join("/")}` : "ext -",
4117
+ f.cdpFrameIds?.length ? `cdp ${f.cdpFrameIds.join("/")}` : "cdp -",
4118
+ ].join(", ");
4119
+ lines.push(` [${f.domIndex}] ${f.srcdoc ? "srcdoc" : abbreviateUrl(f.src || "about:blank")} ${Math.round(f.rect?.width ?? 0)}x${Math.round(f.rect?.height ?? 0)}${f.name ? ` name=${f.name}` : f.id ? ` id=${f.id}` : ""}${f.sandbox !== null && f.sandbox !== undefined ? ` sandbox="${f.sandbox}"` : ""}${f.shadowHost ? ` in shadow root of ${f.shadowHost}` : ""}${flags ? ` [${flags}]` : ""} -> ${links}`);
4120
+ }
4121
+ }
4122
+ if (Array.isArray(data.extensionFrames) && data.extensionFrames.length > 0) {
4123
+ lines.push("", "Extension frames (frame.switch indexes, webNavigation ids):");
4124
+ for (const f of data.extensionFrames) {
4125
+ const reach = f.contentScriptReachable ? "content-script ok" : `content-script unreachable${f.contentScriptError ? ` (${f.contentScriptError})` : ""}`;
4126
+ lines.push(` ${f.isMain ? "main" : `[${f.switchIndex}]`} #${f.frameId}${f.isMain ? "" : ` parent ${f.parentFrameId}`} ${abbreviateUrl(f.url)}${f.crossOrigin ? " [cross-origin]" : ""} - ${reach}`);
4127
+ }
4128
+ }
4129
+ if (Array.isArray(data.cdpFrames) && data.cdpFrames.length > 0) {
4130
+ lines.push("", "CDP frames (frame.js ids):");
4131
+ for (const f of data.cdpFrames) {
4132
+ lines.push(` ${f.frameId}${f.isMain ? " main" : ` parent ${f.parentId}`} ${abbreviateUrl(f.url)}${f.name ? ` name=${f.name}` : ""}${f.extensionFrameIds?.length ? ` -> ext ${f.extensionFrameIds.join("/")}` : ""}`);
4133
+ }
4134
+ }
4135
+ lines.push("", Array.isArray(data.warnings) && data.warnings.length > 0 ? "Warnings:" : "No warnings.");
4136
+ for (const w of Array.isArray(data.warnings) ? data.warnings : []) lines.push(` - ${w}`);
4137
+ console.log(lines.join("\n"));
3708
4138
  } else if (tool === "js") {
3709
4139
  if (data?.result !== undefined) {
3710
4140
  const val = data.result.value ?? data.result;