@mearl/client 2.19.2 → 2.19.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/dist/plugin.js CHANGED
@@ -1,11 +1,12 @@
1
- import { readFileSync, statSync } from 'node:fs';
1
+ import { readFileSync } from 'node:fs';
2
2
  import { dirname, extname, resolve } from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
4
  import { Command } from 'commander';
5
5
  import { COMMAND_MAP } from './commands.js';
6
6
  import { findOfficialPlugin } from './officialPluginCatalog.js';
7
7
  import { ensureOfficialPluginRegistered, } from './officialPluginInstaller.js';
8
- import { findPluginRegistration, normalizePluginToolboxContribution, PluginError, } from './pluginRegistry.js';
8
+ import { findPluginRegistration, pluginRegistrationNeedsRefresh, registerPlugin, pluginDirectory, normalizePluginToolboxContribution, PluginError, } from './pluginRegistry.js';
9
+ import { pluginFileVersion } from './pluginSource.js';
9
10
  import { describePythonPlugin, runPythonPluginCommand } from './pythonPlugin.js';
10
11
  export { assertPluginName, findPluginDirectoryEntry, findPluginEntry, findPluginRegistration, listPluginNames, registerPlugin, resolvePluginEntry, unregisterPlugin, PluginError, readPluginPageBundleChunk, } from './pluginRegistry.js';
11
12
  export { findOfficialPlugin, listOfficialPluginNames, OFFICIAL_PLUGIN_MANAGER, OFFICIAL_PLUGINS, } from './officialPluginCatalog.js';
@@ -67,14 +68,61 @@ function missingPluginEntryMessage(entry, pluginName) {
67
68
  `卸载命令:npx @mearl/setup plugin uninstall ${name}`,
68
69
  ].join('\n');
69
70
  }
71
+ const registrationRefreshes = new Map();
72
+ /** 工具列表仅检查入口;页面资源只在打开对应工具时检查。 */
73
+ export async function refreshRegisteredPlugin(name, includePage = false) {
74
+ const key = `${pluginDirectory()}/${name}`;
75
+ const pending = registrationRefreshes.get(key);
76
+ if (pending) {
77
+ await pending;
78
+ return refreshRegisteredPlugin(name, includePage);
79
+ }
80
+ const registration = findPluginRegistration(name);
81
+ if (!registration)
82
+ return undefined;
83
+ const entryChanged = pluginRegistrationNeedsRefresh(registration);
84
+ if (!entryChanged && (!includePage || !pluginRegistrationNeedsRefresh(registration, true))) {
85
+ return registration;
86
+ }
87
+ const refresh = (async () => {
88
+ const entryVersion = pluginFileVersion(registration.entry);
89
+ const loaded = entryChanged
90
+ ? await loadPlugin(registration.entry, {
91
+ options: {},
92
+ invoke: async () => {
93
+ throw new Error('注册时不能执行浏览器操作');
94
+ },
95
+ progress: () => {
96
+ throw new Error('注册时不能报告执行进度');
97
+ },
98
+ }, name)
99
+ : registration;
100
+ // 异步加载期间其他进程可能卸载或重新绑定同名插件。
101
+ const current = findPluginRegistration(name);
102
+ if (JSON.stringify(current) !== JSON.stringify(registration))
103
+ return current;
104
+ if (pluginFileVersion(registration.entry) !== entryVersion) {
105
+ throw new PluginError('PLUGIN_SOURCE_CHANGED', `插件更新进行中,请重新打开工具: ${name}`);
106
+ }
107
+ registerPlugin(name, registration.entry, loaded.description, {
108
+ toolbox: loaded.toolbox,
109
+ ...(registration.managedBy ? { managedBy: registration.managedBy } : {}),
110
+ });
111
+ return findPluginRegistration(name);
112
+ })();
113
+ registrationRefreshes.set(key, refresh);
114
+ try {
115
+ return await refresh;
116
+ }
117
+ finally {
118
+ registrationRefreshes.delete(key);
119
+ }
120
+ }
70
121
  /** 注册和执行复用同一宿主能力边界;仅 handler 执行期间开放浏览器调用。 */
