@mearl/client 2.17.1 → 2.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -149,7 +149,7 @@ mearl yuque --version
149
149
  Provider 与插件仍可独立安装、更新和卸载,帮助聚合不会启动 Provider 或执行业务命令。
150
150
 
151
151
  目录默认使用 `scripts/main.mjs` 或 `scripts/main.py`;两者同时存在时使用 JavaScript 入口。JavaScript 入口默认导出 `register(app)`,Python 入口导出 `register(app)` 函数;两种入口都可声明简短的 `name` 和 `description`,并通过 `app.command(signature, handler, description?)`
152
- 注册动作、`app.invoke(action, payload, options?)` 复用当前浏览器上下文,并用 `app.progress(message)` 报告可选进度。Python 的 `app.invoke` 是同步调用,handler 可以是普通函数或 `async def`。Python 插件要求 `python3 >= 3.9`,可用 `MEARL_PYTHON` 指定解释器路径;Mearl 不安装 Python 或执行 `pip install`。调用另一个插件时,payload 使用 `{ command, input }` envelope;嵌套调用继承宿主选项并拒绝循环依赖。CLI 默认只输出最终结果,传入
152
+ 注册动作、`app.invoke(action, payload, options?)` 复用当前浏览器上下文,并用 `app.progress(message)` 报告可选进度。JavaScript 插件还可传入 `{ message, data }`,让工具页通过可 JSON 序列化的 `data` 获取稳定的阶段、对象 ID 等机器信息;CLI 仍只显示 `message`。Python 的 `app.invoke` 是同步调用,handler 可以是普通函数或 `async def`。Python 插件要求 `python3 >= 3.9`,可用 `MEARL_PYTHON` 指定解释器路径;Mearl 不安装 Python 或执行 `pip install`。调用另一个插件时,payload 使用 `{ command, input }` envelope;嵌套调用继承宿主选项并拒绝循环依赖。CLI 默认只输出最终结果,传入
153
153
  `--verbose` 时才把进度写入 stderr。handler 返回文本或 JSON,宿主统一处理 `--output`、连接和错误。写命令在用户调用后直接执行,业务模块负责目标、状态、幂等和结果校验。
154
154
  自动注册默认以 skill 安装目录名作为命令名,也可在入口声明 `name` 指定短名称。
155
155
  setup 会补齐缺失或低于自身版本的 client、native-host;安装参数透传给 Ali Skills,并固定全局共享安装。
