@mearl/client 2.20.0 → 2.20.2

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
@@ -268,6 +268,14 @@ export default function register(app) {
268
268
  }
269
269
  ```
270
270
 
271
+ `app.command(signature, handler, description?, examples?)` 注册 CLI、SDK 和 Agent 可调用的业务命令。
272
+ `app.toolboxCommand(signature, handler, description?, examples?)` 注册仅供工具页面调用的命令,参数和 handler 契约相同;
273
+ 它不出现在 CLI 帮助中,CLI(包括 JSON payload)、SDK、MCP 及插件间的 `app.invoke` 都不能调用。
274
+ 工具页面通过 `window.mearl.invoke` 或 `window.mearl.start` 执行;两类命令都必须显式列在 `toolbox.page.commands` 中才能供页面调用。
275
+ 注册的 `toolboxCommand` 若没有列入该白名单,插件加载会失败。分块上传、分块读取和页面临时资源释放适合使用它;
276
+ 对 Agent 有独立业务价值的查询、导入、压缩、比较等完整操作使用 `app.command`。
277
+ Python 入口也支持 `app.toolboxCommand(...)`,包括 `@app.toolboxCommand(..., description="...")` 装饰器。
278
+
271
279
  声明 `agent` 后工具页可以把任务交给对话 Agent;`layout` 默认为 `drawer`,宿主会提供顶部入口。
272
280
  页面也可在 `setAgentPanelVisible` 或 `runAgentTask` 中为单次交互指定 `drawer` / `workspace` / `inline`;
273
281
  Agent 对话会保存在 Codex 会话历史中。带固定
@@ -21,7 +21,7 @@ export function resolveObservedActionTimeoutSec(data) {
21
21
  }
22
22
  export function resolveActionTimeoutSec(action, data, fallback = 15) {
23
23
  if (action === 'send_request') {
24
- const transferTimeoutMs = Array.isArray(data.files) && data.files.length > 0
24
+ const transferTimeoutMs = data.bodyFile !== undefined || (Array.isArray(data.files) && data.files.length > 0)
25
25
  ? SEND_REQUEST_FILE_TRANSFER_TIMEOUT_MS
26
26
  : 0;
27
27
  return Math.max(fallback, Math.ceil(((data.timeout ?? 30000) + transferTimeoutMs + 5000) / 1000));
@@ -305,7 +305,7 @@ export declare const COMMANDS: readonly [{
305
305
  readonly examples: ["get_rules"];
306
306
  }, {
307
307
  readonly name: "send_request";
308
- readonly description: "发送 HTTP 请求,自动携带浏览器 Cookie,支持 multipart 文件上传";
308
+ readonly description: "发送 HTTP 请求,自动携带浏览器 Cookie,支持 multipart 和原始二进制上传";
309
309
  readonly params: [{
310
310
  readonly name: "url";
311
311
  readonly type: "string";
@@ -327,6 +327,10 @@ export declare const COMMANDS: readonly [{
327
327
  readonly name: "body";
328
328
  readonly type: "any";
329
329
  readonly description: "请求体(传对象时自动 JSON 序列化)";
330
+ }, {
331
+ readonly name: "bodyFile";
332
+ readonly type: "object";
333
+ readonly description: "原始二进制请求体:filePath 或 base64 二选一,可选 contentType(默认 application/octet-stream);与 body/formData/files 互斥";
330
334
  }, {
331
335
  readonly name: "formData";
332
336
  readonly type: "object";
@@ -349,9 +353,9 @@ export declare const COMMANDS: readonly [{
349
353
  readonly type: "number";
350
354
  readonly description: "超时毫秒数,默认 30000";
351
355
  }];
352
- readonly notes: readonly ["仅用于非 BUC 请求;BUC 接口使用 mearl buc,不手工传 Token、DPoP 或 Ticket。", "body 不能与 formData/files 组合;multipart 不手工设置 Content-Type,由 Mearl 生成 boundary。", "filePath 属于 native-host 所在机器;无法引用远端路径时用 base64。BUC 上传的路径属于插件执行宿主。"];
356
+ readonly notes: readonly ["仅用于非 BUC 请求;BUC 接口使用 mearl buc,不手工传 Token、DPoP 或 Ticket。", "body 不能与 formData/files 组合;bodyFile 以原始字节发送,不包 multipart boundary,不能用于 GET/HEAD。", "bodyFile.filePath 属于 native-host,最大 40 MiB;base64 解码后最大 7 MiB。multipart 不手工设置 Content-Type,由 Mearl 生成 boundary。", "filePath 属于 native-host 所在机器;无法引用远端路径时用 base64。BUC 上传的路径属于插件执行宿主。"];
353
357
  readonly result: readonly ["返回 status、statusText、headers、body、bodyType、attachedCookieCount;bodyType 为 json/text/base64,二进制 body 需解码后保存。"];
354
- readonly examples: ["send_request --payload '{\"url\":\"https://api.example.com/data\",\"method\":\"GET\"}'", "send_request --payload '{\"url\":\"https://api.example.com/upload\",\"method\":\"POST\",\"formData\":{\"folder\":\"assets\"},\"files\":[{\"fieldName\":\"file\",\"filePath\":\"/absolute/path/image.png\"}]}'"];
358
+ readonly examples: ["send_request --payload '{\"url\":\"https://api.example.com/data\",\"method\":\"GET\"}'", "send_request --payload '{\"url\":\"https://api.example.com/upload\",\"method\":\"POST\",\"formData\":{\"folder\":\"assets\"},\"files\":[{\"fieldName\":\"file\",\"filePath\":\"/absolute/path/image.png\"}]}'", "send_request --payload '{\"url\":\"https://api.example.com/upload\",\"method\":\"POST\",\"bodyFile\":{\"filePath\":\"/absolute/path/image.png\",\"contentType\":\"image/png\"}}'"];
355
359
  }, {
356
360
  readonly name: "send_mtop_request";
357
361
  readonly description: "使用目标浏览器 Cookie 发起 mtop 请求,自动处理签名和 token";
package/dist/commands.js CHANGED
@@ -306,13 +306,18 @@ export const COMMANDS = [
306
306
  // ── 网络代理 ──────────────────────────────────────────────────────────────
307
307
  {
308
308
  name: 'send_request',
309
- description: '发送 HTTP 请求,自动携带浏览器 Cookie,支持 multipart 文件上传',
309
+ description: '发送 HTTP 请求,自动携带浏览器 Cookie,支持 multipart 和原始二进制上传',
310
310
  params: [
311
311
  { name: 'url', type: 'string', required: true, description: '完整目标 URL' },
312
312
  { name: 'method', type: 'string', description: 'HTTP 方法,默认 GET' },
313
313
  { name: 'headers', type: 'object', description: '自定义请求头' },
314
314
  { name: 'params', type: 'object', description: 'URL 查询参数' },
315
315
  { name: 'body', type: 'any', description: '请求体(传对象时自动 JSON 序列化)' },
316
+ {
317
+ name: 'bodyFile',
318
+ type: 'object',
319
+ description: '原始二进制请求体:filePath 或 base64 二选一,可选 contentType(默认 application/octet-stream);与 body/formData/files 互斥',
320
+ },
316
321
  {
317
322
  name: 'formData',
318
323
  type: 'object',
@@ -338,7 +343,8 @@ export const COMMANDS = [
338
343
  ],
339
344
  notes: [
340
345
  '仅用于非 BUC 请求;BUC 接口使用 mearl buc,不手工传 Token、DPoP 或 Ticket。',
341
- 'body 不能与 formData/files 组合;multipart 不手工设置 Content-Type,由 Mearl 生成 boundary。',
346
+ 'body 不能与 formData/files 组合;bodyFile 以原始字节发送,不包 multipart boundary,不能用于 GET/HEAD。',
347
+ 'bodyFile.filePath 属于 native-host,最大 40 MiB;base64 解码后最大 7 MiB。multipart 不手工设置 Content-Type,由 Mearl 生成 boundary。',
342
348
  'filePath 属于 native-host 所在机器;无法引用远端路径时用 base64。BUC 上传的路径属于插件执行宿主。',
343
349
  ],
344
350
  result: [
@@ -347,6 +353,7 @@ export const COMMANDS = [
347
353
  examples: [
348
354
  `send_request --payload '{"url":"https://api.example.com/data","method":"GET"}'`,
349
355
  `send_request --payload '{"url":"https://api.example.com/upload","method":"POST","formData":{"folder":"assets"},"files":[{"fieldName":"file","filePath":"/absolute/path/image.png"}]}'`,
356
+ `send_request --payload '{"url":"https://api.example.com/upload","method":"POST","bodyFile":{"filePath":"/absolute/path/image.png","contentType":"image/png"}}'`,
350
357
  ],
351
358
  },
352
359
  {
@@ -83,12 +83,23 @@ export type SendRequestFile = SendRequestFileMetadata & ({
83
83
  /** Multipart filename for the Base64 payload. */
84
84
  filename: string;
85
85
  });
86
+ export type SendRequestBinaryBody = {
87
+ contentType?: string;
88
+ } & ({
89
+ filePath: string;
90
+ base64?: never;
91
+ } | {
92
+ filePath?: never;
93
+ base64: string;
94
+ });
86
95
  export interface SendRequestParams {
87
96
  url: string;
88
97
  method?: string;
89
98
  headers?: Record<string, string>;
90
99
  params?: Record<string, string>;
91
100
  body?: unknown;
101
+ /** Raw bytes, mutually exclusive with body, formData and files. */
102
+ bodyFile?: SendRequestBinaryBody;
92
103
  formData?: Record<string, SendRequestFormValue>;
93
104
  files?: SendRequestFile[];
94
105
  withCookies?: boolean;
@@ -323,6 +334,12 @@ export type BrowserProviderInputSchema = (BrowserProviderSchemaBase & {
323
334
  maxLength?: number;
324
335
  pattern?: string;
325
336
  placeholder?: string;
337
+ options?: readonly {
338
+ value: string;
339
+ label: string;
340
+ description?: string;
341
+ disabled?: boolean;
342
+ }[];
326
343
  }) | (BrowserProviderSchemaBase & {
327
344
  type: 'number';
328
345
  integer?: boolean;
@@ -353,8 +370,29 @@ export interface BrowserProviderDefinition {
353
370
  configuration?: {
354
371
  inputSchema: BrowserProviderObjectSchema;
355
372
  };
373
+ dynamicLaunchForm?: boolean;
374
+ launchForm?: BrowserProviderLaunchForm;
356
375
  };
357
376
  }
377
+ export interface BrowserProviderLaunchFormVariant {
378
+ id: string;
379
+ title: string;
380
+ description?: string;
381
+ inputSchema?: BrowserProviderObjectSchema;
382
+ values?: Record<string, BrowserJsonValue>;
383
+ refreshOn?: readonly string[];
384
+ dynamicFields?: readonly string[];
385
+ }
386
+ export interface BrowserProviderLaunchForm {
387
+ inputSchema: BrowserProviderObjectSchema;
388
+ variants?: readonly BrowserProviderLaunchFormVariant[];
389
+ variant?: string;
390
+ values?: Record<string, BrowserJsonValue>;
391
+ refreshOn?: readonly string[];
392
+ dynamicFields?: readonly string[];
393
+ message?: string;
394
+ blocked?: boolean;
395
+ }
358
396
  export interface BrowserProviderListResult {
359
397
  providers: BrowserProviderDefinition[];
360
398
  }
package/dist/index.d.ts CHANGED
@@ -8,7 +8,7 @@ export { UnifiedClient, UnifiedClient as MearlClient, decodeBrowserTarget, invok
8
8
  export type { UnifiedClientOptions, UnifiedInvokeOptions, UnifiedInvokeOptions as InvokeOptions, } from './unified.js';
9
9
  export type { PluginInvocation, PluginInvokeOptions, PluginProgress, StructuredPluginProgress, } from './plugin.js';
10
10
  export { APP_PROFILE_NAMES, BROWSER_DEVICE_PRESET_NAMES, BROWSER_ACTIONS, BROWSER_CAPABILITY_ACTIONS, BROWSER_COMMAND_ACTIONS, EXTENSION_BROWSER_ACTIONS, PAGE_ACT_ACTIONS, SEND_REQUEST_MAX_FILES, SEND_REQUEST_MAX_TOTAL_BASE64_FILE_BYTES, SEND_REQUEST_MAX_TOTAL_FILE_BYTES, isBrowserAction, isBrowserCapabilityAction, isBrowserCommandAction, isExtensionBrowserAction, } from './generated-browser-action-protocol.js';
11
- export type { ActionStep, AppProfileName, BrowserAction, BrowserActionMap, BrowserActionRequest, BrowserActionResponse, BrowserCapabilities, BrowserCapabilityAction, BrowserCloseParams, BrowserCommandAction, BrowserInfo, BrowserInfoBase, BrowserJsonValue, BrowserLaunchParams, BrowserProviderOperation, BrowserListResult, BrowserPairingInfo, BrowserReleaseFailure, BrowserReleaseResult, BrowserProviderDefinition, BrowserProviderIdentity, BrowserProviderInputSchema, BrowserProviderListResult, BrowserReference, BrowserDetails, BrowserSource, BrowserStatus, BrowserScreenshotKind, BrowserTransport, CaptureCheckpointParams, CaptureCheckpointResult, BrowserDevicePresetName, BrowserEmulationOptions, ExtensionBrowserAction, GetCookieParams, GetCookieResult, GetEventsParams, GetLogsParams, GetMocksParams, GetRequestsParams, ManagedBrowserInfo, PageClickParams, PageDragEndpoint, PageDragParams, PageDragPoint, PageActAction, PageDiagnosticsOptions, PageDiagnosticsSetting, PageEvalParams, PageFramesParams, PageHoverParams, PageInspectFilter, PageInspectParams, PageNavigateParams, PageObservationWaitOptions, PagePressParams, PageScreenshotParams, PageScrollParams, PageSnapshotParams, PageSnapshotQuery, PageTypeParams, PageUploadParams, PageWaitParams, RegularBrowserInfo, RequestDomainPermissionParams, RulesResult, RunActionName, RunActionsParams, SelectBrowserParams, SelectBrowserResult, SendRequestFile, SendRequestFormValue, SendMtopRequestParams, SendRequestParams, SetAppProfileParams, SetCookieParams, SetCookieResult, SetDeviceEmulationParams, SetMockParams, SetRuleParams, SetTimezoneParams, SetTimezoneResult, TabCloseParams, TabOpenParams, } from './generated-browser-action-protocol.js';
11
+ export type { ActionStep, AppProfileName, BrowserAction, BrowserActionMap, BrowserActionRequest, BrowserActionResponse, BrowserCapabilities, BrowserCapabilityAction, BrowserCloseParams, BrowserCommandAction, BrowserInfo, BrowserInfoBase, BrowserJsonValue, BrowserLaunchParams, BrowserProviderOperation, BrowserListResult, BrowserPairingInfo, BrowserReleaseFailure, BrowserReleaseResult, BrowserProviderDefinition, BrowserProviderIdentity, BrowserProviderInputSchema, BrowserProviderListResult, BrowserReference, BrowserDetails, BrowserSource, BrowserStatus, BrowserScreenshotKind, BrowserTransport, CaptureCheckpointParams, CaptureCheckpointResult, BrowserDevicePresetName, BrowserEmulationOptions, ExtensionBrowserAction, GetCookieParams, GetCookieResult, GetEventsParams, GetLogsParams, GetMocksParams, GetRequestsParams, ManagedBrowserInfo, PageClickParams, PageDragEndpoint, PageDragParams, PageDragPoint, PageActAction, PageDiagnosticsOptions, PageDiagnosticsSetting, PageEvalParams, PageFramesParams, PageHoverParams, PageInspectFilter, PageInspectParams, PageNavigateParams, PageObservationWaitOptions, PagePressParams, PageScreenshotParams, PageScrollParams, PageSnapshotParams, PageSnapshotQuery, PageTypeParams, PageUploadParams, PageWaitParams, RegularBrowserInfo, RequestDomainPermissionParams, RulesResult, RunActionName, RunActionsParams, SelectBrowserParams, SelectBrowserResult, SendRequestFile, SendRequestBinaryBody, SendRequestFormValue, SendMtopRequestParams, SendRequestParams, SetAppProfileParams, SetCookieParams, SetCookieResult, SetDeviceEmulationParams, SetMockParams, SetRuleParams, SetTimezoneParams, SetTimezoneResult, TabCloseParams, TabOpenParams, } from './generated-browser-action-protocol.js';
12
12
  import { type UnifiedInvokeOptions } from './unified.js';
13
13
  import type * as Protocol from './generated-browser-action-protocol.js';
14
14
  export declare function tabCheckpoint(params: Protocol.CaptureCheckpointParams, options?: UnifiedInvokeOptions): Promise<Protocol.BrowserActionResponse<'tab_checkpoint'>>;
package/dist/plugin.d.ts CHANGED
@@ -24,6 +24,8 @@ export interface PluginApp {
24
24
  readonly options: Readonly<PluginOptions>;
25
25
  /** Examples omit the `mearl <plugin>` prefix, which help adds when displayed. */
26
26
  command(signature: string, handler: PluginHandler, description?: string, examples?: string[]): void;
27
+ /** Registers a page-only command, also declared in toolbox.page.commands. */
28
+ toolboxCommand(signature: string, handler: PluginHandler, description?: string, examples?: string[]): void;
27
29
  invoke(action: string, payload?: Record<string, any>, options?: UnifiedInvokeOptions): Promise<any>;
28
30
  progress(progress: PluginProgress): void;
29
31
  }
@@ -39,6 +41,7 @@ export declare function loadPlugin(entry: string, context: Pick<PluginApp, 'invo
39
41
  commandHelp(namespace: string): string;
40
42
  help(namespace: string, name?: string): string;
41
43
  run(positionals: string[], payload?: unknown, namedInput?: Record<string, unknown>): Promise<unknown>;
44
+ runToolbox(invocation: PluginInvocation): Promise<unknown>;
42
45
  }>;
43
46
  export declare function loadRegisteredPlugin(name: string, context: PluginExecutionContext, options?: PluginInvokeOptions): Promise<{
44
47
  name: string | undefined;
@@ -48,5 +51,8 @@ export declare function loadRegisteredPlugin(name: string, context: PluginExecut
48
51
  commandHelp(namespace: string): string;
49
52
  help(namespace: string, name?: string): string;
50
53
  run(positionals: string[], payload?: unknown, namedInput?: Record<string, unknown>): Promise<unknown>;
54
+ runToolbox(invocation: PluginInvocation): Promise<unknown>;
51
55
  }>;
52
56
  export declare function invokeRegisteredPlugin(name: string, invocation: PluginInvocation, context: PluginExecutionContext, options?: PluginInvokeOptions): Promise<unknown>;
57
+ /** Entry point for the native toolbox service; nested plugin calls retain public-command access. */
58
+ export declare function invokeRegisteredToolboxPlugin(name: string, invocation: PluginInvocation, context: PluginExecutionContext, options?: PluginInvokeOptions): Promise<unknown>;
package/dist/plugin.js CHANGED
@@ -133,31 +133,33 @@ export async function loadPlugin(entry, context, pluginName) {
133
133
  let name;
134
134
  let description;
135
135
  let toolbox;
136
+ const registerCommand = (signature, handler, description = '', examples = [], toolboxOnly = false) => {
137
+ if (!registering)
138
+ throw new Error('命令只能在入口注册阶段声明');
139
+ const [name, ...args] = signature.trim().split(/\s+/);
140
+ if (!/^[a-z][a-z0-9-]*$/.test(name) ||
141
+ commands.has(name) ||
142
+ typeof handler !== 'function' ||
143
+ !Array.isArray(examples) ||
144
+ examples.some((example) => typeof example !== 'string' || !example.trim())) {
145
+ throw new Error(`无效或重复的命令: ${signature}`);
146
+ }
147
+ const command = new Command(name).description(description);
148
+ for (const argument of args) {
149
+ if (!/^(?:<[a-zA-Z][\w-]*(?:\.\.\.)?>|\[[a-zA-Z][\w-]*(?:\.\.\.)?\])$/.test(argument)) {
150
+ throw new Error(`无效的位置参数: ${argument}`);
151
+ }
152
+ command.argument(argument);
153
+ }
154
+ const names = command.registeredArguments.map(argument => argument.name());
155
+ if (new Set(names).size !== names.length)
156
+ throw new Error(`参数名称重复: ${signature}`);
157
+ commands.set(name, { command, signature, handler, examples: [...examples], toolboxOnly });
158
+ };
136
159
  const app = Object.freeze({
137
160
  options: Object.freeze({ ...context.options }),
138
- command(signature, handler, description = '', examples = []) {
139
- if (!registering)
140
- throw new Error('命令只能在入口注册阶段声明');
141
- const [name, ...args] = signature.trim().split(/\s+/);
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())) {
147
- throw new Error(`无效或重复的命令: ${signature}`);
148
- }
149
- const command = new Command(name).description(description);
150
- for (const argument of args) {
151
- if (!/^(?:<[a-zA-Z][\w-]*(?:\.\.\.)?>|\[[a-zA-Z][\w-]*(?:\.\.\.)?\])$/.test(argument)) {
152
- throw new Error(`无效的位置参数: ${argument}`);
153
- }
154
- command.argument(argument);
155
- }
156
- const names = command.registeredArguments.map(argument => argument.name());
157
- if (new Set(names).size !== names.length)
158
- throw new Error(`参数名称重复: ${signature}`);
159
- commands.set(name, { command, signature, handler, examples: [...examples] });
160
- },
161
+ command: (signature, handler, description = '', examples = []) => registerCommand(signature, handler, description, examples),
162
+ toolboxCommand: (signature, handler, description = '', examples = []) => registerCommand(signature, handler, description, examples, true),
161
163
  invoke(action, payload = {}, options = {}) {
162
164
  if (!executing)
163
165
  throw new Error('app.invoke 只能在命令执行期间调用');
@@ -177,7 +179,8 @@ export async function loadPlugin(entry, context, pluginName) {
177
179
  toolbox = normalizePluginToolboxContribution(descriptor.toolbox);
178
180
  for (const command of descriptor.commands) {
179
181
  const commandName = command.signature.trim().split(/\s+/)[0];
180
- app.command(command.signature, input => runPythonPluginCommand(entry, commandName, input, app), command.description);
182
+ const register = command.toolboxOnly ? app.toolboxCommand : app.command;
183
+ register(command.signature, input => runPythonPluginCommand(entry, commandName, input, app), command.description);
181
184
  }
182
185
  }
183
186
  else {
@@ -198,6 +201,11 @@ export async function loadPlugin(entry, context, pluginName) {
198
201
  if (commands.size === 0)
199
202
  throw new Error('入口未注册任何命令');
200
203
  const toolboxCommands = toolbox?.page.commands ?? [];
204
+ for (const [name, command] of commands) {
205
+ if (command.toolboxOnly && !toolboxCommands.includes(name)) {
206
+ throw new Error(`toolboxCommand 未声明在 toolbox.page.commands 中: ${name}`);
207
+ }
208
+ }
201
209
  for (const command of toolboxCommands) {
202
210
  if (!commands.has(command))
203
211
  throw new Error(`toolbox 命令未注册: ${command}`);
@@ -209,24 +217,86 @@ export async function loadPlugin(entry, context, pluginName) {
209
217
  finally {
210
218
  registering = false;
211
219
  }
212
- const find = (name) => {
220
+ const find = (name, toolboxPage = false) => {
213
221
  const command = commands.get(name);
214
222
  if (!command)
215
223
  throw new PluginError('PLUGIN_COMMAND_NOT_FOUND', `未知子命令: ${name}`);
224
+ if (toolboxPage) {
225
+ if (!toolbox?.page.commands.includes(name)) {
226
+ throw new PluginError('PLUGIN_COMMAND_NOT_ALLOWED', `工具页面不允许调用命令: ${name}`);
227
+ }
228
+ }
229
+ else if (command.toolboxOnly) {
230
+ throw new PluginError('PLUGIN_COMMAND_TOOLBOX_ONLY', `命令仅供工具页面调用: ${name}`);
231
+ }
216
232
  return command;
217
233
  };
234
+ const publicCommands = () => [...commands.values()].filter(command => !command.toolboxOnly);
235
+ const run = async (positionals, payload, namedInput = {}, toolboxPage = false) => {
236
+ if (payload !== undefined && positionals.length > 0) {
237
+ throw new PluginError('PLUGIN_INVALID_INPUT', '位置参数调用与 --payload 调用不能混用');
238
+ }
239
+ if (payload !== undefined && Object.keys(namedInput).length > 0) {
240
+ throw new PluginError('PLUGIN_INVALID_INPUT', '插件业务选项与 --payload 调用不能混用');
241
+ }
242
+ if (payload !== undefined &&
243
+ (!isObject(payload) || typeof payload.command !== 'string' || !isObject(payload.input))) {
244
+ throw new PluginError('PLUGIN_INVALID_INPUT', '--payload 必须为 {"command":"...","input":{...}}');
245
+ }
246
+ const envelope = payload;
247
+ const { command, handler } = find(envelope?.command ?? positionals[0] ?? '', toolboxPage);
248
+ const positionalInput = envelope
249
+ ? { ...envelope.input }
250
+ : Object.fromEntries(command.registeredArguments
251
+ .map((argument, index) => [
252
+ argument.name(),
253
+ argument.variadic ? positionals.slice(index + 1) : positionals[index + 1],
254
+ ])
255
+ .filter(([, value]) => value !== undefined));
256
+ for (const name of Object.keys(namedInput)) {
257
+ if (Object.hasOwn(positionalInput, name)) {
258
+ throw new PluginError('PLUGIN_INVALID_INPUT', `参数不能重复传入: ${name}`);
259
+ }
260
+ }
261
+ const input = { ...positionalInput, ...namedInput };
262
+ if (!envelope &&
263
+ !command.registeredArguments.some(argument => argument.variadic) &&
264
+ positionals.length - 1 > command.registeredArguments.length) {
265
+ throw new PluginError('PLUGIN_INVALID_INPUT', '位置参数过多');
266
+ }
267
+ for (const argument of command.registeredArguments) {
268
+ const value = Object.hasOwn(input, argument.name()) ? input[argument.name()] : undefined;
269
+ if (argument.required &&
270
+ (value === undefined ||
271
+ value === null ||
272
+ value === '' ||
273
+ (Array.isArray(value) && value.length === 0))) {
274
+ throw new PluginError('PLUGIN_INVALID_INPUT', `缺少必填参数: ${argument.name()}`);
275
+ }
276
+ if (argument.variadic && value !== undefined && !Array.isArray(value)) {
277
+ throw new PluginError('PLUGIN_INVALID_INPUT', `${argument.name()} 必须为数组`);
278
+ }
279
+ }
280
+ executing = true;
281
+ try {
282
+ return await handler(input);
283
+ }
284
+ finally {
285
+ executing = false;
286
+ }
287
+ };
218
288
  return {
219
289
  name,
220
290
  description,
221
291
  toolbox,
222
292
  version: readPluginVersion(entry),
223
293
  commandHelp(namespace) {
224
- return [...commands.values()]
294
+ return publicCommands()
225
295
  .map(({ command, signature }) => ` ${namespace} ${signature} ${command.description()}`)
226
296
  .join('\n');
227
297
  },
228
298
  help(namespace, name) {
229
- const selected = name ? [find(name)] : [...commands.values()];
299
+ const selected = name ? [find(name)] : publicCommands();
230
300
  const examples = selected.flatMap(({ examples }) => examples);
231
301
  return [
232
302
  '用法:',
@@ -236,58 +306,11 @@ export async function loadPlugin(entry, context, pluginName) {
236
306
  : []),
237
307
  ].join('\n');
238
308
  },
239
- async run(positionals, payload, namedInput = {}) {
240
- if (payload !== undefined && positionals.length > 0) {
241
- throw new PluginError('PLUGIN_INVALID_INPUT', '位置参数调用与 --payload 调用不能混用');
242
- }
243
- if (payload !== undefined && Object.keys(namedInput).length > 0) {
244
- throw new PluginError('PLUGIN_INVALID_INPUT', '插件业务选项与 --payload 调用不能混用');
245
- }
246
- if (payload !== undefined &&
247
- (!isObject(payload) || typeof payload.command !== 'string' || !isObject(payload.input))) {
248
- throw new PluginError('PLUGIN_INVALID_INPUT', '--payload 必须为 {"command":"...","input":{...}}');
249
- }
250
- const envelope = payload;
251
- const { command, handler } = find(envelope?.command ?? positionals[0] ?? '');
252
- const positionalInput = envelope
253
- ? { ...envelope.input }
254
- : Object.fromEntries(command.registeredArguments
255
- .map((argument, index) => [
256
- argument.name(),
257
- argument.variadic ? positionals.slice(index + 1) : positionals[index + 1],
258
- ])
259
- .filter(([, value]) => value !== undefined));
260
- for (const name of Object.keys(namedInput)) {
261
- if (Object.hasOwn(positionalInput, name)) {
262
- throw new PluginError('PLUGIN_INVALID_INPUT', `参数不能重复传入: ${name}`);
263
- }
264
- }
265
- const input = { ...positionalInput, ...namedInput };
266
- if (!envelope &&
267
- !command.registeredArguments.some(argument => argument.variadic) &&
268
- positionals.length - 1 > command.registeredArguments.length) {
269
- throw new PluginError('PLUGIN_INVALID_INPUT', '位置参数过多');
270
- }
271
- for (const argument of command.registeredArguments) {
272
- const value = Object.hasOwn(input, argument.name()) ? input[argument.name()] : undefined;
273
- if (argument.required &&
274
- (value === undefined ||
275
- value === null ||
276
- value === '' ||
277
- (Array.isArray(value) && value.length === 0))) {
278
- throw new PluginError('PLUGIN_INVALID_INPUT', `缺少必填参数: ${argument.name()}`);
279
- }
280
- if (argument.variadic && value !== undefined && !Array.isArray(value)) {
281
- throw new PluginError('PLUGIN_INVALID_INPUT', `${argument.name()} 必须为数组`);
282
- }
283
- }
284
- executing = true;
285
- try {
286
- return await handler(input);
287
- }
288
- finally {
289
- executing = false;
290
- }
309
+ run(positionals, payload, namedInput = {}) {
310
+ return run(positionals, payload, namedInput);
311
+ },
312
+ runToolbox(invocation) {
313
+ return run([], invocation, {}, true);
291
314
  },
292
315
  };
293
316
  }
@@ -348,3 +371,8 @@ async function invokeRegisteredPluginWithState(name, invocation, context, option
348
371
  export function invokeRegisteredPlugin(name, invocation, context, options = {}) {
349
372
  return invokeRegisteredPluginWithState(name, invocation, context, options);
350
373
  }
374
+ /** Entry point for the native toolbox service; nested plugin calls retain public-command access. */
375
+ export async function invokeRegisteredToolboxPlugin(name, invocation, context, options = {}) {
376
+ const plugin = await loadRegisteredPluginWithState(name, context, options);
377
+ return plugin.runToolbox(invocation);
378
+ }
@@ -6,4 +6,5 @@ export interface BuiltPluginPageBundle {
6
6
  chunks: number;
7
7
  sources: Record<string, string>;
8
8
  }
9
+ export declare const PLUGIN_PAGE_RUNTIME_HASH: string;
9
10
  export declare function buildPluginPageBundle(entry: string, pageEntry: string): BuiltPluginPageBundle;
@@ -49,7 +49,7 @@ const PAGE_BRIDGE_SCRIPT = String.raw `(() => {
49
49
  modalRestoreFrame = null;
50
50
  if (document.querySelector?.('dialog:modal')) return;
51
51
  const positions = [];
52
- for (const { presentation } of inlineAgentPanels.values()) {
52
+ for (const { presentation } of anchoredAgentPanels.values()) {
53
53
  if (!presentation.element.isConnected) continue;
54
54
  const rect = presentation.element.getBoundingClientRect();
55
55
  if (![rect.top, rect.left, rect.width, rect.height].every(Number.isFinite)) continue;
@@ -103,6 +103,8 @@ const PAGE_BRIDGE_SCRIPT = String.raw `(() => {
103
103
  };
104
104
 
105
105
  const runningOperations = async () => requestHost('invoke-operations');
106
+ const readClipboard = async () => requestHost('clipboard-read');
107
+ const getActiveTab = async () => requestHost('active-tab');
106
108
 
107
109
  const selectDirectory = async () => {
108
110
  const path = await requestHost('select-directory');
@@ -119,6 +121,14 @@ const PAGE_BRIDGE_SCRIPT = String.raw `(() => {
119
121
  await requestHost('clipboard-write', { text });
120
122
  };
121
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
+
122
132
  const openExternalLink = async url => {
123
133
  if (typeof url !== 'string' || !url || url.length > 8192) {
124
134
  throw new Error('External link must be a non-empty URL no longer than 8192 characters');
@@ -206,36 +216,41 @@ const PAGE_BRIDGE_SCRIPT = String.raw `(() => {
206
216
  return context === undefined ? null : context.trim();
207
217
  };
208
218
 
209
- const inlineAgentPanels = new Map();
219
+ const anchoredAgentPanels = new Map();
210
220
 
211
- const stopInlineAgentPanelTracking = key => {
221
+ const stopAgentAnchorTracking = key => {
212
222
  if (key) {
213
- inlineAgentPanels.get(key)?.cleanup();
214
- inlineAgentPanels.delete(key);
223
+ anchoredAgentPanels.get(key)?.cleanup();
224
+ anchoredAgentPanels.delete(key);
215
225
  return;
216
226
  }
217
- for (const tracked of inlineAgentPanels.values()) tracked.cleanup();
218
- inlineAgentPanels.clear();
227
+ for (const tracked of anchoredAgentPanels.values()) tracked.cleanup();
228
+ anchoredAgentPanels.clear();
219
229
  };
220
230
 
221
- const inlineAgentPanelAnchor = (options, key) => {
231
+ const agentPanelAnchor = (options, key, layout) => {
222
232
  const anchor = options.anchor;
223
233
  if (!(anchor instanceof Element)) {
224
- throw new Error('Inline Agent panel anchor must be an element');
234
+ throw new Error('Agent panel anchor must be an element');
225
235
  }
226
236
  const rect = anchor.getBoundingClientRect();
227
237
  if (![rect.top, rect.left, rect.width, rect.height].every(Number.isFinite)) {
228
- throw new Error('Inline Agent panel anchor returned invalid bounds');
238
+ throw new Error('Agent panel anchor returned invalid bounds');
229
239
  }
230
240
  return {
231
241
  element: anchor,
232
242
  key,
243
+ layout,
233
244
  bounds: { top: rect.top, left: rect.left, width: rect.width, height: rect.height },
234
245
  };
235
246
  };
236
247
 
237
- const trackInlineAgentPanel = presentation => {
238
- stopInlineAgentPanelTracking(presentation.key);
248
+ const trackAgentAnchor = presentation => {
249
+ if (presentation.layout === 'workspace' || [...anchoredAgentPanels.values()].some(item => item.presentation.layout === 'workspace')) {
250
+ stopAgentAnchorTracking();
251
+ } else {
252
+ stopAgentAnchorTracking(presentation.key);
253
+ }
239
254
  let frame = null;
240
255
  const update = () => {
241
256
  frame = null;
@@ -281,7 +296,7 @@ const PAGE_BRIDGE_SCRIPT = String.raw `(() => {
281
296
  window.removeEventListener('resize', schedule);
282
297
  document.removeEventListener('scroll', schedule, true);
283
298
  };
284
- inlineAgentPanels.set(presentation.key, { presentation, cleanup });
299
+ anchoredAgentPanels.set(presentation.key, { presentation, cleanup });
285
300
  };
286
301
 
287
302
  const scrollInlineAgentPage = (presentation, deltaX, deltaY) => {
@@ -311,8 +326,11 @@ const PAGE_BRIDGE_SCRIPT = String.raw `(() => {
311
326
  };
312
327
 
313
328
  const agentPanelPresentation = (options, layout, key) => {
314
- if (layout !== 'inline') return null;
315
- return inlineAgentPanelAnchor(options, key);
329
+ if (layout === 'inline') return agentPanelAnchor(options, key, layout);
330
+ if (layout === 'workspace' && options.anchor !== undefined) {
331
+ return agentPanelAnchor(options, key || 'toolbox-workspace', layout);
332
+ }
333
+ return null;
316
334
  };
317
335
 
318
336
  const setAgentPanelVisible = async (visible, options = {}) => {
@@ -332,16 +350,16 @@ const PAGE_BRIDGE_SCRIPT = String.raw `(() => {
332
350
  });
333
351
  if (presentation) {
334
352
  await ready;
335
- trackInlineAgentPanel(presentation);
353
+ trackAgentAnchor(presentation);
336
354
  } else if (!visible && key) {
337
- stopInlineAgentPanelTracking(key);
355
+ stopAgentAnchorTracking(key);
338
356
  } else {
339
- stopInlineAgentPanelTracking();
357
+ stopAgentAnchorTracking();
340
358
  }
341
359
  try {
342
360
  await request;
343
361
  } catch (error) {
344
- if (presentation) stopInlineAgentPanelTracking(presentation.key);
362
+ if (presentation) stopAgentAnchorTracking(presentation.key);
345
363
  throw error;
346
364
  }
347
365
  };
@@ -409,14 +427,14 @@ const PAGE_BRIDGE_SCRIPT = String.raw `(() => {
409
427
  });
410
428
  if (presentation) {
411
429
  await ready;
412
- trackInlineAgentPanel(presentation);
430
+ trackAgentAnchor(presentation);
413
431
  } else {
414
- stopInlineAgentPanelTracking();
432
+ stopAgentAnchorTracking();
415
433
  }
416
434
  try {
417
435
  await request;
418
436
  } catch (error) {
419
- if (presentation) stopInlineAgentPanelTracking(presentation.key);
437
+ if (presentation) stopAgentAnchorTracking(presentation.key);
420
438
  throw error;
421
439
  }
422
440
  };
@@ -433,7 +451,7 @@ const PAGE_BRIDGE_SCRIPT = String.raw `(() => {
433
451
  return query;
434
452
  };
435
453
 
436
- const api = { copyText, invoke, operation, ready: () => ready, runAgentTask, runningOperations, selectDirectory, setAgentGuides, setAgentPanelVisible, setQueryParam, start, toast };
454
+ const api = { copyImage, copyText, getActiveTab, invoke, operation, readClipboard, ready: () => ready, runAgentTask, runningOperations, selectDirectory, setAgentGuides, setAgentPanelVisible, setQueryParam, start, toast };
437
455
  const ui = globalThis.__mearlToolboxUi;
438
456
  if (ui !== undefined) {
439
457
  if (!ui || typeof ui !== 'object' || ui.runtime !== 'preact') {
@@ -480,14 +498,14 @@ const PAGE_BRIDGE_SCRIPT = String.raw `(() => {
480
498
  if (
481
499
  response?.type === 'agent-panel-wheel' &&
482
500
  typeof response.key === 'string' &&
483
- inlineAgentPanels.has(response.key) &&
501
+ anchoredAgentPanels.has(response.key) &&
484
502
  typeof response.deltaX === 'number' &&
485
503
  Number.isFinite(response.deltaX) &&
486
504
  typeof response.deltaY === 'number' &&
487
505
  Number.isFinite(response.deltaY)
488
506
  ) {
489
507
  scrollInlineAgentPage(
490
- inlineAgentPanels.get(response.key).presentation,
508
+ anchoredAgentPanels.get(response.key).presentation,
491
509
  response.deltaX,
492
510
  response.deltaY,
493
511
  );
@@ -596,6 +614,11 @@ body {
596
614
  }
597
615
  button, input, select, textarea { font: inherit; }
598
616
  </style>`;
617
+ export const PLUGIN_PAGE_RUNTIME_HASH = createHash('sha256')
618
+ .update(PAGE_BRIDGE_SCRIPT)
619
+ .update(PAGE_SECURITY_META)
620
+ .update(PAGE_THEME_STYLE)
621
+ .digest('hex');
599
622
  function pluginRoot(entry) {
600
623
  const entryDirectory = dirname(entry);
601
624
  return basename(entryDirectory) === 'scripts' ? dirname(entryDirectory) : entryDirectory;
@@ -18,6 +18,7 @@ export interface PluginToolboxAgentConfig {
18
18
  }
19
19
  export interface PluginToolboxContribution {
20
20
  version: 1;
21
+ displayMode?: 'popup' | 'tab' | 'both';
21
22
  title: string;
22
23
  description?: string;
23
24
  icon?: string;
@@ -40,6 +41,7 @@ export interface PluginRegistration {
40
41
  source?: {
41
42
  entry: string;
42
43
  page?: Record<string, string>;
44
+ pageRuntime?: string;
43
45
  };
44
46
  }
45
47
  export declare function pluginRegistrationNeedsRefresh(registration: PluginRegistration, includePage?: boolean): boolean;
@@ -3,7 +3,7 @@ import { closeSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, re
3
3
  import { homedir } from 'node:os';
4
4
  import { isAbsolute, join, resolve } from 'node:path';
5
5
  import { COMMAND_MAP } from './commands.js';
6
- import { buildPluginPageBundle, PLUGIN_PAGE_CHUNK_BYTES, } from './pluginPageBundle.js';
6
+ import { buildPluginPageBundle, PLUGIN_PAGE_CHUNK_BYTES, PLUGIN_PAGE_RUNTIME_HASH, } from './pluginPageBundle.js';
7
7
  import { pluginFileVersion } from './pluginSource.js';
8
8
  const RESERVED_NAMES = new Set([
9
9
  'check',
@@ -27,6 +27,8 @@ export function pluginRegistrationNeedsRefresh(registration, includePage = false
27
27
  return true;
28
28
  if (!includePage || !registration.toolbox)
29
29
  return false;
30
+ if (registration.source.pageRuntime !== PLUGIN_PAGE_RUNTIME_HASH)
31
+ return true;
30
32
  if (!registration.source.page)
31
33
  return true;
32
34
  return Object.entries(registration.source.page).some(([path, version]) => pluginFileVersion(path) !== version);
@@ -108,6 +110,10 @@ export function normalizePluginToolboxContribution(value) {
108
110
  throw new Error('toolbox 必须是对象');
109
111
  }
110
112
  const candidate = value;
113
+ if (candidate.displayMode !== undefined &&
114
+ !['popup', 'tab', 'both'].includes(candidate.displayMode)) {
115
+ throw new Error('toolbox.displayMode 必须是 popup、tab 或 both');
116
+ }
111
117
  const title = typeof candidate.title === 'string' ? candidate.title.trim() : '';
112
118
  const description = typeof candidate.description === 'string' ? candidate.description.trim() : undefined;
113
119
  const icon = typeof candidate.icon === 'string' ? candidate.icon.trim() : undefined;
@@ -216,6 +222,7 @@ export function normalizePluginToolboxContribution(value) {
216
222
  return {
217
223
  version: 1,
218
224
  title,
225
+ ...(candidate.displayMode !== undefined ? { displayMode: candidate.displayMode } : {}),
219
226
  ...(description ? { description } : {}),
220
227
  ...(icon ? { icon } : {}),
221
228
  ...(locales ? { locales } : {}),
@@ -281,7 +288,10 @@ export function registerPlugin(name, target, description, options = {}) {
281
288
  }
282
289
  const record = {
283
290
  entry,
284
- source: { entry: entryVersion, ...(builtPage ? { page: builtPage.sources } : {}) },
291
+ source: {
292
+ entry: entryVersion,
293
+ ...(builtPage ? { page: builtPage.sources, pageRuntime: PLUGIN_PAGE_RUNTIME_HASH } : {}),
294
+ },
285
295
  ...(summary ? { description: summary } : {}),
286
296
  ...(managedBy ? { managedBy } : {}),
287
297
  ...(toolbox ? { toolbox } : {}),
@@ -344,6 +354,8 @@ export function findPluginRegistration(name) {
344
354
  typeof source !== 'object' ||
345
355
  Array.isArray(source) ||
346
356
  typeof source.entry !== 'string' ||
357
+ (source.pageRuntime !== undefined &&
358
+ (typeof source.pageRuntime !== 'string' || !/^[a-f0-9]{64}$/.test(source.pageRuntime))) ||
347
359
  (source.page !== undefined &&
348
360
  (!source.page ||
349
361
  typeof source.page !== 'object' ||
@@ -2,6 +2,7 @@ import type { PluginOptions } from './plugin.js';
2
2
  export interface PythonPluginCommand {
3
3
  description: string;
4
4
  signature: string;
5
+ toolboxOnly: boolean;
5
6
  }
6
7
  export interface PythonPluginDescriptor {
7
8
  commands: PythonPluginCommand[];
@@ -53,6 +53,12 @@ class _App:
53
53
  self.request_id = 0
54
54
 
55
55
  def command(self, signature, handler=None, description=""):
56
+ return self._register_command(signature, handler, description, False)
57
+
58
+ def toolboxCommand(self, signature, handler=None, description=""):
59
+ return self._register_command(signature, handler, description, True)
60
+
61
+ def _register_command(self, signature, handler, description, toolbox_only):
56
62
  if not self.registering:
57
63
  raise RuntimeError("命令只能在入口注册阶段声明")
58
64
  if not isinstance(signature, str):
@@ -67,6 +73,7 @@ class _App:
67
73
  "signature": signature,
68
74
  "description": description,
69
75
  "handler": candidate,
76
+ "toolboxOnly": toolbox_only,
70
77
  })
71
78
  return candidate
72
79
 
@@ -163,7 +170,7 @@ try:
163
170
  descriptor = {
164
171
  "type": "ready",
165
172
  "commands": [
166
- {"signature": item["signature"], "description": item["description"]}
173
+ {"signature": item["signature"], "description": item["description"], "toolboxOnly": item["toolboxOnly"]}
167
174
  for item in app.commands
168
175
  ],
169
176
  }
@@ -223,10 +230,15 @@ function parseDescriptor(message) {
223
230
  const commands = message.commands.map(value => {
224
231
  if (!isObject(value) ||
225
232
  typeof value.signature !== 'string' ||
226
- typeof value.description !== 'string') {
233
+ typeof value.description !== 'string' ||
234
+ typeof value.toolboxOnly !== 'boolean') {
227
235
  throw new Error('Python 插件返回了无效的命令描述');
228
236
  }
229
- return { signature: value.signature, description: value.description };
237
+ return {
238
+ signature: value.signature,
239
+ description: value.description,
240
+ toolboxOnly: value.toolboxOnly,
241
+ };
230
242
  });
231
243
  if (message.name !== undefined && typeof message.name !== 'string') {
232
244
  throw new Error('Python 插件返回了无效的 name');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mearl/client",
3
- "version": "2.20.0",
3
+ "version": "2.20.2",
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.20.0",
63
- "@mearl/provider": "2.20.0"
62
+ "@mearl/provider": "2.20.2",
63
+ "@mearl/cloud-types": "2.20.2"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@types/node": "^24.9.1",