gclm-code 1.0.0 → 1.0.1

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.
Files changed (43) hide show
  1. package/README.md +1 -1
  2. package/bin/gc.js +53 -25
  3. package/bin/install-runtime.js +253 -0
  4. package/package.json +10 -5
  5. package/vendor/manifest.json +92 -0
  6. package/vendor/modules/node_modules/@ant/claude-for-chrome-mcp/package.json +9 -0
  7. package/vendor/modules/node_modules/@ant/claude-for-chrome-mcp/src/bridgeClient.ts +1126 -0
  8. package/vendor/modules/node_modules/@ant/claude-for-chrome-mcp/src/browserTools.ts +546 -0
  9. package/vendor/modules/node_modules/@ant/claude-for-chrome-mcp/src/index.ts +15 -0
  10. package/vendor/modules/node_modules/@ant/claude-for-chrome-mcp/src/mcpServer.ts +96 -0
  11. package/vendor/modules/node_modules/@ant/claude-for-chrome-mcp/src/mcpSocketClient.ts +493 -0
  12. package/vendor/modules/node_modules/@ant/claude-for-chrome-mcp/src/mcpSocketPool.ts +327 -0
  13. package/vendor/modules/node_modules/@ant/claude-for-chrome-mcp/src/toolCalls.ts +301 -0
  14. package/vendor/modules/node_modules/@ant/claude-for-chrome-mcp/src/types.ts +134 -0
  15. package/vendor/modules/node_modules/@ant/computer-use-input/package.json +9 -0
  16. package/vendor/modules/node_modules/@ant/computer-use-input/src/driver-jxa.js +341 -0
  17. package/vendor/modules/node_modules/@ant/computer-use-input/src/driver-swift.swift +417 -0
  18. package/vendor/modules/node_modules/@ant/computer-use-input/src/implementation.js +204 -0
  19. package/vendor/modules/node_modules/@ant/computer-use-input/src/index.js +5 -0
  20. package/vendor/modules/node_modules/@ant/computer-use-mcp/package.json +11 -0
  21. package/vendor/modules/node_modules/@ant/computer-use-mcp/src/deniedApps.ts +553 -0
  22. package/vendor/modules/node_modules/@ant/computer-use-mcp/src/imageResize.ts +108 -0
  23. package/vendor/modules/node_modules/@ant/computer-use-mcp/src/index.ts +69 -0
  24. package/vendor/modules/node_modules/@ant/computer-use-mcp/src/keyBlocklist.ts +153 -0
  25. package/vendor/modules/node_modules/@ant/computer-use-mcp/src/mcpServer.ts +313 -0
  26. package/vendor/modules/node_modules/@ant/computer-use-mcp/src/pixelCompare.ts +171 -0
  27. package/vendor/modules/node_modules/@ant/computer-use-mcp/src/sentinelApps.ts +43 -0
  28. package/vendor/modules/node_modules/@ant/computer-use-mcp/src/subGates.ts +19 -0
  29. package/vendor/modules/node_modules/@ant/computer-use-mcp/src/toolCalls.ts +3872 -0
  30. package/vendor/modules/node_modules/@ant/computer-use-mcp/src/tools.ts +706 -0
  31. package/vendor/modules/node_modules/@ant/computer-use-mcp/src/types.ts +635 -0
  32. package/vendor/modules/node_modules/@ant/computer-use-swift/package.json +9 -0
  33. package/vendor/modules/node_modules/@ant/computer-use-swift/src/driver-jxa.js +108 -0
  34. package/vendor/modules/node_modules/@ant/computer-use-swift/src/implementation.js +706 -0
  35. package/vendor/modules/node_modules/@ant/computer-use-swift/src/index.js +7 -0
  36. package/vendor/modules/node_modules/audio-capture-napi/package.json +8 -0
  37. package/vendor/modules/node_modules/audio-capture-napi/src/index.ts +226 -0
  38. package/vendor/modules/node_modules/image-processor-napi/package.json +11 -0
  39. package/vendor/modules/node_modules/image-processor-napi/src/index.ts +396 -0
  40. package/vendor/modules/node_modules/modifiers-napi/package.json +8 -0
  41. package/vendor/modules/node_modules/modifiers-napi/src/index.ts +79 -0
  42. package/vendor/modules/node_modules/url-handler-napi/package.json +8 -0
  43. package/vendor/modules/node_modules/url-handler-napi/src/index.ts +62 -0
