surf-cli 2.8.0 → 2.10.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.
Files changed (47) hide show
  1. package/README.md +146 -8
  2. package/native/abort.cjs +65 -0
  3. package/native/activity-journal.cjs +55 -0
  4. package/native/ai-queue.cjs +64 -0
  5. package/native/aistudio-build.cjs +21 -13
  6. package/native/aistudio-client.cjs +40 -20
  7. package/native/browser-lock.cjs +2 -2
  8. package/native/chatgpt-client.cjs +49 -31
  9. package/native/cli.cjs +352 -482
  10. package/native/client-transport.cjs +168 -0
  11. package/native/do-executor.cjs +68 -510
  12. package/native/do-parser.cjs +8 -249
  13. package/native/doctor.cjs +55 -5
  14. package/native/endpoint.cjs +174 -0
  15. package/native/file-transfer.cjs +734 -0
  16. package/native/gemini-client.cjs +156 -71
  17. package/native/grok-client.cjs +98 -89
  18. package/native/host-helpers.cjs +43 -26
  19. package/native/host-sessions.cjs +287 -0
  20. package/native/host.cjs +998 -620
  21. package/native/listener.cjs +20 -0
  22. package/native/mcp-server.cjs +60 -65
  23. package/native/network-export.cjs +116 -0
  24. package/native/network-store.cjs +38 -58
  25. package/native/perplexity-client.cjs +46 -17
  26. package/native/playbook-authoring.cjs +44 -0
  27. package/native/playbook-cli.cjs +157 -0
  28. package/native/playbook-client.cjs +259 -0
  29. package/native/playbook-receipts.cjs +109 -0
  30. package/native/playbook-records.cjs +208 -0
  31. package/native/playbook-runtime.cjs +177 -0
  32. package/native/playbooks.cjs +235 -0
  33. package/native/private-state.cjs +156 -0
  34. package/native/redaction.cjs +104 -0
  35. package/native/remote-auth.cjs +279 -0
  36. package/native/remote-transport.cjs +337 -0
  37. package/native/request-pending.cjs +148 -0
  38. package/native/socket-path.cjs +1 -1
  39. package/native/workflow-definition.cjs +368 -0
  40. package/native/workflow-runtime.cjs +225 -0
  41. package/package.json +9 -6
  42. package/playbooks/page/ops/read.json +22 -0
  43. package/playbooks/page/playbook.json +7 -0
  44. package/scripts/install-native-host.cjs +36 -5
  45. package/skills/README.md +11 -5
  46. package/skills/deep-x-research/SKILL.md +106 -0
  47. package/skills/surf/SKILL.md +72 -5
package/native/cli.cjs CHANGED
@@ -1,19 +1,34 @@
1
1
  #!/usr/bin/env node
2
- const net = require("net");
3
2
  const fs = require("fs");
4
3
  const path = require("path");
5
- const os = require("os");
6
4
  const { execFileSync, execSync } = require("child_process");
7
5
  const { loadConfig, getConfigPath, createStarterConfig } = require("./config.cjs");
8
6
  const networkFormatters = require("./formatters/network.cjs");
9
- const networkStore = require("./network-store.cjs");
10
- const { parseDoCommands } = require("./do-parser.cjs");
7
+ const {
8
+ applyArgDefaults,
9
+ formatStep,
10
+ getWorkflowDirs,
11
+ getWorkflowInfo,
12
+ listWorkflows,
13
+ normalizeWorkflow,
14
+ parseDoCommands,
15
+ resolveWorkflow,
16
+ validateWorkflowArgs,
17
+ validateWorkflowFile,
18
+ } = require("./workflow-definition.cjs");
11
19
  const { executeDoSteps } = require("./do-executor.cjs");
20
+ const { openClientTransport } = require("./client-transport.cjs");
12
21
  const { version: VERSION } = require("../package.json");
22
+ const { formatPlaybookOutput, handlePlaybookCli, playbookCommandNeedsBrowser } = require("./playbook-cli.cjs");
13
23
 
14
24
  const IS_WIN = process.platform === "win32";
15
- const { SOCKET_PATH, SURF_TMP, formatSocketError } = require("./socket-path.cjs");
25
+ const { SURF_TMP, formatSocketError } = require("./socket-path.cjs");
16
26
  const { acquireBrowserLock } = require("./browser-lock.cjs");
27
+ const { selectEndpoint, connectEndpoint, formatEndpointError } = require("./endpoint.cjs");
28
+ const { createFrameParser, createSocketWriter, writeFrame } = require("./remote-transport.cjs");
29
+ const { resolveRequestDeadlineMs } = require("./host-sessions.cjs");
30
+ const { AUTO_SCREENSHOT_TOOLS, prepareRemoteTool, validateLocalToolPaths } = require("./file-transfer.cjs");
31
+ const { authorizeClient, listClients, revokeClient, getStateDir } = require("./remote-auth.cjs");
17
32
  if (IS_WIN) { try { fs.mkdirSync(SURF_TMP, { recursive: true }); } catch {} }
18
33
 
