@mearl/client 2.20.2 → 2.20.3

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/README.md CHANGED
@@ -76,6 +76,8 @@ mearl browser_release
76
76
 
77
77
  多步骤任务首次 `tab_open` 或 `page_snapshot` 使用 `--session new`,响应在原结果旁附带 `session`,后续用其 `sessionId` 自动路由,无需额外创建/查询 action。Session 绑定一个浏览器与传输;页面首次使用自动关联,复用页面与新建页面分别记录。同一页面只允许一个会话控制,旧调用不能绕过占用;Session 不隔离 Cookie 或存储。
78
78
 
79
+ `sessionId` 是形如 `s_aB3d_E-f` 的短 ID。会话及其浏览器、传输映射保存在常驻控制宿主的内存中;新的 CLI 进程会内部查询本机和远端宿主完成路由,无需调用方保存或传递路由信息,也不写入会话映射文件。控制宿主退出后会话失效。
80
+
79
81
  旧调用不必传 `--session`,现有动作、`run_actions` 批次和上传 `filePaths` 参数继续可用。活动会话存在时,旧调用不能操作其占用页面、执行浏览器级释放/关闭或修改共享设置;旧批次中的开页/关闭浏览器需改为独立调用。CDP Mock 按标签页生效,跨页使用同一 Mock 时需分别配置。
80
82
 
