surf-cli 2.16.1 → 2.18.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.
@@ -11,10 +11,14 @@ const {
11
11
  resolveChatGPTEffortMenuOption,
12
12
  resolveChatGPTModelMenuOption,
13
13
  selectEffort,
14
+ selectChatTab,
15
+ selectGitHubTool,
14
16
  selectModel,
17
+ verifyCurrentModel,
15
18
  typePrompt,
16
19
  verifyChatGPTEffortSelection,
17
20
  verifyChatGPTModelSelection,
21
+ waitForChatGPTAttachment,
18
22
  waitForPageLoad,
19
23
  waitForPromptReady,
20
24
  } = require("./chatgpt-client-ui.cjs");
@@ -32,6 +36,12 @@ const {
32
36
 
33
37
  const CHATGPT_URL = "https://chatgpt.com/";
34
38
  const RESPONSE_STARTED_AT = Symbol("responseStartedAt");
39
+ const ATTACHMENT_ERROR_CODES = new Set([
40
+ "attachment_chooser_interception",
41
+ "attachment_file_access",
42
+ "attachment_processing",
43
+ "attachment_selector_drift",
44
+ ]);
35
45
 
36
46
  function hasRequiredCookies(cookies) {
37
47
  if (!cookies || !Array.isArray(cookies)) return false;
@@ -93,6 +103,7 @@ async function dispatch(options) {
93
103
  cdpEvaluate,
94
104
  cdpCommand,
95
105
  uploadFile,
106
+ github = false,
96
107
  beforeSubmit,
97
108
  afterSubmit,
98
109
  startUrl,
@@ -141,6 +152,12 @@ async function dispatch(options) {
141
152
  throw codedError("ChatGPT login required", "auth");
142
153
  }
143
154
  log("Login verified");
155
+ if (github) {
156
+ await selectChatTab(cdp, 10000, signal);
157
+ log("Verified Chat tab");
158
+ await selectGitHubTool(cdp, 10000, signal);
159
+ log("Verified GitHub tool");
160
+ }
144
161
  const promptReady = await waitForPromptReady(cdp, 30000, signal);
145
162
  if (!promptReady) {
146
163
  throw new Error("Prompt textarea not ready");
@@ -149,34 +166,62 @@ async function dispatch(options) {
149
166
  let modelVerified = null;
150
167
  let effortVerified = null;
151
168
  if (model) {
152
- modelVerified = await selectModel(cdp, model, 8000, signal);
169
+ modelVerified = await selectModel(cdp, inputCdp, model, 8000, signal);
153
170
  log(`Verified model: ${modelVerified}`);
154
171
  }
155
172
  if (file) {
156
173
  if (!uploadFile) {
157
- throw new Error(
174
+ throw codedError(
158
175
  "ChatGPT file upload unavailable: native host did not provide upload callback",
176
+ "attachment_chooser_interception",
159
177
  );
160
178
  }
161
179
  const files = Array.isArray(file) ? file : [file];
162
180
  const absFiles = files.map((filePath) => path.resolve(process.cwd(), filePath));
163
181
  log(`Uploading ${absFiles.length} file(s) to ChatGPT...`);
164
- const uploadResult = await guardedUploadFile(tabId, absFiles);
182
+ let uploadResult;
183
+ try {
184
+ uploadResult = await guardedUploadFile(tabId, absFiles);
185
+ } catch (error) {
186
+ throw classifyError(
187
+ error,
188
+ "attachment_chooser_interception",
189
+ ["attachment_file_access", "attachment_processing", "attachment_selector_drift", "attachment_chooser_interception"],
190
+ );
191
+ }
165
192
  if (uploadResult?.error) {
166
- throw new Error(`ChatGPT file upload failed: ${uploadResult.error}`);
193
+ const uploadCode = ATTACHMENT_ERROR_CODES.has(uploadResult.errorCode)
194
+ ? uploadResult.errorCode
195
+ : "attachment_chooser_interception";
196
+ throw codedError(
197
+ `ChatGPT file upload failed: ${uploadResult.error}`,
198
+ uploadCode,
199
+ );
167
200
  }
168
201
  if (!uploadResult?.success) {
169
- throw new Error("ChatGPT file upload failed: upload did not report success");
202
+ throw codedError(
203
+ "ChatGPT attachment processing failed: upload did not report success",
204
+ "attachment_processing",
205
+ );
170
206
  }
171
207
  log("File uploaded, waiting for ChatGPT attachment processing...");
172
208
  await delay(1500, signal);
209
+ try {
210
+ await waitForChatGPTAttachment(cdp, absFiles, 30000, signal);
211
+ } catch (error) {
212
+ throw classifyError(error, "attachment_processing", ["attachment_processing"]);
213
+ }
173
214
  }
174
215
  await typePrompt(cdp, inputCdp, prompt, signal);
175
216
  log("Prompt typed");
176
217
  if (effort) {
177
- effortVerified = await selectEffort(cdp, effort, 8000, signal);
218
+ effortVerified = await selectEffort(cdp, inputCdp, effort, 8000, signal);
178
219
  log(`Verified effort: ${effortVerified}`);
179
220
  }
221
+ if (model) {
222
+ modelVerified = await verifyCurrentModel(cdp, inputCdp, model, 8000, signal);
223
+ log(`Reverified model before submit: ${modelVerified}`);
224
+ }
180
225
  const baseline = normalizeResponseSnapshot(await readChatGPTResponseSnapshot(cdp));
181
226
  if (beforeSubmit) await raceAbort(beforeSubmit, signal);
182
227
  await clickSend(cdp, inputCdp, signal);
@@ -201,6 +246,17 @@ async function dispatch(options) {
201
246
  throw classifyError(error, "dispatch_failed", [
202
247
  "auth",
203
248
  "cloudflare",
249
+ "attachment_chooser_interception",
250
+ "attachment_file_access",
251
+ "attachment_processing",
252
+ "attachment_selector_drift",
253
+ "chat_mode_selection_failed",
254
+ "chat_mode_selector_drift",
255
+ "chat_mode_unavailable",
256
+ "github_tool_disconnected",
257
+ "github_tool_missing",
258
+ "github_tool_selection_failed",
259
+ "github_tool_selector_drift",
204
260
  "model_verification_failed",
205
261
  ]);
206
262
  }
@@ -353,5 +409,8 @@ module.exports = {
353
409
  extractConversationUrl,
354
410
  verifyChatGPTEffortSelection,
355
411
  verifyChatGPTModelSelection,
412
+ selectChatTab,
413
+ selectGitHubTool,
414
+ waitForChatGPTAttachment,
356
415
  CHATGPT_URL,
357
416
  };
package/native/cli.cjs CHANGED
@@ -33,6 +33,7 @@ const { selectEndpoint, connectEndpoint, formatEndpointError } = require("./endp
33
33
  const { createFrameParser, createSocketWriter, writeFrame } = require("./remote-transport.cjs");
34
34
  const { resolveRequestDeadlineMs } = require("./host-sessions.cjs");
35
35
  const { classifyTool } = require("./tool-scope.cjs");
36
+ const { parseVideoFps, validateVideoOutputPath } = require("./video-recorder.cjs");
36
37
  const { AUTO_SCREENSHOT_TOOLS, prepareRemoteTool, validateLocalToolPaths } = require("./file-transfer.cjs");
37
38
  const { authorizeClient, listClients, revokeClient, getStateDir } = require("./remote-auth.cjs");
38
39
  if (IS_WIN) { try { fs.mkdirSync(SURF_TMP, { recursive: true }); } catch {} }
@@ -313,6 +314,14 @@ const TOOLS = {
313
314
  args: [],
314
315
  opts: { refresh: "Validate every binding against Chrome" },
315
316
  },
317
+ "session.cleanup": {
318
+ desc: "Remove idle session bindings and close only Surf-created targets",
319
+ args: [],
320
+ opts: {
321
+ "idle-after": "Required threshold such as 30s, 5m, 1h, or 1d",
322
+ "dry-run": "Report matches without removing bindings or closing targets",
323
+ },
324
+ },
316
325
  "session.info": {
317
326
  desc: "Show one session, target, and scheduler queue state",
318
327
  args: ["name"],
@@ -346,7 +355,7 @@ const TOOLS = {
346
355
  args: ["query"],
347
356
  opts: {
348
357
  "with-page": "Include current page context",
349
- model: "Model: instant, thinking, pro, gpt-5.5, gpt-5.6-sol, or a visible model label",
358
+ model: "Model: gpt-6-astra, latest, gpt-5.6-sol, gpt-5.5",
350
359
  file: "Attach file",
351
360
  timeout: "Timeout in seconds (default: 2700 = 45min)"
352
361
  },
@@ -619,6 +628,37 @@ const TOOLS = {
619
628
  "snap": { desc: "Alias for screenshot (auto-saves to /tmp)", args: [], alias: "screenshot" },
620
629
  }
621
630
  },
631
+ video: {
632
+ desc: "Local WebM recording",
633
+ commands: {
634
+ "video.start": {
635
+ desc: "Start local WebM recording",
636
+ args: ["output"],
637
+ opts: {
638
+ fps: "Frames per second (default: 30, max: 60)",
639
+ },
640
+ examples: [{ cmd: "video start ./demo.webm --fps 30", desc: "Start recording" }],
641
+ },
642
+ "video.stop": {
643
+ desc: "Stop local WebM recording",
644
+ args: [],
645
+ examples: [{ cmd: "video stop", desc: "Stop recording" }],
646
+ },
647
+ "video.status": {
648
+ desc: "Show local WebM recording status",
649
+ args: [],
650
+ examples: [{ cmd: "video status", desc: "Show status" }],
651
+ },
652
+ "video.restart": {
653
+ desc: "Restart local WebM recording",
654
+ args: ["output"],
655
+ opts: {
656
+ fps: "Frames per second (default: 30, max: 60)",
657
+ },
658
+ examples: [{ cmd: "video restart ./take2.webm --fps 60", desc: "Restart recording" }],
659
+ },
660
+ },
661
+ },
622
662
  scroll: {
623
663
  desc: "Scrolling",
624
664
  commands: {
@@ -1561,8 +1601,8 @@ Exclude text content:
1561
1601
  };
1562
1602
 
1563
1603
  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",
1604
+ "session.new", "session.ensure", "session.list", "session.cleanup", "session.info", "session.close", "session.rebind", "session.reopen",
1605
+ "ai", "screenshot", "record", "video.start", "video.stop", "video.status", "video.restart", "animate-audit", "perf-audit", "navigate",
1566
1606
  "form_input", "find_and_type", "autocomplete", "set_value", "smart_type",
1567
1607
  "scroll_to_position", "get_scroll_info", "close_dialogs", "page_state",
1568
1608
  "javascript_tool", "health", "smoke",
@@ -1621,6 +1661,10 @@ const SEE_ALSO = {
1621
1661
  "navigate": ["wait.load", "page.read"],
1622
1662
  "screenshot": ["page.read", "scroll.bottom for fullpage"],
1623
1663
  "record": ["screenshot", "animate-audit", "perf-audit"],
1664
+ "video.start": ["video.stop", "video.status"],
1665
+ "video.stop": ["video.status", "video.restart"],
1666
+ "video.status": ["video.start", "video.stop"],
1667
+ "video.restart": ["video.stop", "video.status"],
1624
1668
  "animate-audit": ["screenshot", "record", "perf-audit", "js"],
1625
1669
  "perf-audit": ["record", "animate-audit", "perf.metrics", "console"],
1626
1670
  "search": ["locate.text", "page.read"],
@@ -1641,11 +1685,16 @@ Usage: surf <command> [args] [options]
1641
1685
 
1642
1686
  Common Commands:
1643
1687
  session.ensure <name> [url] Idempotently create or reuse a tab-bound session
1688
+ session.cleanup --idle-after <duration> Remove idle sessions (opt-in; use --dry-run first)
1644
1689
  navigate <url> Go to URL (alias: go)
1645
1690
  click <ref> Click element by ref or selector
1646
1691
  type <text> Type text at cursor or into element
1647
1692
  screenshot Capture screenshot (alias: snap)
1648
1693
  record Capture screenshot frames into an animated GIF
1694
+ video start <path> Start a long-running local WebM recording
1695
+ video stop Stop the active WebM recording
1696
+ video status Show WebM recording status
1697
+ video restart <path> Replace the active WebM recording
1649
1698
  animate-audit JSON timeline of element animation/style samples
1650
1699
  perf-audit PerformanceObserver snapshot for motion/jank debugging
1651
1700
  page.read Get page accessibility tree (alias: read)
@@ -1698,6 +1747,7 @@ Type: surf type "text" --submit # use --ref e5 to target a fiel
1698
1747
  Screenshot: surf screenshot /tmp/shot.png # auto-saves to /tmp if no path
1699
1748
  Full page screenshot: surf screenshot --full-page /tmp/full.png
1700
1749
  Record animation: surf record --duration 2000 --fps 10 --output /tmp/anim.gif
1750
+ Video recording: surf video start ./demo.webm --fps 30; surf video stop
1701
1751
  Animation audit: surf animate-audit --selector ".thing" --duration 2000 --fps 10
1702
1752
  Performance audit: surf perf-audit --duration 3000 --trigger "click:.cta" --output /tmp/perf.json
1703
1753
  JavaScript: surf js "return document.title"
@@ -1707,6 +1757,7 @@ Device/viewport: surf emulate.device "iPhone 14" | surf resize 375 812
1707
1757
  Cookies: surf cookie list | surf cookie get "name" | surf cookie delete "name"
1708
1758
  Session targeting: surf --session research read | SURF_SESSION=research surf read
1709
1759
  Session status/queue: surf session.info research | surf session.list --refresh
1760
+ Session cleanup: surf session.cleanup --idle-after 1h [--dry-run]
1710
1761
  Recovery: run the exact command printed after Recovery: on tab_gone, session_epoch_stale, tab_busy, or browser_busy
1711
1762
  Concurrency: commands for different session tabs can overlap; each tab remains FIFO; provider flows are browser-exclusive
1712
1763
  Doctor: surf doctor --browser all # native host/socket diagnostics
@@ -2014,12 +2065,17 @@ Options:
2014
2065
  On WSL2, auto installs for Windows Chrome. Use linux for WSLg/Linux browsers.
2015
2066
  --listen <tailscale-ip>:<port>
2016
2067
  Requires surf remote authorize <label> --output <path> first.
2068
+ --socket-mode <600|660>
2069
+ Persist the local POSIX socket mode (default: 600); 660 requires --socket-group.
2070
+ --socket-group <group-or-gid>
2071
+ Persist the local POSIX socket group for mode 660.
2017
2072
 
2018
2073
  Examples:
2019
2074
  surf install hnfbepgmaoklhekckbpjnleifhahkcpl
2020
2075
  surf install hnfbepgmaoklhekckbpjnleifhahkcpl --browser brave
2021
2076
  surf install hnfbepgmaoklhekckbpjnleifhahkcpl --browser all
2022
2077
  surf install hnfbepgmaoklhekckbpjnleifhahkcpl --target linux
2078
+ surf install hnfbepgmaoklhekckbpjnleifhahkcpl --socket-mode 660 --socket-group surf
2023
2079
  `);
2024
2080
  process.exit(0);
2025
2081
  }
@@ -2686,6 +2742,7 @@ if (tool === "session" && firstArg) {
2686
2742
  new: "session.new",
2687
2743
  ensure: "session.ensure",
2688
2744
  list: "session.list",
2745
+ cleanup: "session.cleanup",
2689
2746
  info: "session.info",
2690
2747
  close: "session.close",
2691
2748
  rebind: "session.rebind",
@@ -2715,6 +2772,21 @@ if (tool === "cookie" && firstArg) {
2715
2772
  }
2716
2773
  }
2717
2774
 
2775
+ if (tool === "video" && firstArg) {
2776
+ const videoSubcommands = {
2777
+ start: "video.start",
2778
+ stop: "video.stop",
2779
+ status: "video.status",
2780
+ restart: "video.restart",
2781
+ };
2782
+ const videoTool = videoSubcommands[firstArg];
2783
+ if (videoTool) {
2784
+ tool = videoTool;
2785
+ positional = [tool, ...positional.slice(2)];
2786
+ firstArg = positional[1];
2787
+ }
2788
+ }
2789
+
2718
2790
  if (!tool) {
2719
2791
  console.error("Error: No command specified");
2720
2792
  process.exit(1);
@@ -2879,6 +2951,11 @@ if (tool === "record" && firstArg !== undefined && toolArgs.output === undefined
2879
2951
  firstArg = undefined;
2880
2952
  }
2881
2953
 
2954
+ if ((tool === "video.start" || tool === "video.restart") && firstArg !== undefined && toolArgs.output === undefined) {
2955
+ toolArgs.output = firstArg;
2956
+ firstArg = undefined;
2957
+ }
2958
+
2882
2959
  if (firstArg !== undefined) {
2883
2960
  const primaryKey = PRIMARY_ARG_MAP[tool];
2884
2961
  if (primaryKey && toolArgs[primaryKey] === undefined) {
@@ -3007,8 +3084,11 @@ if (tool === "gemini") {
3007
3084
  if (tool === "network.export" && outputPath !== undefined) {
3008
3085
  toolArgs.output = outputPath;
3009
3086
  }
3087
+ if ((tool === "video.start" || tool === "video.restart") && outputPath !== undefined) {
3088
+ toolArgs.output = outputPath;
3089
+ }
3010
3090
 
3011
- if ((tool === "screenshot" || tool === "record" || tool === "perf-audit" || tool === "page.save") && outputPath && typeof outputPath !== "string") {
3091
+ if ((tool === "screenshot" || tool === "record" || tool === "perf-audit" || tool === "page.save" || tool === "video.start" || tool === "video.restart") && outputPath && typeof outputPath !== "string") {
3012
3092
  console.error("Error: --output requires a file path");
3013
3093
  process.exit(1);
3014
3094
  }
@@ -3018,6 +3098,16 @@ if (tool === "page.save" && !outputPath) {
3018
3098
  process.exit(1);
3019
3099
  }
3020
3100
 
3101
+ if (tool === "video.start" || tool === "video.restart") {
3102
+ try {
3103
+ parseVideoFps(toolArgs.fps);
3104
+ validateVideoOutputPath(toolArgs.output, { createParent: false });
3105
+ } catch (error) {
3106
+ console.error(`Error: ${error.message}`);
3107
+ process.exit(1);
3108
+ }
3109
+ }
3110
+
3021
3111
  if (tool === "screenshot" && outputPath) {
3022
3112
  toolArgs.savePath = outputPath;
3023
3113
  if (options.full) toolArgs.full = true;
@@ -3615,6 +3705,21 @@ async function handleResponse(response) {
3615
3705
  ].join("\t"));
3616
3706
  }
3617
3707
  }
3708
+ } else if (finalTool === "session.cleanup" && data?.success) {
3709
+ const removed = Array.isArray(data.removed) ? data.removed : [];
3710
+ if (removed.length === 0) {
3711
+ console.log("No browser sessions matched the idle cleanup threshold.");
3712
+ } else {
3713
+ const verb = data.dryRun ? "Would remove" : "Removed";
3714
+ for (const entry of removed) {
3715
+ const target = entry.targetAction === "close"
3716
+ ? "target closed"
3717
+ : entry.targetAction === "keep"
3718
+ ? "target kept"
3719
+ : "target already gone";
3720
+ console.log(`${verb} session ${entry.name} tab=${entry.tabId ?? "-"} (${target})`);
3721
+ }
3722
+ }
3618
3723
  } else if (finalTool === "session.info" && data?.session) {
3619
3724
  const entry = data.session;
3620
3725
  console.log(`Session: ${entry.name}`);
@@ -3631,6 +3736,18 @@ async function handleResponse(response) {
3631
3736
  console.log(`Use: export SURF_SESSION=${entry.name}`);
3632
3737
  } else if (finalTool === "session.close" && data?.success) {
3633
3738
  console.log(`Session ${data.name} closed (${data.targetClosed ? "target closed" : "target kept"})`);
3739
+ } else if (finalTool === "video.start" && data?.status === "active") {
3740
+ console.log(`Video recording started: ${data.path} (${data.fps}fps)`);
3741
+ } else if (finalTool === "video.restart" && data?.status === "active") {
3742
+ console.log(`Video recording restarted: ${data.path} (${data.fps}fps)`);
3743
+ } else if (finalTool === "video.stop" && data?.status === "stopped") {
3744
+ console.log(`Saved video recording to ${data.path} (${data.frames} frames, ${data.capturedFrames} captured frames @ ${data.fps}fps)`);
3745
+ } else if (finalTool === "video.status") {
3746
+ if (data?.status === "active") {
3747
+ console.log(`Video recording active: ${data.path} (${data.fps}fps, ${data.frames} frames, ${data.capturedFrames} captured frames)`);
3748
+ } else {
3749
+ console.log("No active video recording");
3750
+ }
3634
3751
  } else if (tool === "screenshot" && data?.base64 && (outputPath || toolArgs.savePath)) {
3635
3752
  const saveTo = transferPlan.downloads?.[0]?.destination || toolArgs.savePath || outputPath;
3636
3753
  fs.writeFileSync(saveTo, Buffer.from(data.base64, "base64"));
@@ -270,10 +270,18 @@ function validateLocalToolPaths(tool, args = {}) {
270
270
  ? args.files.map((value) => normalize("files", value))
271
271
  : String(args.files).split(",").map((value) => normalize("files", value.trim())).join(",");
272
272
  }
273
+ if (tool === "oracle.ask" && args.file !== undefined) {
274
+ normalized.file = Array.isArray(args.file)
275
+ ? args.file.map((value) => normalize("file", value))
276
+ : normalize("file", args.file);
277
+ }
273
278
  if (tool === "screenshot") {
274
279
  if (args.savePath !== undefined && args.output !== undefined) throw transferError("screenshot accepts only one output path", "SURF_PATH_FIELD");
275
280
  for (const field of ["savePath", "output"]) if (args[field] !== undefined) normalized[field] = normalize(field, args[field]);
276
281
  }
282
+ if (tool === "video.start" || tool === "video.restart") {
283
+ if (args.output !== undefined) normalized.output = normalize("output", args.output);
284
+ }
277
285
  if (tool === "network.export") {
278
286
  if (args.har !== undefined && typeof args.har !== "boolean") throw transferError("network.export har must be boolean", "SURF_PATH_FIELD");
279
287
  if (args.jsonl !== undefined && typeof args.jsonl !== "boolean") throw transferError("network.export jsonl must be boolean", "SURF_PATH_FIELD");
@@ -351,6 +359,7 @@ function prepareRemoteTool(tool, args = {}) {
351
359
  if (args.autoScreenshot === true && !AUTO_SCREENSHOT_TOOLS.includes(tool)) throw transferError(`autoScreenshot is not supported for ${tool}`, "SURF_PATH_DESCRIPTOR");
352
360
  if (args.autoScreenshotOutput !== undefined) throw transferError("autoScreenshotOutput is internal", "SURF_PATH_DESCRIPTOR");
353
361
  if (tool === "record") throw transferError("record is not supported with remote endpoint", "SURF_REMOTE_UNSUPPORTED");
362
+ if (typeof tool === "string" && tool.startsWith("video.")) throw transferError("video recording is not supported with remote endpoint", "SURF_REMOTE_UNSUPPORTED");
354
363
  if (tool === "aistudio.build") throw transferError("aistudio.build is not supported for remote connections", "SURF_REMOTE_UNSUPPORTED");
355
364
  if (tool === "smoke" && args.screenshot !== undefined) throw transferError("smoke screenshots are not supported for remote connections", "SURF_REMOTE_UNSUPPORTED");
356
365
  if (tool === "network.export") {
@@ -432,7 +441,7 @@ async function materializeRemoteTool({ tool, args: rawArgs = {}, metadata = null
432
441
  const uploads = meta.uploads === undefined ? [] : meta.uploads;
433
442
  const downloads = meta.downloads === undefined ? [] : meta.downloads;
434
443
  if (!Array.isArray(pathRefs) || !Array.isArray(uploads) || !Array.isArray(downloads) || uploads.length > 1 || downloads.length > 1) throw transferError("invalid transfer metadata", "SURF_PATH_DESCRIPTOR");
435
- if (tool === "record" || tool === "aistudio.build") throw transferError(`${tool} is not supported for remote connections`, "SURF_REMOTE_UNSUPPORTED");
444
+ if (tool === "record" || (typeof tool === "string" && tool.startsWith("video.")) || tool === "aistudio.build") throw transferError(`${tool} is not supported for remote connections`, "SURF_REMOTE_UNSUPPORTED");
436
445
  if (tool === "smoke" && args.screenshot !== undefined) throw transferError("smoke screenshots are not supported for remote connections", "SURF_REMOTE_UNSUPPORTED");
437
446
  if (tool === "network.export") {
438
447
  if (args.har !== undefined && typeof args.har !== "boolean") throw transferError("network.export har must be boolean", "SURF_PATH_FIELD");
@@ -51,7 +51,7 @@ function formatToolContent(result, log = () => {}, options = {}) {
51
51
 
52
52
  if (!result) return text("OK");
53
53
 
54
- if (result.session || Array.isArray(result.sessions) || Object.hasOwn(result, "targetClosed")) {
54
+ if (result.session || Array.isArray(result.sessions) || Array.isArray(result.removed) || Object.hasOwn(result, "targetClosed")) {
55
55
  return text(JSON.stringify(result, null, 2));
56
56
  }
57
57