@@ -186,6 +186,59 @@ MCP Server 通过固定工具 `mearl_plugin` 接受同样的 `plugin`、`command
186
186
  卸载清理命令。其他错误按实际原因处理。
187
187
  编写规范见 [Mearl Skill Creator 的 Skill 插件规范](../skills/mearl-skill-creator/references/cli-plugin.md)。
188
188
 
189
+ 插件可选导出 `toolbox`,注册后会出现在扩展 Popup 的工具箱中。页面资源放在 Skill 目录内,注册时由
190
+ Mearl 将入口 HTML 引用的本地 CSS、JavaScript 和图片内联为不可变页面包,不需要启动 HTTP 服务,
191
+ 也不要求普通 HTML/CSS/JS 页面自行构建:
192
+
193
+ ```javascript
194
+ export const toolbox = {
195
+ version: 1,
196
+ title: '迭代管理',
197
+ description: '查看多个仓库的改动与发布状态',
198
+ icon: 'git-branch',
199
+ locales: {
200
+ en: {
201
+ title: 'Iteration Management',
202
+ description: 'Track changes and release status across repositories',
203
+ },
204
+ },
205
+ agent: {
206
+ skill: 'iteration-manager',
207
+ layout: 'workspace',
208
+ },
209
+ page: {
210
+ type: 'sandbox',
211
+ runtime: 'preact',
212
+ entry: 'assets/toolbox/index.html',
213
+ commands: ['list', 'create', 'delete'],
214
+ },
215
+ order: 100,
216
+ };
217
+
218
+ export default function register(app) {
219
+ app.command('list', async () => ({ items: await listItems() }));
220
+ app.command('create <name>', async input => ({ item: await createItem(input.name) }));
221
+ app.command('delete <id>', async input => ({ item: await deleteItem(input.id) }));
222
+ }
223
+ ```
224
+
225
+ 声明 `agent` 后工具页可以把任务交给对话 Agent;`layout` 默认为 `drawer`,宿主会提供顶部入口。
226
+ 页面也可在 `setAgentPanelVisible` 或 `runAgentTask` 中为单次交互指定 `drawer` / `workspace`。带固定
227
+ 18rem 左侧导航的工作台可默认使用 `workspace`,由导航项切换 Agent,并占满导航栏以外的主内容区;详情页仍可按需唤起右侧抽屉。
228
+
229
+ 页面通过 `await window.mearl.ready()` 取得 `{ locale, theme, title, fontSize }`,通过
230
+ `window.mearl.invoke(command, input?)` 调用 `commands` 白名单中的同一插件命令。页面运行在无扩展
231
+ API、默认不可联网的 sandbox iframe 中;Native Host 校验插件身份、命令白名单和消息大小。页面包按
232
+ 内容哈希缓存并分块传输,因此不会增加 Popup 的启动开销。使用模块脚本时需先打包为不再包含 `import`
233
+ 的单文件;React、Vue 等工程可以把构建后的 `index.html` 作为 `entry`,注册流程仍负责最终资源封装。
234
+ 需要无构建的组件与状态管理时可声明 `page.runtime: 'preact'`,扩展会在页面完整性校验后注入固定的
235
+ Preact、Hooks 和 HTM 运行时。页面从 `window.mearl.ui` 读取 `html`、`render`、`useState` 等 API;
236
+ `window.mearl.ui.version` 仅用于诊断,插件不能选择运行时版本。
237
+ 宿主会同步扩展主题、字号,并注入 `--mearl-bg-primary`、`--mearl-bg-secondary`、
238
+ `--mearl-text-primary`、`--mearl-text-secondary`、`--mearl-border`、`--mearl-bg-hover` 和
239
+ `--mearl-accent` 等语义变量;主操作按钮使用 `--mearl-action-primary`、
240
+ `--mearl-action-primary-hover` 和 `--mearl-action-primary-text`,以保持与扩展按钮一致。
241
+
189
242
  需要 BUC 身份的内部 HTTP 接口通过官方 `buc` 插件调用。CLI 使用 `mearl buc request`,业务插件使用
190
243
  `app.invoke('buc', { command: 'request', input })`;当前身份和人员资料分别使用 `whoami` 与 `profile`
191
244
  子命令。BUC 插件按请求创建一次性凭据,只接受无 URL 凭据的 HTTPS 地址,过滤认证字段与 Cookie;
package/dist/cli.js CHANGED
@@ -504,7 +504,8 @@ async function runPluginCli(args, commandName) {
504
504
  ...invokeOptions,
505
505
  ...(parsed.verbose
506
506
  ? {
507
- onProgress(message) {
507
+ onProgress(progress) {
508
+ const message = typeof progress === 'string' ? progress : progress.message;
508
509
  process.stderr.write(message.endsWith('\n') ? message : `${message}\n`);
509
510
  },
510
511
  }
@@ -365,7 +365,7 @@ export interface BrowserEmulationOptions {
365
365
  /** App 容器横屏时将根页面与业务页面分置左右 WebView;仅与 deviceShell、App 档案同时生效。 */
366
366
  splitScreen?: boolean;
367
367
  }
368
- export type AppBridgeConfigBehavior = 'navigate' | 'closeAll' | 'closeBackData' | 'postMessage' | 'notifySubscribe' | 'notifyUnsubscribe' | 'notifyPost' | 'sharedFileRead' | 'sharedFileWrite' | 'sharedDataGet' | 'sharedDataSet' | 'legacyBroadcastCreate' | 'legacyBroadcastPost' | 'legacyBroadcastClose' | 'memoryGet' | 'memorySet' | 'memoryDelete' | 'availabilityMap' | 'userInfo' | 'networkInfo' | 'geolocation' | 'permission' | 'prefetch' | 'fptTrack' | 'mtopStream' | 'previewFile' | 'toast' | 'unsupportedNativeInput' | 'payment' | 'userTrack' | 'calendar' | 'networkQualityMonitor' | 'beaconRequest' | 'voiceRecognition';
368
+ export type AppBridgeConfigBehavior = 'navigate' | 'closeAll' | 'closeBackData' | 'postMessage' | 'notifySubscribe' | 'notifyUnsubscribe' | 'notifyPost' | 'sharedFileRead' | 'sharedFileWrite' | 'sharedDataGet' | 'sharedDataSet' | 'legacyBroadcastCreate' | 'legacyBroadcastPost' | 'legacyBroadcastClose' | 'memoryGet' | 'memorySet' | 'memoryDelete' | 'availabilityMap' | 'userInfo' | 'networkInfo' | 'geolocation' | 'permission' | 'imagePicker' | 'prefetch' | 'fptTrack' | 'mtopStream' | 'previewFile' | 'toast' | 'unsupportedNativeInput' | 'payment' | 'userTrack' | 'calendar' | 'networkQualityMonitor' | 'beaconRequest' | 'voiceRecognition';
369
369
  /** 单个桥方法的声明式覆盖;行为、固定回包、成功空实现三选一。 */
370
370
  export interface AppBridgeMethodConfig {
371
371
  behavior?: AppBridgeConfigBehavior;
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
  export { UnifiedClient, UnifiedClient as MearlClient, decodeBrowserTarget, invoke, invokePlugin, listCloudConnectors, } from './unified.js';
8
8
  export type { UnifiedClientOptions, UnifiedInvokeOptions, UnifiedInvokeOptions as InvokeOptions, } from './unified.js';
9
- export type { PluginInvocation, PluginInvokeOptions } from './plugin.js';
9
+ export type { PluginInvocation, PluginInvokeOptions, PluginProgress, StructuredPluginProgress, } from './plugin.js';
10
10
  export { APP_PROFILE_NAMES, BROWSER_DEVICE_PRESET_NAMES, BROWSER_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
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';
12
12
  import { type UnifiedInvokeOptions } from './unified.js';
package/dist/plugin.d.ts CHANGED
@@ -1,14 +1,20 @@
1
1
  import { type OfficialPluginInstaller } from './officialPluginInstaller.js';
2
+ import { type PluginToolboxContribution } from './pluginRegistry.js';
2
3
  import type { UnifiedInvokeOptions } from './unified.js';
3
- export { assertPluginName, findPluginDirectoryEntry, findPluginEntry, findPluginRegistration, listPluginNames, registerPlugin, resolvePluginEntry, unregisterPlugin, PluginError, type PluginRegistration, type PluginRegistrationOptions, } from './pluginRegistry.js';
4
+ export { assertPluginName, findPluginDirectoryEntry, findPluginEntry, findPluginRegistration, listPluginNames, registerPlugin, resolvePluginEntry, unregisterPlugin, PluginError, readPluginPageBundleChunk, type PluginRegistration, type PluginRegistrationOptions, type PluginToolboxAgentConfig, type PluginPageBundleRegistration, type PluginToolboxContribution, type PluginToolboxLocale, type PluginToolboxSandboxPage, } from './pluginRegistry.js';
4
5
  export { findOfficialPlugin, listOfficialPluginNames, OFFICIAL_PLUGIN_MANAGER, OFFICIAL_PLUGINS, type OfficialPluginDefinition, type OfficialPluginName, } from './officialPluginCatalog.js';
5
6
  export type PluginOptions = UnifiedInvokeOptions;
7
+ export interface StructuredPluginProgress {
8
+ message: string;
9
+ data?: Record<string, unknown>;
10
+ }
11
+ export type PluginProgress = string | StructuredPluginProgress;
6
12
  export interface PluginInvocation {
7
13
  command: string;
8
14
  input: Record<string, any>;
9
15
  }
10
16
  export interface PluginInvokeOptions extends UnifiedInvokeOptions {
11
- onProgress?: (message: string) => void;
17
+ onProgress?: (progress: PluginProgress) => void;
12
18
  }
13
19
  export interface PluginExecutionContext {
14
20
  ensurePlugin?: OfficialPluginInstaller;
@@ -18,13 +24,14 @@ export interface PluginApp {
18
24
  readonly options: Readonly<PluginOptions>;
19
25
  command(signature: string, handler: PluginHandler, description?: string): void;
20
26
  invoke(action: string, payload?: Record<string, any>, options?: UnifiedInvokeOptions): Promise<any>;
21
- progress(message: string): void;
27
+ progress(progress: PluginProgress): void;
22
28
  }
23
29
  export type PluginHandler = (input: Record<string, any>) => unknown | Promise<unknown>;
24
30
  /** 注册和执行复用同一宿主能力边界;仅 handler 执行期间开放浏览器调用。 */
25
31
  export declare function loadPlugin(entry: string, context: Pick<PluginApp, 'invoke' | 'options' | 'progress'>, pluginName?: string): Promise<{
26
32
  name: string | undefined;
27
33
  description: string | undefined;
34
+ toolbox: PluginToolboxContribution | undefined;
28
35
  version: string | undefined;
29
36
  commandHelp(namespace: string): string;
30
37
  help(namespace: string, name?: string): string;
@@ -33,6 +40,7 @@ export declare function loadPlugin(entry: string, context: Pick<PluginApp, 'invo
33
40
  export declare function loadRegisteredPlugin(name: string, context: PluginExecutionContext, options?: PluginInvokeOptions): Promise<{
34
41
  name: string | undefined;
35
42
  description: string | undefined;
43
+ toolbox: PluginToolboxContribution | undefined;
36
44
  version: string | undefined;
37
45
  commandHelp(namespace: string): string;
38
46
  help(namespace: string, name?: string): string;
package/dist/plugin.js CHANGED
@@ -5,9 +5,9 @@ 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, PluginError } from './pluginRegistry.js';
8
+ import { findPluginRegistration, normalizePluginToolboxContribution, PluginError, } from './pluginRegistry.js';
9
9
  import { describePythonPlugin, runPythonPluginCommand } from './pythonPlugin.js';
10
- export { assertPluginName, findPluginDirectoryEntry, findPluginEntry, findPluginRegistration, listPluginNames, registerPlugin, resolvePluginEntry, unregisterPlugin, PluginError, } from './pluginRegistry.js';
10
+ export { assertPluginName, findPluginDirectoryEntry, findPluginEntry, findPluginRegistration, listPluginNames, registerPlugin, resolvePluginEntry, unregisterPlugin, PluginError, readPluginPageBundleChunk, } from './pluginRegistry.js';
11
11
  export { findOfficialPlugin, listOfficialPluginNames, OFFICIAL_PLUGIN_MANAGER, OFFICIAL_PLUGINS, } from './officialPluginCatalog.js';
12
12
  function readPluginVersion(entry) {
13
13
  try {
@@ -26,6 +26,36 @@ function isObject(value) {
26
26
  function isPluginInvocation(value) {
27
27
  return isObject(value) && typeof value.command === 'string' && isObject(value.input);
28
28
  }
29
+ function normalizePluginProgress(progress) {
30
+ if (typeof progress === 'string') {
31
+ if (!progress)
32
+ throw new Error('app.progress 需要非空消息');
33
+ return progress;
34
+ }
35
+ if (!isObject(progress) || typeof progress.message !== 'string' || !progress.message) {
36
+ throw new Error('app.progress 需要非空消息');
37
+ }
38
+ if (progress.data !== undefined && !isObject(progress.data)) {
39
+ throw new Error('app.progress data 必须是对象');
40
+ }
41
+ let data;
42
+ if (progress.data) {
43
+ try {
44
+ const serialized = JSON.stringify(progress.data);
45
+ const parsed = serialized === undefined ? undefined : JSON.parse(serialized);
46
+ if (!isObject(parsed))
47
+ throw new Error('invalid object');
48
+ data = Object.freeze(parsed);
49
+ }
50
+ catch {
51
+ throw new Error('app.progress data 必须是可 JSON 序列化的对象');
52
+ }
53
+ }
54
+ return Object.freeze({
55
+ message: progress.message,
56
+ ...(data ? { data } : {}),
57
+ });
58
+ }
29
59
  function missingPluginEntryMessage(entry, pluginName) {
30
60
  const name = pluginName ?? '<name>';
31
61
  return [
@@ -54,6 +84,7 @@ export async function loadPlugin(entry, context, pluginName) {
54
84
  let registering = true;
55
85
  let name;
56
86
  let description;
87
+ let toolbox;
57
88
  const app = Object.freeze({
58
89
  options: Object.freeze({ ...context.options }),
59
90
  command(signature, handler, description = '') {
@@ -80,12 +111,10 @@ export async function loadPlugin(entry, context, pluginName) {
80
111
  throw new Error('app.invoke 只能在命令执行期间调用');
81
112
  return context.invoke(action, payload, options);
82
113
  },
83
- progress(message) {
114
+ progress(progress) {
84
115
  if (!executing)
85
116
  throw new Error('app.progress 只能在命令执行期间调用');
86
- if (typeof message !== 'string' || !message)
87
- throw new Error('app.progress 需要非空字符串');
88
- context.progress(message);
117
+ context.progress(normalizePluginProgress(progress));
89
118
  },
90
119
  });
91
120
  try {
@@ -93,6 +122,7 @@ export async function loadPlugin(entry, context, pluginName) {
93
122
  const descriptor = await describePythonPlugin(entry, app.options);
94
123
  name = descriptor.name;
95
124
  description = descriptor.description;
125
+ toolbox = normalizePluginToolboxContribution(descriptor.toolbox);
96
126
  for (const command of descriptor.commands) {
97
127
  const commandName = command.signature.trim().split(/\s+/)[0];
98
128
  app.command(command.signature, input => runPythonPluginCommand(entry, commandName, input, app), command.description);
@@ -108,12 +138,18 @@ export async function loadPlugin(entry, context, pluginName) {
108
138
  if (module.description !== undefined && typeof module.description !== 'string')
109
139
  throw new Error('入口的 description 导出必须为字符串');
110
140
  description = module.description;
141
+ toolbox = normalizePluginToolboxContribution(module.toolbox);
111
142
  if (typeof module.default !== 'function')
112
143
  throw new Error('入口必须默认导出 register(app) 函数');
113
144
  await module.default(app);
114
145
  }
115
146
  if (commands.size === 0)
116
147
  throw new Error('入口未注册任何命令');
148
+ const toolboxCommands = toolbox?.page.commands ?? [];
149
+ for (const command of toolboxCommands) {
150
+ if (!commands.has(command))
151
+ throw new Error(`toolbox 命令未注册: ${command}`);
152
+ }
117
153
  }
118
154
  catch (error) {
119
155
  throw new PluginError('PLUGIN_LOAD_FAILED', `${entry}: ${error instanceof Error ? error.message : String(error)}`);
@@ -130,6 +166,7 @@ export async function loadPlugin(entry, context, pluginName) {
130
166
  return {
131
167
  name,
132
168
  description,
169
+ toolbox,
133
170
  version: readPluginVersion(entry),
134
171
  commandHelp(namespace) {
135
172
  return [...commands.values()]
@@ -215,7 +252,7 @@ async function loadRegisteredPluginWithState(name, context, options = {}, state
215
252
  }
216
253
  let registration = findPluginRegistration(name);
217
254
  if (!registration && findOfficialPlugin(name)) {
218
- await (context.ensurePlugin ?? ensureOfficialPluginRegistered)(name, options.onProgress);
255
+ await (context.ensurePlugin ?? ensureOfficialPluginRegistered)(name, options.onProgress ? message => options.onProgress?.(message) : undefined);
219
256
  registration = findPluginRegistration(name);
220
257
  }
221
258
  if (!registration) {
@@ -0,0 +1,8 @@
1
+ export declare const PLUGIN_PAGE_CHUNK_BYTES: number;
2
+ export interface BuiltPluginPageBundle {
3
+ content: string;
4
+ hash: string;
5
+ size: number;
6
+ chunks: number;
7
+ }
8
+ export declare function buildPluginPageBundle(entry: string, pageEntry: string): BuiltPluginPageBundle;
@@ -0,0 +1,420 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync, realpathSync } from 'node:fs';
3
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
4
+ export const PLUGIN_PAGE_CHUNK_BYTES = 384 * 1024;
5
+ const MAX_PAGE_SOURCE_BYTES = 16 * 1024 * 1024;
6
+ const MAX_PAGE_BUNDLE_BYTES = 24 * 1024 * 1024;
7
+ const MIME_TYPES = {
8
+ '.avif': 'image/avif',
9
+ '.gif': 'image/gif',
10
+ '.ico': 'image/x-icon',
11
+ '.jpeg': 'image/jpeg',
12
+ '.jpg': 'image/jpeg',
13
+ '.mp3': 'audio/mpeg',
14
+ '.mp4': 'video/mp4',
15
+ '.ogg': 'audio/ogg',
16
+ '.otf': 'font/otf',
17
+ '.png': 'image/png',
18
+ '.svg': 'image/svg+xml',
19
+ '.ttf': 'font/ttf',
20
+ '.wav': 'audio/wav',
21
+ '.webm': 'video/webm',
22
+ '.webp': 'image/webp',
23
+ '.woff': 'font/woff',
24
+ '.woff2': 'font/woff2',
25
+ };
26
+ const PAGE_BRIDGE_SCRIPT = String.raw `(() => {
27
+ let context = null;
28
+ let port = null;
29
+ let requestSequence = 0;
30
+ const pending = new Map();
31
+ let resolveReady;
32
+ const ready = new Promise(resolve => { resolveReady = resolve; });
33
+
34
+ const requestHost = async (type, payload = {}) => {
35
+ await ready;
36
+ const requestId = String(++requestSequence);
37
+ return new Promise((resolve, reject) => {
38
+ pending.set(requestId, { resolve, reject });
39
+ port.postMessage({ type, requestId, ...payload });
40
+ });
41
+ };
42
+
43
+ const invoke = async (command, input = {}) => {
44
+ if (typeof command !== 'string' || !/^[a-z][a-z0-9-]*$/.test(command)) {
45
+ throw new Error('Invalid plugin command');
46
+ }
47
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
48
+ throw new Error('Plugin command input must be an object');
49
+ }
50
+ return requestHost('invoke', { command, input });
51
+ };
52
+
53
+ const start = async (command, input = {}) => {
54
+ if (typeof command !== 'string' || !/^[a-z][a-z0-9-]*$/.test(command)) {
55
+ throw new Error('Invalid plugin command');
56
+ }
57
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
58
+ throw new Error('Plugin command input must be an object');
59
+ }
60
+ return requestHost('invoke-start', { command, input });
61
+ };
62
+
63
+ const operation = async operationId => {
64
+ if (typeof operationId !== 'string' || !/^[0-9a-f-]{36}$/i.test(operationId)) {
65
+ throw new Error('Invalid plugin operation id');
66
+ }
67
+ return requestHost('invoke-operation', { operationId });
68
+ };
69
+
70
+ const runningOperations = async () => requestHost('invoke-operations');
71
+
72
+ const selectDirectory = async () => {
73
+ const path = await requestHost('select-directory');
74
+ if (path !== null && typeof path !== 'string') {
75
+ throw new Error('Directory selection returned an invalid path');
76
+ }
77
+ return path;
78
+ };
79
+
80
+ const copyText = async text => {
81
+ if (typeof text !== 'string' || text.length > 1000000) {
82
+ throw new Error('Clipboard text must be a string no longer than 1000000 characters');
83
+ }
84
+ await requestHost('clipboard-write', { text });
85
+ };
86
+
87
+ const toast = async (message, options = {}) => {
88
+ if (typeof message !== 'string' || !message.trim() || message.length > 4000) {
89
+ throw new Error('Toast message must be a non-empty string no longer than 4000 characters');
90
+ }
91
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
92
+ throw new Error('Toast options must be an object');
93
+ }
94
+ const type = options.type ?? 'info';
95
+ const duration = options.duration ?? 3000;
96
+ if (!['success', 'error', 'info'].includes(type)) throw new Error('Invalid toast type');
97
+ if (!Number.isInteger(duration) || duration < 1000 || duration > 30000) {
98
+ throw new Error('Toast duration must be between 1000 and 30000 milliseconds');
99
+ }
100
+ await ready;
101
+ port.postMessage({ type: 'toast', message: message.trim(), toastType: type, duration });
102
+ };
103
+
104
+ const agentPanelLayout = options => {
105
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
106
+ throw new Error('Agent panel options must be an object');
107
+ }
108
+ const layout = options.layout;
109
+ if (layout !== undefined && layout !== 'drawer' && layout !== 'workspace') {
110
+ throw new Error('Agent panel layout must be drawer or workspace');
111
+ }
112
+ return layout;
113
+ };
114
+
115
+ const setAgentPanelVisible = async (visible, options = {}) => {
116
+ if (typeof visible !== 'boolean') throw new Error('Agent panel visibility must be a boolean');
117
+ const layout = agentPanelLayout(options);
118
+ await requestHost('agent-panel', { visible, ...(layout ? { layout } : {}) });
119
+ };
120
+
121
+ const runAgentTask = async (prompt, options = {}) => {
122
+ if (typeof prompt !== 'string' || !prompt.trim() || prompt.length > 16000) {
123
+ throw new Error('Agent task prompt must be a non-empty string no longer than 16000 characters');
124
+ }
125
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
126
+ throw new Error('Agent task options must be an object');
127
+ }
128
+ const cwd = options.cwd;
129
+ const layout = agentPanelLayout(options);
130
+ if (
131
+ cwd !== undefined &&
132
+ (typeof cwd !== 'string' ||
133
+ !cwd.trim() ||
134
+ cwd.length > 4096 ||
135
+ !/^(?:\/|[a-zA-Z]:[\\/])/.test(cwd.trim()))
136
+ ) {
137
+ throw new Error('Agent task cwd must be an absolute path no longer than 4096 characters');
138
+ }
139
+ await requestHost('agent-task', {
140
+ prompt: prompt.trim(),
141
+ ...(cwd !== undefined ? { cwd: cwd.trim() } : {}),
142
+ ...(layout ? { layout } : {}),
143
+ });
144
+ };
145
+
146
+ const setQueryParam = async (name, value) => {
147
+ if (typeof name !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/.test(name)) {
148
+ throw new Error('Query parameter name is invalid');
149
+ }
150
+ if (value !== null && (typeof value !== 'string' || value.length > 512)) {
151
+ throw new Error('Query parameter value must be null or a string no longer than 512 characters');
152
+ }
153
+ const query = await requestHost('query-param', { name, value });
154
+ applyContext({ query });
155
+ return query;
156
+ };
157
+
158
+ const api = { copyText, invoke, operation, ready: () => ready, runAgentTask, runningOperations, selectDirectory, setAgentPanelVisible, setQueryParam, start, toast };
159
+ const ui = globalThis.__mearlToolboxUi;
160
+ if (ui !== undefined) {
161
+ if (!ui || typeof ui !== 'object' || ui.runtime !== 'preact') {
162
+ throw new Error('Invalid Mearl toolbox UI runtime');
163
+ }
164
+ Object.defineProperty(api, 'ui', { enumerable: true, value: ui });
165
+ delete globalThis.__mearlToolboxUi;
166
+ }
167
+ Object.defineProperty(api, 'context', { enumerable: true, get: () => context });
168
+ Object.defineProperty(window, 'mearl', { configurable: false, value: Object.freeze(api) });
169
+
170
+ const applyContext = nextContext => {
171
+ const theme =
172
+ nextContext?.theme === 'dark' ||
173
+ (nextContext?.theme !== 'light' && context?.theme === 'dark')
174
+ ? 'dark'
175
+ : 'light';
176
+ const fontSize = String(nextContext?.fontSize || context?.fontSize || '');
177
+ document.documentElement.dataset.mearlTheme = theme;
178
+ document.documentElement.style.colorScheme = theme;
179
+ if (/^\d+(?:\.\d+)?px$/.test(fontSize)) document.documentElement.style.fontSize = fontSize;
180
+ context = Object.freeze({ ...(context || {}), ...(nextContext || {}), theme });
181
+ return context;
182
+ };
183
+
184
+ const connect = event => {
185
+ const message = event.data;
186
+ const nextPort = event.ports && event.ports[0];
187
+ if (
188
+ (event.source && event.source !== window.parent) ||
189
+ message?.type !== 'mearl-plugin-connect' ||
190
+ !nextPort
191
+ ) return;
192
+ window.removeEventListener('message', connect);
193
+ applyContext(message.context);
194
+ port = nextPort;
195
+ port.onmessage = responseEvent => {
196
+ const response = responseEvent.data;
197
+ if (response?.type === 'context') {
198
+ applyContext(response.context);
199
+ return;
200
+ }
201
+ if (response?.type !== 'result' || typeof response.requestId !== 'string') return;
202
+ const request = pending.get(response.requestId);
203
+ if (!request) return;
204
+ pending.delete(response.requestId);
205
+ if (response.ok) request.resolve(response.result);
206
+ else request.reject(new Error(response.error || 'Plugin command failed'));
207
+ };
208
+ port.start();
209
+ resolveReady(context);
210
+ port.postMessage({ type: 'ready' });
211
+ };
212
+ window.addEventListener('message', connect);
213
+ window.parent.postMessage({ type: 'mearl-plugin-ready' }, '*');
214
+ })();`;
215
+ const PAGE_SECURITY_META = "<meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; media-src data: blob:; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'\">";
216
+ const PAGE_THEME_STYLE = `<style data-mearl-theme>
217
+ :root {
218
+ color-scheme: light dark;
219
+ --mearl-toolbar: #f3f3f3;
220
+ --mearl-toolbar-border: #d0d0d0;
221
+ --mearl-text-primary: #2c2c2c;
222
+ --mearl-text-secondary: #6e6e6e;
223
+ --mearl-bg-primary: #ffffff;
224
+ --mearl-bg-secondary: #f8f9fa;
225
+ --mearl-bg-list: #fafbfc;
226
+ --mearl-bg-hover: #f0f1f3;
227
+ --mearl-bg-selected: #f5f5f5;
228
+ --mearl-border: #e1e4e8;
229
+ --mearl-accent: #2a57c9;
230
+ --mearl-accent-hover: #1d4ed8;
231
+ --mearl-action-primary: oklch(54.6% 0.245 262.881);
232
+ --mearl-action-primary-hover: oklch(48.8% 0.243 264.376);
233
+ --mearl-action-primary-text: #ffffff;
234
+ --mearl-danger: #dc2626;
235
+ --mearl-danger-bg: rgb(220 38 38 / 8%);
236
+ --mearl-success: #16a34a;
237
+ --mearl-scrollbar-track: #f6f8fa;
238
+ --mearl-scrollbar-thumb: #d1d5db;
239
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
240
+ background: var(--mearl-bg-primary);
241
+ }
242
+ :root[data-mearl-theme="dark"] {
243
+ color-scheme: dark;
244
+ --mearl-toolbar: #1f1f1f;
245
+ --mearl-toolbar-border: #343434;
246
+ --mearl-text-primary: #e4e4e7;
247
+ --mearl-text-secondary: #a1a1aa;
248
+ --mearl-bg-primary: #0f0f0f;
249
+ --mearl-bg-secondary: #1a1a1a;
250
+ --mearl-bg-list: #0f0f0f;
251
+ --mearl-bg-hover: #262626;
252
+ --mearl-bg-selected: #1f1f1f;
253
+ --mearl-border: #30363d;
254
+ --mearl-accent: #58a6ff;
255
+ --mearl-accent-hover: #79b8ff;
256
+ --mearl-danger: #f87171;
257
+ --mearl-danger-bg: rgb(248 113 113 / 10%);
258
+ --mearl-success: #4ade80;
259
+ --mearl-scrollbar-track: #1a1a1a;
260
+ --mearl-scrollbar-thumb: #3b3b3b;
261
+ }
262
+ @media (prefers-color-scheme: dark) {
263
+ :root:not([data-mearl-theme="light"]) {
264
+ color-scheme: dark;
265
+ --mearl-toolbar: #1f1f1f;
266
+ --mearl-toolbar-border: #343434;
267
+ --mearl-text-primary: #e4e4e7;
268
+ --mearl-text-secondary: #a1a1aa;
269
+ --mearl-bg-primary: #0f0f0f;
270
+ --mearl-bg-secondary: #1a1a1a;
271
+ --mearl-bg-list: #0f0f0f;
272
+ --mearl-bg-hover: #262626;
273
+ --mearl-bg-selected: #1f1f1f;
274
+ --mearl-border: #30363d;
275
+ --mearl-accent: #58a6ff;
276
+ --mearl-accent-hover: #79b8ff;
277
+ --mearl-danger: #f87171;
278
+ --mearl-danger-bg: rgb(248 113 113 / 10%);
279
+ --mearl-success: #4ade80;
280
+ --mearl-scrollbar-track: #1a1a1a;
281
+ --mearl-scrollbar-thumb: #3b3b3b;
282
+ }
283
+ }
284
+ body {
285
+ margin: 0;
286
+ background: var(--mearl-bg-primary);
287
+ color: var(--mearl-text-primary);
288
+ }
289
+ button, input, select, textarea { font: inherit; }
290
+ </style>`;
291
+ function pluginRoot(entry) {
292
+ const entryDirectory = dirname(entry);
293
+ return basename(entryDirectory) === 'scripts' ? dirname(entryDirectory) : entryDirectory;
294
+ }
295
+ function attributeValue(tag, name) {
296
+ const match = tag.match(new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i'));
297
+ return match?.[1] ?? match?.[2] ?? match?.[3];
298
+ }
299
+ function removeAttribute(tag, name) {
300
+ return tag.replace(new RegExp(`\\s+${name}\\s*=\\s*(?:"[^"]*"|'[^']*'|[^\\s>]+)`, 'i'), '');
301
+ }
302
+ function isEmbeddedReference(reference) {
303
+ return /^(?:data:|blob:|#)/i.test(reference.trim());
304
+ }
305
+ function isRemoteReference(reference) {
306
+ return /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(reference.trim());
307
+ }
308
+ function contentType(path) {
309
+ return MIME_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream';
310
+ }
311
+ function escapeStyleText(value) {
312
+ return value.replace(/<\/style/gi, '\\3C /style');
313
+ }
314
+ function escapeScriptText(value) {
315
+ return value.replace(/<\/script/gi, '<\\/script');
316
+ }
317
+ export function buildPluginPageBundle(entry, pageEntry) {
318
+ const root = realpathSync(pluginRoot(entry));
319
+ let sourceBytes = 0;
320
+ const countedFiles = new Set();
321
+ const resolveLocalFile = (reference, fromFile) => {
322
+ const cleanReference = reference.trim().split(/[?#]/, 1)[0];
323
+ if (!cleanReference || isEmbeddedReference(cleanReference))
324
+ return '';
325
+ if (isRemoteReference(cleanReference) || isAbsolute(cleanReference)) {
326
+ throw new Error(`toolbox.page 不允许远程或绝对资源: ${reference}`);
327
+ }
328
+ const resolved = realpathSync(resolve(dirname(fromFile), cleanReference));
329
+ const relativePath = relative(root, resolved);
330
+ if (relativePath === '..' || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) {
331
+ throw new Error(`toolbox.page 资源超出插件目录: ${reference}`);
332
+ }
333
+ return resolved;
334
+ };
335
+ const readAsset = (path) => {
336
+ const content = readFileSync(path);
337
+ if (!countedFiles.has(path)) {
338
+ countedFiles.add(path);
339
+ sourceBytes += content.byteLength;
340
+ if (sourceBytes > MAX_PAGE_SOURCE_BYTES) {
341
+ throw new Error('toolbox.page 源文件总大小不能超过 16 MiB');
342
+ }
343
+ }
344
+ return content;
345
+ };
346
+ const dataUrl = (reference, fromFile) => {
347
+ if (isEmbeddedReference(reference))
348
+ return reference;
349
+ const path = resolveLocalFile(reference, fromFile);
350
+ return `data:${contentType(path)};base64,${readAsset(path).toString('base64')}`;
351
+ };
352
+ const inlineCss = (css, cssPath) => css.replace(/url\(\s*(['"]?)([^'"()]+)\1\s*\)/gi, (_match, _quote, reference) => {
353
+ const value = reference.trim();
354
+ if (isEmbeddedReference(value))
355
+ return `url("${value}")`;
356
+ return `url("${dataUrl(value, cssPath)}")`;
357
+ });
358
+ const absoluteEntry = resolveLocalFile(pageEntry, join(root, '__toolbox_entry__'));
359
+ if (extname(absoluteEntry).toLowerCase() !== '.html') {
360
+ throw new Error('toolbox.page.entry 必须指向 HTML 文件');
361
+ }
362
+ let html = readAsset(absoluteEntry)
363
+ .toString('utf8')
364
+ .replace(/^\uFEFF/, '');
365
+ if (!/<html\b|<!doctype\b/i.test(html)) {
366
+ throw new Error('toolbox.page.entry 必须是完整 HTML 文档');
367
+ }
368
+ if (/<base\b/i.test(html))
369
+ throw new Error('toolbox.page 不允许声明 base 标签');
370
+ if (/<meta\b[^>]*http-equiv\s*=\s*["']?content-security-policy/i.test(html)) {
371
+ throw new Error('toolbox.page 的安全策略由 Mearl 管理,请移除 CSP meta 标签');
372
+ }
373
+ html = html.replace(/<link\b[^>]*>/gi, tag => {
374
+ const rel = attributeValue(tag, 'rel')?.toLowerCase().split(/\s+/) ?? [];
375
+ const href = attributeValue(tag, 'href');
376
+ if (rel.includes('stylesheet')) {
377
+ if (!href)
378
+ throw new Error('toolbox.page stylesheet 缺少 href');
379
+ const cssPath = resolveLocalFile(href, absoluteEntry);
380
+ const css = inlineCss(readAsset(cssPath).toString('utf8'), cssPath);
381
+ return `<style data-mearl-source="${href.replaceAll('"', '&quot;')}">${escapeStyleText(css)}</style>`;
382
+ }
383
+ if (href && rel.some(value => ['icon', 'apple-touch-icon'].includes(value))) {
384
+ return tag.replace(href, dataUrl(href, absoluteEntry));
385
+ }
386
+ if (href && isRemoteReference(href)) {
387
+ throw new Error(`toolbox.page 不允许远程 link 资源: ${href}`);
388
+ }
389
+ return tag;
390
+ });
391
+ html = html.replace(/<script\b[^>]*>[\s\S]*?<\/script\s*>/gi, tag => {
392
+ const src = attributeValue(tag, 'src');
393
+ if (!src)
394
+ return tag;
395
+ const scriptPath = resolveLocalFile(src, absoluteEntry);
396
+ const script = readAsset(scriptPath).toString('utf8');
397
+ if (attributeValue(tag, 'type')?.toLowerCase() === 'module' &&
398
+ /(?:^|[;\n]\s*)(?:import\s|export\s)|\bimport\s*\(/m.test(script)) {
399
+ throw new Error('toolbox.page 的模块脚本必须先打包为无 import 的单文件');
400
+ }
401
+ const openingTag = removeAttribute(tag.slice(0, tag.indexOf('>') + 1), 'src');
402
+ return `${openingTag}${escapeScriptText(script)}</script>`;
403
+ });
404
+ html = html.replace(/(<(?:img|source|video|audio|track|input)\b[^>]*?\s(?:src|poster)\s*=\s*)(["'])([^"']+)\2/gi, (_match, prefix, quote, reference) => `${prefix}${quote}${dataUrl(reference, absoluteEntry)}${quote}`);
405
+ const injection = `<meta name="color-scheme" content="light dark">${PAGE_SECURITY_META}${PAGE_THEME_STYLE}<script data-mearl-bridge>${escapeScriptText(PAGE_BRIDGE_SCRIPT)}</script>`;
406
+ html = /<head\b[^>]*>/i.test(html)
407
+ ? html.replace(/<head\b[^>]*>/i, tag => `${tag}${injection}`)
408
+ : html.replace(/<html\b[^>]*>/i, tag => `${tag}<head>${injection}</head>`);
409
+ const content = html;
410
+ const buffer = Buffer.from(content, 'utf8');
411
+ if (buffer.byteLength > MAX_PAGE_BUNDLE_BYTES) {
412
+ throw new Error('toolbox.page 打包后不能超过 24 MiB');
413
+ }
414
+ return {
415
+ content,
416
+ hash: createHash('sha256').update(buffer).digest('hex'),
417
+ size: buffer.byteLength,
418
+ chunks: Math.ceil(buffer.byteLength / PLUGIN_PAGE_CHUNK_BYTES),
419
+ };
420
+ }
@@ -2,20 +2,59 @@ export declare class PluginError extends Error {
2
2
  readonly code: string;
3
3
  constructor(code: string, message: string);
4
4
  }
5
+ export interface PluginToolboxSandboxPage {
6
+ type: 'sandbox';
7
+ runtime?: 'preact';
8
+ entry: string;
9
+ commands: string[];
10
+ }
11
+ export interface PluginToolboxLocale {
12
+ title?: string;
13
+ description?: string;
14
+ }
15
+ export interface PluginToolboxAgentConfig {
16
+ skill: string;
17
+ layout?: 'drawer' | 'workspace';
18
+ }
19
+ export interface PluginToolboxContribution {
20
+ version: 1;
21
+ title: string;
22
+ description?: string;
23
+ icon?: string;
24
+ locales?: Record<string, PluginToolboxLocale>;
25
+ agent?: PluginToolboxAgentConfig;
26
+ page: PluginToolboxSandboxPage;
27
+ order?: number;
28
+ }
29
+ export interface PluginPageBundleRegistration {
30
+ hash: string;
31
+ size: number;
32
+ chunks: number;
33
+ }
5
34
  export interface PluginRegistration {
6
35
  entry: string;
7
36
  description?: string;
8
37
  managedBy?: string;
38
+ toolbox?: PluginToolboxContribution;
39
+ pageBundle?: PluginPageBundleRegistration;
9
40
  }
10
41
  export interface PluginRegistrationOptions {
11
42
  managedBy?: string;
43
+ toolbox?: PluginToolboxContribution;
12
44
  }
13
45
  export declare function pluginDirectory(): string;
14
46
  export declare function assertPluginName(name: string): void;
47
+ export declare function normalizePluginToolboxContribution(value: unknown): PluginToolboxContribution | undefined;
15
48
  export declare function findPluginDirectoryEntry(directory: string): string | undefined;
16
49
  export declare function resolvePluginEntry(target: string): string;
17
50
  export declare function registerPlugin(name: string, target: string, description?: string, options?: PluginRegistrationOptions): string;
18
51
  export declare function unregisterPlugin(name: string): boolean;
19
52
  export declare function findPluginRegistration(name: string): PluginRegistration | undefined;
53
+ export declare function readPluginPageBundleChunk(name: string, hash: string, index: number): {
54
+ data: string;
55
+ hash: string;
56
+ index: number;
57
+ total: number;
58
+ };
20
59
  export declare function findPluginEntry(name: string): string | undefined;
21
60
  export declare function listPluginNames(): string[];
@@ -1,8 +1,9 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
2
+ import { closeSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
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
7
  const RESERVED_NAMES = new Set([
7
8
  'check',
8
9
  'help',
@@ -21,11 +22,196 @@ export class PluginError extends Error {
21
22
  export function pluginDirectory() {
22
23
  return resolve(process.env.MEARL_PLUGIN_DIR || join(homedir(), '.mearl', 'plugins'));
23
24
  }
25
+ function pluginPageDirectory() {
26
+ return join(pluginDirectory(), '.pages');
27
+ }
28
+ function pluginPageBundlePath(name, hash) {
29
+ return join(pluginPageDirectory(), `${name}-${hash}.html`);
30
+ }
31
+ function removePluginPageBundles(name, exceptHash) {
32
+ let files;
33
+ try {
34
+ files = readdirSync(pluginPageDirectory());
35
+ }
36
+ catch (error) {
37
+ if (error.code === 'ENOENT')
38
+ return;
39
+ throw error;
40
+ }
41
+ const keep = exceptHash ? `${name}-${exceptHash}.html` : undefined;
42
+ for (const file of files) {
43
+ if (file === keep || !file.startsWith(`${name}-`) || !file.endsWith('.html'))
44
+ continue;
45
+ rmSync(join(pluginPageDirectory(), file), { force: true });
46
+ }
47
+ }
48
+ function normalizePageBundle(value) {
49
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
50
+ throw new Error('invalid page bundle');
51
+ }
52
+ const candidate = value;
53
+ if (typeof candidate.hash !== 'string' ||
54
+ !/^[a-f0-9]{64}$/.test(candidate.hash) ||
55
+ !Number.isInteger(candidate.size) ||
56
+ (candidate.size ?? 0) <= 0 ||
57
+ !Number.isInteger(candidate.chunks) ||
58
+ candidate.chunks !== Math.ceil((candidate.size ?? 0) / PLUGIN_PAGE_CHUNK_BYTES)) {
59
+ throw new Error('invalid page bundle');
60
+ }
61
+ return {
62
+ hash: candidate.hash,
63
+ size: candidate.size,
64
+ chunks: candidate.chunks,
65
+ };
66
+ }
67
+ function persistPluginPageBundle(name, bundle) {
68
+ const directory = pluginPageDirectory();
69
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
70
+ const target = pluginPageBundlePath(name, bundle.hash);
71
+ try {
72
+ if (statSync(target).isFile())
73
+ return;
74
+ }
75
+ catch {
76
+ // 页面内容哈希变化时创建新的不可变产物。
77
+ }
78
+ const temporary = `${target}.${randomUUID()}.tmp`;
79
+ try {
80
+ writeFileSync(temporary, bundle.content, { mode: 0o600, flag: 'wx' });
81
+ renameSync(temporary, target);
82
+ }
83
+ finally {
84
+ rmSync(temporary, { force: true });
85
+ }
86
+ }
24
87
  export function assertPluginName(name) {
25
88
  if (!/^[a-z][a-z0-9-]*$/.test(name) || RESERVED_NAMES.has(name) || COMMAND_MAP.has(name)) {
26
89
  throw new PluginError('PLUGIN_INVALID_NAME', `不可用的插件名称: ${name}`);
27
90
  }
28
91
  }
92
+ export function normalizePluginToolboxContribution(value) {
93
+ if (value === undefined)
94
+ return undefined;
95
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
96
+ throw new Error('toolbox 必须是对象');
97
+ }
98
+ const candidate = value;
99
+ const title = typeof candidate.title === 'string' ? candidate.title.trim() : '';
100
+ const description = typeof candidate.description === 'string' ? candidate.description.trim() : undefined;
101
+ const icon = typeof candidate.icon === 'string' ? candidate.icon.trim() : undefined;
102
+ if (candidate.version !== 1)
103
+ throw new Error('toolbox.version 必须为 1');
104
+ if (!title || title.length > 80)
105
+ throw new Error('toolbox.title 必须是 1-80 个字符');
106
+ if (description && description.length > 240) {
107
+ throw new Error('toolbox.description 不能超过 240 个字符');
108
+ }
109
+ if (icon && (!/^[a-z][a-z0-9-]*$/.test(icon) || icon.length > 40)) {
110
+ throw new Error('toolbox.icon 必须是短横线命名的图标名称');
111
+ }
112
+ let locales;
113
+ if (candidate.locales !== undefined) {
114
+ if (!candidate.locales ||
115
+ typeof candidate.locales !== 'object' ||
116
+ Array.isArray(candidate.locales)) {
117
+ throw new Error('toolbox.locales 必须是对象');
118
+ }
119
+ const entries = Object.entries(candidate.locales);
120
+ if (entries.length === 0 || entries.length > 20) {
121
+ throw new Error('toolbox.locales 必须包含 1-20 个语言');
122
+ }
123
+ locales = Object.fromEntries(entries.map(([locale, localized]) => {
124
+ if (!/^[a-z]{2,3}(?:[_-][A-Za-z0-9]{2,8})*$/.test(locale)) {
125
+ throw new Error(`toolbox.locales 包含无效语言: ${locale}`);
126
+ }
127
+ if (!localized || typeof localized !== 'object' || Array.isArray(localized)) {
128
+ throw new Error(`toolbox.locales.${locale} 必须是对象`);
129
+ }
130
+ const localizedTitle = typeof localized.title === 'string' ? localized.title.trim() : undefined;
131
+ const localizedDescription = typeof localized.description === 'string' ? localized.description.trim() : undefined;
132
+ if (localizedTitle !== undefined && (!localizedTitle || localizedTitle.length > 80)) {
133
+ throw new Error(`toolbox.locales.${locale}.title 必须是 1-80 个字符`);
134
+ }
135
+ if (localizedDescription !== undefined && localizedDescription.length > 240) {
136
+ throw new Error(`toolbox.locales.${locale}.description 不能超过 240 个字符`);
137
+ }
138
+ if (!localizedTitle && !localizedDescription) {
139
+ throw new Error(`toolbox.locales.${locale} 至少声明 title 或 description`);
140
+ }
141
+ return [
142
+ locale,
143
+ {
144
+ ...(localizedTitle ? { title: localizedTitle } : {}),
145
+ ...(localizedDescription ? { description: localizedDescription } : {}),
146
+ },
147
+ ];
148
+ }));
149
+ }
150
+ if (!candidate.page || typeof candidate.page !== 'object' || Array.isArray(candidate.page)) {
151
+ throw new Error('toolbox.page 必须是对象');
152
+ }
153
+ const pageCandidate = candidate.page;
154
+ if (pageCandidate.type !== 'sandbox') {
155
+ throw new Error('toolbox.page.type 必须为 sandbox');
156
+ }
157
+ if (pageCandidate.runtime !== undefined && pageCandidate.runtime !== 'preact') {
158
+ throw new Error('toolbox.page.runtime 仅支持 preact');
159
+ }
160
+ const pageEntry = typeof pageCandidate.entry === 'string' ? pageCandidate.entry.trim() : '';
161
+ if (!pageEntry ||
162
+ pageEntry.length > 240 ||
163
+ isAbsolute(pageEntry) ||
164
+ pageEntry.includes('\\') ||
165
+ pageEntry.split('/').some(segment => !segment || segment === '.' || segment === '..')) {
166
+ throw new Error('toolbox.page.entry 必须是插件目录内的相对 HTML 路径');
167
+ }
168
+ if (!Array.isArray(pageCandidate.commands)) {
169
+ throw new Error('toolbox.page.commands 必须是命令名称数组');
170
+ }
171
+ const commands = pageCandidate.commands.map(command => typeof command === 'string' ? command.trim() : '');
172
+ if (commands.length === 0 ||
173
+ commands.length > 64 ||
174
+ new Set(commands).size !== commands.length ||
175
+ commands.some(command => !/^[a-z][a-z0-9-]*$/.test(command))) {
176
+ throw new Error('toolbox.page.commands 必须包含 1-64 个不重复的插件命令名称');
177
+ }
178
+ const page = {
179
+ type: 'sandbox',
180
+ ...(pageCandidate.runtime ? { runtime: pageCandidate.runtime } : {}),
181
+ entry: pageEntry,
182
+ commands,
183
+ };
184
+ let agent;
185
+ if (candidate.agent !== undefined) {
186
+ if (!candidate.agent || typeof candidate.agent !== 'object' || Array.isArray(candidate.agent)) {
187
+ throw new Error('toolbox.agent 必须是对象');
188
+ }
189
+ const agentCandidate = candidate.agent;
190
+ const skill = typeof agentCandidate.skill === 'string' ? agentCandidate.skill.trim() : '';
191
+ if (!skill || skill.length > 128 || !/^[A-Za-z0-9][A-Za-z0-9._:@/-]*$/.test(skill)) {
192
+ throw new Error('toolbox.agent.skill 必须是有效的 Skill 名称');
193
+ }
194
+ const layout = agentCandidate.layout;
195
+ if (layout !== undefined && layout !== 'drawer' && layout !== 'workspace') {
196
+ throw new Error('toolbox.agent.layout 必须是 drawer 或 workspace');
197
+ }
198
+ agent = { skill, ...(layout ? { layout } : {}) };
199
+ }
200
+ if (candidate.order !== undefined &&
201
+ (!Number.isInteger(candidate.order) || candidate.order < 0 || candidate.order > 10_000)) {
202
+ throw new Error('toolbox.order 必须是 0-10000 的整数');
203
+ }
204
+ return {
205
+ version: 1,
206
+ title,
207
+ ...(description ? { description } : {}),
208
+ ...(icon ? { icon } : {}),
209
+ ...(locales ? { locales } : {}),
210
+ ...(agent ? { agent } : {}),
211
+ page,
212
+ ...(candidate.order !== undefined ? { order: candidate.order } : {}),
213
+ };
214
+ }
29
215
  export function findPluginDirectoryEntry(directory) {
30
216
  for (const filename of ['main.mjs', 'main.py']) {
31
217
  const entry = join(directory, 'scripts', filename);
@@ -63,10 +249,31 @@ export function registerPlugin(name, target, description, options = {}) {
63
249
  const temporary = `${file}.${randomUUID()}.tmp`;
64
250
  const summary = description?.trim();
65
251
  const managedBy = options.managedBy?.trim();
252
+ let toolbox;
253
+ try {
254
+ toolbox = normalizePluginToolboxContribution(options.toolbox);
255
+ }
256
+ catch (error) {
257
+ throw new PluginError('PLUGIN_INVALID_TOOLBOX', error instanceof Error ? error.message : String(error));
258
+ }
259
+ let builtPage;
260
+ if (toolbox) {
261
+ try {
262
+ builtPage = buildPluginPageBundle(entry, toolbox.page.entry);
263
+ }
264
+ catch (error) {
265
+ throw new PluginError('PLUGIN_INVALID_TOOLBOX_PAGE', error instanceof Error ? error.message : String(error));
266
+ }
267
+ persistPluginPageBundle(name, builtPage);
268
+ }
66
269
  const record = {
67
270
  entry,
68
271
  ...(summary ? { description: summary } : {}),
69
272
  ...(managedBy ? { managedBy } : {}),
273
+ ...(toolbox ? { toolbox } : {}),
274
+ ...(builtPage
275
+ ? { pageBundle: { hash: builtPage.hash, size: builtPage.size, chunks: builtPage.chunks } }
276
+ : {}),
70
277
  };
71
278
  try {
72
279
  writeFileSync(temporary, `${JSON.stringify(record)}\n`, { mode: 0o600, flag: 'wx' });
@@ -75,6 +282,7 @@ export function registerPlugin(name, target, description, options = {}) {
75
282
  finally {
76
283
  rmSync(temporary, { force: true });
77
284
  }
285
+ removePluginPageBundles(name, builtPage?.hash);
78
286
  return entry;
79
287
  }
80
288
  export function unregisterPlugin(name) {
@@ -82,6 +290,7 @@ export function unregisterPlugin(name) {
82
290
  const file = join(pluginDirectory(), `${name}.json`);
83
291
  try {
84
292
  rmSync(file);
293
+ removePluginPageBundles(name);
85
294
  return true;
86
295
  }
87
296
  catch (error) {
@@ -113,16 +322,67 @@ export function findPluginRegistration(name) {
113
322
  const managedBy = typeof record.managedBy === 'string' && record.managedBy.trim()
114
323
  ? record.managedBy.trim()
115
324
  : undefined;
325
+ const toolbox = normalizePluginToolboxContribution(record.toolbox);
326
+ const pageBundle = toolbox ? normalizePageBundle(record.pageBundle) : undefined;
116
327
  return {
117
328
  entry: record.entry,
118
329
  ...(description ? { description } : {}),
119
330
  ...(managedBy ? { managedBy } : {}),
331
+ ...(toolbox ? { toolbox } : {}),
332
+ ...(pageBundle ? { pageBundle } : {}),
120
333
  };
121
334
  }
122
335
  catch {
123
336
  throw new PluginError('PLUGIN_REGISTRY_INVALID', `无效的插件记录: ${file}`);
124
337
  }
125
338
  }
339
+ export function readPluginPageBundleChunk(name, hash, index) {
340
+ assertPluginName(name);
341
+ const registration = findPluginRegistration(name);
342
+ const bundle = registration?.pageBundle;
343
+ if (!bundle || bundle.hash !== hash) {
344
+ throw new PluginError('PLUGIN_PAGE_CHANGED', `插件页面已更新: ${name}`);
345
+ }
346
+ if (!Number.isInteger(index) || index < 0 || index >= bundle.chunks) {
347
+ throw new PluginError('PLUGIN_PAGE_CHUNK_INVALID', `插件页面分块无效: ${index}`);
348
+ }
349
+ const path = pluginPageBundlePath(name, hash);
350
+ let size;
351
+ try {
352
+ size = statSync(path).size;
353
+ }
354
+ catch {
355
+ throw new PluginError('PLUGIN_PAGE_INVALID', `插件页面产物不存在: ${name}`);
356
+ }
357
+ if (size !== bundle.size) {
358
+ throw new PluginError('PLUGIN_PAGE_INVALID', `插件页面产物损坏: ${name}`);
359
+ }
360
+ const start = index * PLUGIN_PAGE_CHUNK_BYTES;
361
+ const content = Buffer.allocUnsafe(Math.min(PLUGIN_PAGE_CHUNK_BYTES, bundle.size - start));
362
+ let descriptor;
363
+ try {
364
+ descriptor = openSync(path, 'r');
365
+ }
366
+ catch {
367
+ throw new PluginError('PLUGIN_PAGE_INVALID', `插件页面产物无法读取: ${name}`);
368
+ }
369
+ let bytesRead;
370
+ try {
371
+ bytesRead = readSync(descriptor, content, 0, content.byteLength, start);
372
+ }
373
+ finally {
374
+ closeSync(descriptor);
375
+ }
376
+ if (bytesRead !== content.byteLength) {
377
+ throw new PluginError('PLUGIN_PAGE_INVALID', `插件页面产物损坏: ${name}`);
378
+ }
379
+ return {
380
+ data: content.toString('base64'),
381
+ hash,
382
+ index,
383
+ total: bundle.chunks,
384
+ };
385
+ }
126
386
  export function findPluginEntry(name) {
127
387
  return findPluginRegistration(name)?.entry;
128
388
  }
@@ -7,6 +7,7 @@ export interface PythonPluginDescriptor {
7
7
  commands: PythonPluginCommand[];
8
8
  description?: string;
9
9
  name?: string;
10
+ toolbox?: unknown;
10
11
  }
11
12
  export interface PythonPluginApp {
12
13
  readonly options: Readonly<PluginOptions>;
@@ -143,10 +143,13 @@ try:
143
143
 
144
144
  plugin_name = getattr(module, "name", None)
145
145
  plugin_description = getattr(module, "description", None)
146
+ plugin_toolbox = getattr(module, "toolbox", None)
146
147
  if plugin_name is not None and not isinstance(plugin_name, str):
147
148
  raise TypeError("入口的 name 必须为字符串")
148
149
  if plugin_description is not None and not isinstance(plugin_description, str):
149
150
  raise TypeError("入口的 description 必须为字符串")
151
+ if plugin_toolbox is not None and not isinstance(plugin_toolbox, dict):
152
+ raise TypeError("入口的 toolbox 必须为对象")
150
153
  register = getattr(module, "register", None)
151
154
  if not callable(register):
152
155
  raise TypeError("入口必须导出 register(app) 函数")
@@ -168,6 +171,8 @@ try:
168
171
  descriptor["name"] = plugin_name
169
172
  if plugin_description is not None:
170
173
  descriptor["description"] = plugin_description
174
+ if plugin_toolbox is not None:
175
+ descriptor["toolbox"] = plugin_toolbox
171
176
  _send(descriptor)
172
177
 
173
178
  if mode == "run":
@@ -233,6 +238,7 @@ function parseDescriptor(message) {
233
238
  commands,
234
239
  ...(typeof message.name === 'string' ? { name: message.name } : {}),
235
240
  ...(typeof message.description === 'string' ? { description: message.description } : {}),
241
+ ...(message.toolbox !== undefined ? { toolbox: message.toolbox } : {}),
236
242
  };
237
243
  }
238
244
  function bridgedError(message) {
package/dist/socket.js CHANGED
@@ -13,7 +13,8 @@ import os from 'node:os';
13
13
  import path from 'node:path';
14
14
  import { createRequire } from 'node:module';
15
15
  import { StringDecoder } from 'node:string_decoder';
16
- import { ensureInstalledProvidersRunning } from '@mearl/provider/runtime';
16
+ import { listInstalledProviders } from '@mearl/provider/host';
17
+ import { readLiveProvider } from '@mearl/provider/runtime';
17
18
  import { resolveFileRefs } from './fileRefs.js';
18
19
  import { listLiveBrowsers, findBrowser, describeBrowser, } from './browserRegistry.js';
19
20
  import { resolveActionTimeoutSec } from './actionTimeouts.js';
@@ -95,12 +96,8 @@ function browserListFromProviderRecords(browsers) {
95
96
  browsers: providerBrowsers,
96
97
  };
97
98
  }
98
- function mergeWarnings(result, warnings) {
99
- const resultWarnings = Array.isArray(result?.warnings)
100
- ? result.warnings.filter((warning) => typeof warning === 'string')
101
- : [];
102
- const merged = [...new Set([...resultWarnings, ...warnings])];
103
- return { ...result, ...(merged.length > 0 ? { warnings: merged } : {}) };
99
+ function areInstalledProvidersRunning() {
100
+ return listInstalledProviders().every(provider => readLiveProvider(provider.providerId));
104
101
  }
105
102
  function isConnectedBrowser(browser) {
106
103
  return browser.status === undefined || browser.status === 'connected';
@@ -243,11 +240,7 @@ export function invoke(action, data = {}, options = {}) {
243
240
  // 统一解析 payload 顶层字段中的 `@<path>` 文件引用(所有 action 通用)
244
241
  const resolvedData = resolveFileRefs(data);
245
242
  if (action === 'browser_list') {
246
- const hasNativeAggregator = listLiveBrowsers().some(liveBrowser => liveBrowser.transport !== 'provider');
247
- const providerStartup = hasNativeAggregator
248
- ? Promise.resolve([])
249
- : ensureInstalledProvidersRunning();
250
- return providerStartup.then(providerWarnings => dispatchInvoke(action, resolvedData, timeoutSec, undefined, undefined, controlSource).then(result => decorateBrowserListResult(mergeWarnings(result, providerWarnings))));
243
+ return dispatchInvoke(action, resolvedData, timeoutSec, undefined, undefined, controlSource).then(result => decorateBrowserListResult(result));
251
244
  }
252
245
  const selector = browser ?? defaultBrowser ?? process.env.MEARL_BROWSER ?? undefined;
253
246
  return dispatchInvoke(action, resolvedData, timeoutSec, transport, selector, controlSource);
@@ -325,7 +318,9 @@ async function dispatchInvoke(action, data, timeoutSec, transport, selector, con
325
318
  throw error;
326
319
  }
327
320
  }
328
- if (action === 'browser_list' && browsers.some(browser => browser.transport === 'provider')) {
321
+ if (action === 'browser_list' &&
322
+ browsers.some(browser => browser.transport === 'provider') &&
323
+ areInstalledProvidersRunning()) {
329
324
  return browserListFromProviderRecords(browsers);
330
325
  }
331
326
  return invokeViaLegacy(action, data, timeoutSec, transport, controlSource, /* isRetry */ false);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mearl/client",
3
- "version": "2.17.1",
3
+ "version": "2.18.0",
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.17.1",
63
- "@mearl/provider": "2.17.1"
62
+ "@mearl/cloud-types": "2.18.0",
63
+ "@mearl/provider": "2.18.0"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@types/node": "^24.9.1",