81
83
  ```bash
@@ -271,7 +273,7 @@ export default function register(app) {
271
273
  `app.command(signature, handler, description?, examples?)` 注册 CLI、SDK 和 Agent 可调用的业务命令。
272
274
  `app.toolboxCommand(signature, handler, description?, examples?)` 注册仅供工具页面调用的命令,参数和 handler 契约相同;
273
275
  它不出现在 CLI 帮助中,CLI(包括 JSON payload)、SDK、MCP 及插件间的 `app.invoke` 都不能调用。
274
- 工具页面通过 `window.mearl.invoke` 或 `window.mearl.start` 执行;两类命令都必须显式列在 `toolbox.page.commands` 中才能供页面调用。
276
+ 工具页面通过 `window.mearl.invoke(command, input)` 或 `window.mearl.invoke('host.operation.start', { command, input })` 执行;两类命令都必须显式列在 `toolbox.page.commands` 中才能供页面调用。
275
277
  注册的 `toolboxCommand` 若没有列入该白名单,插件加载会失败。分块上传、分块读取和页面临时资源释放适合使用它;
276
278
  对 Agent 有独立业务价值的查询、导入、压缩、比较等完整操作使用 `app.command`。
277
279
  Python 入口也支持 `app.toolboxCommand(...)`,包括 `@app.toolboxCommand(..., description="...")` 装饰器。
@@ -722,7 +722,19 @@ export declare const COMMANDS: readonly [{
722
722
  readonly name: "expression";
723
723
  readonly type: "string";
724
724
  readonly required: true;
725
- readonly description: "要执行的 JS 表达式";
725
+ readonly description: "要执行的 JS 表达式;传 args 时须求值为函数";
726
+ }, {
727
+ readonly name: "args";
728
+ readonly type: "unknown[]";
729
+ readonly description: "传给页面函数的 JSON 参数;SDK/CLI 自动处理大参数分块,保持指定执行环境";
730
+ }, {
731
+ readonly name: "executionWorld";
732
+ readonly type: "string";
733
+ readonly description: "main(默认)读取网页 JS;isolated 使用命名隔离环境,扩展连接无需 debugger";
734
+ }, {
735
+ readonly name: "worldId";
736
+ readonly type: "string";
737
+ readonly description: "isolated 必填,小写字母开头,允许数字和连字符,最长 64 字符";
726
738
  }, {
727
739
  readonly name: "awaitPromise";
728
740
  readonly type: "boolean";
@@ -953,6 +965,10 @@ export declare const COMMANDS: readonly [{
953
965
  readonly name: "quality";
954
966
  readonly type: "number";
955
967
  readonly description: "JPEG 压缩质量(0-100),默认 80";
968
+ }, {
969
+ readonly name: "captureMode";
970
+ readonly type: "\"cdp\" | \"visible\"";
971
+ readonly description: "默认 cdp;visible 使用活动标签页截图,普通网页无需 debugger;设备模拟复用模拟会话并截取业务视口,不支持 selector";
956
972
  }, {
957
973
  readonly name: "tabId";
958
974
  readonly type: "number";
@@ -960,7 +976,7 @@ export declare const COMMANDS: readonly [{
960
976
  readonly description: "目标标签页 ID";
961
977
  }];
962
978
  readonly notes: readonly ["使用 --output <path> 保存图片;省略时 stdout 返回含 base64 data 的 JSON,避免直接将其加载为文本。"];
963
- readonly result: readonly ["meta 包含 format、width、height、size、url、title;宽高为物理像素(CSS 尺寸 × DPR)。"];
979
+ readonly result: readonly ["meta 包含 format、width、height、size、url、title;宽高为实际截图的物理像素。"];
964
980
  readonly examples: ["page_screenshot --payload '{\"tabId\":12345}' --output ./screenshot.png", "page_screenshot --payload '{\"tabId\":12345,\"selector\":\".product-card\"}' --output ./card.png"];
965
981
  }, {
966
982
  readonly name: "set_device_emulation";
package/dist/commands.js CHANGED
@@ -787,7 +787,27 @@ export const COMMANDS = [
787
787
  name: 'page_eval',
788
788
  description: '在页面上下文中执行任意 JavaScript 表达式',
789
789
  params: [
790
- { name: 'expression', type: 'string', required: true, description: '要执行的 JS 表达式' },
790
+ {
791
+ name: 'expression',
792
+ type: 'string',
793
+ required: true,
794
+ description: '要执行的 JS 表达式;传 args 时须求值为函数',
795
+ },
796
+ {
797
+ name: 'args',
798
+ type: 'unknown[]',
799
+ description: '传给页面函数的 JSON 参数;SDK/CLI 自动处理大参数分块,保持指定执行环境',
800
+ },
801
+ {
802
+ name: 'executionWorld',
803
+ type: 'string',
804
+ description: 'main(默认)读取网页 JS;isolated 使用命名隔离环境,扩展连接无需 debugger',
805
+ },
806
+ {
807
+ name: 'worldId',
808
+ type: 'string',
809
+ description: 'isolated 必填,小写字母开头,允许数字和连字符,最长 64 字符',
810
+ },
791
811
  {
792
812
  name: 'awaitPromise',
793
813
  type: 'boolean',
@@ -1049,14 +1069,17 @@ export const COMMANDS = [
1049
1069
  { name: 'selector', type: 'string', description: 'CSS 选择器,提供后只截取该元素区域' },
1050
1070
  { name: 'format', type: '"png" | "jpeg"', description: '图片格式,默认 png' },
1051
1071
  { name: 'quality', type: 'number', description: 'JPEG 压缩质量(0-100),默认 80' },
1072
+ {
1073
+ name: 'captureMode',
1074
+ type: '"cdp" | "visible"',
1075
+ description: '默认 cdp;visible 使用活动标签页截图,普通网页无需 debugger;设备模拟复用模拟会话并截取业务视口,不支持 selector',
1076
+ },
1052
1077
  { name: 'tabId', type: 'number', required: true, description: '目标标签页 ID' },
1053
1078
  ],
1054
1079
  notes: [
1055
1080
  '使用 --output <path> 保存图片;省略时 stdout 返回含 base64 data 的 JSON,避免直接将其加载为文本。',
1056
1081
  ],
1057
- result: [
1058
- 'meta 包含 format、width、height、size、url、title;宽高为物理像素(CSS 尺寸 × DPR)。',
1059
- ],
1082
+ result: ['meta 包含 format、width、height、size、url、title;宽高为实际截图的物理像素。'],
1060
1083
  examples: [
1061
1084
  `page_screenshot --payload '{"tabId":12345}' --output ./screenshot.png`,
1062
1085
  `page_screenshot --payload '{"tabId":12345,"selector":".product-card"}' --output ./card.png`,
@@ -437,6 +437,7 @@ export interface PageScreenshotParams {
437
437
  selector?: string;
438
438
  format?: 'png' | 'jpeg';
439
439
  quality?: number;
440
+ captureMode?: 'cdp' | 'visible';
440
441
  }
441
442
  export declare const BROWSER_DEVICE_PRESET_NAMES: readonly ["iphone-duo-folded", "iphone-duo-unfolded", "iphone-17-pro", "iphone-17e", "pixel-11", "ipad-mini-a17-pro", "galaxy-s26", "pura-x-max", "iphone-15-pro", "iphone-se", "pixel-8", "ipad-mini", "galaxy-s23"];
442
443
  export type BrowserDevicePresetName = (typeof BROWSER_DEVICE_PRESET_NAMES)[number];
@@ -615,11 +616,22 @@ export interface PageHoverParams {
615
616
  export interface PageEvalParams {
616
617
  tabId: number;
617
618
  expression: string;
619
+ /** When provided, expression must evaluate to a function called with these JSON arguments. */
620
+ args?: unknown[];
618
621
  frameId?: string;
619
622
  awaitPromise?: boolean;
620
- observe?: PageObservationWaitOptions;
623
+ observe?: PageObservationWaitOptions | false;
621
624
  diagnostics?: PageDiagnosticsSetting;
625
+ executionWorld?: 'main' | 'isolated';
626
+ worldId?: string;
627
+ }
628
+ export declare function resolvePageEvalExpression(params: Pick<PageEvalParams, 'expression' | 'args'>): string;
629
+ export interface PageUserScriptParams {
630
+ tabId: number;
631
+ worldId: string;
632
+ code: string;
622
633
  }
634
+ export declare function validatePageUserScriptParams(params: PageUserScriptParams): void;
623
635
  export interface PageSnapshotQuery {
624
636
  text?: string;
625
637
  role?: string;
@@ -50,6 +50,26 @@ export const APP_PROFILE_NAMES = [
50
50
  'didi',
51
51
  'easybike',
52
52
  ];
53
+ export function resolvePageEvalExpression(params) {
54
+ if (typeof params.expression !== 'string' || !params.expression.trim())
55
+ throw new Error('Invalid expression');
56
+ if (params.args === undefined)
57
+ return params.expression;
58
+ if (!Array.isArray(params.args))
59
+ throw new Error('page_eval args must be an array');
60
+ return `(${params.expression})(...JSON.parse(${JSON.stringify(JSON.stringify(params.args))}))`;
61
+ }
62
+ export function validatePageUserScriptParams(params) {
63
+ if (!Number.isSafeInteger(params?.tabId) || params.tabId < 0) {
64
+ throw new Error('Invalid target tab');
65
+ }
66
+ if (typeof params.worldId !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/.test(params.worldId)) {
67
+ throw new Error('Invalid user script worldId');
68
+ }
69
+ if (typeof params.code !== 'string' || !params.code.trim() || params.code.length > 256 * 1024) {
70
+ throw new Error('User script code must be non-empty and within 256 KiB characters');
71
+ }
72
+ }
53
73
  export const TAB_ID_REQUIRED_ACTIONS = [
54
74
  'tab_checkpoint',
55
75
  'capture_checkpoint',
@@ -0,0 +1,3 @@
1
+ import { type PageEvalParams } from './generated-browser-action-protocol.js';
2
+ /** Serialize page_eval arguments within transport limits while retaining the caller's execution world. */
3
+ export declare function invokePageEvalWithArgs(invoke: (payload: Record<string, unknown>, transfer?: boolean) => Promise<any>, params: PageEvalParams): Promise<any>;
@@ -0,0 +1,47 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { validatePageUserScriptParams, resolvePageEvalExpression, } from './generated-browser-action-protocol.js';
3
+ const CHUNK_CHARACTERS = 48 * 1024;
4
+ /** Serialize page_eval arguments within transport limits while retaining the caller's execution world. */
5
+ export async function invokePageEvalWithArgs(invoke, params) {
6
+ if (!Number.isSafeInteger(params.tabId) || params.tabId < 0)
7
+ throw new Error('Invalid target tab');
8
+ resolvePageEvalExpression({ expression: params.expression, args: [] });
9
+ if (!Array.isArray(params.args))
10
+ throw new Error('page_eval args must be an array');
11
+ if (params.executionWorld !== undefined && !['main', 'isolated'].includes(params.executionWorld))
12
+ throw new Error('Invalid page executionWorld');
13
+ if (params.executionWorld === 'isolated')
14
+ validatePageUserScriptParams({
15
+ tabId: params.tabId,
16
+ worldId: params.worldId ?? '',
17
+ code: params.expression,
18
+ });
19
+ const args = JSON.stringify(params.args);
20
+ if (args.length > 64 * 1024 * 1024)
21
+ throw new Error('Page script arguments must fit within 64 MiB characters');
22
+ const { args: _args, ...request } = params;
23
+ const evaluate = async (expression, transfer = false) => {
24
+ const payload = {
25
+ ...request,
26
+ ...(transfer ? { observe: false, diagnostics: false } : {}),
27
+ expression,
28
+ };
29
+ const result = await (transfer ? invoke(payload, true) : invoke(payload));
30
+ if (transfer && result?.action?.success === false)
31
+ throw new Error(result.action.error || 'page_eval failed');
32
+ return result;
33
+ };
34
+ if (args.length + params.expression.length <= CHUNK_CHARACTERS) {
35
+ return evaluate(resolvePageEvalExpression(params));
36
+ }
37
+ const key = JSON.stringify('__mearlPageScriptTransfer_' + randomUUID());
38
+ try {
39
+ for (let offset = 0; offset < args.length; offset += CHUNK_CHARACTERS) {
40
+ await evaluate(`void (window[${key}] = (window[${key}] || '') + ${JSON.stringify(args.slice(offset, offset + CHUNK_CHARACTERS))})`, true);
41
+ }
42
+ return await evaluate(`(() => { const args = JSON.parse(window[${key}]); delete window[${key}]; return (${params.expression})(...args); })()`);
43
+ }
44
+ finally {
45
+ await evaluate(`delete window[${key}]`, true).catch(() => { });
46
+ }
47
+ }
package/dist/plugin.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export { invokePageEvalWithArgs } from './pageEvalArguments.js';
1
2
  import { type OfficialPluginInstaller } from './officialPluginInstaller.js';
2
3
  import { type PluginToolboxContribution, type PluginRegistration } from './pluginRegistry.js';
3
4
  import type { UnifiedInvokeOptions } from './unified.js';
package/dist/plugin.js CHANGED
@@ -1,3 +1,4 @@
1
+ export { invokePageEvalWithArgs } from './pageEvalArguments.js';
1
2
  import { readFileSync } from 'node:fs';
2
3
  import { dirname, extname, resolve } from 'node:path';
3
4
  import { pathToFileURL } from 'node:url';
@@ -29,6 +29,8 @@ const PAGE_BRIDGE_SCRIPT = String.raw `(() => {
29
29
  let port = null;
30
30
  let requestSequence = 0;
31
31
  const pending = new Map();
32
+ const pageEventListeners = new Map();
33
+ let subscriptionSequence = 0;
32
34
  let resolveReady;
33
35
  const ready = new Promise(resolve => { resolveReady = resolve; });
34
36
 
@@ -76,64 +78,25 @@ const PAGE_BRIDGE_SCRIPT = String.raw `(() => {
76
78
  };
77
79
 
78
80
  const invoke = async (command, input = {}) => {
79
- if (typeof command !== 'string' || !/^[a-z][a-z0-9-]*$/.test(command)) {
80
- throw new Error('Invalid plugin command');
81
- }
81
+ if (typeof command !== 'string' || (!/^[a-z][a-z0-9-]*$/.test(command) && !/^host\.[a-z][a-zA-Z]*(?:\.[a-z][a-zA-Z]*)*$/.test(command))) throw new Error('Invalid command');
82
82
  if (!input || typeof input !== 'object' || Array.isArray(input)) {
83
83
  throw new Error('Plugin command input must be an object');
84
84
  }
85
85
  return requestHost('invoke', { command, input });
86
86
  };
87
87
 
88
- const start = async (command, input = {}) => {
89
- if (typeof command !== 'string' || !/^[a-z][a-z0-9-]*$/.test(command)) {
90
- throw new Error('Invalid plugin command');
91
- }
92
- if (!input || typeof input !== 'object' || Array.isArray(input)) {
93
- throw new Error('Plugin command input must be an object');
94
- }
95
- return requestHost('invoke-start', { command, input });
96
- };
97
-
98
- const operation = async operationId => {
99
- if (typeof operationId !== 'string' || !/^[0-9a-f-]{36}$/i.test(operationId)) {
100
- throw new Error('Invalid plugin operation id');
101
- }
102
- return requestHost('invoke-operation', { operationId });
103
- };
104
-
105
- const runningOperations = async () => requestHost('invoke-operations');
106
- const readClipboard = async () => requestHost('clipboard-read');
107
- const getActiveTab = async () => requestHost('active-tab');
108
-
109
- const selectDirectory = async () => {
110
- const path = await requestHost('select-directory');
111
- if (path !== null && typeof path !== 'string') {
112
- throw new Error('Directory selection returned an invalid path');
113
- }
114
- return path;
115
- };
116
-
117
- const copyText = async text => {
118
- if (typeof text !== 'string' || text.length > 1000000) {
119
- throw new Error('Clipboard text must be a string no longer than 1000000 characters');
120
- }
121
- await requestHost('clipboard-write', { text });
122
- };
123
-
124
- const copyImage = async data => {
125
- if (typeof data !== 'string' || data.length > 16 * 1024 * 1024 ||
126
- !/^data:image\/(png|jpeg|webp);base64,/.test(data)) {
127
- throw new Error('Clipboard image must be a PNG, JPEG or WebP data URL within 16 MiB');
128
- }
129
- await requestHost('clipboard-image-write', { data });
130
- };
131
-
132
- const openExternalLink = async url => {
133
- if (typeof url !== 'string' || !url || url.length > 8192) {
134
- throw new Error('External link must be a non-empty URL no longer than 8192 characters');
135
- }
136
- await requestHost('open-external-link', { url });
88
+ const subscribe = async (source, target, listener) => {
89
+ if (source !== 'page' || !target || !Number.isSafeInteger(target.tabId) || target.tabId < 0 ||
90
+ typeof target.runtimeId !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/.test(target.runtimeId) ||
91
+ typeof listener !== 'function') throw new Error('Invalid event subscription');
92
+ const subscriptionId = String(++subscriptionSequence);
93
+ pageEventListeners.set(subscriptionId, listener);
94
+ try { await requestHost('subscribe', { source, target, subscriptionId }); }
95
+ catch (error) { pageEventListeners.delete(subscriptionId); throw error; }
96
+ return () => {
97
+ if (!pageEventListeners.delete(subscriptionId)) return;
98
+ void requestHost('unsubscribe', { subscriptionId }).catch(() => {});
99
+ };
137
100
  };
138
101
 
139
102
  const handleExternalLinkClick = event => {
@@ -150,28 +113,11 @@ const PAGE_BRIDGE_SCRIPT = String.raw `(() => {
150
113
  }
151
114
  if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
152
115
  event.preventDefault();
153
- void openExternalLink(url.href).catch(error => {
116
+ void invoke('host.link.open', { url: url.href }).catch(error => {
154
117
  console.error('Failed to open external link', error);
155
118
  });
156
119
  };
157
120
 
158
- const toast = async (message, options = {}) => {
159
- if (typeof message !== 'string' || !message.trim() || message.length > 4000) {
160
- throw new Error('Toast message must be a non-empty string no longer than 4000 characters');
161
- }
162
- if (!options || typeof options !== 'object' || Array.isArray(options)) {
163
- throw new Error('Toast options must be an object');
164
- }
165
- const type = options.type ?? 'info';
166
- const duration = options.duration ?? 3000;
167
- if (!['success', 'error', 'info'].includes(type)) throw new Error('Invalid toast type');
168
- if (!Number.isInteger(duration) || duration < 1000 || duration > 30000) {
169
- throw new Error('Toast duration must be between 1000 and 30000 milliseconds');
170
- }
171
- await ready;
172
- port.postMessage({ type: 'toast', message: message.trim(), toastType: type, duration });
173
- };
174
-
175
121
  const agentPanelLayout = options => {
176
122
  if (!options || typeof options !== 'object' || Array.isArray(options)) {
177
123
  throw new Error('Agent panel options must be an object');
@@ -451,7 +397,7 @@ const PAGE_BRIDGE_SCRIPT = String.raw `(() => {
451
397
  return query;
452
398
  };
453
399
 
454
- const api = { copyImage, copyText, getActiveTab, invoke, operation, readClipboard, ready: () => ready, runAgentTask, runningOperations, selectDirectory, setAgentGuides, setAgentPanelVisible, setQueryParam, start, toast };
400
+ const api = { invoke, subscribe, ready: () => ready, runAgentTask, setAgentGuides, setAgentPanelVisible, setQueryParam };
455
401
  const ui = globalThis.__mearlToolboxUi;
456
402
  if (ui !== undefined) {
457
403
  if (!ui || typeof ui !== 'object' || ui.runtime !== 'preact') {
@@ -491,6 +437,10 @@ const PAGE_BRIDGE_SCRIPT = String.raw `(() => {
491
437
  port = nextPort;
492
438
  port.onmessage = responseEvent => {
493
439
  const response = responseEvent.data;
440
+ if (response?.type === 'event' && typeof response.subscriptionId === 'string') {
441
+ pageEventListeners.get(response.subscriptionId)?.(response.event, response.metadata);
442
+ return;
443
+ }
494
444
  if (response?.type === 'context') {
495
445
  applyContext(response.context);
496
446
  return;
@@ -516,7 +466,7 @@ const PAGE_BRIDGE_SCRIPT = String.raw `(() => {
516
466
  if (!request) return;
517
467
  pending.delete(response.requestId);
518
468
  if (response.ok) request.resolve(response.result);
519
- else request.reject(new Error(response.error || 'Plugin command failed'));
469
+ else request.reject(Object.assign(new Error(response.error || 'Plugin command failed'), { handledByHost: response.handledByHost === true }));
520
470
  };
521
471
  port.start();
522
472
  resolveReady(context);
package/dist/socket.d.ts CHANGED
@@ -18,6 +18,8 @@ export type ControlSource = 'local' | 'remote';
18
18
  type CompatibleControlSource = ControlSource | 'cloud';
19
19
  export interface InvokeOptions {
20
20
  controlSessionId?: string;
21
+ /** Pin a known local endpoint for a sequence of transport requests. */
22
+ socketPath?: string;
21
23
  /** 超时时间(秒),默认 15 */
22
24
  timeoutSec?: number;
23
25
  /** Force CDP transport instead of extension */
@@ -31,6 +33,11 @@ export interface InvokeOptions {
31
33
  export declare function setDefaultBrowser(selector: string | undefined): void;
32
34
  export declare function getDefaultBrowser(): string | undefined;
33
35
  export declare function decorateBrowserListResult(result: Record<string, any>): Record<string, any>;
36
+ /** Resolve an existing local route without executing the action. */
37
+ export declare function resolveLocalActionTarget(action: string, data: Record<string, any>, options?: InvokeOptions): {
38
+ browser?: string;
39
+ socketPath?: string;
40
+ } | undefined;
34
41
  /**
35
42
  * 向 native host 发送一个 action 请求并等待响应。
36
43
  *
package/dist/socket.js CHANGED
@@ -18,6 +18,7 @@ import { readLiveProvider } from '@mearl/provider/runtime';
18
18
  import { resolveFileRefs } from './fileRefs.js';
19
19
  import { listLiveBrowsers, findBrowser, describeBrowser, } from './browserRegistry.js';
20
20
  import { resolveActionTimeoutSec } from './actionTimeouts.js';
21
+ import { isBrowserCapabilityAction } from './generated-browser-action-protocol.js';
21
22
  const require = createRequire(import.meta.url);
22
23
  export const CLIENT_VERSION = require('../package.json').version;
23
24
  const MAX_SOCKET_BUFFER_SIZE = 64 * 1024 * 1024;
@@ -160,6 +161,24 @@ function supportsAction(browser, action, data) {
160
161
  return (browser.transport !== 'provider' ||
161
162
  browser.capabilities?.actions.some(capability => capability === action) === true);
162
163
  }
164
+ function routableBrowsers(browsers, action, data, transport) {
165
+ return browsers.filter(browser => isConnectedBrowser(browser) &&
166
+ (action !== 'browser_list' || browser.transport !== 'provider') &&
167
+ !(transport === 'cdp' && browser.transport === 'provider') &&
168
+ supportsAction(browser, action, data));
169
+ }
170
+ /** Resolve an existing local route without executing the action. */
171
+ export function resolveLocalActionTarget(action, data, options = {}) {
172
+ const selector = options.browser ?? defaultBrowser ?? process.env.MEARL_BROWSER ?? undefined;
173
+ const socketPath = options.socketPath ?? process.env.MEARL_SOCKET_PATH?.trim();
174
+ if (socketPath && (options.socketPath || !selector))
175
+ return { socketPath };
176
+ const browsers = listLiveBrowsers();
177
+ const target = selector
178
+ ? findBrowser(browsers, selector)
179
+ : routableBrowsers(browsers, action, data, options.transport)[0];
180
+ return target ? { browser: target.browserId } : undefined;
181
+ }
163
182
  function selectBrowserResult(data) {
164
183
  const selector = typeof data.browser === 'string' ? data.browser.trim() : '';
165
184
  if (!selector) {
@@ -329,6 +348,17 @@ function removeStaleLegacySocket(identity) {
329
348
  * legacy well-known socket,并在 socket 不存在或已残留时自动拉起 native host。
330
349
  */
331
350
  export function invoke(action, data = {}, options = {}) {
351
+ if (action === '__resolve_action_target') {
352
+ if (!isBrowserCapabilityAction(data.action))
353
+ return Promise.reject(new Error('Invalid browser capability action'));
354
+ const target = resolveLocalActionTarget(data.action, data.data ?? {}, options);
355
+ const browser = target?.browser ??
356
+ (target?.socketPath
357
+ ? listLiveBrowsers().find(item => item.socketPath === target.socketPath &&
358
+ supportsAction(item, data.action, data.data ?? {}))?.browserId
359
+ : undefined);
360
+ return Promise.resolve(browser ? { browser } : null);
361
+ }
332
362
  if (action === 'browser_select_browser') {
333
363
  try {
334
364
  return Promise.resolve(selectBrowserResult(data));
@@ -338,14 +368,50 @@ export function invoke(action, data = {}, options = {}) {
338
368
  }
339
369
  }
340
370
  const { timeoutSec = resolveActionTimeoutSec(action, data), transport, browser, controlSource = 'local', } = options;
371
+ if (action === '__control_session_resolve') {
372
+ return resolveControlSession(data.sessionId, timeoutSec, controlSource);
373
+ }
341
374
  // 统一解析 payload 顶层字段中的 `@<path>` 文件引用(所有 action 通用)
342
375
  const resolvedData = resolveFileRefs(data);
343
376
  Object.defineProperty(resolvedData, SESSION_OPTION, { value: options.controlSessionId });
344
377
  if (action === 'browser_list') {
345
- return dispatchInvoke(action, resolvedData, timeoutSec, transport, undefined, controlSource).then(result => decorateBrowserListResult(transport === 'cdp' ? cdpOnlyBrowserList(result) : result));
378
+ return dispatchInvoke(action, resolvedData, timeoutSec, transport, undefined, controlSource, options.socketPath).then(result => decorateBrowserListResult(transport === 'cdp' ? cdpOnlyBrowserList(result) : result));
346
379
  }
347
380
  const selector = browser ?? defaultBrowser ?? process.env.MEARL_BROWSER ?? undefined;
348
- return dispatchInvoke(action, resolvedData, timeoutSec, transport, selector, controlSource);
381
+ return dispatchInvoke(action, resolvedData, timeoutSec, transport, selector, controlSource, options.socketPath);
382
+ }
383
+ /** Session ownership lives in the control hosts and survives individual CLI connections. */
384
+ async function resolveControlSession(sessionId, timeoutSec, controlSource) {
385
+ const browsers = listLiveBrowsers();
386
+ const configuredSocketPath = process.env.MEARL_SOCKET_PATH?.trim();
387
+ const sockets = configuredSocketPath
388
+ ? [configuredSocketPath]
389
+ : [
390
+ ...new Set(browsers
391
+ .filter(browser => browser.transport !== 'provider')
392
+ .map(browser => browser.socketPath)),
393
+ ];
394
+ const provider = configuredSocketPath
395
+ ? undefined
396
+ : browsers.find(browser => browser.transport === 'provider');
397
+ if (!sockets.length && !provider)
398
+ sockets.push(SOCKET_PATH);
399
+ const requests = sockets.map(socketPath => invokeOnce('__control_session_resolve', { sessionId }, timeoutSec, undefined, socketPath, controlSource));
400
+ if (provider) {
401
+ requests.push(invokeOnce('__control_session_resolve', { sessionId }, timeoutSec, undefined, provider.socketPath, controlSource, provider.browserId));
402
+ }
403
+ const results = await Promise.allSettled(requests);
404
+ const sessions = results.flatMap(result => result.status === 'fulfilled' && result.value?.session?.sessionId === sessionId
405
+ ? [result.value.session]
406
+ : []);
407
+ if (sessions.length > 1)
408
+ throw new Error('CONTROL_SESSION_AMBIGUOUS: multiple hosts own this id');
409
+ if (sessions.length)
410
+ return { session: sessions[0] };
411
+ const failure = results.find(result => result.status === 'rejected' && !isConnError(result.reason));
412
+ if (failure?.status === 'rejected')
413
+ throw failure.reason;
414
+ return { session: null };
349
415
  }
350
416
  function isConnError(error) {
351
417
  const code = error?.code;
@@ -359,9 +425,9 @@ function isUnavailableConnection(error) {
359
425
  const code = cause?.code;
360
426
  return isConnError(cause) || isSocketSandboxError(cause) || code === 'EACCES';
361
427
  }
362
- async function dispatchInvoke(action, data, timeoutSec, transport, selector, controlSource) {
363
- const configuredSocketPath = process.env.MEARL_SOCKET_PATH?.trim();
364
- if (!selector && configuredSocketPath) {
428
+ async function dispatchInvoke(action, data, timeoutSec, transport, selector, controlSource, socketPath) {
429
+ const configuredSocketPath = socketPath ?? process.env.MEARL_SOCKET_PATH?.trim();
430
+ if (configuredSocketPath && (socketPath || !selector)) {
365
431
  try {
366
432
  return await invokeOnce(action, data, timeoutSec, transport, configuredSocketPath, controlSource);
367
433
  }
@@ -447,11 +513,9 @@ async function dispatchInvoke(action, data, timeoutSec, transport, selector, con
447
513
  }
448
514
  // No explicit target: prefer the most-recently-focused live browser; on a
449
515
  // stale socket, fall through to the legacy path + auto-spawn.
450
- const routableBrowsers = controllableBrowsers.filter(browser => (action !== 'browser_list' || browser.transport !== 'provider') &&
451
- !(transport === 'cdp' && browser.transport === 'provider') &&
452
- supportsAction(browser, action, data));
453
- if (routableBrowsers.length > 0) {
454
- const target = routableBrowsers[0];
516
+ const candidates = routableBrowsers(browsers, action, data, transport);
517
+ if (candidates.length > 0) {
518
+ const target = candidates[0];
455
519
  try {
456
520
  return await invokeOnce(action, data, timeoutSec, transport, target.socketPath, controlSource, target.transport === 'provider' ? target.browserId : undefined);
457
521
  }
package/dist/unified.d.ts CHANGED
@@ -30,12 +30,8 @@ interface BrowserTarget {
30
30
  }
31
31
  export declare function decodeBrowserTarget(selector: string | undefined): BrowserTarget | undefined;
32
32
  export declare function normalizeBrowserSelector(selector: string): string;
33
- export declare function decodeControlSessionId(id: string): {
34
- browser: string;
35
- id: string;
36
- transport: 'extension' | 'cdp' | 'provider';
37
- };
38
33
  export declare class UnifiedClient {
34
+ private readonly controlSessions;
39
35
  private readonly options;
40
36
  private defaultBrowser;
41
37
  private defaultConnector;
@@ -55,6 +51,7 @@ export declare class UnifiedClient {
55
51
  outputPath: string;
56
52
  bytes: number;
57
53
  }>;
54
+ private pinPageEvalTarget;
58
55
  private invokeRouted;
59
56
  private invokeExplicitLocal;
60
57
  private invokeLaunchWithoutControlBrowser;
@@ -71,6 +68,7 @@ export declare class UnifiedClient {
71
68
  private invokeLocal;
72
69
  private invokeRemote;
73
70
  private decorateActionResult;
71
+ private resolveControlSession;
74
72
  private ensureConnected;
75
73
  private connect;
76
74
  private startHeartbeat;
package/dist/unified.js CHANGED
@@ -1,9 +1,10 @@
1
+ import { invokePageEvalWithArgs } from './pageEvalArguments.js';
1
2
  import { stageFile, exportArtifact } from './artifacts.js';
2
3
  import WebSocket from 'ws';
3
4
  import { DEFAULT_CONNECT_TIMEOUT, DEFAULT_HEARTBEAT_INTERVAL, DEFAULT_REQUEST_TIMEOUT, MAX_BUFFER_SIZE, isHeartbeatMessage, resolveCloudConnectorSelector, } from '@mearl/cloud-types';
4
5
  import { resolveActionTimeoutSec } from './actionTimeouts.js';
5
6
  import { validateRequiredActionTabId, } from './generated-browser-action-protocol.js';
6
- import { invoke as invokeLocalBrowser } from './socket.js';
7
+ import { invoke as invokeLocalBrowser, resolveLocalActionTarget } from './socket.js';
7
8
  import { loadCloudServerConfig, loadLocalConnectorId } from './unifiedConfig.js';
8
9
  const RESPONSE_GRACE_MS = 1_000;
9
10
  const GLOBAL_BROWSER_PREFIX = 'mearl:';
@@ -49,16 +50,6 @@ export function normalizeBrowserSelector(selector) {
49
50
  const decodedTarget = decodeBrowserTarget(selector);
50
51
  return (decodedTarget ? encodeBrowserTarget(decodedTarget) : selector.trim()).toLowerCase();
51
52
  }
52
- export function decodeControlSessionId(id) {
53
- const match = /^mearl-session:([^:]+):(cs_[a-f0-9-]+):(extension|cdp|provider)$/.exec(id);
54
- if (!match)
55
- throw new Error('Invalid session id; reuse session.sessionId from the first response');
56
- return {
57
- browser: decodeURIComponent(match[1]),
58
- id: match[2],
59
- transport: match[3],
60
- };
61
- }
62
53
  function decorateBrowser(browser, source) {
63
54
  const safeBrowser = omitTransientBrowserSecrets(browser);
64
55
  const originalBrowserId = String(browser.browserId);
@@ -102,6 +93,7 @@ function errorMessage(error) {
102
93
  return error instanceof Error ? error.message : String(error);
103
94
  }
104
95
  export class UnifiedClient {
96
+ controlSessions = new Map();
105
97
  options;
106
98
  defaultBrowser;
107
99
  defaultConnector;
@@ -139,7 +131,7 @@ export class UnifiedClient {
139
131
  }
140
132
  async invoke(action, data = {}, options = {}) {
141
133
  if (options.session && options.session !== 'new') {
142
- const session = decodeControlSessionId(options.session);
134
+ const session = await this.resolveControlSession(options.session, options);
143
135
  const selected = decodeBrowserTarget(options.browser);
144
136
  const pinned = decodeBrowserTarget(session.browser);
145
137
  if (options.browser &&
@@ -162,6 +154,25 @@ export class UnifiedClient {
162
154
  throw new Error('--session new requires tab_open, page_snapshot or page_screenshot');
163
155
  options = { ...options, controlSessionId: 'new' };
164
156
  }
157
+ if (action === 'page_eval' && data.args !== undefined) {
158
+ let pinnedOptions = {
159
+ ...options,
160
+ ...(options.browser || !this.defaultBrowser ? {} : { browser: this.defaultBrowser }),
161
+ ...(options.connector ||
162
+ !this.defaultConnector ||
163
+ decodeBrowserTarget(options.browser ?? this.defaultBrowser)
164
+ ? {}
165
+ : { connector: this.defaultConnector }),
166
+ };
167
+ let targetPinned = false;
168
+ return invokePageEvalWithArgs(async (request, transfer) => {
169
+ if (transfer && !targetPinned) {
170
+ pinnedOptions = await this.pinPageEvalTarget(pinnedOptions, request);
171
+ targetPinned = true;
172
+ }
173
+ return this.invokeRouted(action, request, pinnedOptions);
174
+ }, data);
175
+ }
165
176
  if (action === 'page_upload' &&
166
177
  data.files !== undefined &&
167
178
  (!Array.isArray(data.files) ||
@@ -198,9 +209,63 @@ export class UnifiedClient {
198
209
  saveArtifact(artifact, outputPath, options = {}) {
199
210
  return exportArtifact(this, artifact, outputPath, options);
200
211
  }
212
+ async pinPageEvalTarget(options, data) {
213
+ const target = decodeBrowserTarget(options.browser);
214
+ if (target && !options.connector)
215
+ return options;
216
+ const local = options.local ||
217
+ options.transport === 'cdp' ||
218
+ (!this.options.serverUrl && !options.connector);
219
+ if (local) {
220
+ const resolved = resolveLocalActionTarget('page_eval', data, {
221
+ browser: options.browser,
222
+ transport: options.transport,
223
+ });
224
+ if (resolved?.socketPath)
225
+ return { ...options, socketPath: resolved.socketPath };
226
+ if (resolved?.browser)
227
+ return { ...options, browser: encodeBrowserTarget({ browser: resolved.browser }) };
228
+ }
229
+ if (options.connector && !options.browser) {
230
+ const timeoutSec = options.timeoutSec ?? this.options.requestTimeoutSec;
231
+ const connector = await this.resolveConnector(options.connector, timeoutSec);
232
+ const resolved = await this.requestRemote('__resolve_action_target', { action: 'page_eval', data }, timeoutSec, undefined, connector.connectorId);
233
+ if (!resolved?.browser)
234
+ throw new Error('Cannot pin the connector browser for large page_eval arguments. Pass --browser <global-id>.');
235
+ const pinned = {
236
+ ...options,
237
+ browser: encodeBrowserTarget({
238
+ browser: resolved.browser,
239
+ connector: connector.connectorId,
240
+ }),
241
+ };
242
+ delete pinned.connector;
243
+ return pinned;
244
+ }
245
+ const listing = (await this.invokeRouted('browser_list', {}, options));
246
+ const connected = listing.browsers.filter(browser => browser.status === 'connected' && (!local || browser.transport !== 'provider'));
247
+ const implicitHost = options.local || options.transport === 'cdp' || options.connector || !this.options.serverUrl;
248
+ const automatic = connected.find(browser => browser.browserId === listing.defaultBrowserId) ??
249
+ (implicitHost || connected.length === 1 ? connected[0] : undefined);
250
+ const candidates = options.browser
251
+ ? findBrowsers(listing.browsers, options.browser)
252
+ : automatic
253
+ ? [automatic]
254
+ : [];
255
+ if (candidates.length !== 1)
256
+ throw new Error('Large page_eval arguments require a unique connected browser. Pass --browser <global-id>.');
257
+ const browser = candidates[0];
258
+ const pinned = { ...options, browser: browser.browserId };
259
+ delete pinned.connector;
260
+ if (browser.transport === 'cdp' && !decodeBrowserTarget(browser.browserId)?.connector)
261
+ pinned.transport = 'cdp';
262
+ return pinned;
263
+ }
201
264
  async invokeRouted(action, data = {}, options = {}) {
202
265
  validateRequiredActionTabId(action, data);
203
266
  const timeoutSec = options.timeoutSec ?? resolveActionTimeoutSec(action, data, this.options.requestTimeoutSec);
267
+ if (options.socketPath)
268
+ return this.invokeLocal(action, data, timeoutSec, undefined, options.transport, options.controlSessionId, options.socketPath);
204
269
  if (action === 'connector_list')
205
270
  return this.listConnectors(timeoutSec);
206
271
  if (action === 'browser_list') {
@@ -482,11 +547,12 @@ export class UnifiedClient {
482
547
  ...(warnings.length > 0 ? { warnings } : {}),
483
548
  });
484
549
  }
485
- async invokeLocal(action, data, timeoutSec, browser, transport, controlSessionId) {
550
+ async invokeLocal(action, data, timeoutSec, browser, transport, controlSessionId, socketPath) {
486
551
  const result = await invokeLocalBrowser(action, data, {
487
552
  timeoutSec,
488
553
  ...(browser ? { browser } : {}),
489
554
  ...(transport ? { transport } : {}),
555
+ ...(socketPath ? { socketPath } : {}),
490
556
  controlSource: 'local',
491
557
  ...(controlSessionId ? { controlSessionId } : {}),
492
558
  });
@@ -501,17 +567,23 @@ export class UnifiedClient {
501
567
  });
502
568
  }
503
569
  decorateActionResult(action, result, source) {
504
- if (result?.session?.sessionId && !result.session.sessionId.startsWith('mearl-session:')) {
570
+ if (result?.session?.sessionId) {
505
571
  const browserId = encodeBrowserTarget({
506
572
  browser: result.session.browserId,
507
573
  ...(source.connectorId ? { connector: source.connectorId } : {}),
508
574
  });
575
+ if (action !== '__control_session_resolve') {
576
+ this.controlSessions.set(result.session.sessionId, {
577
+ browser: browserId,
578
+ id: result.session.sessionId,
579
+ transport: result.session.transport,
580
+ });
581
+ }
509
582
  result = {
510
583
  ...result,
511
584
  session: {
512
585
  ...result.session,
513
586
  browserId,
514
- sessionId: `mearl-session:${encodeURIComponent(browserId)}:${result.session.sessionId}:${result.session.transport}`,
515
587
  },
516
588
  };
517
589
  }
@@ -539,6 +611,57 @@ export class UnifiedClient {
539
611
  return result;
540
612
  return decorateBrowser(result, source);
541
613
  }
614
+ async resolveControlSession(id, options) {
615
+ if (!/^s_[A-Za-z0-9_-]{8}$/.test(id))
616
+ throw new Error('Invalid session id; reuse session.sessionId from the first response');
617
+ const cached = this.controlSessions.get(id);
618
+ if (cached)
619
+ return cached;
620
+ const timeoutSec = options.timeoutSec ?? 15;
621
+ const localLookup = this.invokeLocal('__control_session_resolve', { sessionId: id }, timeoutSec, undefined);
622
+ const discover = async () => {
623
+ const { connectors } = await this.listConnectors(timeoutSec);
624
+ return Promise.allSettled(connectors.map(connector => this.invokeRemote('__control_session_resolve', { sessionId: id }, timeoutSec, undefined, connector)));
625
+ };
626
+ const [local, remote] = await Promise.allSettled([
627
+ localLookup,
628
+ this.options.serverUrl && !options.local && !options.transport
629
+ ? discover()
630
+ : Promise.resolve([]),
631
+ ]);
632
+ const localSession = local.status === 'fulfilled' ? local.value?.session : undefined;
633
+ const localTarget = decodeBrowserTarget(localSession?.browserId);
634
+ const localConnectorId = localSession?.sessionId === id ? loadLocalConnectorId() : null;
635
+ const results = [local, ...(remote.status === 'fulfilled' ? remote.value : [remote])].filter(result => {
636
+ if (result === local || result.status === 'rejected')
637
+ return true;
638
+ const session = result.value?.session;
639
+ const target = decodeBrowserTarget(session?.browserId);
640
+ // The same host can be reached over both its local socket and its cloud connector.
641
+ return !(localConnectorId &&
642
+ target?.connector === localConnectorId &&
643
+ target.browser === localTarget?.browser &&
644
+ session?.sessionId === id &&
645
+ session.transport === localSession.transport);
646
+ });
647
+ const sessions = results.flatMap(result => result.status === 'fulfilled' && result.value?.session?.sessionId === id
648
+ ? [result.value.session]
649
+ : []);
650
+ const ambiguous = results.some(result => result.status === 'rejected' &&
651
+ errorMessage(result.reason).startsWith('CONTROL_SESSION_AMBIGUOUS'));
652
+ if (sessions.length > 1 || ambiguous) {
653
+ this.controlSessions.delete(id);
654
+ throw new Error('CONTROL_SESSION_AMBIGUOUS: multiple hosts own this id');
655
+ }
656
+ const session = sessions[0];
657
+ if (!session) {
658
+ const errors = results.flatMap(result => result.status === 'rejected' ? [errorMessage(result.reason)] : []);
659
+ throw new Error(`CONTROL_SESSION_NOT_FOUND: ${id}${errors.length ? ` (${errors.join('; ')})` : ''}`);
660
+ }
661
+ const target = { browser: session.browserId, id, transport: session.transport };
662
+ this.controlSessions.set(id, target);
663
+ return target;
664
+ }
542
665
  async ensureConnected() {
543
666
  if (this.ws?.readyState === WebSocket.OPEN)
544
667
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mearl/client",
3
- "version": "2.20.2",
3
+ "version": "2.20.3",
4
4
  "description": "Unified Mearl SDK & CLI for local and remote browsers",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -59,8 +59,8 @@
59
59
  "commander": "^15.0.0",
60
60
  "fastest-levenshtein": "^1.0.16",
61
61
  "ws": "^8.18.0",
62
- "@mearl/provider": "2.20.2",
63
- "@mearl/cloud-types": "2.20.2"
62
+ "@mearl/cloud-types": "2.20.3",
63
+ "@mearl/provider": "2.20.3"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@types/node": "^24.9.1",