71
122
  export async function loadPlugin(entry, context, pluginName) {
72
123
  let entryVersion;
73
124
  try {
74
- const stat = statSync(entry);
75
- if (!stat.isFile())
76
- throw new Error('not a file');
77
- entryVersion = `${stat.mtimeMs}-${stat.size}`;
125
+ entryVersion = pluginFileVersion(entry);
78
126
  }
79
127
  catch {
80
128
  throw new PluginError('PLUGIN_ENTRY_MISSING', missingPluginEntryMessage(entry, pluginName));
@@ -87,11 +135,15 @@ export async function loadPlugin(entry, context, pluginName) {
87
135
  let toolbox;
88
136
  const app = Object.freeze({
89
137
  options: Object.freeze({ ...context.options }),
90
- command(signature, handler, description = '') {
138
+ command(signature, handler, description = '', examples = []) {
91
139
  if (!registering)
92
140
  throw new Error('命令只能在入口注册阶段声明');
93
141
  const [name, ...args] = signature.trim().split(/\s+/);
94
- if (!/^[a-z][a-z0-9-]*$/.test(name) || commands.has(name) || typeof handler !== 'function') {
142
+ if (!/^[a-z][a-z0-9-]*$/.test(name) ||
143
+ commands.has(name) ||
144
+ typeof handler !== 'function' ||
145
+ !Array.isArray(examples) ||
146
+ examples.some((example) => typeof example !== 'string' || !example.trim())) {
95
147
  throw new Error(`无效或重复的命令: ${signature}`);
96
148
  }
97
149
  const command = new Command(name).description(description);
@@ -104,7 +156,7 @@ export async function loadPlugin(entry, context, pluginName) {
104
156
  const names = command.registeredArguments.map(argument => argument.name());
105
157
  if (new Set(names).size !== names.length)
106
158
  throw new Error(`参数名称重复: ${signature}`);
107
- commands.set(name, { command, signature, handler });
159
+ commands.set(name, { command, signature, handler, examples: [...examples] });
108
160
  },
109
161
  invoke(action, payload = {}, options = {}) {
110
162
  if (!executing)
@@ -175,20 +227,13 @@ export async function loadPlugin(entry, context, pluginName) {
175
227
  },
176
228
  help(namespace, name) {
177
229
  const selected = name ? [find(name)] : [...commands.values()];
178
- const example = selected[0].command;
179
- const input = Object.fromEntries(example.registeredArguments.map(argument => [
180
- argument.name(),
181
- argument.variadic ? [`<${argument.name()}>`] : `<${argument.name()}>`,
182
- ]));
230
+ const examples = selected.flatMap(({ examples }) => examples);
183
231
  return [
232
+ '用法:',
184
233
  ...selected.map(({ command, signature }) => ` ${namespace} ${signature} ${command.description()}`),
185
- '',
186
- ` ${namespace} --payload '${JSON.stringify({ command: example.name(), input })}'`,
187
- '',
188
- '常用选项: --browser --timeout --compact --output',
189
- '连接或传输排障运行 mearl check --help。',
190
- '插件选项: --name=value 传入字符串业务字段;--all 传入 all=true;--verbose; -h, --help; -v, --version',
191
- '同名宿主选项及非字符串字段使用 --payload;业务字段与校验规则见对应 skill。',
234
+ ...(examples.length
235
+ ? ['', '示例:', ...examples.map(example => ` ${namespace} ${example}`)]
236
+ : []),
192
237
  ].join('\n');
193
238
  },
194
239
  async run(positionals, payload, namedInput = {}) {
@@ -4,5 +4,6 @@ export interface BuiltPluginPageBundle {
4
4
  hash: string;
5
5
  size: number;
6
6
  chunks: number;
7
+ sources: Record<string, string>;
7
8
  }
8
9
  export declare function buildPluginPageBundle(entry: string, pageEntry: string): BuiltPluginPageBundle;
@@ -1,6 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { readFileSync, realpathSync } from 'node:fs';
3
3
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
4
+ import { pluginFileVersion } from './pluginSource.js';
4
5
  export const PLUGIN_PAGE_CHUNK_BYTES = 384 * 1024;
5
6
  const MAX_PAGE_SOURCE_BYTES = 16 * 1024 * 1024;
6
7
  const MAX_PAGE_BUNDLE_BYTES = 24 * 1024 * 1024;
@@ -578,6 +579,7 @@ export function buildPluginPageBundle(entry, pageEntry) {
578
579
  const root = realpathSync(pluginRoot(entry));
579
580
  let sourceBytes = 0;
580
581
  const countedFiles = new Set();
582
+ const sources = {};
581
583
  const resolveLocalFile = (reference, fromFile) => {
582
584
  const cleanReference = reference.trim().split(/[?#]/, 1)[0];
583
585
  if (!cleanReference || isEmbeddedReference(cleanReference))
@@ -585,14 +587,17 @@ export function buildPluginPageBundle(entry, pageEntry) {
585
587
  if (isRemoteReference(cleanReference) || isAbsolute(cleanReference)) {
586
588
  throw new Error(`toolbox.page 不允许远程或绝对资源: ${reference}`);
587
589
  }
588
- const resolved = realpathSync(resolve(dirname(fromFile), cleanReference));
590
+ const sourcePath = resolve(dirname(fromFile), cleanReference);
591
+ const resolved = realpathSync(sourcePath);
589
592
  const relativePath = relative(root, resolved);
590
593
  if (relativePath === '..' || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) {
591
594
  throw new Error(`toolbox.page 资源超出插件目录: ${reference}`);
592
595
  }
596
+ sources[sourcePath] = pluginFileVersion(sourcePath);
593
597
  return resolved;
594
598
  };
595
599
  const readAsset = (path) => {
600
+ sources[path] ??= pluginFileVersion(path);
596
601
  const content = readFileSync(path);
597
602
  if (!countedFiles.has(path)) {
598
603
  countedFiles.add(path);
@@ -676,5 +681,6 @@ export function buildPluginPageBundle(entry, pageEntry) {
676
681
  hash: createHash('sha256').update(buffer).digest('hex'),
677
682
  size: buffer.byteLength,
678
683
  chunks: Math.ceil(buffer.byteLength / PLUGIN_PAGE_CHUNK_BYTES),
684
+ sources,
679
685
  };
680
686
  }
@@ -37,7 +37,12 @@ export interface PluginRegistration {
37
37
  managedBy?: string;
38
38
  toolbox?: PluginToolboxContribution;
39
39
  pageBundle?: PluginPageBundleRegistration;
40
+ source?: {
41
+ entry: string;
42
+ page?: Record<string, string>;
43
+ };
40
44
  }
45
+ export declare function pluginRegistrationNeedsRefresh(registration: PluginRegistration, includePage?: boolean): boolean;
41
46
  export interface PluginRegistrationOptions {
42
47
  managedBy?: string;
43
48
  toolbox?: PluginToolboxContribution;
@@ -4,6 +4,7 @@ import { homedir } from 'node:os';
4
4
  import { isAbsolute, join, resolve } from 'node:path';
5
5
  import { COMMAND_MAP } from './commands.js';
6
6
  import { buildPluginPageBundle, PLUGIN_PAGE_CHUNK_BYTES, } from './pluginPageBundle.js';
7
+ import { pluginFileVersion } from './pluginSource.js';
7
8
  const RESERVED_NAMES = new Set([
8
9
  'check',
9
10
  'help',
@@ -19,6 +20,17 @@ export class PluginError extends Error {
19
20
  this.code = code;
20
21
  }
21
22
  }
23
+ export function pluginRegistrationNeedsRefresh(registration, includePage = false) {
24
+ if (!registration.source)
25
+ return true;
26
+ if (pluginFileVersion(registration.entry) !== registration.source.entry)
27
+ return true;
28
+ if (!includePage || !registration.toolbox)
29
+ return false;
30
+ if (!registration.source.page)
31
+ return true;
32
+ return Object.entries(registration.source.page).some(([path, version]) => pluginFileVersion(path) !== version);
33
+ }
22
34
  export function pluginDirectory() {
23
35
  return resolve(process.env.MEARL_PLUGIN_DIR || join(homedir(), '.mearl', 'plugins'));
24
36
  }
@@ -242,6 +254,7 @@ export function resolvePluginEntry(target) {
242
254
  export function registerPlugin(name, target, description, options = {}) {
243
255
  assertPluginName(name);
244
256
  const entry = resolvePluginEntry(target);
257
+ const entryVersion = pluginFileVersion(entry);
245
258
  const directory = pluginDirectory();
246
259
  mkdirSync(directory, { recursive: true, mode: 0o700 });
247
260
  const file = join(directory, `${name}.json`);
@@ -268,6 +281,7 @@ export function registerPlugin(name, target, description, options = {}) {
268
281
  }
269
282
  const record = {
270
283
  entry,
284
+ source: { entry: entryVersion, ...(builtPage ? { page: builtPage.sources } : {}) },
271
285
  ...(summary ? { description: summary } : {}),
272
286
  ...(managedBy ? { managedBy } : {}),
273
287
  ...(toolbox ? { toolbox } : {}),
@@ -324,12 +338,26 @@ export function findPluginRegistration(name) {
324
338
  : undefined;
325
339
  const toolbox = normalizePluginToolboxContribution(record.toolbox);
326
340
  const pageBundle = toolbox ? normalizePageBundle(record.pageBundle) : undefined;
341
+ const source = record.source;
342
+ if (source !== undefined &&
343
+ (!source ||
344
+ typeof source !== 'object' ||
345
+ Array.isArray(source) ||
346
+ typeof source.entry !== 'string' ||
347
+ (source.page !== undefined &&
348
+ (!source.page ||
349
+ typeof source.page !== 'object' ||
350
+ Array.isArray(source.page) ||
351
+ Object.entries(source.page).some(([path, version]) => !isAbsolute(path) || typeof version !== 'string'))))) {
352
+ throw new Error('invalid source');
353
+ }
327
354
  return {
328
355
  entry: record.entry,
329
356
  ...(description ? { description } : {}),
330
357
  ...(managedBy ? { managedBy } : {}),
331
358
  ...(toolbox ? { toolbox } : {}),
332
359
  ...(pageBundle ? { pageBundle } : {}),
360
+ ...(source ? { source } : {}),
333
361
  };
334
362
  }
335
363
  catch {
@@ -0,0 +1,2 @@
1
+ /** 文件替换和同大小写入都需要使缓存失效;检测只读取文件元数据。 */
2
+ export declare function pluginFileVersion(path: string): string;
@@ -0,0 +1,8 @@
1
+ import { statSync } from 'node:fs';
2
+ /** 文件替换和同大小写入都需要使缓存失效;检测只读取文件元数据。 */
3
+ export function pluginFileVersion(path) {
4
+ const stat = statSync(path);
5
+ if (!stat.isFile())
6
+ throw new Error(`插件资源不是文件: ${path}`);
7
+ return `${stat.dev}-${stat.ino}-${stat.mtimeMs}-${stat.ctimeMs}-${stat.size}`;
8
+ }
package/dist/socket.js CHANGED
@@ -96,12 +96,38 @@ function browserListFromProviderRecords(browsers) {
96
96
  browsers: providerBrowsers,
97
97
  };
98
98
  }
99
+ function withDiscoveryWarning(result, error) {
100
+ const cause = error.cause ?? error;
101
+ const code = cause?.code;
102
+ const detail = code ?? (error instanceof Error ? error.message.split('\n')[0] : String(error));
103
+ return {
104
+ ...result,
105
+ warnings: [
106
+ ...(Array.isArray(result.warnings) ? result.warnings : []),
107
+ `Local CDP discovery could not complete: ${detail}`,
108
+ ],
109
+ };
110
+ }
111
+ function cdpOnlyBrowserList(result) {
112
+ const browsers = Array.isArray(result.browsers) ? result.browsers : [];
113
+ const cdpBrowsers = browsers.filter(browser => browser.transport === 'cdp');
114
+ return {
115
+ ...result,
116
+ count: cdpBrowsers.length,
117
+ connectedCount: cdpBrowsers.filter(browser => browser.status === 'connected').length,
118
+ browsers: cdpBrowsers,
119
+ };
120
+ }
99
121
  function areInstalledProvidersRunning() {
100
122
  return listInstalledProviders().every(provider => readLiveProvider(provider.providerId));
101
123
  }
102
124
  function isConnectedBrowser(browser) {
103
125
  return browser.status === undefined || browser.status === 'connected';
104
126
  }
127
+ function isOrdinaryLocalBrowser(browser) {
128
+ return (!browser.browserId.startsWith('managed:') &&
129
+ (browser.transport === 'extension' || browser.transport === 'cdp'));
130
+ }
105
131
  function supportsAction(browser, action, data) {
106
132
  if (action === 'get_versions')
107
133
  return true;
@@ -141,47 +167,62 @@ function selectBrowserResult(data) {
141
167
  }
142
168
  /** Deduplicates concurrent recovery attempts in long-lived clients such as the MCP server. */
143
169
  let persistentSpawnPromise = null;
170
+ let discoveryListPromise = null;
171
+ let discoveryListCache = null;
172
+ const DISCOVERY_STARTUP_TIMEOUT_MS = 3_000;
173
+ const DISCOVERY_RETRY_INTERVAL_MS = 50;
174
+ const DISCOVERY_MISS_CACHE_MS = 2_000;
175
+ function instanceSocketPath(pid) {
176
+ return process.platform === 'win32'
177
+ ? `\\\\.\\pipe\\mearl-agent-${pid}`
178
+ : path.join(os.tmpdir(), `mearl-agent-${pid}.sock`);
179
+ }
180
+ function discoveryCacheKey() {
181
+ const browsers = listLiveBrowsers()
182
+ .map(browser => [
183
+ browser.browserId,
184
+ browser.pid,
185
+ browser.socketPath,
186
+ browser.status,
187
+ browser.updatedAt,
188
+ ])
189
+ .sort((a, b) => String(a[0]).localeCompare(String(b[0])));
190
+ const providers = listInstalledProviders()
191
+ .map(provider => provider.providerId)
192
+ .sort();
193
+ return JSON.stringify({ browsers, providers });
194
+ }
195
+ async function spawnNativeHost(args) {
196
+ const { spawn } = await import('node:child_process');
197
+ return new Promise((resolve, reject) => {
198
+ const child = spawn('mearl-native-host', args, {
199
+ detached: true,
200
+ stdio: 'ignore',
201
+ windowsHide: true,
202
+ });
203
+ child.once('error', reject);
204
+ child.once('spawn', () => {
205
+ child.unref();
206
+ resolve(child.pid);
207
+ });
208
+ });
209
+ }
144
210
  /**
145
211
  * Attempt to spawn `mearl-native-host` in the background (detached).
146
- * Browser discovery hosts probe the default local CDP endpoint for browser_list and only
147
- * persist when a real browser is available.
148
212
  * Returns true if a spawn was attempted (regardless of success).
149
213
  */
150
- async function trySpawnNativeHost(mode = 'control') {
151
- const persistent = mode === 'control';
152
- if (persistent && persistentSpawnPromise)
214
+ async function trySpawnNativeHost() {
215
+ if (persistentSpawnPromise)
153
216
  return persistentSpawnPromise;
154
217
  const spawnPromise = (async () => {
155
218
  try {
156
- const { spawn } = await import('node:child_process');
157
- return await new Promise(resolve => {
158
- const args = persistent ? ['--daemon'] : ['--daemon', '--discover-cdp'];
159
- const child = spawn('mearl-native-host', args, {
160
- detached: true,
161
- stdio: 'ignore',
162
- windowsHide: true,
163
- });
164
- let settled = false;
165
- const finish = (ok) => {
166
- if (settled)
167
- return;
168
- settled = true;
169
- resolve(ok);
170
- };
171
- // `spawn ENOENT` 等错误会通过事件异步抛出;必须显式监听避免进程崩溃。
172
- child.once('error', () => finish(false));
173
- child.once('spawn', () => {
174
- child.unref();
175
- finish(true);
176
- });
177
- });
219
+ await spawnNativeHost(['--daemon']);
220
+ return true;
178
221
  }
179
222
  catch {
180
223
  return false;
181
224
  }
182
225
  })();
183
- if (!persistent)
184
- return spawnPromise;
185
226
  persistentSpawnPromise = spawnPromise;
186
227
  try {
187
228
  return await spawnPromise;
@@ -191,6 +232,54 @@ async function trySpawnNativeHost(mode = 'control') {
191
232
  persistentSpawnPromise = null;
192
233
  }
193
234
  }
235
+ async function discoverLocalBrowserList(timeoutSec, controlSource) {
236
+ if (discoveryListPromise)
237
+ return discoveryListPromise;
238
+ const cacheKey = discoveryCacheKey();
239
+ if (discoveryListCache &&
240
+ discoveryListCache.key === cacheKey &&
241
+ Date.now() < discoveryListCache.until) {
242
+ if (discoveryListCache.error)
243
+ throw discoveryListCache.error;
244
+ return discoveryListCache.result;
245
+ }
246
+ discoveryListCache = null;
247
+ const request = (async () => {
248
+ const pid = await spawnNativeHost(['--daemon', '--discover-cdp']);
249
+ if (pid === undefined)
250
+ throw new Error('Native host did not report its process id');
251
+ const socketPath = instanceSocketPath(pid);
252
+ const deadline = Date.now() + Math.min(timeoutSec * 1_000, DISCOVERY_STARTUP_TIMEOUT_MS);
253
+ while (true) {
254
+ try {
255
+ return await invokeOnce('browser_list', {}, timeoutSec, undefined, socketPath, controlSource);
256
+ }
257
+ catch (error) {
258
+ if (!isConnError(error) || Date.now() >= deadline)
259
+ throw error;
260
+ await new Promise(resolve => setTimeout(resolve, DISCOVERY_RETRY_INTERVAL_MS));
261
+ }
262
+ }
263
+ })();
264
+ discoveryListPromise = request;
265
+ try {
266
+ const result = await request;
267
+ if (!result.browsers?.some((browser) => browser.transport === 'cdp' &&
268
+ browser.status === 'connected' &&
269
+ !browser.browserId?.startsWith('managed:'))) {
270
+ discoveryListCache = { key: cacheKey, result, until: Date.now() + DISCOVERY_MISS_CACHE_MS };
271
+ }
272
+ return result;
273
+ }
274
+ catch (error) {
275
+ discoveryListCache = { key: cacheKey, error, until: Date.now() + DISCOVERY_MISS_CACHE_MS };
276
+ throw error;
277
+ }
278
+ finally {
279
+ if (discoveryListPromise === request)
280
+ discoveryListPromise = null;
281
+ }
282
+ }
194
283
  function readOwnedLegacySocketIdentity() {
195
284
  if (process.platform === 'win32')
196
285
  return null;
@@ -240,7 +329,7 @@ export function invoke(action, data = {}, options = {}) {
240
329
  // 统一解析 payload 顶层字段中的 `@<path>` 文件引用(所有 action 通用)
241
330
  const resolvedData = resolveFileRefs(data);
242
331
  if (action === 'browser_list') {
243
- return dispatchInvoke(action, resolvedData, timeoutSec, undefined, undefined, controlSource).then(result => decorateBrowserListResult(result));
332
+ return dispatchInvoke(action, resolvedData, timeoutSec, transport, undefined, controlSource).then(result => decorateBrowserListResult(transport === 'cdp' ? cdpOnlyBrowserList(result) : result));
244
333
  }
245
334
  const selector = browser ?? defaultBrowser ?? process.env.MEARL_BROWSER ?? undefined;
246
335
  return dispatchInvoke(action, resolvedData, timeoutSec, transport, selector, controlSource);
@@ -252,6 +341,11 @@ function isConnError(error) {
252
341
  function isSocketSandboxError(error) {
253
342
  return error?.code === 'EPERM';
254
343
  }
344
+ function isUnavailableConnection(error) {
345
+ const cause = error.cause ?? error;
346
+ const code = cause?.code;
347
+ return isConnError(cause) || isSocketSandboxError(cause) || code === 'EACCES';
348
+ }
255
349
  async function dispatchInvoke(action, data, timeoutSec, transport, selector, controlSource) {
256
350
  const configuredSocketPath = process.env.MEARL_SOCKET_PATH?.trim();
257
351
  if (!selector && configuredSocketPath) {
@@ -269,6 +363,44 @@ async function dispatchInvoke(action, data, timeoutSec, transport, selector, con
269
363
  }
270
364
  const browsers = listLiveBrowsers();
271
365
  const controllableBrowsers = browsers.filter(isConnectedBrowser);
366
+ if (action === 'browser_list' && transport === 'cdp') {
367
+ const registeredCdp = controllableBrowsers.find(browser => browser.transport === 'cdp' && isOrdinaryLocalBrowser(browser));
368
+ if (registeredCdp) {
369
+ try {
370
+ return await invokeOnce(action, data, timeoutSec, transport, registeredCdp.socketPath, controlSource);
371
+ }
372
+ catch (error) {
373
+ if (!isConnError(error))
374
+ throw error;
375
+ }
376
+ }
377
+ try {
378
+ return await discoverLocalBrowserList(timeoutSec, controlSource);
379
+ }
380
+ catch (error) {
381
+ if (isUnavailableConnection(error) &&
382
+ browsers.some(browser => browser.transport === 'provider') &&
383
+ areInstalledProvidersRunning()) {
384
+ return withDiscoveryWarning(browserListFromProviderRecords(browsers), error);
385
+ }
386
+ throw error;
387
+ }
388
+ }
389
+ // A Provider record can keep the registry nonempty without representing a local
390
+ // browser transport. Let the discovery daemon probe CDP before returning that inventory.
391
+ if (action === 'browser_list' && !controllableBrowsers.some(isOrdinaryLocalBrowser)) {
392
+ try {
393
+ return await discoverLocalBrowserList(timeoutSec, controlSource);
394
+ }
395
+ catch (error) {
396
+ if (isUnavailableConnection(error) &&
397
+ browsers.some(browser => browser.transport === 'provider') &&
398
+ areInstalledProvidersRunning()) {
399
+ return withDiscoveryWarning(browserListFromProviderRecords(browsers), error);
400
+ }
401
+ throw error;
402
+ }
403
+ }
272
404
  // Explicit target: must match a live browser; do not silently fall back.
273
405
  if (selector) {
274
406
  const match = findBrowser(browsers, selector);
@@ -301,7 +433,8 @@ async function dispatchInvoke(action, data, timeoutSec, transport, selector, con
301
433
  }
302
434
  // No explicit target: prefer the most-recently-focused live browser; on a
303
435
  // stale socket, fall through to the legacy path + auto-spawn.
304
- const routableBrowsers = controllableBrowsers.filter(browser => !(transport === 'cdp' && browser.transport === 'provider') &&
436
+ const routableBrowsers = controllableBrowsers.filter(browser => (action !== 'browser_list' || browser.transport !== 'provider') &&
437
+ !(transport === 'cdp' && browser.transport === 'provider') &&
305
438
  supportsAction(browser, action, data));
306
439
  if (routableBrowsers.length > 0) {
307
440
  const target = routableBrowsers[0];
@@ -318,10 +451,18 @@ async function dispatchInvoke(action, data, timeoutSec, transport, selector, con
318
451
  throw error;
319
452
  }
320
453
  }
321
- if (action === 'browser_list' &&
322
- browsers.some(browser => browser.transport === 'provider') &&
323
- areInstalledProvidersRunning()) {
324
- return browserListFromProviderRecords(browsers);
454
+ if (action === 'browser_list') {
455
+ try {
456
+ return await discoverLocalBrowserList(timeoutSec, controlSource);
457
+ }
458
+ catch (error) {
459
+ if (isUnavailableConnection(error) &&
460
+ browsers.some(browser => browser.transport === 'provider') &&
461
+ areInstalledProvidersRunning()) {
462
+ return withDiscoveryWarning(browserListFromProviderRecords(browsers), error);
463
+ }
464
+ throw error;
465
+ }
325
466
  }
326
467
  return invokeViaLegacy(action, data, timeoutSec, transport, controlSource, /* isRetry */ false);
327
468
  }
@@ -337,15 +478,9 @@ async function invokeViaLegacy(action, data, timeoutSec, transport, controlSourc
337
478
  if (shouldAttemptSpawn && !isRetry) {
338
479
  if (err.code === 'ECONNREFUSED')
339
480
  removeStaleLegacySocket(legacySocketIdentity);
340
- const spawned = await trySpawnNativeHost(action === 'browser_list' ? 'discovery' : 'control');
481
+ const spawned = await trySpawnNativeHost();
341
482
  if (spawned) {
342
- // Wait for the native host to start listening. Discovery retries the legacy socket directly
343
- // so the spawned host receives the probe even if another browser registers in the meantime.
344
483
  await new Promise(r => setTimeout(r, 1200));
345
- if (action === 'browser_list') {
346
- return invokeViaLegacy(action, data, timeoutSec, transport, controlSource,
347
- /* isRetry */ true);
348
- }
349
484
  const browsers = listLiveBrowsers();
350
485
  const target = browsers.find(browser => isConnectedBrowser(browser) &&
351
486
  !(transport === 'cdp' && browser.transport === 'provider') &&
@@ -453,7 +588,8 @@ function formatConnectError(error, socketPath) {
453
588
  if (error.code === 'ENOENT') {
454
589
  return [
455
590
  `未找到 Socket: ${socketPath}`,
456
- 'native host 自动拉起失败,请手动排查:',
591
+ 'native host 自动拉起失败。先运行 mearl check --local;缺少命令时运行 npx @mearl/setup --local --yes。',
592
+ '自动拉起只使用默认 daemon 参数;自定义 CDP/Profile/浏览器参数需手工启动,参见 mearl-native-host --help。',
457
593
  '',
458
594
  '【方式一:浏览器插件模式】',
459
595
  `1. 确认已安装 Mearl 浏览器插件(${EXTENSION_INSTALL_URL})`,
@@ -469,7 +605,7 @@ function formatConnectError(error, socketPath) {
469
605
  if (error.code === 'ECONNREFUSED') {
470
606
  return [
471
607
  `Socket 连接被拒绝: ${socketPath}`,
472
- 'native host 自动恢复失败。',
608
+ '已尝试处理 stale socket,但 native host 自动恢复仍失败。请运行 mearl check --local 定位故障。',
473
609
  '请确认 Chrome 浏览器正在运行,可尝试在 chrome://extensions 重新加载插件。',
474
610
  '',
475
611
  installHint,
package/dist/unified.js CHANGED
@@ -449,7 +449,8 @@ export class UnifiedClient {
449
449
  connect() {
450
450
  return new Promise((resolve, reject) => {
451
451
  if (!this.options.serverUrl) {
452
- reject(new Error('Cloud server URL is required. Start cloud-server, pass --server, or set MEARL_SERVER_URL.'));
452
+ reject(new Error('Cloud server URL is required. Run mearl-cloud-server status on the Agent machine. ' +
453
+ 'Resolution order: --server > MEARL_SERVER_URL > ~/.mearl/cloud-server.json.'));
453
454
  return;
454
455
  }
455
456
  const ws = new WebSocket(this.options.serverUrl);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mearl/client",
3
- "version": "2.19.2",
3
+ "version": "2.19.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/cloud-types": "2.19.2",
63
- "@mearl/provider": "2.19.2"
62
+ "@mearl/cloud-types": "2.19.3",
63
+ "@mearl/provider": "2.19.3"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@types/node": "^24.9.1",