19
34
  function parseBrowserLockOptions(noLockFlag) {
@@ -29,11 +44,11 @@ function parseBrowserLockOptions(noLockFlag) {
29
44
  return { noLock, timeoutMs };
30
45
  }
31
46
 
32
- function installBrowserLock({ noLock, timeoutMs }) {
47
+ function installBrowserLock({ noLock, timeoutMs }, endpoint) {
33
48
  let releaseBrowserLock = () => {};
34
49
  if (!noLock) {
35
50
  try {
36
- const lock = acquireBrowserLock(SOCKET_PATH, SURF_TMP, { timeoutMs });
51
+ const lock = acquireBrowserLock(endpoint.key, SURF_TMP, { timeoutMs });
37
52
  releaseBrowserLock = lock.release;
38
53
  } catch (error) {
39
54
  console.error("Error:", error && error.message ? error.message : String(error));
@@ -58,249 +73,6 @@ function installBrowserLock({ noLock, timeoutMs }) {
58
73
  });
59
74
  }
60
75
 
61
- // ============================================================================
62
- // Workflow Resolution and Management
63
- // ============================================================================
64
-
65
- /**
66
- * Get workflow search directories
67
- * @returns {Array<{path: string, scope: string}>}
68
- */
69
- function getWorkflowDirs() {
70
- return [
71
- { path: path.join(process.cwd(), '.surf', 'workflows'), scope: 'project' },
72
- { path: path.join(os.homedir(), '.surf', 'workflows'), scope: 'user' },
73
- ];
74
- }
75
-
76
- /**
77
- * Resolve a workflow by name or path
78
- * @param {string} nameOrPath - Workflow name or file path
79
- * @returns {{ type: 'inline'|'file'|'not_found', content?: string, path?: string, name?: string }}
80
- */
81
- function resolveWorkflow(nameOrPath) {
82
- // Check if it's an inline workflow (contains pipe)
83
- if (nameOrPath.includes('|')) {
84
- return { type: 'inline', content: nameOrPath };
85
- }
86
-
87
- // Check if it's a direct file path (with extension or path separator)
88
- if (nameOrPath.includes('/') || nameOrPath.includes('\\') || nameOrPath.endsWith('.json')) {
89
- if (fs.existsSync(nameOrPath)) {
90
- return { type: 'file', path: nameOrPath };
91
- }
92
- return { type: 'not_found', name: nameOrPath };
93
- }
94
-
95
- // Look up by name in workflow directories
96
- const searchDirs = getWorkflowDirs();
97
-
98
- for (const { path: dir } of searchDirs) {
99
- const filePath = path.join(dir, `${nameOrPath}.json`);
100
- if (fs.existsSync(filePath)) {
101
- return { type: 'file', path: filePath };
102
- }
103
- }
104
-
105
- return { type: 'not_found', name: nameOrPath };
106
- }
107
-
108
- /**
109
- * List all available workflows
110
- * @returns {Array<{name: string, description: string, scope: string, path: string, args?: object}>}
111
- */
112
- function listWorkflows() {
113
- const workflows = [];
114
- const searchDirs = getWorkflowDirs();
115
-
116
- for (const { path: dir, scope } of searchDirs) {
117
- if (fs.existsSync(dir)) {
118
- try {
119
- const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
120
- for (const file of files) {
121
- const filePath = path.join(dir, file);
122
- try {
123
- const content = JSON.parse(fs.readFileSync(filePath, 'utf8'));
124
- workflows.push({
125
- name: content.name || file.replace('.json', ''),
126
- description: content.description || '',
127
- scope,
128
- path: filePath,
129
- args: content.args,
130
- stepCount: content.steps?.length || 0,
131
- });
132
- } catch {
133
- // Skip invalid JSON files
134
- }
135
- }
136
- } catch {
137
- // Skip inaccessible directories
138
- }
139
- }
140
- }
141
-
142
- return workflows;
143
- }
144
-
145
- /**
146
- * Get detailed info about a workflow
147
- * @param {string} name - Workflow name
148
- * @returns {{ error?: string, name?: string, description?: string, args?: object, steps?: Array, path?: string }}
149
- */
150
- function getWorkflowInfo(name) {
151
- const resolved = resolveWorkflow(name);
152
-
153
- if (resolved.type === 'not_found') {
154
- return { error: `Workflow not found: ${name}` };
155
- }
156
-
157
- if (resolved.type === 'inline') {
158
- return { error: 'Cannot get info for inline workflows' };
159
- }
160
-
161
- try {
162
- const content = JSON.parse(fs.readFileSync(resolved.path, 'utf8'));
163
- return {
164
- name: content.name || name,
165
- description: content.description || '',
166
- args: content.args || {},
167
- steps: content.steps || [],
168
- path: resolved.path,
169
- };
170
- } catch (e) {
171
- return { error: `Failed to parse workflow: ${e.message}` };
172
- }
173
- }
174
-
175
- /**
176
- * Validate workflow args against schema
177
- * @param {object} workflow - Workflow with args schema
178
- * @param {object} providedArgs - User-provided args
179
- * @returns {string[]} - Array of error messages
180
- */
181
- function validateWorkflowArgs(workflow, providedArgs) {
182
- const errors = [];
183
- if (workflow.args) {
184
- for (const [name, spec] of Object.entries(workflow.args)) {
185
- if (spec.required && providedArgs[name] === undefined) {
186
- errors.push(`Missing required argument: --${name}`);
187
- }
188
- }
189
- }
190
- return errors;
191
- }
192
-
193
- /**
194
- * Apply default values to workflow args
195
- * @param {object} workflow - Workflow with args schema
196
- * @param {object} providedArgs - User-provided args
197
- * @returns {object} - Args with defaults applied
198
- */
199
- function applyArgDefaults(workflow, providedArgs) {
200
- const vars = { ...providedArgs };
201
- if (workflow.args) {
202
- for (const [name, spec] of Object.entries(workflow.args)) {
203
- if (vars[name] === undefined && spec.default !== undefined) {
204
- vars[name] = spec.default;
205
- }
206
- }
207
- }
208
- return vars;
209
- }
210
-
211
- /**
212
- * Validate a workflow JSON file
213
- * @param {string} filePath - Path to workflow file
214
- * @returns {{ valid: boolean, error?: string, workflow?: object }}
215
- */
216
- function validateWorkflowFile(filePath) {
217
- if (!fs.existsSync(filePath)) {
218
- return { valid: false, error: `File not found: ${filePath}` };
219
- }
220
-
221
- try {
222
- const content = fs.readFileSync(filePath, 'utf8');
223
- const workflow = JSON.parse(content);
224
-
225
- // Basic structure validation
226
- if (!workflow.steps || !Array.isArray(workflow.steps)) {
227
- return { valid: false, error: "Workflow must have a 'steps' array" };
228
- }
229
-
230
- if (workflow.steps.length === 0) {
231
- return { valid: false, error: "Workflow has no steps" };
232
- }
233
-
234
- // Validate each step
235
- for (let i = 0; i < workflow.steps.length; i++) {
236
- const step = workflow.steps[i];
237
-
238
- // Check for loops
239
- if (step.repeat !== undefined || step.each !== undefined) {
240
- if (!step.steps || !Array.isArray(step.steps)) {
241
- return { valid: false, error: `Step ${i + 1}: loop must have a 'steps' array` };
242
- }
243
- continue;
244
- }
245
-
246
- // Regular step must have tool/cmd
247
- if (!step.tool && !step.cmd) {
248
- return { valid: false, error: `Step ${i + 1}: must have 'tool' field` };
249
- }
250
- }
251
-
252
- // Validate args schema if present
253
- if (workflow.args && typeof workflow.args !== 'object') {
254
- return { valid: false, error: "'args' must be an object" };
255
- }
256
-
257
- return { valid: true, workflow };
258
- } catch (e) {
259
- return { valid: false, error: `Invalid JSON: ${e.message}` };
260
- }
261
- }
262
-
263
- /**
264
- * Format a step for display
265
- * @param {object} step - Workflow step
266
- * @param {number} indent - Indentation level
267
- * @returns {string}
268
- */
269
- function formatStep(step, indent = 0) {
270
- const pad = ' '.repeat(indent);
271
-
272
- if (step.repeat !== undefined) {
273
- const lines = [`${pad}repeat ${step.repeat} times:`];
274
- for (const s of step.steps || []) {
275
- lines.push(formatStep(s, indent + 1));
276
- }
277
- if (step.until) {
278
- lines.push(`${pad} until: ${step.until.tool || step.until.cmd}`);
279
- }
280
- return lines.join('\n');
281
- }
282
-
283
- if (step.each !== undefined) {
284
- const lines = [`${pad}each ${step.each} as ${step.as || 'item'}:`];
285
- for (const s of step.steps || []) {
286
- lines.push(formatStep(s, indent + 1));
287
- }
288
- return lines.join('\n');
289
- }
290
-
291
- const tool = step.tool || step.cmd;
292
- const args = step.args || {};
293
- const argStr = Object.entries(args)
294
- .map(([k, v]) => `${k}=${JSON.stringify(v)}`)
295
- .join(' ');
296
-
297
- let line = `${pad}${tool}`;
298
- if (argStr) line += ` ${argStr}`;
299
- if (step.as) line += ` → ${step.as}`;
300
-
301
- return line;
302
- }
303
-
304
76
  // Cross-platform image resize (macOS: sips, Linux: ImageMagick)
305
77
  function resizeImage(filePath, maxSize) {
306
78
  const platform = process.platform;
@@ -336,7 +108,67 @@ function resizeImage(filePath, maxSize) {
336
108
  return { success: false, error: e.message };
337
109
  }
338
110
  }
339
- const args = process.argv.slice(2);
111
+ let args = process.argv.slice(2);
112
+ if (args[0] === "remote") {
113
+ const remoteArgs = args.slice(1);
114
+ const subcommand = remoteArgs[0];
115
+ const stateDir = getStateDir();
116
+ try {
117
+ if (subcommand === "authorize") {
118
+ const label = remoteArgs[1];
119
+ const outputIndex = remoteArgs.indexOf("--output");
120
+ const output = outputIndex === -1 ? undefined : remoteArgs[outputIndex + 1];
121
+ if (!label || !output || output.startsWith("--")) throw new Error("Usage: surf remote authorize <label> --output <credential-file>");
122
+ const client = authorizeClient(label, output, stateDir);
123
+ console.log(`Authorized remote client: ${client.label}`);
124
+ console.log(`Credential: ${client.output}`);
125
+ process.exit(0);
126
+ }
127
+ if (subcommand === "list") {
128
+ const clients = listClients(stateDir);
129
+ if (clients.length === 0) console.log("No authorized remote clients.");
130
+ else for (const client of clients) console.log(`${client.label}\t${client.id}\t${client.createdAt}`);
131
+ process.exit(0);
132
+ }
133
+ if (subcommand === "revoke") {
134
+ const label = remoteArgs[1];
135
+ if (!label || label.startsWith("--")) throw new Error("Usage: surf remote revoke <label>");
136
+ revokeClient(label, stateDir);
137
+ console.log(`Revoked remote client: ${label}`);
138
+ process.exit(0);
139
+ }
140
+ console.error("Usage: surf remote authorize <label> --output <credential-file> | list | revoke <label>");
141
+ process.exit(1);
142
+ } catch (error) {
143
+ console.error(`Error: ${error.message}`);
144
+ process.exit(1);
145
+ }
146
+ }
147
+
148
+ let endpoint;
149
+ try {
150
+ ({ args, endpoint } = selectEndpoint(args));
151
+ } catch (error) {
152
+ console.error(`Error: ${error.message}`);
153
+ process.exit(1);
154
+ }
155
+
156
+ if (["playbook", "pb", "use"].includes(args[0])) {
157
+ if (playbookCommandNeedsBrowser(args)) {
158
+ installBrowserLock(parseBrowserLockOptions(args.includes("--no-lock")), endpoint);
159
+ }
160
+ handlePlaybookCli(args, { endpoint, cwd: process.cwd() })
161
+ .then((result) => {
162
+ if (!result.handled) throw new Error("Playbook command was not handled");
163
+ if (result.value !== undefined) console.log(formatPlaybookOutput(result.value, result.json));
164
+ process.exit(0);
165
+ })
166
+ .catch((error) => {
167
+ console.error(`Error: ${error.message}`);
168
+ process.exit(1);
169
+ });
170
+ return;
171
+ }
340
172
 
341
173
  const ALIASES = {
342
174
  snap: "screenshot",
@@ -405,7 +237,7 @@ const TOOLS = {
405
237
  args: ["query"],
406
238
  opts: {
407
239
  "with-page": "Include current page context",
408
- model: "Model: gemini-3-pro (default), gemini-2.5-pro, gemini-2.5-flash",
240
+ model: "Model: gemini-3.1-pro (default), gemini-3.5-flash, gemini-3.1-flash-lite",
409
241
  file: "Attach file to analyze",
410
242
  "generate-image": "Generate image and save to path",
411
243
  "edit-image": "Edit existing image (use with --output)",
@@ -528,6 +360,12 @@ const TOOLS = {
528
360
  opts: { ids: "Close multiple tabs" },
529
361
  examples: [{ cmd: "tab.close 123", desc: "Close tab" }]
530
362
  },
363
+ "tab.move": {
364
+ desc: "Move tab to another window",
365
+ args: ["id"],
366
+ opts: { ids: "Move multiple tabs", "to-window": "Destination window ID", index: "Destination index" },
367
+ examples: [{ cmd: "tab.move 123 --to-window 456", desc: "Move tab to window" }]
368
+ },
531
369
  "tab.name": {
532
370
  desc: "Register current tab with a name",
533
371
  args: ["name"],
@@ -675,6 +513,7 @@ const TOOLS = {
675
513
  "no-text": "Exclude visible text content",
676
514
  depth: "Maximum tree depth (default: unlimited)",
677
515
  compact: "Remove empty structural elements",
516
+ "max-bytes": "Maximum visible text bytes",
678
517
  },
679
518
  examples: [
680
519
  { cmd: "page.read", desc: "Interactive elements + text content" },
@@ -682,7 +521,7 @@ const TOOLS = {
682
521
  { cmd: "page.read --no-text", desc: "Interactive elements only (no text)" },
683
522
  { cmd: "page.read --depth 3", desc: "Limit to 3 levels deep" },
684
523
  { cmd: "page.read --compact", desc: "Skip empty containers" },
685
- { cmd: "page.read --depth 3 --compact", desc: "Shallow + compact (60% smaller)" },
524
+ { cmd: "page.read --depth 3 --compact --max-bytes 2000", desc: "Shallow + compact output" },
686
525
  { cmd: "read", desc: "Alias" },
687
526
  ]
688
527
  },
@@ -823,7 +662,7 @@ const TOOLS = {
823
662
  ref: "Element ref (uses JS DOM method, more reliable for modals)",
824
663
  submit: "Press enter after",
825
664
  clear: "Clear first",
826
- method: "cdp|js (default: cdp, but ref uses JS automatically)"
665
+ method: "cdp|js (cursor typing uses CDP; selector/ref targets use JS)"
827
666
  },
828
667
  examples: [
829
668
  { cmd: 'type "hello world"', desc: "Type at cursor (CDP events)" },
@@ -896,6 +735,9 @@ const TOOLS = {
896
735
  all: "Show all (no limit)",
897
736
  v: "Verbose output",
898
737
  vv: "Very verbose output",
738
+ "body-mode": "Response bodies: none, text, or all (default: text)",
739
+ "per-body-bytes": "Maximum captured bytes per response body",
740
+ "total-body-bytes": "Maximum captured response-body bytes per tab session",
899
741
  clear: "Clear after reading",
900
742
  stream: "Continuous output"
901
743
  },
@@ -960,9 +802,17 @@ const TOOLS = {
960
802
  "network.export": {
961
803
  desc: "Export captured requests",
962
804
  args: [],
963
- opts: { jsonl: "Export as JSONL", output: "Output file path" },
805
+ opts: {
806
+ har: "Export as HAR 1.2",
807
+ jsonl: "Export as JSONL",
808
+ output: "Output file path",
809
+ "body-mode": "Response bodies: none, text, or all (default: text)",
810
+ "per-body-bytes": "Maximum captured bytes per response body",
811
+ "total-body-bytes": "Maximum captured response-body bytes per tab session",
812
+ },
964
813
  examples: [
965
- { cmd: "network.export --jsonl --output /tmp/requests.jsonl", desc: "Export as JSONL" }
814
+ { cmd: "network.export --jsonl --output /tmp/requests.jsonl", desc: "Export as JSONL" },
815
+ { cmd: "network.export --har --output /tmp/requests.har", desc: "Export as HAR" },
966
816
  ]
967
817
  },
968
818
  "network.path": {
@@ -1567,7 +1417,7 @@ const ALL_SOCKET_TOOLS = [
1567
1417
  "computer",
1568
1418
  "page.read", "page.text", "page.state",
1569
1419
  "locate.role", "locate.text", "locate.label",
1570
- "tab.list", "tab.new", "tab.switch", "tab.close", "tab.name", "tab.unname", "tab.named",
1420
+ "tab.list", "tab.new", "tab.switch", "tab.close", "tab.move", "tab.name", "tab.unname", "tab.named",
1571
1421
  "tab.group", "tab.ungroup", "tab.groups", "tab.reload",
1572
1422
  "scroll.top", "scroll.bottom", "scroll.to", "scroll.info",
1573
1423
  "wait.element", "wait.network", "wait.url", "wait.dom", "wait.load",
@@ -1659,6 +1509,10 @@ Quick Examples:
1659
1509
  surf window.new "https://example.com" && surf --window-id 123 go "https://other.com"
1660
1510
 
1661
1511
  More Help:
1512
+ --remote <host>:<port> Route requests to a remote native host
1513
+ --remote-credential <path> Use a mode-0600 Ed25519 remote credential file
1514
+ surf remote authorize <label> --output <path>
1515
+ surf remote list | surf remote revoke <label>
1662
1516
  surf --help-full All commands
1663
1517
  surf --llm-context Compact reference for AI agents
1664
1518
  surf --help-topic <topic> Topic guide (refs, semantic, frames, devices...)
@@ -1701,6 +1555,10 @@ const showFullHelp = () => {
1701
1555
 
1702
1556
  Usage: surf <command> [args] [options]
1703
1557
 
1558
+ Playbooks:
1559
+ surf playbook|pb <list|show|ops|run|record|suggest|save|client|trace|export|import>
1560
+ surf use <playbook> <op> [--arg value]
1561
+
1704
1562
  `);
1705
1563
  for (const [groupName, group] of Object.entries(TOOLS)) {
1706
1564
  console.log(`${groupName.toUpperCase()} - ${group.desc}`);
@@ -1715,6 +1573,8 @@ Usage: surf <command> [args] [options]
1715
1573
  console.log(`Aliases: snap -> screenshot, read -> page.read, find -> search, go -> navigate
1716
1574
 
1717
1575
  Options:
1576
+ --remote <host>:<port> Route requests to a remote native host
1577
+ --remote-credential <path> Use a mode-0600 Ed25519 remote credential file
1718
1578
  --tab-id <id> Target specific tab
1719
1579
  --window-id <id> Target specific window (isolate from your browsing)
1720
1580
  --json Output raw JSON
@@ -1722,6 +1582,11 @@ Options:
1722
1582
  --soft-fail On error: warn and exit 0 (for non-critical commands)
1723
1583
  --no-lock Bypass the per-socket browser request lock
1724
1584
 
1585
+ Remote Credentials (run on the browser host):
1586
+ surf remote authorize <label> --output <credential-file>
1587
+ surf remote list
1588
+ surf remote revoke <label>
1589
+
1725
1590
  Script Mode:
1726
1591
  surf --script <file> Run workflow from JSON
1727
1592
  surf --script <file> --dry-run
@@ -1937,7 +1802,7 @@ if (args[0] === "server") {
1937
1802
  process.exit(0);
1938
1803
  }
1939
1804
  const { PiChromeMcpServer } = require("./mcp-server.cjs");
1940
- const server = new PiChromeMcpServer();
1805
+ const server = new PiChromeMcpServer(endpoint);
1941
1806
  server.start().catch((err) => {
1942
1807
  console.error("MCP Server error:", err.message);
1943
1808
  process.exit(1);
@@ -1953,7 +1818,7 @@ if (args[0] === "extension-path" || args[0] === "path") {
1953
1818
 
1954
1819
  if (args[0] === "doctor") {
1955
1820
  const { runDoctorCli } = require("./doctor.cjs");
1956
- runDoctorCli(args.slice(1)).then((code) => process.exit(code));
1821
+ runDoctorCli(args.slice(1), endpoint).then((code) => process.exit(code));
1957
1822
  return;
1958
1823
  }
1959
1824
 
@@ -1978,6 +1843,8 @@ Options:
1978
1843
  Multiple: --browser chrome,brave
1979
1844
  --target Install target: auto, linux, windows
1980
1845
  On WSL2, auto installs for Windows Chrome. Use linux for WSLg/Linux browsers.
1846
+ --listen <tailscale-ip>:<port>
1847
+ Requires surf remote authorize <label> --output <path> first.
1981
1848
 
1982
1849
  Examples:
1983
1850
  surf install hnfbepgmaoklhekckbpjnleifhahkcpl
@@ -2121,44 +1988,24 @@ if (args.includes("--script")) {
2121
1988
  process.exit(1);
2122
1989
  }
2123
1990
 
1991
+ let scriptTransport;
2124
1992
  const sendScriptRequest = (toolName, toolArgs = {}) => {
2125
- return new Promise((resolve, reject) => {
2126
- const sock = net.createConnection(SOCKET_PATH, () => {
2127
- const req = {
2128
- type: "tool_request",
2129
- method: "execute_tool",
2130
- params: { tool: toolName, args: toolArgs },
2131
- id: "cli-" + Date.now() + "-" + Math.random(),
2132
- };
2133
- if (scriptTabId) req.tabId = parseInt(scriptTabId, 10);
2134
- sock.write(JSON.stringify(req) + "\n");
2135
- });
2136
- let buf = "";
2137
- sock.on("data", (d) => {
2138
- buf += d.toString();
2139
- const lines = buf.split("\n");
2140
- buf = lines.pop();
2141
- for (const line of lines) {
2142
- if (!line.trim()) continue;
2143
- try {
2144
- const resp = JSON.parse(line);
2145
- sock.end();
2146
- resolve(resp);
2147
- } catch {
2148
- sock.end();
2149
- reject(new Error("Invalid JSON"));
2150
- }
2151
- }
2152
- });
2153
- sock.on("error", (e) => reject(new Error(formatSocketError(e))));
2154
- let timeoutId;
2155
- timeoutId = setTimeout(() => { sock.destroy(); reject(new Error("Timeout")); }, 30000);
2156
- sock.on("close", () => clearTimeout(timeoutId));
2157
- });
1993
+ const req = {
1994
+ type: "tool_request",
1995
+ method: "execute_tool",
1996
+ params: { tool: toolName, args: toolArgs },
1997
+ id: "cli-" + Date.now() + "-" + Math.random(),
1998
+ };
1999
+ if (scriptTabId) req.tabId = parseInt(scriptTabId, 10);
2000
+ const prepared = endpoint.kind === "remote" ? prepareRemoteTool(toolName, toolArgs) : (() => { const args = validateLocalToolPaths(toolName, toolArgs); return { args, uploads: [], downloads: [] }; })();
2001
+ req.params.args = prepared.args;
2002
+ return scriptTransport.request(req, resolveRequestDeadlineMs(toolName, prepared.args), prepared);
2158
2003
  };
2159
2004
 
2160
2005
  const runScript = async () => {
2161
- const total = script.steps.length;
2006
+ try {
2007
+ if (!dryRun) scriptTransport = await openClientTransport(endpoint);
2008
+ const total = script.steps.length;
2162
2009
  const results = [];
2163
2010
  let failed = 0;
2164
2011
 
@@ -2216,14 +2063,22 @@ if (args.includes("--script")) {
2216
2063
  console.log(`Summary: ${passed} passed, ${failed} failed, ${total} total`);
2217
2064
  }
2218
2065
 
2219
- process.exit(failed > 0 ? 1 : 0);
2066
+ return failed > 0 ? 1 : 0;
2067
+ } finally {
2068
+ await scriptTransport?.close();
2069
+ }
2220
2070
  };
2221
2071
 
2222
2072
  if (!dryRun) {
2223
- installBrowserLock(parseBrowserLockOptions(args.includes("--no-lock")));
2073
+ installBrowserLock(parseBrowserLockOptions(args.includes("--no-lock")), endpoint);
2224
2074
  }
2225
2075
 
2226
- runScript();
2076
+ runScript()
2077
+ .then((code) => process.exit(code))
2078
+ .catch((error) => {
2079
+ console.error(`Error: ${error.message}`);
2080
+ process.exit(1);
2081
+ });
2227
2082
  return;
2228
2083
  }
2229
2084
 
@@ -2346,9 +2201,7 @@ if (args[0] === "do") {
2346
2201
 
2347
2202
  // Process workflow file if loaded
2348
2203
  if (workflow) {
2349
- if (!workflow.steps || !Array.isArray(workflow.steps)) {
2350
- throw new Error("Workflow must have a 'steps' array");
2351
- }
2204
+ workflow = normalizeWorkflow(workflow);
2352
2205
 
2353
2206
  // Validate required args
2354
2207
  const argErrors = validateWorkflowArgs(workflow, workflowArgs);
@@ -2368,30 +2221,7 @@ if (args[0] === "do") {
2368
2221
  process.exit(1);
2369
2222
  }
2370
2223
 
2371
- // Convert steps: support both { tool, args } and { cmd, args } formats
2372
- // Also preserve loop steps as-is
2373
- steps = workflow.steps.map(s => {
2374
- if (s.repeat !== undefined || s.each !== undefined) {
2375
- // Loop step - convert nested steps recursively
2376
- const convertSteps = (stepsArr) => stepsArr.map(ns => {
2377
- if (ns.repeat !== undefined || ns.each !== undefined) {
2378
- // Recursively convert nested loop steps and until condition
2379
- return {
2380
- ...ns,
2381
- steps: convertSteps(ns.steps || []),
2382
- until: ns.until ? { cmd: ns.until.tool || ns.until.cmd, args: ns.until.args || {} } : undefined
2383
- };
2384
- }
2385
- return { cmd: ns.tool || ns.cmd, args: ns.args || {}, as: ns.as };
2386
- });
2387
- return {
2388
- ...s,
2389
- steps: convertSteps(s.steps || []),
2390
- until: s.until ? { cmd: s.until.tool || s.until.cmd, args: s.until.args || {} } : undefined
2391
- };
2392
- }
2393
- return { cmd: s.tool || s.cmd, args: s.args || {}, as: s.as };
2394
- });
2224
+ steps = workflow.steps;
2395
2225
  }
2396
2226
  } catch (e) {
2397
2227
  console.error(`Error: Failed to parse workflow: ${e.message}`);
@@ -2425,7 +2255,7 @@ if (args[0] === "do") {
2425
2255
  process.exit(0);
2426
2256
  }
2427
2257
 
2428
- installBrowserLock(parseBrowserLockOptions(doArgs.includes("--no-lock")));
2258
+ installBrowserLock(parseBrowserLockOptions(doArgs.includes("--no-lock")), endpoint);
2429
2259
 
2430
2260
  if (!wantJson) {
2431
2261
  if (workflowName) {
@@ -2436,7 +2266,10 @@ if (args[0] === "do") {
2436
2266
  }
2437
2267
 
2438
2268
  const runWorkflow = async () => {
2439
- const result = await executeDoSteps(steps, {
2269
+ let transport;
2270
+ try {
2271
+ transport = await openClientTransport(endpoint);
2272
+ const result = await executeDoSteps(steps, {
2440
2273
  onError,
2441
2274
  autoWait: !noAutoWait,
2442
2275
  stepDelay,
@@ -2445,30 +2278,40 @@ if (args[0] === "do") {
2445
2278
  context: {
2446
2279
  tabId,
2447
2280
  windowId,
2281
+ endpoint,
2282
+ transport,
2448
2283
  },
2449
- });
2284
+ });
2450
2285
 
2451
2286
  // Print summary
2452
2287
  if (wantJson) {
2453
2288
  console.log(JSON.stringify(result, null, 2));
2454
- process.exit(result.status === "completed" ? 0 : 1);
2289
+ return result.status === "completed" ? 0 : 1;
2455
2290
  }
2456
2291
 
2457
2292
  console.log("");
2458
2293
  if (result.status === "completed") {
2459
2294
  console.log(`Completed: ${result.completedSteps}/${result.totalSteps} steps (${result.totalMs}ms)`);
2460
- process.exit(0);
2295
+ return 0;
2461
2296
  } else if (result.status === "partial") {
2462
2297
  console.log(`Partial: ${result.completedSteps}/${result.totalSteps} steps completed, ${result.failed} failed`);
2463
- process.exit(1);
2298
+ return 1;
2464
2299
  } else {
2465
2300
  console.error(`Failed: ${result.completedSteps}/${result.totalSteps} steps completed`);
2466
2301
  if (result.error) console.error(`Error: ${result.error}`);
2467
- process.exit(1);
2302
+ return 1;
2303
+ }
2304
+ } finally {
2305
+ transport?.close();
2468
2306
  }
2469
2307
  };
2470
2308
 
2471
- runWorkflow();
2309
+ runWorkflow()
2310
+ .then((code) => process.exit(code))
2311
+ .catch((error) => {
2312
+ console.error(`Error: ${error.message}`);
2313
+ process.exit(1);
2314
+ });
2472
2315
  return;
2473
2316
  }
2474
2317
 
@@ -2595,8 +2438,6 @@ if (args[0] === "workflow.validate") {
2595
2438
 
2596
2439
  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"];
2597
2440
 
2598
- const AUTO_SCREENSHOT_TOOLS = ["click", "type", "key", "smart_type", "form.fill", "form_input", "drag", "hover", "scroll", "scroll.top", "scroll.bottom", "scroll.to", "dialog.accept", "dialog.dismiss", "js", "eval"];
2599
-
2600
2441
  const parseArgs = (rawArgs) => {
2601
2442
  const result = { positional: [], options: {} };
2602
2443
  for (let i = 0; i < rawArgs.length; i++) {
@@ -2732,6 +2573,7 @@ const PRIMARY_ARG_MAP = {
2732
2573
  "tab.switch": "id",
2733
2574
  close_tab: "tab_id",
2734
2575
  "tab.close": "id",
2576
+ "tab.move": "id",
2735
2577
  "tab.name": "name",
2736
2578
  "tab.unname": "name",
2737
2579
  scroll_to_position: "position",
@@ -2763,7 +2605,7 @@ const PRIMARY_ARG_MAP = {
2763
2605
  "select": "selector",
2764
2606
  };
2765
2607
 
2766
- const toolArgs = { ...options };
2608
+ let toolArgs = { ...options };
2767
2609
 
2768
2610
  if (tool === "scroll" && firstArg) {
2769
2611
  if (firstArg === "top" || firstArg === "bottom") {
@@ -2824,7 +2666,7 @@ if (firstArg !== undefined) {
2824
2666
  }
2825
2667
  }
2826
2668
 
2827
- if (tool === "js" && toolArgs.file) {
2669
+ if ((tool === "js" || tool === "frame.js") && toolArgs.file) {
2828
2670
  try {
2829
2671
  toolArgs.code = fs.readFileSync(toolArgs.file, "utf8");
2830
2672
  delete toolArgs.file;
@@ -2834,6 +2676,17 @@ if (tool === "js" && toolArgs.file) {
2834
2676
  }
2835
2677
  }
2836
2678
 
2679
+ if (tool === "batch" && toolArgs.file) {
2680
+ try {
2681
+ const parsed = JSON.parse(fs.readFileSync(toolArgs.file, "utf8"));
2682
+ toolArgs.actions = parsed;
2683
+ delete toolArgs.file;
2684
+ } catch (e) {
2685
+ console.error(`Error: Failed to read batch file: ${e.message}`);
2686
+ process.exit(1);
2687
+ }
2688
+ }
2689
+
2837
2690
  // Handle select command: capture multiple values after selector
2838
2691
  if (tool === "select" && positional.length > 2) {
2839
2692
  const values = positional.slice(2); // All args after "select <selector>"
@@ -2869,9 +2722,9 @@ if (toolArgs["window-id"] !== undefined) {
2869
2722
  globalOpts.windowId = wid;
2870
2723
  delete toolArgs["window-id"];
2871
2724
  }
2872
- if (toolArgs["network-path"] !== undefined) {
2873
- networkStore.setBasePath(toolArgs["network-path"]);
2874
- delete toolArgs["network-path"];
2725
+ if (toolArgs["network-path"] !== undefined && typeof toolArgs["network-path"] !== "string") {
2726
+ console.error("Error: --network-path requires a directory");
2727
+ process.exit(1);
2875
2728
  }
2876
2729
  const wantJson = toolArgs.json === true;
2877
2730
  delete toolArgs.json;
@@ -2898,23 +2751,18 @@ if (tool === "aistudio.build" && outputPath) {
2898
2751
  toolArgs.output = path.resolve(outputPath);
2899
2752
  }
2900
2753
  if (tool === "gemini") {
2901
- if (outputPath) toolArgs.output = path.resolve(outputPath);
2902
- if (toolArgs["generate-image"] && typeof toolArgs["generate-image"] === "string") {
2903
- toolArgs["generate-image"] = path.resolve(toolArgs["generate-image"]);
2904
- }
2905
- if (toolArgs["edit-image"] && typeof toolArgs["edit-image"] === "string") {
2906
- toolArgs["edit-image"] = path.resolve(toolArgs["edit-image"]);
2907
- }
2908
- if (toolArgs.file && typeof toolArgs.file === "string") {
2909
- toolArgs.file = path.resolve(toolArgs.file);
2754
+ if (outputPath !== undefined) toolArgs.output = outputPath;
2755
+ if (toolArgs.model) {
2756
+ const known = ["gemini-3.1-pro", "gemini-3.5-flash", "gemini-3.1-flash-lite"];
2757
+ if (!known.includes(toolArgs.model)) {
2758
+ process.stderr.write(
2759
+ `warning: unknown Gemini model "${toolArgs.model}"; using "gemini-3.1-pro". Available: ${known.join(", ")}\n`,
2760
+ );
2761
+ }
2910
2762
  }
2911
2763
  }
2912
- if (tool === "chatgpt" && toolArgs.file) {
2913
- if (Array.isArray(toolArgs.file)) {
2914
- toolArgs.file = toolArgs.file.map((filePath) => path.resolve(filePath));
2915
- } else if (typeof toolArgs.file === "string") {
2916
- toolArgs.file = path.resolve(toolArgs.file);
2917
- }
2764
+ if (tool === "network.export" && outputPath !== undefined) {
2765
+ toolArgs.output = outputPath;
2918
2766
  }
2919
2767
 
2920
2768
  if ((tool === "screenshot" || tool === "record" || tool === "perf-audit") && outputPath && typeof outputPath !== "string") {
@@ -2938,7 +2786,9 @@ const streamMode = toolArgs.stream === true;
2938
2786
  delete toolArgs.stream;
2939
2787
 
2940
2788
  const streamLevel = toolArgs.level;
2941
- delete toolArgs.level;
2789
+ if (tool === "console" || tool === "network") {
2790
+ delete toolArgs.level;
2791
+ }
2942
2792
 
2943
2793
  const streamFilter = toolArgs.filter;
2944
2794
  delete toolArgs.filter;
@@ -2946,11 +2796,15 @@ delete toolArgs.filter;
2946
2796
  let finalTool = tool;
2947
2797
  if (methodFlag === "js") {
2948
2798
  if (tool === "type") {
2949
- if (!toolArgs.selector) {
2950
- console.error("Error: --selector or --into required for type with --method js");
2951
- process.exit(1);
2799
+ if (toolArgs.ref) {
2800
+ finalTool = "type";
2801
+ } else {
2802
+ if (!toolArgs.selector) {
2803
+ console.error("Error: --selector, --into, or --ref required for type with --method js");
2804
+ process.exit(1);
2805
+ }
2806
+ finalTool = "smart_type";
2952
2807
  }
2953
- finalTool = "smart_type";
2954
2808
  } else if (tool === "click") {
2955
2809
  if (!toolArgs.selector) {
2956
2810
  console.error("Error: --selector required for click with --method js");
@@ -2961,8 +2815,13 @@ if (methodFlag === "js") {
2961
2815
  finalTool = "js";
2962
2816
  }
2963
2817
  } else if (methodFlag === "cdp") {
2818
+ if (tool === "type" && (toolArgs.selector || toolArgs.ref)) {
2819
+ console.error("Error: --method cdp types at the current focus and cannot be combined with --into, --selector, or --ref");
2820
+ process.exit(1);
2821
+ }
2964
2822
  if (tool === "smart_type") {
2965
- finalTool = "type";
2823
+ console.error("Error: smart_type uses the JS input path and cannot be combined with --method cdp");
2824
+ process.exit(1);
2966
2825
  }
2967
2826
  }
2968
2827
 
@@ -2980,8 +2839,10 @@ if (streamMode && (tool === "console" || tool === "network")) {
2980
2839
 
2981
2840
  let connectionTimeout = null;
2982
2841
  let receivedData = false;
2842
+ let streamWriter;
2983
2843
 
2984
- const sock = net.createConnection(SOCKET_PATH, () => {
2844
+ const sock = connectEndpoint(endpoint, () => {
2845
+ streamWriter = createSocketWriter(sock, { onOverflow: ({ error }) => sock.destroy(error) });
2985
2846
  const req = {
2986
2847
  type: "stream_request",
2987
2848
  streamType,
@@ -2989,7 +2850,11 @@ if (streamMode && (tool === "console" || tool === "network")) {
2989
2850
  id: "cli-stream-" + Date.now(),
2990
2851
  ...globalOpts,
2991
2852
  };
2992
- sock.write(JSON.stringify(req) + "\n");
2853
+ streamWriter.send(req).catch((error) => sock.destroy(error));
2854
+ if (connectionTimeout) {
2855
+ clearTimeout(connectionTimeout);
2856
+ connectionTimeout = null;
2857
+ }
2993
2858
  connectionTimeout = setTimeout(() => {
2994
2859
  if (!receivedData) {
2995
2860
  console.error("Error: Stream connection timeout (10s) - no data received");
@@ -2999,57 +2864,62 @@ if (streamMode && (tool === "console" || tool === "network")) {
2999
2864
  }, 10000);
3000
2865
  });
3001
2866
 
3002
- let buf = "";
3003
- sock.on("data", (d) => {
3004
- if (!receivedData) {
3005
- receivedData = true;
3006
- if (connectionTimeout) {
3007
- clearTimeout(connectionTimeout);
3008
- connectionTimeout = null;
3009
- }
3010
- }
3011
- buf += d.toString();
3012
- const lines = buf.split("\n");
3013
- buf = lines.pop();
3014
- for (const line of lines) {
3015
- if (!line.trim()) continue;
3016
- try {
3017
- const msg = JSON.parse(line);
3018
- if (msg.error) {
3019
- console.error("Error:", msg.error);
3020
- sock.end();
3021
- process.exit(1);
3022
- }
3023
- if (msg.type === "extension_disconnected") {
3024
- console.error(msg.message);
3025
- sock.end();
3026
- process.exit(1);
3027
- }
3028
- if (msg.type === "stream_started") {
3029
- continue;
3030
- }
3031
- if (msg.type === "console_event") {
3032
- const { level, text, timestamp } = msg;
3033
- if (streamLevel && level !== streamLevel) continue;
3034
- console.log(`[console] [${level}] ${formatTime(timestamp)} ${text}`);
3035
- } else if (msg.type === "network_event") {
3036
- const { method, url, status, duration } = msg;
3037
- if (streamFilter && !url.includes(streamFilter)) continue;
3038
- const statusStr = status !== undefined ? status : "...";
3039
- const durationStr = duration !== undefined ? ` (${duration}ms)` : "";
3040
- console.log(`[network] ${method} ${url} ${statusStr}${durationStr}`);
2867
+ connectionTimeout = setTimeout(() => {
2868
+ console.error(`Error: Stream connection timeout (10s) - could not connect to ${endpoint.display}`);
2869
+ sock.destroy();
2870
+ process.exit(1);
2871
+ }, 10000);
2872
+
2873
+ const parser = createFrameParser({
2874
+ onFrame(msg) {
2875
+ if (!receivedData) {
2876
+ receivedData = true;
2877
+ if (connectionTimeout) {
2878
+ clearTimeout(connectionTimeout);
2879
+ connectionTimeout = null;
3041
2880
  }
3042
- } catch {}
3043
- }
2881
+ }
2882
+ if (msg.error) {
2883
+ console.error("Error:", msg.error);
2884
+ sock.end();
2885
+ process.exit(1);
2886
+ }
2887
+ if (msg.type === "extension_disconnected") {
2888
+ console.error(msg.message);
2889
+ sock.end();
2890
+ process.exit(1);
2891
+ }
2892
+ if (msg.type === "stream_started") return;
2893
+ if (msg.type === "console_event") {
2894
+ const { level, text, timestamp } = msg;
2895
+ if (streamLevel && level !== streamLevel) return;
2896
+ console.log(`[console] [${level}] ${formatTime(timestamp)} ${text}`);
2897
+ } else if (msg.type === "network_event") {
2898
+ const { method, url, status, duration } = msg;
2899
+ if (streamFilter && !url.includes(streamFilter)) return;
2900
+ const statusStr = status !== undefined ? status : "...";
2901
+ const durationStr = duration !== undefined ? ` (${duration}ms)` : "";
2902
+ console.log(`[network] ${method} ${url} ${statusStr}${durationStr}`);
2903
+ }
2904
+ },
2905
+ onError(error) {
2906
+ if (connectionTimeout) clearTimeout(connectionTimeout);
2907
+ console.error("Error:", error.message);
2908
+ sock.destroy();
2909
+ process.exit(1);
2910
+ },
3044
2911
  });
2912
+ sock.on("data", (data) => parser.push(data));
3045
2913
 
3046
2914
  sock.on("error", (e) => {
3047
- console.error("Error:", formatSocketError(e));
2915
+ if (connectionTimeout) clearTimeout(connectionTimeout);
2916
+ console.error("Error:", formatEndpointError(e, endpoint, formatSocketError));
3048
2917
  process.exit(1);
3049
2918
  });
3050
2919
 
3051
2920
  process.on("SIGINT", () => {
3052
- sock.write(JSON.stringify({ type: "stream_stop" }) + "\n");
2921
+ if (connectionTimeout) clearTimeout(connectionTimeout);
2922
+ streamWriter?.send({ type: "stream_stop" }).catch(() => {});
3053
2923
  sock.end();
3054
2924
  process.exit(0);
3055
2925
  });
@@ -3057,6 +2927,17 @@ if (streamMode && (tool === "console" || tool === "network")) {
3057
2927
  return;
3058
2928
  }
3059
2929
 
2930
+ let transferPlan;
2931
+ try {
2932
+ transferPlan = endpoint.kind === "remote" ? prepareRemoteTool(finalTool, toolArgs) : (() => { const args = validateLocalToolPaths(finalTool, toolArgs); return { args, uploads: [], downloads: [] }; })();
2933
+ } catch (error) {
2934
+ const message = finalTool === "record" && endpoint.kind === "remote"
2935
+ ? `record is not supported with remote endpoint ${endpoint.display}`
2936
+ : error.message;
2937
+ console.error(`Error: ${message}`);
2938
+ process.exit(1);
2939
+ }
2940
+ toolArgs = transferPlan.args;
3060
2941
  const request = {
3061
2942
  type: "tool_request",
3062
2943
  method: "execute_tool",
@@ -3065,45 +2946,20 @@ const request = {
3065
2946
  ...globalOpts,
3066
2947
  };
3067
2948
 
3068
- const sendRequest = (toolName, toolArgs = {}, timeoutMs = 5000) => {
3069
- return new Promise((resolve, reject) => {
3070
- const sock = net.createConnection(SOCKET_PATH, () => {
3071
- const req = {
3072
- type: "tool_request",
3073
- method: "execute_tool",
3074
- params: { tool: toolName, args: toolArgs },
3075
- id: "cli-" + Date.now() + "-" + Math.random(),
3076
- ...globalOpts,
3077
- };
3078
- sock.write(JSON.stringify(req) + "\n");
3079
- });
3080
- let buf = "";
3081
- sock.on("data", (d) => {
3082
- buf += d.toString();
3083
- const lines = buf.split("\n");
3084
- buf = lines.pop();
3085
- for (const line of lines) {
3086
- if (!line.trim()) continue;
3087
- try {
3088
- const resp = JSON.parse(line);
3089
- if (resp.type === "extension_disconnected") {
3090
- sock.end();
3091
- reject(new Error(resp.message));
3092
- return;
3093
- }
3094
- sock.end();
3095
- resolve(resp);
3096
- } catch {
3097
- sock.end();
3098
- reject(new Error("Invalid JSON"));
3099
- }
3100
- }
3101
- });
3102
- sock.on("error", (e) => reject(new Error(formatSocketError(e))));
3103
- let timeoutId;
3104
- timeoutId = setTimeout(() => { sock.destroy(); reject(new Error("Timeout")); }, timeoutMs);
3105
- sock.on("close", () => clearTimeout(timeoutId));
3106
- });
2949
+ const sendRequest = async (toolName, toolArgs = {}, timeoutMs = 5000) => {
2950
+ const transport = await openClientTransport(endpoint, { requestTimeoutMs: timeoutMs });
2951
+ try {
2952
+ const prepared = endpoint.kind === "remote" ? prepareRemoteTool(toolName, toolArgs) : (() => { const args = validateLocalToolPaths(toolName, toolArgs); return { args, uploads: [], downloads: [] }; })();
2953
+ return await transport.request({
2954
+ type: "tool_request",
2955
+ method: "execute_tool",
2956
+ params: { tool: toolName, args: prepared.args },
2957
+ id: "cli-" + Date.now() + "-" + Math.random(),
2958
+ ...globalOpts,
2959
+ }, timeoutMs, prepared);
2960
+ } finally {
2961
+ await transport.close();
2962
+ }
3107
2963
  };
3108
2964
 
3109
2965
  function parseRecordNumber(value, fallback, name, min, max) {
@@ -3270,7 +3126,11 @@ const performAutoCapture = async () => {
3270
3126
  };
3271
3127
 
3272
3128
  if (finalTool === "record") {
3273
- installBrowserLock(lockOptions);
3129
+ if (endpoint.kind === "remote") {
3130
+ console.error(`Error: record is not supported with remote endpoint ${endpoint.display}`);
3131
+ process.exit(1);
3132
+ }
3133
+ installBrowserLock(lockOptions, endpoint);
3274
3134
  runRecord()
3275
3135
  .then(() => process.exit(0))
3276
3136
  .catch((error) => {
@@ -3280,57 +3140,67 @@ if (finalTool === "record") {
3280
3140
  return;
3281
3141
  }
3282
3142
 
3283
- installBrowserLock(lockOptions);
3143
+ installBrowserLock(lockOptions, endpoint);
3144
+ let socket;
3145
+ let timeout;
3146
+
3147
+ if (endpoint.kind === "remote") {
3148
+ socket = { end() {}, destroy() {} };
3149
+ const requestTimeout = resolveRequestDeadlineMs(tool, toolArgs);
3150
+ openClientTransport(endpoint, { requestTimeoutMs: requestTimeout })
3151
+ .then(async (transport) => {
3152
+ try {
3153
+ const response = await transport.request(request, requestTimeout, transferPlan);
3154
+ await handleResponse(response);
3155
+ } finally {
3156
+ await transport.close();
3157
+ }
3158
+ })
3159
+ .catch((error) => {
3160
+ console.error(`Error: ${error.message}`);
3161
+ process.exit(1);
3162
+ });
3163
+ return;
3164
+ }
3284
3165
 
3285
- const socket = net.createConnection(SOCKET_PATH, () => {
3286
- socket.write(JSON.stringify(request) + "\n");
3166
+ socket = connectEndpoint(endpoint, () => {
3167
+ writeFrame(socket, request).catch((error) => socket.destroy(error));
3287
3168
  });
3288
3169
 
3289
- const AI_TOOLS = ["smoke", "chatgpt", "gemini", "perplexity", "grok", "aistudio", "aistudio.build", "ai"];
3290
- let requestTimeout = AI_TOOLS.includes(tool) ? 300000 : 30000;
3291
- if (tool === "aistudio.build") {
3292
- const userTimeoutSec = parseInt(options.timeout || "600", 10);
3293
- requestTimeout = (userTimeoutSec * 1000) + 60000;
3294
- }
3295
- const timeout = setTimeout(() => {
3170
+ const requestTimeout = resolveRequestDeadlineMs(tool, options);
3171
+ timeout = setTimeout(() => {
3296
3172
  console.error(`Error: Request timed out (${requestTimeout / 1000}s)`);
3297
3173
  socket.destroy();
3298
3174
  process.exit(1);
3299
3175
  }, requestTimeout);
3300
3176
 
3301
- let buffer = "";
3302
-
3303
- socket.on("data", (data) => {
3304
- buffer += data.toString();
3305
- const lines = buffer.split("\n");
3306
- buffer = lines.pop();
3307
-
3308
- for (const line of lines) {
3309
- if (!line.trim()) continue;
3310
- try {
3311
- const msg = JSON.parse(line);
3312
-
3313
- if (msg.type === "extension_disconnected") {
3314
- clearTimeout(timeout);
3315
- console.error(msg.message);
3316
- socket.end();
3317
- process.exit(1);
3318
- }
3319
-
3320
- handleResponse(msg).catch((err) => {
3321
- console.error("Handler error:", err.message);
3322
- process.exit(1);
3323
- });
3324
- } catch (e) {
3325
- console.error("Invalid JSON response:", line);
3177
+ const responseParser = createFrameParser({
3178
+ onFrame(msg) {
3179
+ if (msg.type === "extension_disconnected") {
3180
+ clearTimeout(timeout);
3181
+ console.error(msg.message);
3182
+ socket.end();
3326
3183
  process.exit(1);
3327
3184
  }
3328
- }
3185
+ if (msg.id !== request.id) return;
3186
+ handleResponse(msg).catch((err) => {
3187
+ console.error("Handler error:", err.message);
3188
+ process.exit(1);
3189
+ });
3190
+ },
3191
+ onError(error) {
3192
+ clearTimeout(timeout);
3193
+ console.error("Invalid response frame:", error.message);
3194
+ socket.destroy();
3195
+ process.exit(1);
3196
+ },
3329
3197
  });
3330
3198
 
3199
+ socket.on("data", (data) => responseParser.push(data));
3200
+
3331
3201
  socket.on("error", (err) => {
3332
3202
  clearTimeout(timeout);
3333
- console.error("Error:", formatSocketError(err));
3203
+ console.error("Error:", formatEndpointError(err, endpoint, formatSocketError));
3334
3204
  process.exit(1);
3335
3205
  });
3336
3206
 
@@ -3389,7 +3259,7 @@ async function handleResponse(response) {
3389
3259
  }
3390
3260
 
3391
3261
  if (tool === "screenshot" && data?.base64 && (outputPath || toolArgs.savePath)) {
3392
- const saveTo = outputPath || toolArgs.savePath;
3262
+ const saveTo = transferPlan.downloads?.[0]?.destination || toolArgs.savePath || outputPath;
3393
3263
  fs.writeFileSync(saveTo, Buffer.from(data.base64, "base64"));
3394
3264
 
3395
3265
  const skipResize = options.full || toolArgs.full;