@@ -0,0 +1,1126 @@
1
+ /**
2
+ * WebSocket bridge client for the Chrome extension MCP server.
3
+ * Communicates with the Chrome extension via the office bridge server's /chrome path.
4
+ */
5
+
6
+ import WebSocket from "ws";
7
+
8
+ import { SocketConnectionError } from "./mcpSocketClient.js";
9
+ import {
10
+ localPlatformLabel,
11
+ type BridgePermissionRequest,
12
+ type ChromeExtensionInfo,
13
+ type ClaudeForChromeContext,
14
+ type PermissionMode,
15
+ type PermissionOverrides,
16
+ type SocketClient,
17
+ } from "./types.js";
18
+
19
+ /** Timeout for list_extensions response from the bridge. */
20
+ const DISCOVERY_TIMEOUT_MS = 5000;
21
+
22
+ /** How long to wait for a peer_connected event when 0 extensions are found. */
23
+ const PEER_WAIT_TIMEOUT_MS = 10_000;
24
+
25
+ interface PendingToolCall {
26
+ resolve: (value: unknown) => void;
27
+ reject: (reason: Error) => void;
28
+ timer: NodeJS.Timeout;
29
+ results: unknown[];
30
+ isTabsContext: boolean;
31
+ onPermissionRequest?: (request: BridgePermissionRequest) => Promise<boolean>;
32
+ startTime: number;
33
+ toolName: string;
34
+ }
35
+
36
+ export class BridgeClient implements SocketClient {
37
+ private ws: WebSocket | null = null;
38
+ private connected = false;
39
+ private authenticated = false;
40
+ private connecting = false;
41
+ private reconnectTimer: NodeJS.Timeout | null = null;
42
+ private reconnectAttempts = 0;
43
+ private pendingCalls = new Map<string, PendingToolCall>();
44
+ private notificationHandler:
45
+ | ((notification: {
46
+ method: string;
47
+ params?: Record<string, unknown>;
48
+ }) => void)
49
+ | null = null;
50
+ private context: ClaudeForChromeContext;
51
+ private permissionMode: PermissionMode = "ask";
52
+ private allowedDomains: string[] | undefined;
53
+ private tabsContextCollectionTimeoutMs = 2000;
54
+ private toolCallTimeoutMs = 120_000;
55
+ private connectionStartTime: number | null = null;
56
+ private connectionEstablishedTime: number | null = null;
57
+ /** The device_id of the selected Chrome extension for targeted routing. */
58
+ private selectedDeviceId: string | undefined;
59
+ /** True after first discovery attempt completes (success or timeout). */
60
+ private discoveryComplete = false;
61
+ /** Shared promise so concurrent callTool invocations join the same discovery. */
62
+ private discoveryPromise: Promise<void> | null = null;
63
+ /** Pending discovery response from bridge. */
64
+ private pendingDiscovery: {
65
+ resolve: (extensions: ChromeExtensionInfo[]) => void;
66
+ timeout: NodeJS.Timeout;
67
+ } | null = null;
68
+ /** The device_id we had selected before a peer_disconnected — for auto-reselect. */
69
+ private previousSelectedDeviceId: string | undefined;
70
+ /** Callbacks waiting for the next peer_connected event. Receives `true` on peer arrival, `false` on abort. */
71
+ private peerConnectedWaiters: Array<(arrived: boolean) => void> = [];
72
+ /** The request_id of the current pending pairing broadcast. */
73
+ private pendingPairingRequestId: string | undefined;
74
+ /** True while a pairing broadcast is in flight and no response yet. */
75
+ private pairingInProgress = false;
76
+ /** The deviceId from a previous persisted pairing. */
77
+ private persistedDeviceId: string | undefined;
78
+ /** Resolve callback for a blocking switchBrowser() call. */
79
+ private pendingSwitchResolve:
80
+ | ((result: { deviceId: string; name: string } | null) => void)
81
+ | null = null;
82
+
83
+ constructor(context: ClaudeForChromeContext) {
84
+ this.context = context;
85
+ if (context.initialPermissionMode) {
86
+ this.permissionMode = context.initialPermissionMode;
87
+ }
88
+ }
89
+
90
+ public async ensureConnected(): Promise<boolean> {
91
+ const { logger, serverName } = this.context;
92
+ logger.info(
93
+ `[${serverName}] ensureConnected called, connected=${this.connected}, authenticated=${this.authenticated}, wsState=${this.ws?.readyState}`,
94
+ );
95
+
96
+ if (
97
+ this.connected &&
98
+ this.authenticated &&
99
+ this.ws?.readyState === WebSocket.OPEN
100
+ ) {
101
+ logger.info(`[${serverName}] Already connected and authenticated`);
102
+ return true;
103
+ }
104
+
105
+ if (!this.connecting) {
106
+ logger.info(`[${serverName}] Not connecting, starting connection...`);
107
+ await this.connect();
108
+ } else {
109
+ logger.info(`[${serverName}] Already connecting, waiting...`);
110
+ }
111
+
112
+ // Wait for authentication with timeout
113
+ return new Promise((resolve) => {
114
+ const timeout = setTimeout(() => {
115
+ logger.info(
116
+ `[${serverName}] Connection timeout, connected=${this.connected}, authenticated=${this.authenticated}`,
117
+ );
118
+ resolve(false);
119
+ }, 10_000);
120
+ const check = () => {
121
+ if (this.connected && this.authenticated) {
122
+ logger.info(`[${serverName}] Connection successful`);
123
+ clearTimeout(timeout);
124
+ resolve(true);
125
+ } else if (!this.connecting) {
126
+ logger.info(`[${serverName}] No longer connecting, giving up`);
127
+ clearTimeout(timeout);
128
+ resolve(false);
129
+ } else {
130
+ setTimeout(check, 200);
131
+ }
132
+ };
133
+ check();
134
+ });
135
+ }
136
+
137
+ public async callTool(
138
+ name: string,
139
+ args: Record<string, unknown>,
140
+ permissionOverrides?: PermissionOverrides,
141
+ ): Promise<unknown> {
142
+ const { logger, serverName, trackEvent } = this.context;
143
+
144
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
145
+ throw new SocketConnectionError(`[${serverName}] Bridge not connected`);
146
+ }
147
+
148
+ // Lazy discovery: run on first tool call if no extension selected yet.
149
+ // Use a shared promise so concurrent callers join the same discovery.
150
+ if (!this.selectedDeviceId && !this.discoveryComplete) {
151
+ this.discoveryPromise ??= this.discoverAndSelectExtension().finally(
152
+ () => {
153
+ this.discoveryPromise = null;
154
+ },
155
+ );
156
+ await this.discoveryPromise;
157
+ }
158
+
159
+ // TODO: Once all extensions support pairing, throw here for multi-extension
160
+ // cases where pairingInProgress is true. For now, let the bridge handle
161
+ // routing — it auto-routes to a single extension or returns an error for
162
+ // multiple extensions without a target_device_id.
163
+
164
+ const toolUseId = crypto.randomUUID();
165
+ const isTabsContext = name === "tabs_context_mcp";
166
+ const startTime = Date.now();
167
+ const timeoutMs = isTabsContext
168
+ ? this.tabsContextCollectionTimeoutMs
169
+ : this.toolCallTimeoutMs;
170
+
171
+ // Track tool call start
172
+ trackEvent?.("chrome_bridge_tool_call_started", {
173
+ tool_name: name,
174
+ tool_use_id: toolUseId,
175
+ });
176
+
177
+ // Per-call overrides (from session context) take priority over
178
+ // instance values (from set_permission_mode on the singleton).
179
+ const effectivePermissionMode =
180
+ permissionOverrides?.permissionMode ?? this.permissionMode;
181
+ const effectiveAllowedDomains =
182
+ permissionOverrides?.allowedDomains ?? this.allowedDomains;
183
+
184
+ return new Promise((resolve, reject) => {
185
+ const timer = setTimeout(() => {
186
+ const pending = this.pendingCalls.get(toolUseId);
187
+ if (pending) {
188
+ this.pendingCalls.delete(toolUseId);
189
+ const durationMs = Date.now() - pending.startTime;
190
+
191
+ if (isTabsContext && pending.results.length > 0) {
192
+ // For tabs_context, resolve with collected results even on timeout
193
+ trackEvent?.("chrome_bridge_tool_call_completed", {
194
+ tool_name: name,
195
+ tool_use_id: toolUseId,
196
+ duration_ms: durationMs,
197
+ });
198
+ resolve(this.mergeTabsResults(pending.results));
199
+ } else {
200
+ logger.warn(
201
+ `[${serverName}] Tool call timeout: ${name} (${toolUseId.slice(0, 8)}) after ${durationMs}ms, pending calls: ${this.pendingCalls.size}`,
202
+ );
203
+ trackEvent?.("chrome_bridge_tool_call_timeout", {
204
+ tool_name: name,
205
+ tool_use_id: toolUseId,
206
+ duration_ms: durationMs,
207
+ timeout_ms: timeoutMs,
208
+ });
209
+ reject(
210
+ new SocketConnectionError(
211
+ `[${serverName}] Tool call timed out: ${name}`,
212
+ ),
213
+ );
214
+ }
215
+ }
216
+ }, timeoutMs);
217
+
218
+ this.pendingCalls.set(toolUseId, {
219
+ resolve,
220
+ reject,
221
+ timer,
222
+ results: [],
223
+ isTabsContext,
224
+ onPermissionRequest: permissionOverrides?.onPermissionRequest,
225
+ startTime,
226
+ toolName: name,
227
+ });
228
+
229
+ const message: Record<string, unknown> = {
230
+ type: "tool_call",
231
+ tool_use_id: toolUseId,
232
+ client_type: this.context.clientTypeId,
233
+ tool: name,
234
+ args,
235
+ };
236
+
237
+ // Target the selected extension for routing
238
+ if (this.selectedDeviceId) {
239
+ message.target_device_id = this.selectedDeviceId;
240
+ }
241
+
242
+ // Only include permission fields when a value exists.
243
+ // Priority: per-call override (from session context) > instance value (from set_permission_mode).
244
+ if (effectivePermissionMode) {
245
+ message.permission_mode = effectivePermissionMode;
246
+ }
247
+ if (effectiveAllowedDomains?.length) {
248
+ message.allowed_domains = effectiveAllowedDomains;
249
+ }
250
+ if (permissionOverrides?.onPermissionRequest) {
251
+ message.handle_permission_prompts = true;
252
+ }
253
+
254
+ logger.debug(
255
+ `[${serverName}] Sending tool_call: ${name} (${toolUseId.slice(0, 8)})`,
256
+ );
257
+ this.ws!.send(JSON.stringify(message));
258
+ });
259
+ }
260
+
261
+ public isConnected(): boolean {
262
+ return (
263
+ this.connected &&
264
+ this.authenticated &&
265
+ this.ws?.readyState === WebSocket.OPEN
266
+ );
267
+ }
268
+
269
+ public disconnect(): void {
270
+ this.cleanup();
271
+ }
272
+
273
+ public setNotificationHandler(
274
+ handler: (notification: {
275
+ method: string;
276
+ params?: Record<string, unknown>;
277
+ }) => void,
278
+ ): void {
279
+ this.notificationHandler = handler;
280
+ }
281
+
282
+ public async setPermissionMode(
283
+ mode: PermissionMode,
284
+ allowedDomains?: string[],
285
+ ): Promise<void> {
286
+ this.permissionMode = mode;
287
+ this.allowedDomains = allowedDomains;
288
+ }
289
+
290
+ // ===========================================================================
291
+ // Extension discovery and selection
292
+ // ===========================================================================
293
+
294
+ /**
295
+ * Discover connected extensions and auto-select one, or broadcast a pairing request.
296
+ * Called lazily on the first tool call.
297
+ */
298
+ private async discoverAndSelectExtension(): Promise<void> {
299
+ const { logger, serverName } = this.context;
300
+
301
+ this.persistedDeviceId ??= this.context.getPersistedDeviceId?.();
302
+
303
+ let extensions = await this.queryBridgeExtensions();
304
+
305
+ if (extensions.length === 0) {
306
+ logger.info(
307
+ `[${serverName}] No extensions connected, waiting up to ${PEER_WAIT_TIMEOUT_MS}ms for peer_connected`,
308
+ );
309
+ const peerArrived = await this.waitForPeerConnected(PEER_WAIT_TIMEOUT_MS);
310
+ if (peerArrived) {
311
+ extensions = await this.queryBridgeExtensions();
312
+ }
313
+ }
314
+
315
+ this.discoveryComplete = true;
316
+
317
+ if (extensions.length === 0) {
318
+ // Still nothing — callTool will throw a clear error
319
+ logger.info(`[${serverName}] No extensions found after waiting`);
320
+ return;
321
+ }
322
+
323
+ // Single extension: auto-select silently
324
+ if (extensions.length === 1) {
325
+ const ext = extensions[0]!;
326
+ if (!this.isLocalExtension(ext)) {
327
+ this.context.onRemoteExtensionWarning?.(ext);
328
+ }
329
+ this.selectExtension(ext.deviceId);
330
+ return;
331
+ }
332
+
333
+ // Multiple extensions: check for persisted selection
334
+ if (this.persistedDeviceId) {
335
+ const persisted = extensions.find(
336
+ (e) => e.deviceId === this.persistedDeviceId,
337
+ );
338
+ if (persisted) {
339
+ logger.info(
340
+ `[${serverName}] Auto-connecting to persisted extension: ${persisted.name || persisted.deviceId.slice(0, 8)}`,
341
+ );
342
+ this.selectExtension(persisted.deviceId);
343
+ return;
344
+ }
345
+ }
346
+
347
+ // Multiple extensions, no valid persisted selection: broadcast and fail fast
348
+ this.broadcastPairingRequest();
349
+ this.pairingInProgress = true;
350
+ }
351
+
352
+ /**
353
+ * Query the bridge for connected extensions. Returns empty array on timeout.
354
+ * Deduplicates by deviceId, keeping the most recent connection — the bridge
355
+ * may report stale duplicates (e.g. after a service worker restart).
356
+ */
357
+ private async queryBridgeExtensions(): Promise<ChromeExtensionInfo[]> {
358
+ const raw: ChromeExtensionInfo[] = await new Promise((resolve) => {
359
+ const timeout = setTimeout(() => {
360
+ this.pendingDiscovery = null;
361
+ resolve([]);
362
+ }, DISCOVERY_TIMEOUT_MS);
363
+
364
+ this.pendingDiscovery = { resolve, timeout };
365
+ this.ws?.send(JSON.stringify({ type: "list_extensions" }));
366
+ });
367
+
368
+ const byDeviceId = new Map<string, ChromeExtensionInfo>();
369
+ for (const ext of raw) {
370
+ const existing = byDeviceId.get(ext.deviceId);
371
+ if (!existing || ext.connectedAt > existing.connectedAt) {
372
+ byDeviceId.set(ext.deviceId, ext);
373
+ }
374
+ }
375
+ return [...byDeviceId.values()];
376
+ }
377
+
378
+ /**
379
+ * Select an extension by device ID for per-message targeted routing.
380
+ */
381
+ private selectExtension(deviceId: string): void {
382
+ const { logger, serverName } = this.context;
383
+ this.selectedDeviceId = deviceId;
384
+ this.previousSelectedDeviceId = undefined;
385
+ logger.info(
386
+ `[${serverName}] Selected Chrome extension: ${deviceId.slice(0, 8)}...`,
387
+ );
388
+ }
389
+
390
+ /**
391
+ * Check if an extension might be on the same machine as this MCP client
392
+ * by comparing OS platform. Extensions can't provide a real hostname from
393
+ * the service worker sandbox, so platform is a weak heuristic. The profile
394
+ * email is the primary differentiator shown in the selection dialog.
395
+ */
396
+ private isLocalExtension(ext: ChromeExtensionInfo): boolean {
397
+ if (!ext.osPlatform) return false;
398
+ return ext.osPlatform === localPlatformLabel();
399
+ }
400
+
401
+ /**
402
+ * Returns a promise that resolves to `true` when a peer_connected event
403
+ * fires, or `false` if the timeout elapses first.
404
+ */
405
+ private waitForPeerConnected(timeoutMs: number): Promise<boolean> {
406
+ return new Promise((resolve) => {
407
+ const timer = setTimeout(() => {
408
+ this.peerConnectedWaiters = this.peerConnectedWaiters.filter(
409
+ (w) => w !== onPeer,
410
+ );
411
+ resolve(false);
412
+ }, timeoutMs);
413
+
414
+ const onPeer = (arrived: boolean) => {
415
+ clearTimeout(timer);
416
+ resolve(arrived);
417
+ };
418
+
419
+ this.peerConnectedWaiters.push(onPeer);
420
+ });
421
+ }
422
+
423
+ /**
424
+ * Broadcast a pairing request to all connected extensions.
425
+ * Non-blocking — the pairing_response handler will select the extension.
426
+ */
427
+ private broadcastPairingRequest(): void {
428
+ const requestId = crypto.randomUUID();
429
+ this.pendingPairingRequestId = requestId;
430
+ this.ws?.send(
431
+ JSON.stringify({
432
+ type: "pairing_request",
433
+ request_id: requestId,
434
+ client_type: this.context.clientTypeId,
435
+ }),
436
+ );
437
+ }
438
+
439
+ /**
440
+ * Switch to a different browser. Broadcasts a pairing request and blocks
441
+ * until a response arrives or timeout (120s). Returns the paired extension
442
+ * info, or null on timeout.
443
+ */
444
+ public async switchBrowser(): Promise<
445
+ | {
446
+ deviceId: string;
447
+ name: string;
448
+ }
449
+ | "no_other_browsers"
450
+ | null
451
+ > {
452
+ const extensions = await this.queryBridgeExtensions();
453
+ const currentDeviceId =
454
+ this.selectedDeviceId ?? this.previousSelectedDeviceId;
455
+ if (
456
+ extensions.length === 0 ||
457
+ (extensions.length === 1 &&
458
+ (!currentDeviceId || extensions[0]!.deviceId === currentDeviceId))
459
+ ) {
460
+ return "no_other_browsers";
461
+ }
462
+
463
+ this.previousSelectedDeviceId = this.selectedDeviceId;
464
+ this.selectedDeviceId = undefined;
465
+ this.discoveryComplete = false;
466
+ this.pairingInProgress = false;
467
+
468
+ const requestId = crypto.randomUUID();
469
+ this.pendingPairingRequestId = requestId;
470
+ if (this.ws?.readyState !== WebSocket.OPEN) {
471
+ return null;
472
+ }
473
+ this.ws.send(
474
+ JSON.stringify({
475
+ type: "pairing_request",
476
+ request_id: requestId,
477
+ client_type: this.context.clientTypeId,
478
+ }),
479
+ );
480
+
481
+ // Resolve any previous pending switch so the caller doesn't hang forever
482
+ if (this.pendingSwitchResolve) {
483
+ this.pendingSwitchResolve(null);
484
+ }
485
+
486
+ // Block for switch_browser since user is actively engaged
487
+ return new Promise((resolve) => {
488
+ const timer = setTimeout(() => {
489
+ if (this.pendingPairingRequestId === requestId) {
490
+ this.pendingPairingRequestId = undefined;
491
+ }
492
+ this.pendingSwitchResolve = null;
493
+ resolve(null);
494
+ }, 120_000);
495
+
496
+ this.pendingSwitchResolve = (result) => {
497
+ clearTimeout(timer);
498
+ this.pendingSwitchResolve = null;
499
+ resolve(result);
500
+ };
501
+ });
502
+ }
503
+
504
+ private async connect(): Promise<void> {
505
+ const { logger, serverName, bridgeConfig, trackEvent } = this.context;
506
+
507
+ if (!bridgeConfig) {
508
+ logger.error(`[${serverName}] No bridge config provided`);
509
+ return;
510
+ }
511
+
512
+ if (this.connecting) {
513
+ return;
514
+ }
515
+
516
+ this.connecting = true;
517
+ this.authenticated = false;
518
+ this.connectionStartTime = Date.now();
519
+ this.closeSocket();
520
+
521
+ // Get user ID for the connection path
522
+ let userId: string;
523
+ let token: string | undefined;
524
+
525
+ if (bridgeConfig.devUserId) {
526
+ userId = bridgeConfig.devUserId;
527
+ logger.debug(`[${serverName}] Using dev user ID for bridge connection`);
528
+ } else {
529
+ logger.debug(`[${serverName}] Fetching user ID for bridge connection`);
530
+ const fetchedUserId = await bridgeConfig.getUserId();
531
+ if (!fetchedUserId) {
532
+ const durationMs = Date.now() - this.connectionStartTime;
533
+ logger.error(
534
+ `[${serverName}] No user ID available after ${durationMs}ms`,
535
+ );
536
+ trackEvent?.("chrome_bridge_connection_failed", {
537
+ duration_ms: durationMs,
538
+ error_type: "no_user_id",
539
+ reconnect_attempt: this.reconnectAttempts,
540
+ });
541
+ this.connecting = false;
542
+ this.context.onAuthenticationError?.();
543
+ return;
544
+ }
545
+ userId = fetchedUserId;
546
+
547
+ logger.debug(
548
+ `[${serverName}] Fetching OAuth token for bridge connection`,
549
+ );
550
+ token = await bridgeConfig.getOAuthToken();
551
+ if (!token) {
552
+ const durationMs = Date.now() - this.connectionStartTime;
553
+ logger.error(
554
+ `[${serverName}] No OAuth token available after ${durationMs}ms`,
555
+ );
556
+ trackEvent?.("chrome_bridge_connection_failed", {
557
+ duration_ms: durationMs,
558
+ error_type: "no_oauth_token",
559
+ reconnect_attempt: this.reconnectAttempts,
560
+ });
561
+ this.connecting = false;
562
+ this.context.onAuthenticationError?.();
563
+ return;
564
+ }
565
+ }
566
+
567
+ // Connect to user-specific endpoint: /chrome/<user_id>
568
+ const wsUrl = `${bridgeConfig.url}/chrome/${userId}`;
569
+ logger.info(`[${serverName}] Connecting to bridge: ${wsUrl}`);
570
+
571
+ // Track connection started
572
+ trackEvent?.("chrome_bridge_connection_started", {
573
+ bridge_url: wsUrl,
574
+ });
575
+
576
+ try {
577
+ this.ws = new WebSocket(wsUrl);
578
+ } catch (error) {
579
+ const durationMs = Date.now() - this.connectionStartTime;
580
+ logger.error(
581
+ `[${serverName}] Failed to create WebSocket after ${durationMs}ms:`,
582
+ error,
583
+ );
584
+ trackEvent?.("chrome_bridge_connection_failed", {
585
+ duration_ms: durationMs,
586
+ error_type: "websocket_error",
587
+ reconnect_attempt: this.reconnectAttempts,
588
+ });
589
+ this.connecting = false;
590
+ this.scheduleReconnect();
591
+ return;
592
+ }
593
+
594
+ this.ws.on("open", () => {
595
+ logger.info(
596
+ `[${serverName}] WebSocket connected, sending connect message`,
597
+ );
598
+
599
+ // First message must be connect (same format as office path)
600
+ const connectMessage: Record<string, unknown> = {
601
+ type: "connect",
602
+ client_type: this.context.clientTypeId,
603
+ };
604
+
605
+ if (bridgeConfig.devUserId) {
606
+ connectMessage.dev_user_id = bridgeConfig.devUserId;
607
+ } else {
608
+ connectMessage.oauth_token = token;
609
+ }
610
+
611
+ this.ws?.send(JSON.stringify(connectMessage));
612
+ });
613
+
614
+ this.ws.on("message", (data: WebSocket.Data) => {
615
+ try {
616
+ const message = JSON.parse(data.toString()) as Record<string, unknown>;
617
+ logger.debug(
618
+ `[${serverName}] Bridge received: ${JSON.stringify(message)}`,
619
+ );
620
+ this.handleMessage(message);
621
+ } catch (error) {
622
+ logger.error(`[${serverName}] Failed to parse bridge message:`, error);
623
+ }
624
+ });
625
+
626
+ this.ws.on("close", (code: number) => {
627
+ const durationSinceConnect = this.connectionEstablishedTime
628
+ ? Date.now() - this.connectionEstablishedTime
629
+ : 0;
630
+ logger.info(
631
+ `[${serverName}] Bridge connection closed (code: ${code}, duration: ${durationSinceConnect}ms)`,
632
+ );
633
+ trackEvent?.("chrome_bridge_disconnected", {
634
+ close_code: code,
635
+ duration_since_connect_ms: durationSinceConnect,
636
+ reconnect_attempt: this.reconnectAttempts + 1,
637
+ });
638
+ this.connected = false;
639
+ this.authenticated = false;
640
+ this.connecting = false;
641
+ this.connectionEstablishedTime = null;
642
+ this.scheduleReconnect();
643
+ });
644
+
645
+ this.ws.on("error", (error: Error) => {
646
+ const durationMs = this.connectionStartTime
647
+ ? Date.now() - this.connectionStartTime
648
+ : 0;
649
+ logger.error(
650
+ `[${serverName}] Bridge WebSocket error after ${durationMs}ms: ${error.message}`,
651
+ );
652
+ trackEvent?.("chrome_bridge_connection_failed", {
653
+ duration_ms: durationMs,
654
+ error_type: "websocket_error",
655
+ reconnect_attempt: this.reconnectAttempts,
656
+ });
657
+ this.connected = false;
658
+ this.authenticated = false;
659
+ this.connecting = false;
660
+ });
661
+ }
662
+
663
+ private handleMessage(message: Record<string, unknown>): void {
664
+ const { logger, serverName, trackEvent } = this.context;
665
+
666
+ switch (message.type) {
667
+ case "paired": {
668
+ const durationMs = this.connectionStartTime
669
+ ? Date.now() - this.connectionStartTime
670
+ : 0;
671
+ logger.info(
672
+ `[${serverName}] Paired with Chrome extension (duration: ${durationMs}ms)`,
673
+ );
674
+ this.connected = true;
675
+ this.authenticated = true;
676
+ this.connecting = false;
677
+ this.reconnectAttempts = 0;
678
+ this.connectionEstablishedTime = Date.now();
679
+ trackEvent?.("chrome_bridge_connection_succeeded", {
680
+ duration_ms: durationMs,
681
+ status: "paired",
682
+ });
683
+ break;
684
+ }
685
+
686
+ case "waiting": {
687
+ const durationMs = this.connectionStartTime
688
+ ? Date.now() - this.connectionStartTime
689
+ : 0;
690
+ logger.info(
691
+ `[${serverName}] Waiting for Chrome extension to connect (duration: ${durationMs}ms)`,
692
+ );
693
+ this.connected = true;
694
+ this.authenticated = true;
695
+ this.connecting = false;
696
+ this.reconnectAttempts = 0;
697
+ this.connectionEstablishedTime = Date.now();
698
+ trackEvent?.("chrome_bridge_connection_succeeded", {
699
+ duration_ms: durationMs,
700
+ status: "waiting",
701
+ });
702
+ break;
703
+ }
704
+
705
+ case "peer_connected":
706
+ logger.info(`[${serverName}] Chrome extension connected to bridge`);
707
+ trackEvent?.("chrome_bridge_peer_connected", null);
708
+ // If no extension selected, mark discovery as needed (next tool call will discover)
709
+ if (!this.selectedDeviceId) {
710
+ this.discoveryComplete = false;
711
+ }
712
+ // Auto-reselect if the previously selected extension reconnected (e.g., service worker restart)
713
+ if (
714
+ this.previousSelectedDeviceId &&
715
+ message.deviceId === this.previousSelectedDeviceId &&
716
+ !this.pendingSwitchResolve
717
+ ) {
718
+ logger.info(
719
+ `[${serverName}] Previously selected extension reconnected, auto-reselecting`,
720
+ );
721
+ this.selectExtension(this.previousSelectedDeviceId);
722
+ this.previousSelectedDeviceId = undefined;
723
+ }
724
+ if (this.peerConnectedWaiters.length > 0) {
725
+ const waiters = this.peerConnectedWaiters;
726
+ this.peerConnectedWaiters = [];
727
+ for (const waiter of waiters) {
728
+ waiter(true);
729
+ }
730
+ }
731
+ break;
732
+
733
+ case "peer_disconnected":
734
+ logger.info(
735
+ `[${serverName}] Chrome extension disconnected from bridge`,
736
+ );
737
+ trackEvent?.("chrome_bridge_peer_disconnected", null);
738
+ // If the selected extension disconnected, clear selection for re-discovery
739
+ if (message.deviceId && message.deviceId === this.selectedDeviceId) {
740
+ logger.info(
741
+ `[${serverName}] Selected extension disconnected, clearing selection`,
742
+ );
743
+ this.previousSelectedDeviceId = this.selectedDeviceId;
744
+ this.selectedDeviceId = undefined;
745
+ this.discoveryComplete = false;
746
+ }
747
+ break;
748
+
749
+ case "extensions_list":
750
+ // Response to list_extensions — resolve pending discovery
751
+ if (this.pendingDiscovery) {
752
+ clearTimeout(this.pendingDiscovery.timeout);
753
+ this.pendingDiscovery.resolve(
754
+ (message.extensions as ChromeExtensionInfo[]) ?? [],
755
+ );
756
+ this.pendingDiscovery = null;
757
+ }
758
+ break;
759
+
760
+ case "pairing_response": {
761
+ const requestId = message.request_id as string;
762
+ const responseDeviceId = message.device_id as string;
763
+ const responseName = message.name as string;
764
+ if (
765
+ this.pendingPairingRequestId === requestId &&
766
+ responseDeviceId &&
767
+ responseName
768
+ ) {
769
+ this.pendingPairingRequestId = undefined;
770
+ this.pairingInProgress = false;
771
+ this.selectExtension(responseDeviceId);
772
+ this.context.onExtensionPaired?.(responseDeviceId, responseName);
773
+ logger.info(
774
+ `[${serverName}] Paired with "${responseName}" (${responseDeviceId.slice(0, 8)})`,
775
+ );
776
+ if (this.pendingSwitchResolve) {
777
+ this.pendingSwitchResolve({
778
+ deviceId: responseDeviceId,
779
+ name: responseName,
780
+ });
781
+ this.pendingSwitchResolve = null;
782
+ }
783
+ }
784
+ break;
785
+ }
786
+
787
+ case "ping":
788
+ this.ws?.send(JSON.stringify({ type: "pong" }));
789
+ break;
790
+
791
+ case "pong":
792
+ // Response to our keepalive, nothing to do
793
+ break;
794
+
795
+ case "tool_result":
796
+ this.handleToolResult(message);
797
+ break;
798
+
799
+ case "permission_request":
800
+ void this.handlePermissionRequest(message);
801
+ break;
802
+
803
+ case "notification":
804
+ if (this.notificationHandler) {
805
+ this.notificationHandler({
806
+ method: message.method as string,
807
+ params: message.params as Record<string, unknown> | undefined,
808
+ });
809
+ }
810
+ break;
811
+
812
+ case "error":
813
+ logger.warn(`[${serverName}] Bridge error: ${message.error}`);
814
+ // If we had a selected extension, the error may indicate it's gone
815
+ // (e.g., extension disconnected between list and select). Clear state
816
+ // so the next tool call re-discovers.
817
+ if (this.selectedDeviceId) {
818
+ this.selectedDeviceId = undefined;
819
+ this.discoveryComplete = false;
820
+ }
821
+ break;
822
+
823
+ default:
824
+ logger.warn(
825
+ `[${serverName}] Unrecognized bridge message type: ${message.type}`,
826
+ );
827
+ }
828
+ }
829
+
830
+ private async handlePermissionRequest(
831
+ message: Record<string, unknown>,
832
+ ): Promise<void> {
833
+ const { logger, serverName } = this.context;
834
+ const toolUseId = message.tool_use_id as string;
835
+ const requestId = message.request_id as string;
836
+
837
+ if (!toolUseId || !requestId) {
838
+ logger.warn(
839
+ `[${serverName}] permission_request missing tool_use_id or request_id`,
840
+ );
841
+ return;
842
+ }
843
+
844
+ const pending = this.pendingCalls.get(toolUseId);
845
+ if (!pending?.onPermissionRequest) {
846
+ // Don't auto-deny — the bridge broadcasts permission_request to all
847
+ // connected MCP clients, and only the client that made the tool call
848
+ // has the pending entry. Auto-denying here would race with the correct
849
+ // client's handler when multiple Desktop instances are connected.
850
+ logger.debug(
851
+ `[${serverName}] Ignoring permission_request for unknown tool_use_id ${toolUseId.slice(0, 8)} (not our call)`,
852
+ );
853
+ return;
854
+ }
855
+
856
+ const request: BridgePermissionRequest = {
857
+ toolUseId,
858
+ requestId,
859
+ toolType: (message.tool_type as string) ?? "unknown",
860
+ url: (message.url as string) ?? "",
861
+ actionData: message.action_data as Record<string, unknown> | undefined,
862
+ };
863
+
864
+ try {
865
+ const allowed = await pending.onPermissionRequest(request);
866
+ this.sendPermissionResponse(requestId, allowed);
867
+ } catch (error) {
868
+ logger.error(`[${serverName}] Error handling permission request:`, error);
869
+ this.sendPermissionResponse(requestId, false);
870
+ }
871
+ }
872
+
873
+ private sendPermissionResponse(requestId: string, allowed: boolean): void {
874
+ if (this.ws?.readyState === WebSocket.OPEN) {
875
+ const message: Record<string, unknown> = {
876
+ type: "permission_response",
877
+ request_id: requestId,
878
+ allowed,
879
+ };
880
+ if (this.selectedDeviceId) {
881
+ message.target_device_id = this.selectedDeviceId;
882
+ }
883
+ this.ws.send(JSON.stringify(message));
884
+ }
885
+ }
886
+
887
+ private handleToolResult(message: Record<string, unknown>): void {
888
+ const { logger, serverName, trackEvent } = this.context;
889
+ const toolUseId = message.tool_use_id as string;
890
+ if (!toolUseId) {
891
+ logger.warn(`[${serverName}] Received tool_result without tool_use_id`);
892
+ return;
893
+ }
894
+
895
+ const pending = this.pendingCalls.get(toolUseId);
896
+ if (!pending) {
897
+ logger.debug(
898
+ `[${serverName}] Received tool_result for unknown call: ${toolUseId.slice(0, 8)}`,
899
+ );
900
+ return;
901
+ }
902
+
903
+ const durationMs = Date.now() - pending.startTime;
904
+
905
+ // Normalize bridge response format to match socket client format.
906
+ // Bridge sends: { type, tool_use_id, content: [...], is_error?: boolean }
907
+ // Socket sends: { result: { content: [...] } } or { error: { content: [...] } }
908
+ const normalized = this.normalizeBridgeResponse(message);
909
+ const isError = Boolean(message.is_error) || "error" in normalized;
910
+
911
+ if (pending.isTabsContext && !this.selectedDeviceId) {
912
+ // No extension selected: collect results from all extensions (pre-selection / backward compat)
913
+ pending.results.push(normalized);
914
+ // Don't resolve yet — let the timer handle collection
915
+ } else {
916
+ // For other tools, resolve on first result
917
+ clearTimeout(pending.timer);
918
+ this.pendingCalls.delete(toolUseId);
919
+
920
+ if (isError) {
921
+ // Extract error message for telemetry
922
+ const errorContent = (normalized as { error?: { content?: unknown[] } })
923
+ .error?.content;
924
+ let errorMessage = "Unknown error";
925
+ if (Array.isArray(errorContent)) {
926
+ const textItem = errorContent.find(
927
+ (item) =>
928
+ typeof item === "object" && item !== null && "text" in item,
929
+ ) as { text?: string } | undefined;
930
+ if (textItem?.text) {
931
+ errorMessage = textItem.text.slice(0, 200);
932
+ }
933
+ }
934
+
935
+ logger.warn(
936
+ `[${serverName}] Tool call error: ${pending.toolName} (${toolUseId.slice(0, 8)}) after ${durationMs}ms`,
937
+ );
938
+ trackEvent?.("chrome_bridge_tool_call_error", {
939
+ tool_name: pending.toolName,
940
+ tool_use_id: toolUseId,
941
+ duration_ms: durationMs,
942
+ error_message: errorMessage,
943
+ });
944
+ } else {
945
+ logger.debug(
946
+ `[${serverName}] Tool call completed: ${pending.toolName} (${toolUseId.slice(0, 8)}) in ${durationMs}ms`,
947
+ );
948
+ trackEvent?.("chrome_bridge_tool_call_completed", {
949
+ tool_name: pending.toolName,
950
+ tool_use_id: toolUseId,
951
+ duration_ms: durationMs,
952
+ });
953
+ }
954
+
955
+ pending.resolve(normalized);
956
+ }
957
+ }
958
+
959
+ private normalizeBridgeResponse(
960
+ message: Record<string, unknown>,
961
+ ): Record<string, unknown> {
962
+ // Already has result/error wrapper (socket format) — pass through
963
+ if (message.result || message.error) {
964
+ return message;
965
+ }
966
+
967
+ // Bridge format has content at top level — wrap it
968
+ if (message.content) {
969
+ if (message.is_error) {
970
+ return { error: { content: message.content } };
971
+ }
972
+ return { result: { content: message.content } };
973
+ }
974
+
975
+ return message;
976
+ }
977
+
978
+ private mergeTabsResults(results: unknown[]): unknown {
979
+ const mergedTabs: unknown[] = [];
980
+
981
+ for (const result of results) {
982
+ const msg = result as Record<string, unknown>;
983
+ const resultData = msg.result as
984
+ | { content?: Array<{ type: string; text?: string }> }
985
+ | undefined;
986
+ const content = resultData?.content;
987
+
988
+ if (!content || !Array.isArray(content)) continue;
989
+
990
+ for (const item of content) {
991
+ if (item.type === "text" && item.text) {
992
+ try {
993
+ const parsed = JSON.parse(item.text);
994
+ if (Array.isArray(parsed)) {
995
+ mergedTabs.push(...parsed);
996
+ } else if (
997
+ parsed?.availableTabs &&
998
+ Array.isArray(parsed.availableTabs)
999
+ ) {
1000
+ mergedTabs.push(...parsed.availableTabs);
1001
+ }
1002
+ } catch {
1003
+ // Not JSON, skip
1004
+ }
1005
+ }
1006
+ }
1007
+ }
1008
+
1009
+ if (mergedTabs.length > 0) {
1010
+ const tabListText = mergedTabs
1011
+ .map((t) => {
1012
+ const tab = t as { tabId: number; title: string; url: string };
1013
+ return ` \u2022 tabId ${tab.tabId}: "${tab.title}" (${tab.url})`;
1014
+ })
1015
+ .join("\n");
1016
+
1017
+ return {
1018
+ result: {
1019
+ content: [
1020
+ {
1021
+ type: "text",
1022
+ text: JSON.stringify({ availableTabs: mergedTabs }),
1023
+ },
1024
+ {
1025
+ type: "text",
1026
+ text: `\n\nTab Context:\n- Available tabs:\n${tabListText}`,
1027
+ },
1028
+ ],
1029
+ },
1030
+ };
1031
+ }
1032
+
1033
+ // Return first result as fallback
1034
+ return results[0];
1035
+ }
1036
+
1037
+ private scheduleReconnect(): void {
1038
+ const { logger, serverName, trackEvent } = this.context;
1039
+
1040
+ if (this.reconnectTimer) return;
1041
+
1042
+ this.reconnectAttempts++;
1043
+
1044
+ if (this.reconnectAttempts > 100) {
1045
+ logger.warn(
1046
+ `[${serverName}] Giving up bridge reconnection after 100 attempts`,
1047
+ );
1048
+ trackEvent?.("chrome_bridge_reconnect_exhausted", {
1049
+ total_attempts: 100,
1050
+ });
1051
+ this.reconnectAttempts = 0;
1052
+ return;
1053
+ }
1054
+
1055
+ const delay = Math.min(
1056
+ 2000 * Math.pow(1.5, this.reconnectAttempts - 1),
1057
+ 30_000,
1058
+ );
1059
+
1060
+ if (this.reconnectAttempts <= 10 || this.reconnectAttempts % 10 === 0) {
1061
+ logger.info(
1062
+ `[${serverName}] Bridge reconnecting in ${Math.round(delay)}ms (attempt ${this.reconnectAttempts})`,
1063
+ );
1064
+ }
1065
+
1066
+ this.reconnectTimer = setTimeout(() => {
1067
+ this.reconnectTimer = null;
1068
+ void this.connect();
1069
+ }, delay);
1070
+ }
1071
+
1072
+ private closeSocket(): void {
1073
+ if (this.ws) {
1074
+ this.ws.removeAllListeners();
1075
+ this.ws.close();
1076
+ this.ws = null;
1077
+ }
1078
+ this.connected = false;
1079
+ this.authenticated = false;
1080
+ // Clear extension selection state so reconnections start fresh
1081
+ this.selectedDeviceId = undefined;
1082
+ this.discoveryComplete = false;
1083
+ this.pendingPairingRequestId = undefined;
1084
+ this.pairingInProgress = false;
1085
+ if (this.pendingSwitchResolve) {
1086
+ this.pendingSwitchResolve(null);
1087
+ this.pendingSwitchResolve = null;
1088
+ }
1089
+ if (this.pendingDiscovery) {
1090
+ clearTimeout(this.pendingDiscovery.timeout);
1091
+ this.pendingDiscovery.resolve([]);
1092
+ this.pendingDiscovery = null;
1093
+ }
1094
+ // Unblock any in-progress waitForPeerConnected so it doesn't hang until its timeout
1095
+ if (this.peerConnectedWaiters.length > 0) {
1096
+ const waiters = this.peerConnectedWaiters;
1097
+ this.peerConnectedWaiters = [];
1098
+ for (const waiter of waiters) {
1099
+ waiter(false);
1100
+ }
1101
+ }
1102
+ }
1103
+
1104
+ private cleanup(): void {
1105
+ if (this.reconnectTimer) {
1106
+ clearTimeout(this.reconnectTimer);
1107
+ this.reconnectTimer = null;
1108
+ }
1109
+
1110
+ // Reject all pending calls
1111
+ for (const [id, pending] of this.pendingCalls) {
1112
+ clearTimeout(pending.timer);
1113
+ pending.reject(new SocketConnectionError("Bridge client disconnected"));
1114
+ this.pendingCalls.delete(id);
1115
+ }
1116
+
1117
+ this.closeSocket();
1118
+ this.reconnectAttempts = 0;
1119
+ }
1120
+ }
1121
+
1122
+ export function createBridgeClient(
1123
+ context: ClaudeForChromeContext,
1124
+ ): BridgeClient {
1125
+ return new BridgeClient(context);
1126
+ }