@mearl/client 2.20.1 → 2.20.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,6 +29,8 @@ const PAGE_BRIDGE_SCRIPT = String.raw `(() => {
29
29
  let port = null;
30
30
  let requestSequence = 0;
31
31
  const pending = new Map();
32
+ const pageEventListeners = new Map();
33
+ let subscriptionSequence = 0;
32
34
  let resolveReady;
33
35
  const ready = new Promise(resolve => { resolveReady = resolve; });
34
36
 
@@ -76,54 +78,25 @@ const PAGE_BRIDGE_SCRIPT = String.raw `(() => {
76
78
  };
77
79
 
78
80
  const invoke = async (command, input = {}) => {
79
- if (typeof command !== 'string' || !/^[a-z][a-z0-9-]*$/.test(command)) {
80
- throw new Error('Invalid plugin command');
81
- }
81
+ if (typeof command !== 'string' || (!/^[a-z][a-z0-9-]*$/.test(command) && !/^host\.[a-z][a-zA-Z]*(?:\.[a-z][a-zA-Z]*)*$/.test(command))) throw new Error('Invalid command');
82
82
  if (!input || typeof input !== 'object' || Array.isArray(input)) {
83
83
  throw new Error('Plugin command input must be an object');
84
84
  }
85
85
  return requestHost('invoke', { command, input });
86
86
  };
87
87
 
88
- const start = async (command, input = {}) => {
89
- if (typeof command !== 'string' || !/^[a-z][a-z0-9-]*$/.test(command)) {
90
- throw new Error('Invalid plugin command');
91
- }
92
- if (!input || typeof input !== 'object' || Array.isArray(input)) {
93
- throw new Error('Plugin command input must be an object');
94
- }
95
- return requestHost('invoke-start', { command, input });
96
- };
97
-
98
- const operation = async operationId => {
99
- if (typeof operationId !== 'string' || !/^[0-9a-f-]{36}$/i.test(operationId)) {
100
- throw new Error('Invalid plugin operation id');
101
- }
102
- return requestHost('invoke-operation', { operationId });
103
- };
104
-
105
- const runningOperations = async () => requestHost('invoke-operations');
106
-
107
- const selectDirectory = async () => {
108
- const path = await requestHost('select-directory');
109
- if (path !== null && typeof path !== 'string') {
110
- throw new Error('Directory selection returned an invalid path');
111
- }
112
- return path;
113
- };
114
-
115
- const copyText = async text => {
116
- if (typeof text !== 'string' || text.length > 1000000) {
117
- throw new Error('Clipboard text must be a string no longer than 1000000 characters');
118
- }
119
- await requestHost('clipboard-write', { text });
120
- };
121
-
122
- const openExternalLink = async url => {
123
- if (typeof url !== 'string' || !url || url.length > 8192) {
124
- throw new Error('External link must be a non-empty URL no longer than 8192 characters');
125
- }
126
- await requestHost('open-external-link', { url });
88
+ const subscribe = async (source, target, listener) => {
89
+ if (source !== 'page' || !target || !Number.isSafeInteger(target.tabId) || target.tabId < 0 ||
90
+ typeof target.runtimeId !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/.test(target.runtimeId) ||
91
+ typeof listener !== 'function') throw new Error('Invalid event subscription');
92
+ const subscriptionId = String(++subscriptionSequence);
93
+ pageEventListeners.set(subscriptionId, listener);
94
+ try { await requestHost('subscribe', { source, target, subscriptionId }); }
95
+ catch (error) { pageEventListeners.delete(subscriptionId); throw error; }
96
+ return () => {
97
+ if (!pageEventListeners.delete(subscriptionId)) return;
98
+ void requestHost('unsubscribe', { subscriptionId }).catch(() => {});
99
+ };
127
100
  };
128
101
 
129
102
  const handleExternalLinkClick = event => {
@@ -140,28 +113,11 @@ const PAGE_BRIDGE_SCRIPT = String.raw `(() => {
140
113
  }
141
114
  if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
142
115
  event.preventDefault();
143
- void openExternalLink(url.href).catch(error => {
116
+ void invoke('host.link.open', { url: url.href }).catch(error => {
144
117
  console.error('Failed to open external link', error);
145
118
  });
146
119
  };
147
120
 
148
- const toast = async (message, options = {}) => {
149
- if (typeof message !== 'string' || !message.trim() || message.length > 4000) {
150
- throw new Error('Toast message must be a non-empty string no longer than 4000 characters');
151
- }
152
- if (!options || typeof options !== 'object' || Array.isArray(options)) {
153
- throw new Error('Toast options must be an object');
154
- }
155
- const type = options.type ?? 'info';
156
- const duration = options.duration ?? 3000;
157
- if (!['success', 'error', 'info'].includes(type)) throw new Error('Invalid toast type');
158
- if (!Number.isInteger(duration) || duration < 1000 || duration > 30000) {
159
- throw new Error('Toast duration must be between 1000 and 30000 milliseconds');
160
- }
161
- await ready;
162
- port.postMessage({ type: 'toast', message: message.trim(), toastType: type, duration });
163
- };
164
-
165
121
  const agentPanelLayout = options => {
166
122
  if (!options || typeof options !== 'object' || Array.isArray(options)) {
167
123
  throw new Error('Agent panel options must be an object');
@@ -441,7 +397,7 @@ const PAGE_BRIDGE_SCRIPT = String.raw `(() => {
441
397
  return query;
442
398
  };
443
399
 
444
- const api = { copyText, invoke, operation, ready: () => ready, runAgentTask, runningOperations, selectDirectory, setAgentGuides, setAgentPanelVisible, setQueryParam, start, toast };
400
+ const api = { invoke, subscribe, ready: () => ready, runAgentTask, setAgentGuides, setAgentPanelVisible, setQueryParam };
445
401
  const ui = globalThis.__mearlToolboxUi;
446
402
  if (ui !== undefined) {
447
403
  if (!ui || typeof ui !== 'object' || ui.runtime !== 'preact') {
@@ -481,6 +437,10 @@ const PAGE_BRIDGE_SCRIPT = String.raw `(() => {
481
437
  port = nextPort;
482
438
  port.onmessage = responseEvent => {
483
439
  const response = responseEvent.data;
440
+ if (response?.type === 'event' && typeof response.subscriptionId === 'string') {
441
+ pageEventListeners.get(response.subscriptionId)?.(response.event, response.metadata);
442
+ return;
443
+ }
484
444
  if (response?.type === 'context') {
485
445
  applyContext(response.context);
486
446
  return;
@@ -506,7 +466,7 @@ const PAGE_BRIDGE_SCRIPT = String.raw `(() => {
506
466
  if (!request) return;
507
467
  pending.delete(response.requestId);
508
468
  if (response.ok) request.resolve(response.result);
509
- else request.reject(new Error(response.error || 'Plugin command failed'));
469
+ else request.reject(Object.assign(new Error(response.error || 'Plugin command failed'), { handledByHost: response.handledByHost === true }));
510
470
  };
511
471
  port.start();
512
472
  resolveReady(context);
@@ -604,6 +564,11 @@ body {
604
564
  }
605
565
  button, input, select, textarea { font: inherit; }
606
566
  </style>`;
567
+ export const PLUGIN_PAGE_RUNTIME_HASH = createHash('sha256')
568
+ .update(PAGE_BRIDGE_SCRIPT)
569
+ .update(PAGE_SECURITY_META)
570
+ .update(PAGE_THEME_STYLE)
571
+ .digest('hex');
607
572
  function pluginRoot(entry) {
608
573
  const entryDirectory = dirname(entry);
609
574
  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/dist/socket.d.ts CHANGED
@@ -18,6 +18,8 @@ export type ControlSource = 'local' | 'remote';
18
18
  type CompatibleControlSource = ControlSource | 'cloud';
19
19
  export interface InvokeOptions {
20
20
  controlSessionId?: string;
21
+ /** Pin a known local endpoint for a sequence of transport requests. */
22
+ socketPath?: string;
21
23
  /** 超时时间(秒),默认 15 */
22
24
  timeoutSec?: number;
23
25
  /** Force CDP transport instead of extension */
@@ -31,6 +33,11 @@ export interface InvokeOptions {
31
33
  export declare function setDefaultBrowser(selector: string | undefined): void;
32
34
  export declare function getDefaultBrowser(): string | undefined;
33
35
  export declare function decorateBrowserListResult(result: Record<string, any>): Record<string, any>;
36
+ /** Resolve an existing local route without executing the action. */
37
+ export declare function resolveLocalActionTarget(action: string, data: Record<string, any>, options?: InvokeOptions): {
38
+ browser?: string;
39
+ socketPath?: string;
40
+ } | undefined;
34
41
  /**
35
42
  * 向 native host 发送一个 action 请求并等待响应。
36
43
  *
package/dist/socket.js CHANGED
@@ -18,6 +18,7 @@ import { readLiveProvider } from '@mearl/provider/runtime';
18
18
  import { resolveFileRefs } from './fileRefs.js';
19
19
  import { listLiveBrowsers, findBrowser, describeBrowser, } from './browserRegistry.js';
20
20
  import { resolveActionTimeoutSec } from './actionTimeouts.js';
21
+ import { isBrowserCapabilityAction } from './generated-browser-action-protocol.js';
21
22
  const require = createRequire(import.meta.url);
22
23
  export const CLIENT_VERSION = require('../package.json').version;
23
24
  const MAX_SOCKET_BUFFER_SIZE = 64 * 1024 * 1024;
@@ -160,6 +161,24 @@ function supportsAction(browser, action, data) {
160
161
  return (browser.transport !== 'provider' ||
161
162
  browser.capabilities?.actions.some(capability => capability === action) === true);
162
163
  }
164
+ function routableBrowsers(browsers, action, data, transport) {
165
+ return browsers.filter(browser => isConnectedBrowser(browser) &&
166
+ (action !== 'browser_list' || browser.transport !== 'provider') &&
167
+ !(transport === 'cdp' && browser.transport === 'provider') &&
168
+ supportsAction(browser, action, data));
169
+ }
170
+ /** Resolve an existing local route without executing the action. */
171
+ export function resolveLocalActionTarget(action, data, options = {}) {
172
+ const selector = options.browser ?? defaultBrowser ?? process.env.MEARL_BROWSER ?? undefined;
173
+ const socketPath = options.socketPath ?? process.env.MEARL_SOCKET_PATH?.trim();
174
+ if (socketPath && (options.socketPath || !selector))
175
+ return { socketPath };
176
+ const browsers = listLiveBrowsers();
177
+ const target = selector
178
+ ? findBrowser(browsers, selector)
179
+ : routableBrowsers(browsers, action, data, options.transport)[0];
180
+ return target ? { browser: target.browserId } : undefined;
181
+ }
163
182
  function selectBrowserResult(data) {
164
183
  const selector = typeof data.browser === 'string' ? data.browser.trim() : '';
165
184
  if (!selector) {
@@ -329,6 +348,17 @@ function removeStaleLegacySocket(identity) {
329
348
  * legacy well-known socket,并在 socket 不存在或已残留时自动拉起 native host。
330
349
  */
331
350
  export function invoke(action, data = {}, options = {}) {
351
+ if (action === '__resolve_action_target') {
352
+ if (!isBrowserCapabilityAction(data.action))
353
+ return Promise.reject(new Error('Invalid browser capability action'));
354
+ const target = resolveLocalActionTarget(data.action, data.data ?? {}, options);
355
+ const browser = target?.browser ??
356
+ (target?.socketPath
357
+ ? listLiveBrowsers().find(item => item.socketPath === target.socketPath &&
358
+ supportsAction(item, data.action, data.data ?? {}))?.browserId
359
+ : undefined);
360
+ return Promise.resolve(browser ? { browser } : null);
361
+ }
332
362
  if (action === 'browser_select_browser') {
333
363
  try {
334
364
  return Promise.resolve(selectBrowserResult(data));
@@ -338,14 +368,50 @@ export function invoke(action, data = {}, options = {}) {
338
368
  }
339
369
  }
340
370
  const { timeoutSec = resolveActionTimeoutSec(action, data), transport, browser, controlSource = 'local', } = options;
371
+ if (action === '__control_session_resolve') {
372
+ return resolveControlSession(data.sessionId, timeoutSec, controlSource);
373
+ }
341
374
  // 统一解析 payload 顶层字段中的 `@<path>` 文件引用(所有 action 通用)
342
375
  const resolvedData = resolveFileRefs(data);
343
376
  Object.defineProperty(resolvedData, SESSION_OPTION, { value: options.controlSessionId });
344
377
  if (action === 'browser_list') {
345
- return dispatchInvoke(action, resolvedData, timeoutSec, transport, undefined, controlSource).then(result => decorateBrowserListResult(transport === 'cdp' ? cdpOnlyBrowserList(result) : result));
378
+ return dispatchInvoke(action, resolvedData, timeoutSec, transport, undefined, controlSource, options.socketPath).then(result => decorateBrowserListResult(transport === 'cdp' ? cdpOnlyBrowserList(result) : result));
346
379
  }
347
380
  const selector = browser ?? defaultBrowser ?? process.env.MEARL_BROWSER ?? undefined;
348
- return dispatchInvoke(action, resolvedData, timeoutSec, transport, selector, controlSource);
381
+ return dispatchInvoke(action, resolvedData, timeoutSec, transport, selector, controlSource, options.socketPath);
382
+ }
383
+ /** Session ownership lives in the control hosts and survives individual CLI connections. */
384
+ async function resolveControlSession(sessionId, timeoutSec, controlSource) {
385
+ const browsers = listLiveBrowsers();
386
+ const configuredSocketPath = process.env.MEARL_SOCKET_PATH?.trim();
387
+ const sockets = configuredSocketPath
388
+ ? [configuredSocketPath]
389
+ : [
390
+ ...new Set(browsers
391
+ .filter(browser => browser.transport !== 'provider')
392
+ .map(browser => browser.socketPath)),
393
+ ];
394
+ const provider = configuredSocketPath
395
+ ? undefined
396
+ : browsers.find(browser => browser.transport === 'provider');
397
+ if (!sockets.length && !provider)
398
+ sockets.push(SOCKET_PATH);
399
+ const requests = sockets.map(socketPath => invokeOnce('__control_session_resolve', { sessionId }, timeoutSec, undefined, socketPath, controlSource));
400
+ if (provider) {
401
+ requests.push(invokeOnce('__control_session_resolve', { sessionId }, timeoutSec, undefined, provider.socketPath, controlSource, provider.browserId));
402
+ }
403
+ const results = await Promise.allSettled(requests);
404
+ const sessions = results.flatMap(result => result.status === 'fulfilled' && result.value?.session?.sessionId === sessionId
405
+ ? [result.value.session]
406
+ : []);
407
+ if (sessions.length > 1)
408
+ throw new Error('CONTROL_SESSION_AMBIGUOUS: multiple hosts own this id');
409
+ if (sessions.length)
410
+ return { session: sessions[0] };
411
+ const failure = results.find(result => result.status === 'rejected' && !isConnError(result.reason));
412
+ if (failure?.status === 'rejected')
413
+ throw failure.reason;
414
+ return { session: null };
349
415
  }
350
416
  function isConnError(error) {
351
417
  const code = error?.code;
@@ -359,9 +425,9 @@ function isUnavailableConnection(error) {
359
425
  const code = cause?.code;
360
426
  return isConnError(cause) || isSocketSandboxError(cause) || code === 'EACCES';
361
427
  }
362
- async function dispatchInvoke(action, data, timeoutSec, transport, selector, controlSource) {
363
- const configuredSocketPath = process.env.MEARL_SOCKET_PATH?.trim();
364
- if (!selector && configuredSocketPath) {
428
+ async function dispatchInvoke(action, data, timeoutSec, transport, selector, controlSource, socketPath) {
429
+ const configuredSocketPath = socketPath ?? process.env.MEARL_SOCKET_PATH?.trim();
430
+ if (configuredSocketPath && (socketPath || !selector)) {
365
431
  try {
366
432
  return await invokeOnce(action, data, timeoutSec, transport, configuredSocketPath, controlSource);
367
433
  }
@@ -447,11 +513,9 @@ async function dispatchInvoke(action, data, timeoutSec, transport, selector, con
447
513
  }
448
514
  // No explicit target: prefer the most-recently-focused live browser; on a
449
515
  // stale socket, fall through to the legacy path + auto-spawn.
450
- const routableBrowsers = controllableBrowsers.filter(browser => (action !== 'browser_list' || browser.transport !== 'provider') &&
451
- !(transport === 'cdp' && browser.transport === 'provider') &&
452
- supportsAction(browser, action, data));
453
- if (routableBrowsers.length > 0) {
454
- const target = routableBrowsers[0];
516
+ const candidates = routableBrowsers(browsers, action, data, transport);
517
+ if (candidates.length > 0) {
518
+ const target = candidates[0];
455
519
  try {
456
520
  return await invokeOnce(action, data, timeoutSec, transport, target.socketPath, controlSource, target.transport === 'provider' ? target.browserId : undefined);
457
521
  }
package/dist/unified.d.ts CHANGED
@@ -30,12 +30,8 @@ interface BrowserTarget {
30
30
  }
31
31
  export declare function decodeBrowserTarget(selector: string | undefined): BrowserTarget | undefined;
32
32
  export declare function normalizeBrowserSelector(selector: string): string;
33
- export declare function decodeControlSessionId(id: string): {
34
- browser: string;
35
- id: string;
36
- transport: 'extension' | 'cdp' | 'provider';
37
- };
38
33
  export declare class UnifiedClient {
34
+ private readonly controlSessions;
39
35
  private readonly options;
40
36
  private defaultBrowser;
41
37
  private defaultConnector;
@@ -55,6 +51,7 @@ export declare class UnifiedClient {
55
51
  outputPath: string;
56
52
  bytes: number;
57
53
  }>;
54
+ private pinPageEvalTarget;
58
55
  private invokeRouted;
59
56
  private invokeExplicitLocal;
60
57
  private invokeLaunchWithoutControlBrowser;
@@ -71,6 +68,7 @@ export declare class UnifiedClient {
71
68
  private invokeLocal;
72
69
  private invokeRemote;
73
70
  private decorateActionResult;
71
+ private resolveControlSession;
74
72
  private ensureConnected;
75
73
  private connect;
76
74
  private startHeartbeat;