codex-chrome-bridge 0.1.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +281 -0
  3. package/package.json +49 -0
  4. package/src/bridge.js +2931 -0
package/src/bridge.js ADDED
@@ -0,0 +1,2931 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execFile } from 'node:child_process';
4
+ import fs from 'node:fs';
5
+ import fsp from 'node:fs/promises';
6
+ import net from 'node:net';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import process from 'node:process';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { promisify } from 'node:util';
12
+
13
+ const execFileAsync = promisify(execFile);
14
+
15
+ function envOrDefault(name, fallback) {
16
+ return process.env[name] || fallback;
17
+ }
18
+
19
+ function envInt(name, fallback) {
20
+ const value = process.env[name];
21
+ if (!value) {
22
+ return fallback;
23
+ }
24
+
25
+ const parsed = Number.parseInt(value, 10);
26
+ return Number.isFinite(parsed) ? parsed : fallback;
27
+ }
28
+
29
+ const HOME = os.homedir();
30
+ const USER = os.userInfo().username;
31
+ const CLIENT_ID = 'codex-chrome-bridge';
32
+ const SOCKET_ROOT = envOrDefault(
33
+ 'CLAUDE_BRIDGE_SOCKET_ROOT',
34
+ path.join('/tmp', `claude-mcp-browser-bridge-${USER}`),
35
+ );
36
+ const MANIFEST_PATH = envOrDefault(
37
+ 'CLAUDE_BRIDGE_MANIFEST_PATH',
38
+ path.join(
39
+ HOME,
40
+ 'Library',
41
+ 'Application Support',
42
+ 'Google',
43
+ 'Chrome',
44
+ 'NativeMessagingHosts',
45
+ 'com.anthropic.claude_code_browser_extension.json',
46
+ ),
47
+ );
48
+ const LAUNCHER_PATH = envOrDefault(
49
+ 'CLAUDE_BRIDGE_LAUNCHER_PATH',
50
+ path.join(HOME, '.claude', 'chrome', 'chrome-native-host'),
51
+ );
52
+ const DISCOVERY_TIMEOUT_MS = envInt('CLAUDE_BRIDGE_DISCOVERY_TIMEOUT_MS', 5000);
53
+ const TOOL_TIMEOUT_MS = envInt('CLAUDE_BRIDGE_TOOL_TIMEOUT_MS', 15000);
54
+ const MCP_TOOL_CALL_TIMEOUT_MS = envInt(
55
+ 'CLAUDE_BRIDGE_MCP_TOOL_CALL_TIMEOUT_MS',
56
+ Math.max(TOOL_TIMEOUT_MS + 5000, 20000),
57
+ );
58
+ const MCP_TRACE_PATH = process.env.CLAUDE_BRIDGE_MCP_TRACE_PATH || null;
59
+ const SUPPORTED_PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
60
+ const DEFAULT_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS[0];
61
+ const SESSION_SCOPE = {
62
+ sessionId: `codex-bridge-${process.pid}-${Date.now().toString(36)}`,
63
+ displayName: 'Codex (MCP)',
64
+ };
65
+ const SHARED_IMAGE_CACHE = new Map();
66
+ const MAX_SHARED_IMAGE_CACHE_ENTRIES = 24;
67
+
68
+ class BridgeError extends Error {
69
+ constructor(stage, message, detail = undefined) {
70
+ super(message);
71
+ this.name = 'BridgeError';
72
+ this.stage = stage;
73
+ this.detail = detail;
74
+ }
75
+ }
76
+
77
+ function traceMcp(direction, payload) {
78
+ if (!MCP_TRACE_PATH) {
79
+ return;
80
+ }
81
+
82
+ try {
83
+ fs.appendFileSync(
84
+ MCP_TRACE_PATH,
85
+ `${JSON.stringify({
86
+ timestamp: new Date().toISOString(),
87
+ pid: process.pid,
88
+ direction,
89
+ payload,
90
+ })}\n`,
91
+ 'utf8',
92
+ );
93
+ } catch {
94
+ // Trace logging must never interfere with the MCP transport.
95
+ }
96
+ }
97
+
98
+ function safeJsonParse(text) {
99
+ try {
100
+ return JSON.parse(text);
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ function delay(ms) {
107
+ return new Promise((resolve) => setTimeout(resolve, ms));
108
+ }
109
+
110
+ const DEFAULT_MANIFEST_PATH = path.join(
111
+ HOME,
112
+ 'Library',
113
+ 'Application Support',
114
+ 'Google',
115
+ 'Chrome',
116
+ 'NativeMessagingHosts',
117
+ 'com.anthropic.claude_code_browser_extension.json',
118
+ );
119
+ const DEFAULT_LAUNCHER_PATH = path.join(HOME, '.claude', 'chrome', 'chrome-native-host');
120
+
121
+ async function readTextIfExists(filePath) {
122
+ try {
123
+ return await fsp.readFile(filePath, 'utf8');
124
+ } catch (error) {
125
+ if (error.code === 'ENOENT') {
126
+ return null;
127
+ }
128
+
129
+ throw error;
130
+ }
131
+ }
132
+
133
+ async function readJsonIfExists(filePath) {
134
+ const text = await readTextIfExists(filePath);
135
+ return text ? JSON.parse(text) : null;
136
+ }
137
+
138
+ async function execText(command, args) {
139
+ const { stdout } = await execFileAsync(command, args, {
140
+ encoding: 'utf8',
141
+ maxBuffer: 1024 * 1024 * 16,
142
+ });
143
+
144
+ return stdout;
145
+ }
146
+
147
+ async function statSocket(filePath) {
148
+ try {
149
+ const stat = await fsp.lstat(filePath);
150
+ return stat.isSocket() ? stat : null;
151
+ } catch (error) {
152
+ if (error.code === 'ENOENT') {
153
+ return null;
154
+ }
155
+
156
+ throw error;
157
+ }
158
+ }
159
+
160
+ function parseLauncherTarget(scriptText) {
161
+ if (!scriptText) {
162
+ return null;
163
+ }
164
+
165
+ const quoted = scriptText.match(/exec\s+"([^"]+)"\s+--chrome-native-host/);
166
+ if (quoted) {
167
+ return quoted[1];
168
+ }
169
+
170
+ const unquoted = scriptText.match(/exec\s+(\S+)\s+--chrome-native-host/);
171
+ return unquoted ? unquoted[1] : null;
172
+ }
173
+
174
+ function parseVersionFromBinary(binaryPath) {
175
+ const match = binaryPath?.match(/\/versions\/([^/]+)$/);
176
+ return match ? match[1] : null;
177
+ }
178
+
179
+ function mimeTypeFromPath(filePath) {
180
+ const extension = path.extname(filePath).toLowerCase();
181
+ const known = {
182
+ '.png': 'image/png',
183
+ '.jpg': 'image/jpeg',
184
+ '.jpeg': 'image/jpeg',
185
+ '.gif': 'image/gif',
186
+ '.webp': 'image/webp',
187
+ '.svg': 'image/svg+xml',
188
+ '.txt': 'text/plain',
189
+ '.json': 'application/json',
190
+ '.pdf': 'application/pdf',
191
+ '.csv': 'text/csv',
192
+ };
193
+ return known[extension] ?? 'application/octet-stream';
194
+ }
195
+
196
+ async function discoverHostProcesses() {
197
+ const stdout = await execText('ps', ['-axo', 'pid=,ppid=,command=']);
198
+ const lines = stdout.split('\n').filter(Boolean);
199
+ const hostLines = lines.filter((line) => line.includes('--chrome-native-host'));
200
+ const processes = [];
201
+
202
+ for (const line of hostLines) {
203
+ const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.*)$/);
204
+ if (!match) {
205
+ continue;
206
+ }
207
+
208
+ const pid = Number.parseInt(match[1], 10);
209
+ const ppid = Number.parseInt(match[2], 10);
210
+ const command = match[3];
211
+ const binaryPath = command.replace(/\s+--chrome-native-host(?:\s+.*)?$/, '');
212
+ const socketPath = path.join(SOCKET_ROOT, `${pid}.sock`);
213
+ const socketStat = await statSocket(socketPath);
214
+
215
+ processes.push({
216
+ pid,
217
+ ppid,
218
+ command,
219
+ binaryPath,
220
+ binaryVersion: parseVersionFromBinary(binaryPath),
221
+ socketPath,
222
+ socketExists: Boolean(socketStat),
223
+ });
224
+ }
225
+
226
+ return processes.sort((a, b) => b.pid - a.pid);
227
+ }
228
+
229
+ async function discoverEnvironment() {
230
+ const [manifest, launcherScript, processes] = await Promise.all([
231
+ readJsonIfExists(MANIFEST_PATH),
232
+ readTextIfExists(LAUNCHER_PATH),
233
+ discoverHostProcesses(),
234
+ ]);
235
+
236
+ const launcherTarget = parseLauncherTarget(launcherScript);
237
+ const selectedProcess =
238
+ processes.find((entry) => entry.socketExists) ?? processes[0] ?? null;
239
+ const warnings = [];
240
+
241
+ if (!manifest) {
242
+ warnings.push('chrome_manifest_missing');
243
+ }
244
+ if (MANIFEST_PATH !== DEFAULT_MANIFEST_PATH) {
245
+ warnings.push('manifest_path_overridden');
246
+ }
247
+
248
+ if (!launcherScript) {
249
+ warnings.push('launcher_script_missing');
250
+ }
251
+ if (LAUNCHER_PATH !== DEFAULT_LAUNCHER_PATH) {
252
+ warnings.push('launcher_path_overridden');
253
+ }
254
+
255
+ if (!selectedProcess) {
256
+ warnings.push('native_host_process_missing');
257
+ }
258
+
259
+ if (selectedProcess && !selectedProcess.socketExists) {
260
+ warnings.push('socket_missing_for_selected_process');
261
+ }
262
+
263
+ if (
264
+ launcherTarget &&
265
+ selectedProcess?.binaryPath &&
266
+ launcherTarget !== selectedProcess.binaryPath
267
+ ) {
268
+ warnings.push('launcher_target_differs_from_live_host');
269
+ }
270
+
271
+ return {
272
+ manifestPath: MANIFEST_PATH,
273
+ launcherPath: LAUNCHER_PATH,
274
+ launcherTarget,
275
+ launcherVersion: parseVersionFromBinary(launcherTarget),
276
+ manifest,
277
+ processes,
278
+ selectedProcess,
279
+ warnings,
280
+ };
281
+ }
282
+
283
+ function summarizeContent(content = []) {
284
+ return content.map((item) => {
285
+ if (item.type === 'text') {
286
+ return item.text;
287
+ }
288
+
289
+ if (item.type === 'image') {
290
+ const mediaType = item.source?.media_type ?? 'unknown';
291
+ return `[image:${mediaType}]`;
292
+ }
293
+
294
+ return `[${item.type ?? 'unknown'}]`;
295
+ });
296
+ }
297
+
298
+ function findStructuredJson(content = []) {
299
+ for (const item of content) {
300
+ if (item.type !== 'text') {
301
+ continue;
302
+ }
303
+
304
+ const trimmed = item.text.trim();
305
+ if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
306
+ continue;
307
+ }
308
+
309
+ const parsed = safeJsonParse(trimmed);
310
+ if (parsed) {
311
+ return parsed;
312
+ }
313
+ }
314
+
315
+ return null;
316
+ }
317
+
318
+ function extractTextItems(content = []) {
319
+ return content
320
+ .filter((item) => item?.type === 'text' && typeof item.text === 'string')
321
+ .map((item) => item.text);
322
+ }
323
+
324
+ function extractPrimaryText(content = []) {
325
+ return extractTextItems(content)[0] ?? null;
326
+ }
327
+
328
+ function normalizeToolContent(content = []) {
329
+ const structured = findStructuredJson(content);
330
+ if (structured !== null) {
331
+ return structured;
332
+ }
333
+
334
+ const texts = extractTextItems(content);
335
+ if (texts.length === 0) {
336
+ return null;
337
+ }
338
+
339
+ return texts.length === 1 ? texts[0] : texts;
340
+ }
341
+
342
+ function extractImageItem(content = []) {
343
+ for (const item of content) {
344
+ if (item?.type !== 'image' || !item.source?.data) {
345
+ continue;
346
+ }
347
+
348
+ return {
349
+ mediaType: item.source.media_type ?? 'image/png',
350
+ base64: item.source.data,
351
+ };
352
+ }
353
+
354
+ return null;
355
+ }
356
+
357
+ function extractImageIdFromContent(content = []) {
358
+ for (const item of content) {
359
+ if (item?.type !== 'text' || typeof item.text !== 'string') {
360
+ continue;
361
+ }
362
+
363
+ const match = item.text.match(/\bID:\s*([A-Za-z0-9_-]+)/);
364
+ if (match) {
365
+ return match[1];
366
+ }
367
+ }
368
+
369
+ return null;
370
+ }
371
+
372
+ function extractTabGroupIdFromContent(content = []) {
373
+ const structured = findStructuredJson(content);
374
+ if (Number.isFinite(structured?.tabGroupId)) {
375
+ return Number(structured.tabGroupId);
376
+ }
377
+
378
+ return null;
379
+ }
380
+
381
+ function contentSignalsMissingTabGroup(content = []) {
382
+ const texts = extractTextItems(content);
383
+ return texts.some(
384
+ (text) =>
385
+ text.includes('No tab group exists for this session') ||
386
+ text.includes('No MCP tab groups found'),
387
+ );
388
+ }
389
+
390
+ function responseSignalsTabsBusy(response) {
391
+ const text = response?.error?.content;
392
+ return (
393
+ typeof text === 'string' &&
394
+ text.includes('Tabs cannot be edited right now')
395
+ );
396
+ }
397
+
398
+ function toolSupportsSessionContextRecovery(tool) {
399
+ return tool !== 'tabs_context_mcp';
400
+ }
401
+
402
+ function extractScreenshotMetadata(content = []) {
403
+ for (const item of content) {
404
+ if (item?.type !== 'text' || typeof item.text !== 'string') {
405
+ continue;
406
+ }
407
+
408
+ const match = item.text.match(
409
+ /Successfully captured screenshot \((\d+)x(\d+),\s*([^)]+)\)\s*-\s*ID:\s*([A-Za-z0-9_-]+)/i,
410
+ );
411
+ if (!match) {
412
+ continue;
413
+ }
414
+
415
+ return {
416
+ width: Number.parseInt(match[1], 10),
417
+ height: Number.parseInt(match[2], 10),
418
+ format: match[3],
419
+ imageId: match[4],
420
+ };
421
+ }
422
+
423
+ return null;
424
+ }
425
+
426
+ function cacheImageEntry(imageId, entry, store = SHARED_IMAGE_CACHE) {
427
+ if (!imageId || !entry?.base64) {
428
+ return;
429
+ }
430
+
431
+ if (store.has(imageId)) {
432
+ store.delete(imageId);
433
+ }
434
+
435
+ store.set(imageId, {
436
+ ...entry,
437
+ cachedAt: new Date().toISOString(),
438
+ });
439
+
440
+ while (store.size > MAX_SHARED_IMAGE_CACHE_ENTRIES) {
441
+ const oldestKey = store.keys().next().value;
442
+ if (!oldestKey) {
443
+ break;
444
+ }
445
+ store.delete(oldestKey);
446
+ }
447
+ }
448
+
449
+ function encodeFileArtifact(filePath) {
450
+ const buffer = fs.readFileSync(filePath);
451
+ return {
452
+ path: filePath,
453
+ name: path.basename(filePath),
454
+ mediaType: mimeTypeFromPath(filePath),
455
+ base64: buffer.toString('base64'),
456
+ };
457
+ }
458
+
459
+ function writeTempArtifact(fileEntry, preferredName = null) {
460
+ const extension = path.extname(preferredName || fileEntry.name || fileEntry.path || '') || '';
461
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-bridge-upload-'));
462
+ const tempName = preferredName || fileEntry.name || `upload${extension || ''}`;
463
+ const tempPath = path.join(tempDir, tempName);
464
+ fs.writeFileSync(tempPath, Buffer.from(fileEntry.base64, 'base64'));
465
+ return tempPath;
466
+ }
467
+
468
+ function buildUploadScript({
469
+ files,
470
+ ref = null,
471
+ selector = null,
472
+ coordinate = null,
473
+ allowDrop = false,
474
+ }) {
475
+ const payload = {
476
+ files,
477
+ ref,
478
+ selector,
479
+ coordinate,
480
+ allowDrop,
481
+ };
482
+
483
+ return `(() => {
484
+ const payload = ${JSON.stringify(payload)};
485
+ const decodeFile = (entry) => {
486
+ const binary = atob(entry.base64);
487
+ const bytes = new Uint8Array(binary.length);
488
+ for (let index = 0; index < binary.length; index += 1) {
489
+ bytes[index] = binary.charCodeAt(index);
490
+ }
491
+ const blob = new Blob([bytes], { type: entry.mediaType || 'application/octet-stream' });
492
+ return new File([blob], entry.name, {
493
+ type: entry.mediaType || 'application/octet-stream',
494
+ lastModified: Date.now(),
495
+ });
496
+ };
497
+ const files = payload.files.map(decodeFile);
498
+ const transfer = new DataTransfer();
499
+ for (const file of files) {
500
+ transfer.items.add(file);
501
+ }
502
+
503
+ const resolveRefTarget = (ref) => {
504
+ const map = window.__claudeElementMap;
505
+ if (!map?.[ref]) {
506
+ return { error: 'Element ref not found: "' + ref + '"' };
507
+ }
508
+ const node = map[ref].deref() || null;
509
+ if (!node || !document.contains(node)) {
510
+ delete map[ref];
511
+ return { error: 'Element is no longer in the document: "' + ref + '"' };
512
+ }
513
+ return { node };
514
+ };
515
+
516
+ const resolveCoordinateTarget = (coordinate) => {
517
+ let node = document.elementFromPoint(coordinate[0], coordinate[1]);
518
+ if (!node) {
519
+ return { error: 'No element found at coordinates (' + coordinate[0] + ', ' + coordinate[1] + ')' };
520
+ }
521
+ if (node.tagName === 'IFRAME') {
522
+ try {
523
+ const frameRect = node.getBoundingClientRect();
524
+ const innerDoc = node.contentDocument || node.contentWindow?.document || null;
525
+ if (innerDoc) {
526
+ const innerNode = innerDoc.elementFromPoint(
527
+ coordinate[0] - frameRect.left,
528
+ coordinate[1] - frameRect.top,
529
+ );
530
+ if (innerNode) {
531
+ node = innerNode;
532
+ }
533
+ }
534
+ } catch {
535
+ // Fall back to the iframe element when same-origin access is unavailable.
536
+ }
537
+ }
538
+ return { node };
539
+ };
540
+
541
+ const resolveSelectorTarget = (selector) => {
542
+ const node = document.querySelector(selector);
543
+ if (!node) {
544
+ return { error: 'No element matched selector: ' + selector };
545
+ }
546
+ return { node };
547
+ };
548
+
549
+ let resolved = null;
550
+ if (payload.ref) {
551
+ resolved = resolveRefTarget(payload.ref);
552
+ } else if (payload.selector) {
553
+ resolved = resolveSelectorTarget(payload.selector);
554
+ } else if (payload.coordinate) {
555
+ resolved = resolveCoordinateTarget(payload.coordinate);
556
+ } else {
557
+ return JSON.stringify({
558
+ ok: false,
559
+ error: 'Neither ref, selector, nor coordinate was provided',
560
+ });
561
+ }
562
+
563
+ if (resolved.error) {
564
+ return JSON.stringify({ ok: false, error: resolved.error });
565
+ }
566
+
567
+ const target = resolved.node;
568
+ target.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'center' });
569
+
570
+ if (target.tagName === 'INPUT' && target.type === 'file') {
571
+ target.files = transfer.files;
572
+ target.focus();
573
+ target.dispatchEvent(new Event('input', { bubbles: true }));
574
+ target.dispatchEvent(new Event('change', { bubbles: true }));
575
+ target.dispatchEvent(
576
+ new CustomEvent('filechange', { bubbles: true, detail: { files: transfer.files } }),
577
+ );
578
+ return JSON.stringify({
579
+ ok: true,
580
+ mode: 'file_input',
581
+ fileNames: Array.from(transfer.files).map((file) => file.name),
582
+ });
583
+ }
584
+
585
+ if (!payload.allowDrop) {
586
+ return JSON.stringify({
587
+ ok: false,
588
+ error:
589
+ 'Target is not a file input. Use coordinate upload or a file-input ref.',
590
+ });
591
+ }
592
+
593
+ const rect = target.getBoundingClientRect();
594
+ const x = payload.coordinate ? payload.coordinate[0] : rect.left + rect.width / 2;
595
+ const y = payload.coordinate ? payload.coordinate[1] : rect.top + rect.height / 2;
596
+ const dragOptions = {
597
+ bubbles: true,
598
+ cancelable: true,
599
+ dataTransfer: transfer,
600
+ clientX: x,
601
+ clientY: y,
602
+ screenX: x + window.screenX,
603
+ screenY: y + window.screenY,
604
+ };
605
+ target.focus();
606
+ target.dispatchEvent(new DragEvent('dragenter', dragOptions));
607
+ target.dispatchEvent(new DragEvent('dragover', dragOptions));
608
+ target.dispatchEvent(new DragEvent('drop', dragOptions));
609
+ return JSON.stringify({
610
+ ok: true,
611
+ mode: 'drop',
612
+ coordinate: [Math.round(x), Math.round(y)],
613
+ fileNames: Array.from(transfer.files).map((file) => file.name),
614
+ });
615
+ })()`;
616
+ }
617
+
618
+ function extractTabIdFromContent(content = []) {
619
+ for (const item of content) {
620
+ if (item.type !== 'text') {
621
+ continue;
622
+ }
623
+
624
+ const match = item.text.match(/Tab ID:\s*(\d+)/i);
625
+ if (match) {
626
+ return Number.parseInt(match[1], 10);
627
+ }
628
+ }
629
+
630
+ return null;
631
+ }
632
+
633
+ function findTabInContext(browserContext, tabId) {
634
+ const availableTabs = Array.isArray(browserContext?.availableTabs)
635
+ ? browserContext.availableTabs
636
+ : [];
637
+
638
+ return (
639
+ availableTabs.find((tab) => Number(tab?.tabId) === Number(tabId)) ?? null
640
+ );
641
+ }
642
+
643
+ function selectTabsInContext(browserContext, selector) {
644
+ const availableTabs = Array.isArray(browserContext?.availableTabs)
645
+ ? browserContext.availableTabs
646
+ : [];
647
+
648
+ if (Number.isFinite(selector?.tabId)) {
649
+ return availableTabs.filter(
650
+ (tab) => Number(tab?.tabId) === Number(selector.tabId),
651
+ );
652
+ }
653
+
654
+ if (typeof selector?.url === 'string' && selector.url.length > 0) {
655
+ return availableTabs.filter((tab) => String(tab?.url ?? '') === selector.url);
656
+ }
657
+
658
+ return [];
659
+ }
660
+
661
+ function normalizeCoordinate(input) {
662
+ if (Array.isArray(input?.coordinate) && input.coordinate.length === 2) {
663
+ return [
664
+ Number.parseFloat(input.coordinate[0]),
665
+ Number.parseFloat(input.coordinate[1]),
666
+ ];
667
+ }
668
+
669
+ if (Number.isFinite(input?.x) && Number.isFinite(input?.y)) {
670
+ return [Number(input.x), Number(input.y)];
671
+ }
672
+
673
+ throw new BridgeError(
674
+ 'tool_call',
675
+ 'coordinate or x/y is required for this tool',
676
+ );
677
+ }
678
+
679
+ function normalizeOptionalCoordinate(input) {
680
+ if (
681
+ (Array.isArray(input?.coordinate) && input.coordinate.length === 2) ||
682
+ (Number.isFinite(input?.x) && Number.isFinite(input?.y))
683
+ ) {
684
+ return normalizeCoordinate(input);
685
+ }
686
+
687
+ return null;
688
+ }
689
+
690
+ function normalizeStartCoordinate(input) {
691
+ if (
692
+ Array.isArray(input?.startCoordinate) &&
693
+ input.startCoordinate.length === 2
694
+ ) {
695
+ return [
696
+ Number.parseFloat(input.startCoordinate[0]),
697
+ Number.parseFloat(input.startCoordinate[1]),
698
+ ];
699
+ }
700
+
701
+ if (Number.isFinite(input?.startX) && Number.isFinite(input?.startY)) {
702
+ return [Number(input.startX), Number(input.startY)];
703
+ }
704
+
705
+ if (
706
+ Array.isArray(input?.start_coordinate) &&
707
+ input.start_coordinate.length === 2
708
+ ) {
709
+ return [
710
+ Number.parseFloat(input.start_coordinate[0]),
711
+ Number.parseFloat(input.start_coordinate[1]),
712
+ ];
713
+ }
714
+
715
+ return null;
716
+ }
717
+
718
+ function normalizeRegion(input) {
719
+ if (!Array.isArray(input?.region) || input.region.length !== 4) {
720
+ throw new BridgeError('tool_call', 'region is required for this tool');
721
+ }
722
+
723
+ return input.region.map((value) => Number.parseFloat(value));
724
+ }
725
+
726
+ class NativeBridgeClient {
727
+ constructor(socketPath, clientId = CLIENT_ID, options = {}) {
728
+ this.socketPath = socketPath;
729
+ this.clientId = clientId;
730
+ this.sessionScope = options.sessionScope ?? null;
731
+ }
732
+
733
+ executeTool(tool, args, timeoutMs = 15000) {
734
+ return new Promise((resolve, reject) => {
735
+ const socket = net.createConnection(this.socketPath);
736
+ let timer = null;
737
+ let buffer = Buffer.alloc(0);
738
+ let resolved = false;
739
+ const requestArgs =
740
+ this.sessionScope && Number.isFinite(this.sessionScope.tabGroupId)
741
+ ? {
742
+ ...(args ?? {}),
743
+ ...(
744
+ Number.isFinite(args?.tabGroupId)
745
+ ? {}
746
+ : { tabGroupId: Number(this.sessionScope.tabGroupId) }
747
+ ),
748
+ }
749
+ : args;
750
+
751
+ const finish = (callback) => {
752
+ if (resolved) {
753
+ return;
754
+ }
755
+
756
+ resolved = true;
757
+
758
+ if (timer) {
759
+ clearTimeout(timer);
760
+ timer = null;
761
+ }
762
+
763
+ socket.removeAllListeners();
764
+ socket.end();
765
+ callback();
766
+ };
767
+
768
+ timer = setTimeout(() => {
769
+ socket.destroy();
770
+ reject(new BridgeError('connect', `timeout waiting for ${tool}`));
771
+ }, timeoutMs);
772
+
773
+ socket.on('connect', () => {
774
+ const payload = Buffer.from(
775
+ JSON.stringify({
776
+ method: 'execute_tool',
777
+ params: {
778
+ client_id: this.clientId,
779
+ tool,
780
+ args: requestArgs,
781
+ ...(this.sessionScope ? { session_scope: this.sessionScope } : {}),
782
+ },
783
+ }),
784
+ );
785
+ const prefix = Buffer.alloc(4);
786
+ prefix.writeUInt32LE(payload.length, 0);
787
+ socket.write(Buffer.concat([prefix, payload]));
788
+ });
789
+
790
+ socket.on('data', (chunk) => {
791
+ buffer = Buffer.concat([buffer, chunk]);
792
+
793
+ if (buffer.length < 4) {
794
+ return;
795
+ }
796
+
797
+ const payloadLength = buffer.readUInt32LE(0);
798
+ if (buffer.length < payloadLength + 4) {
799
+ return;
800
+ }
801
+
802
+ const payload = buffer.subarray(4, payloadLength + 4).toString('utf8');
803
+ const parsed = safeJsonParse(payload);
804
+
805
+ if (!parsed) {
806
+ finish(() => {
807
+ reject(new BridgeError('response_parse', 'bridge returned invalid JSON'));
808
+ });
809
+ return;
810
+ }
811
+
812
+ finish(() => resolve(parsed));
813
+ });
814
+
815
+ socket.on('error', (error) => {
816
+ finish(() => reject(new BridgeError('connect', error.message)));
817
+ });
818
+ });
819
+ }
820
+ }
821
+
822
+ function uniqueWarnings(warnings = []) {
823
+ return [...new Set(warnings)];
824
+ }
825
+
826
+ function isMissingTabGroupError(error) {
827
+ if (!error || error.name !== 'BridgeError') {
828
+ return false;
829
+ }
830
+
831
+ const message = String(error.message ?? '');
832
+ return message.includes('No MCP tab group exists');
833
+ }
834
+
835
+ async function probeCandidateSocket(candidate, timeoutMs = DISCOVERY_TIMEOUT_MS) {
836
+ const client = new NativeBridgeClient(candidate.socketPath, `${CLIENT_ID}-discovery`);
837
+
838
+ try {
839
+ const response = await client.executeTool(
840
+ 'tabs_context_mcp',
841
+ { createIfEmpty: false },
842
+ timeoutMs,
843
+ );
844
+
845
+ if (response.error) {
846
+ return {
847
+ ok: false,
848
+ pid: candidate.pid,
849
+ socketPath: candidate.socketPath,
850
+ stage: 'tool_call',
851
+ reason: response.error.content ?? 'candidate returned tool error',
852
+ };
853
+ }
854
+
855
+ const result = response.result ?? response;
856
+ return {
857
+ ok: true,
858
+ pid: candidate.pid,
859
+ socketPath: candidate.socketPath,
860
+ result,
861
+ status_payload: findStructuredJson(result.content),
862
+ status_summary: summarizeContent(result.content),
863
+ };
864
+ } catch (error) {
865
+ return {
866
+ ok: false,
867
+ pid: candidate.pid,
868
+ socketPath: candidate.socketPath,
869
+ stage: error.stage ?? 'connect',
870
+ reason: error.message,
871
+ };
872
+ }
873
+ }
874
+
875
+ async function resolveUsableSocket(discovery) {
876
+ const candidates = discovery.processes.filter((entry) => entry.socketExists);
877
+ const attempts = [];
878
+
879
+ if (candidates.length === 0) {
880
+ throw new BridgeError(
881
+ 'discover',
882
+ 'no live native-host process with socket was found',
883
+ discovery,
884
+ );
885
+ }
886
+
887
+ for (const candidate of candidates) {
888
+ const probe = await probeCandidateSocket(candidate);
889
+ attempts.push(probe);
890
+
891
+ if (probe.ok) {
892
+ const warnings = [...discovery.warnings];
893
+
894
+ if (candidates.length > 1) {
895
+ warnings.push('multiple_socket_candidates_detected');
896
+ }
897
+
898
+ if (attempts.length > 1) {
899
+ warnings.push('candidate_probe_fallback_used');
900
+ }
901
+
902
+ const failedCandidates = attempts
903
+ .filter((entry) => !entry.ok)
904
+ .map((entry) => entry.pid);
905
+ if (failedCandidates.length > 0) {
906
+ warnings.push('bogus_socket_candidates_rejected');
907
+ }
908
+
909
+ return {
910
+ discovery: {
911
+ ...discovery,
912
+ selectedProcess: candidate,
913
+ warnings: uniqueWarnings(warnings),
914
+ },
915
+ attempts,
916
+ probe,
917
+ };
918
+ }
919
+ }
920
+
921
+ throw new BridgeError('connect', 'no usable native-host socket responded', {
922
+ attempts,
923
+ discovery,
924
+ });
925
+ }
926
+
927
+ class ClaudeChromeAdapter {
928
+ constructor(discovery, options = {}) {
929
+ this.discovery = discovery;
930
+ this.candidateAttempts = options.candidateAttempts ?? [];
931
+ this.initialProbe = options.initialProbe ?? null;
932
+ this.imageCache = options.imageCache ?? SHARED_IMAGE_CACHE;
933
+ this.sessionScope = options.sessionScope ?? SESSION_SCOPE;
934
+ this.client =
935
+ options.client ??
936
+ new NativeBridgeClient(discovery.selectedProcess.socketPath, CLIENT_ID, {
937
+ sessionScope: this.sessionScope,
938
+ });
939
+ this.updateSessionScopeFromBrowserContext(this.initialProbe?.status_payload ?? null);
940
+ }
941
+
942
+ updateSessionScopeFromBrowserContext(browserContext) {
943
+ if (!this.sessionScope || typeof this.sessionScope !== 'object') {
944
+ return;
945
+ }
946
+
947
+ if (Number.isFinite(browserContext?.tabGroupId)) {
948
+ this.sessionScope.tabGroupId = Number(browserContext.tabGroupId);
949
+ return;
950
+ }
951
+
952
+ delete this.sessionScope.tabGroupId;
953
+ }
954
+
955
+ async tool(tool, args, timeoutMs = TOOL_TIMEOUT_MS) {
956
+ return this.executeToolWithRecovery(tool, args, timeoutMs, false);
957
+ }
958
+
959
+ async executeToolWithRecovery(
960
+ tool,
961
+ args,
962
+ timeoutMs,
963
+ attemptedRecovery,
964
+ attemptedBusyRetry = false,
965
+ ) {
966
+ const response = await this.client.executeTool(tool, args, timeoutMs);
967
+
968
+ if (response.error) {
969
+ if (!attemptedBusyRetry && responseSignalsTabsBusy(response)) {
970
+ await delay(350);
971
+ return this.executeToolWithRecovery(
972
+ tool,
973
+ args,
974
+ timeoutMs,
975
+ attemptedRecovery,
976
+ true,
977
+ );
978
+ }
979
+ throw new BridgeError('tool_call', response.error.content ?? 'tool failed', {
980
+ tool,
981
+ args,
982
+ response,
983
+ });
984
+ }
985
+
986
+ const result = response.result ?? response;
987
+ const tabGroupId = extractTabGroupIdFromContent(result.content ?? []);
988
+ if (Number.isFinite(tabGroupId)) {
989
+ this.updateSessionScopeFromBrowserContext({ tabGroupId });
990
+ } else if (contentSignalsMissingTabGroup(result.content ?? [])) {
991
+ this.updateSessionScopeFromBrowserContext(null);
992
+ if (!attemptedRecovery && toolSupportsSessionContextRecovery(tool)) {
993
+ await this.client.executeTool(
994
+ 'tabs_context_mcp',
995
+ { createIfEmpty: true },
996
+ timeoutMs,
997
+ );
998
+ const recoveryProbe = await this.client.executeTool(
999
+ 'tabs_context_mcp',
1000
+ { createIfEmpty: false },
1001
+ timeoutMs,
1002
+ );
1003
+ this.updateSessionScopeFromBrowserContext(
1004
+ findStructuredJson(recoveryProbe.result?.content ?? recoveryProbe.content ?? []),
1005
+ );
1006
+ return this.executeToolWithRecovery(
1007
+ tool,
1008
+ args,
1009
+ timeoutMs,
1010
+ true,
1011
+ attemptedBusyRetry,
1012
+ );
1013
+ }
1014
+ }
1015
+
1016
+ return result;
1017
+ }
1018
+
1019
+ async health() {
1020
+ const fallbackTabs = this.initialProbe
1021
+ ? null
1022
+ : await this.tool('tabs_context_mcp', { createIfEmpty: false });
1023
+ const statusPayload =
1024
+ this.initialProbe?.status_payload ??
1025
+ findStructuredJson(fallbackTabs?.content);
1026
+ const statusSummary =
1027
+ this.initialProbe?.status_summary ??
1028
+ summarizeContent(fallbackTabs?.content);
1029
+
1030
+ return {
1031
+ source: 'claude-code-native-host',
1032
+ host_process: this.discovery.selectedProcess,
1033
+ launcher_target: this.discovery.launcherTarget,
1034
+ socket_path: this.discovery.selectedProcess.socketPath,
1035
+ connect_ok: true,
1036
+ status_ok: true,
1037
+ warnings: [...this.discovery.warnings],
1038
+ candidate_attempts: this.candidateAttempts,
1039
+ status_payload: statusPayload,
1040
+ status_summary: statusSummary,
1041
+ };
1042
+ }
1043
+
1044
+ async snapshot() {
1045
+ const tabs = await this.tool('tabs_context_mcp', { createIfEmpty: false });
1046
+ return {
1047
+ source: 'claude-code-native-host',
1048
+ warnings: [...this.discovery.warnings],
1049
+ browser_context: findStructuredJson(tabs.content),
1050
+ raw_summary: summarizeContent(tabs.content),
1051
+ };
1052
+ }
1053
+
1054
+ async tabsContext(input = {}) {
1055
+ const response = await this.tool('tabs_context_mcp', {
1056
+ createIfEmpty: Boolean(input.createIfEmpty),
1057
+ });
1058
+
1059
+ return {
1060
+ source: 'claude-code-native-host',
1061
+ action_taken: 'tabs_context',
1062
+ createIfEmpty: Boolean(input.createIfEmpty),
1063
+ warnings: [...this.discovery.warnings],
1064
+ browser_context: findStructuredJson(response.content),
1065
+ raw_summary: summarizeContent(response.content),
1066
+ };
1067
+ }
1068
+
1069
+ async createTab() {
1070
+ await this.ensureSessionContext(true);
1071
+ const created = await this.tool('tabs_create_mcp', {});
1072
+
1073
+ const tabId = extractTabIdFromContent(created.content);
1074
+ if (!tabId) {
1075
+ throw new BridgeError(
1076
+ 'response_parse',
1077
+ 'could not extract tabId from tabs_create_mcp response',
1078
+ created,
1079
+ );
1080
+ }
1081
+
1082
+ const snapshot = await this.snapshot();
1083
+ return {
1084
+ source: 'claude-code-native-host',
1085
+ action_taken: 'create_tab',
1086
+ tabId,
1087
+ warnings: [...this.discovery.warnings],
1088
+ browser_context: snapshot.browser_context,
1089
+ raw_summary: summarizeContent(created.content),
1090
+ snapshot_summary: snapshot.raw_summary,
1091
+ };
1092
+ }
1093
+
1094
+ async navigateTab(input) {
1095
+ if (!Number.isFinite(input?.tabId)) {
1096
+ throw new BridgeError('tool_call', 'tabId is required');
1097
+ }
1098
+
1099
+ if (!input?.url) {
1100
+ throw new BridgeError('tool_call', 'url is required');
1101
+ }
1102
+
1103
+ const response = await this.tool('navigate', {
1104
+ tabId: Number(input.tabId),
1105
+ url: String(input.url),
1106
+ ...(input.force === undefined ? {} : { force: Boolean(input.force) }),
1107
+ });
1108
+
1109
+ return {
1110
+ source: 'claude-code-native-host',
1111
+ action_taken: 'navigate',
1112
+ tabId: Number(input.tabId),
1113
+ target: String(input.url),
1114
+ force: Boolean(input.force),
1115
+ warnings: [...this.discovery.warnings],
1116
+ result: normalizeToolContent(response.content),
1117
+ raw_summary: summarizeContent(response.content),
1118
+ };
1119
+ }
1120
+
1121
+ async openOrFocus(input) {
1122
+ if (input.url) {
1123
+ let snapshot = null;
1124
+
1125
+ try {
1126
+ snapshot = await this.snapshot();
1127
+ } catch (error) {
1128
+ if (!isMissingTabGroupError(error)) {
1129
+ throw error;
1130
+ }
1131
+ }
1132
+
1133
+ const matchedTabs = snapshot
1134
+ ? selectTabsInContext(snapshot.browser_context, { url: input.url })
1135
+ : [];
1136
+
1137
+ if (matchedTabs.length > 1) {
1138
+ throw new BridgeError('tool_call', 'selector matched multiple visible tabs', {
1139
+ selector: { url: input.url },
1140
+ matched_tabs: matchedTabs,
1141
+ });
1142
+ }
1143
+
1144
+ if (matchedTabs.length === 1) {
1145
+ const matchedTab = matchedTabs[0];
1146
+
1147
+ return {
1148
+ source: 'claude-code-native-host',
1149
+ action_taken: 'reuse',
1150
+ tabId: Number(matchedTab.tabId),
1151
+ target: input.url,
1152
+ warnings: [...this.discovery.warnings],
1153
+ matched_tab: matchedTab,
1154
+ browser_context: snapshot.browser_context,
1155
+ raw_summary: snapshot.raw_summary,
1156
+ };
1157
+ }
1158
+
1159
+ await this.ensureSessionContext(true);
1160
+ const created = await this.tool('tabs_create_mcp', {});
1161
+
1162
+ const tabId = extractTabIdFromContent(created.content);
1163
+
1164
+ if (!tabId) {
1165
+ throw new BridgeError(
1166
+ 'response_parse',
1167
+ 'could not extract tabId from tabs_create_mcp response',
1168
+ created,
1169
+ );
1170
+ }
1171
+
1172
+ const navigated = await this.tool('navigate', {
1173
+ tabId,
1174
+ url: input.url,
1175
+ });
1176
+
1177
+ return {
1178
+ source: 'claude-code-native-host',
1179
+ action_taken: 'open',
1180
+ tabId,
1181
+ target: input.url,
1182
+ warnings: [...this.discovery.warnings],
1183
+ downstream_summary: summarizeContent(navigated.content),
1184
+ };
1185
+ }
1186
+
1187
+ if (input.tabId) {
1188
+ const snapshot = await this.reuseTab(input);
1189
+ return {
1190
+ source: 'claude-code-native-host',
1191
+ action_taken: 'focus_unsupported_snapshot_returned',
1192
+ tabId: Number(input.tabId),
1193
+ warnings: [
1194
+ ...snapshot.warnings,
1195
+ 'downstream_focus_action_not_confirmed',
1196
+ ],
1197
+ matched_tab: snapshot.matched_tab,
1198
+ browser_context: snapshot.browser_context,
1199
+ raw_summary: snapshot.raw_summary,
1200
+ };
1201
+ }
1202
+
1203
+ throw new BridgeError('tool_call', 'url or tabId is required');
1204
+ }
1205
+
1206
+ async reuseTab(input) {
1207
+ const selectorCount = [
1208
+ Number.isFinite(input?.tabId),
1209
+ typeof input?.url === 'string' && input.url.length > 0,
1210
+ ].filter(Boolean).length;
1211
+
1212
+ if (selectorCount !== 1) {
1213
+ throw new BridgeError(
1214
+ 'tool_call',
1215
+ 'exactly one selector is required: tabId or url',
1216
+ );
1217
+ }
1218
+
1219
+ const snapshot = await this.snapshot();
1220
+ const matchedTabs = selectTabsInContext(snapshot.browser_context, input ?? {});
1221
+
1222
+ if (matchedTabs.length === 0) {
1223
+ throw new BridgeError(
1224
+ 'tool_call',
1225
+ 'no visible tab matched the requested selector',
1226
+ {
1227
+ selector: input,
1228
+ browser_context: snapshot.browser_context,
1229
+ },
1230
+ );
1231
+ }
1232
+
1233
+ if (matchedTabs.length > 1) {
1234
+ throw new BridgeError(
1235
+ 'tool_call',
1236
+ 'selector matched multiple visible tabs',
1237
+ {
1238
+ selector: input,
1239
+ matched_tabs: matchedTabs,
1240
+ },
1241
+ );
1242
+ }
1243
+
1244
+ const matchedTab = matchedTabs[0];
1245
+
1246
+ return {
1247
+ source: 'claude-code-native-host',
1248
+ action_taken: 'reuse_confirmed',
1249
+ tabId: Number(matchedTab.tabId),
1250
+ warnings: [...this.discovery.warnings],
1251
+ selector: input,
1252
+ matched_tab: matchedTab,
1253
+ browser_context: snapshot.browser_context,
1254
+ raw_summary: snapshot.raw_summary,
1255
+ };
1256
+ }
1257
+
1258
+ async click(input) {
1259
+ const coordinate = normalizeCoordinate(input);
1260
+ const response = await this.tool('computer', {
1261
+ action: 'left_click',
1262
+ coordinate,
1263
+ tabId: Number(input.tabId),
1264
+ });
1265
+
1266
+ return {
1267
+ source: 'claude-code-native-host',
1268
+ tabId: Number(input.tabId),
1269
+ coordinate,
1270
+ warnings: [...this.discovery.warnings],
1271
+ downstream_summary: summarizeContent(response.content),
1272
+ };
1273
+ }
1274
+
1275
+ async computer(input) {
1276
+ if (!Number.isFinite(input?.tabId)) {
1277
+ throw new BridgeError('tool_call', 'tabId is required');
1278
+ }
1279
+ if (!input?.action) {
1280
+ throw new BridgeError('tool_call', 'action is required');
1281
+ }
1282
+
1283
+ const action = String(input.action);
1284
+
1285
+ if (action === 'screenshot') {
1286
+ return this.screenshot(input);
1287
+ }
1288
+
1289
+ const args = {
1290
+ action,
1291
+ tabId: Number(input.tabId),
1292
+ };
1293
+
1294
+ const hasRef = typeof input?.ref === 'string' && input.ref.length > 0;
1295
+ const coordinate = normalizeOptionalCoordinate(input);
1296
+
1297
+ switch (action) {
1298
+ case 'left_click':
1299
+ case 'right_click':
1300
+ case 'double_click':
1301
+ case 'triple_click':
1302
+ case 'hover':
1303
+ if (hasRef === Boolean(coordinate)) {
1304
+ throw new BridgeError(
1305
+ 'tool_call',
1306
+ 'provide exactly one target: ref or coordinate/x/y',
1307
+ );
1308
+ }
1309
+ if (hasRef) {
1310
+ args.ref = String(input.ref);
1311
+ } else {
1312
+ args.coordinate = coordinate;
1313
+ }
1314
+ if (typeof input?.modifiers === 'string' && input.modifiers.length > 0) {
1315
+ args.modifiers = String(input.modifiers);
1316
+ }
1317
+ break;
1318
+ case 'scroll': {
1319
+ const scrollDirection =
1320
+ typeof input?.scrollDirection === 'string'
1321
+ ? input.scrollDirection
1322
+ : input?.scroll_direction;
1323
+ if (!coordinate) {
1324
+ throw new BridgeError(
1325
+ 'tool_call',
1326
+ 'coordinate or x/y is required for scroll',
1327
+ );
1328
+ }
1329
+ if (
1330
+ !['up', 'down', 'left', 'right'].includes(String(scrollDirection ?? ''))
1331
+ ) {
1332
+ throw new BridgeError(
1333
+ 'tool_call',
1334
+ 'scrollDirection is required and must be one of up/down/left/right',
1335
+ );
1336
+ }
1337
+ args.coordinate = coordinate;
1338
+ args.scroll_direction = String(scrollDirection);
1339
+ if (
1340
+ Number.isFinite(input?.scrollAmount) ||
1341
+ Number.isFinite(input?.scroll_amount)
1342
+ ) {
1343
+ args.scroll_amount = Number(
1344
+ Number.isFinite(input?.scrollAmount)
1345
+ ? input.scrollAmount
1346
+ : input.scroll_amount,
1347
+ );
1348
+ }
1349
+ break;
1350
+ }
1351
+ case 'key':
1352
+ case 'type':
1353
+ if (!input?.text) {
1354
+ throw new BridgeError('tool_call', 'text is required');
1355
+ }
1356
+ args.text = String(input.text);
1357
+ if (action === 'key' && Number.isFinite(input?.repeat)) {
1358
+ args.repeat = Number(input.repeat);
1359
+ }
1360
+ break;
1361
+ case 'wait':
1362
+ if (!Number.isFinite(input?.duration)) {
1363
+ throw new BridgeError('tool_call', 'duration is required');
1364
+ }
1365
+ args.duration = Number(input.duration);
1366
+ break;
1367
+ case 'left_click_drag': {
1368
+ const startCoordinate = normalizeStartCoordinate(input);
1369
+ if (!coordinate) {
1370
+ throw new BridgeError(
1371
+ 'tool_call',
1372
+ 'coordinate or x/y is required for left_click_drag',
1373
+ );
1374
+ }
1375
+ if (!startCoordinate) {
1376
+ throw new BridgeError(
1377
+ 'tool_call',
1378
+ 'startCoordinate or startX/startY is required for left_click_drag',
1379
+ );
1380
+ }
1381
+ args.coordinate = coordinate;
1382
+ args.start_coordinate = startCoordinate;
1383
+ break;
1384
+ }
1385
+ case 'zoom':
1386
+ args.region = normalizeRegion(input);
1387
+ break;
1388
+ case 'scroll_to':
1389
+ if (!hasRef) {
1390
+ throw new BridgeError('tool_call', 'ref is required for scroll_to');
1391
+ }
1392
+ args.ref = String(input.ref);
1393
+ break;
1394
+ default:
1395
+ throw new BridgeError('tool_call', `unsupported computer action: ${action}`);
1396
+ }
1397
+
1398
+ const response = await this.tool('computer', args);
1399
+
1400
+ return {
1401
+ source: 'claude-code-native-host',
1402
+ action_taken: 'computer',
1403
+ computer_action: action,
1404
+ tabId: Number(input.tabId),
1405
+ ref: args.ref ?? null,
1406
+ coordinate: args.coordinate ?? null,
1407
+ startCoordinate: args.start_coordinate ?? null,
1408
+ region: args.region ?? null,
1409
+ text: args.text ?? null,
1410
+ duration: args.duration ?? null,
1411
+ scrollDirection: args.scroll_direction ?? null,
1412
+ scrollAmount: args.scroll_amount ?? null,
1413
+ repeat: args.repeat ?? null,
1414
+ modifiers: args.modifiers ?? null,
1415
+ result: normalizeToolContent(response.content),
1416
+ warnings: [...this.discovery.warnings],
1417
+ raw_summary: summarizeContent(response.content),
1418
+ };
1419
+ }
1420
+
1421
+ async type(input) {
1422
+ if (!input.text) {
1423
+ throw new BridgeError('tool_call', 'text is required');
1424
+ }
1425
+
1426
+ const steps = [];
1427
+
1428
+ if (input.coordinate || (Number.isFinite(input.x) && Number.isFinite(input.y))) {
1429
+ steps.push(await this.click(input));
1430
+ }
1431
+
1432
+ const response = await this.tool('computer', {
1433
+ action: 'type',
1434
+ text: String(input.text),
1435
+ tabId: Number(input.tabId),
1436
+ });
1437
+
1438
+ return {
1439
+ source: 'claude-code-native-host',
1440
+ tabId: Number(input.tabId),
1441
+ text: String(input.text),
1442
+ warnings: [...this.discovery.warnings],
1443
+ pre_steps: steps,
1444
+ downstream_summary: summarizeContent(response.content),
1445
+ };
1446
+ }
1447
+
1448
+ async closeTab(input) {
1449
+ if (!Number.isFinite(input?.tabId)) {
1450
+ throw new BridgeError('tool_call', 'tabId is required');
1451
+ }
1452
+
1453
+ const response = await this.tool('tabs_close_mcp', {
1454
+ tabId: Number(input.tabId),
1455
+ });
1456
+ const snapshot = await this.snapshot();
1457
+
1458
+ return {
1459
+ source: 'claude-code-native-host',
1460
+ action_taken: 'close',
1461
+ tabId: Number(input.tabId),
1462
+ warnings: [...this.discovery.warnings],
1463
+ close_summary: summarizeContent(response.content),
1464
+ browser_context: snapshot.browser_context,
1465
+ raw_summary: snapshot.raw_summary,
1466
+ };
1467
+ }
1468
+
1469
+ async javascriptExec(input) {
1470
+ if (!Number.isFinite(input?.tabId)) {
1471
+ throw new BridgeError('tool_call', 'tabId is required');
1472
+ }
1473
+
1474
+ if (!input?.script) {
1475
+ throw new BridgeError('tool_call', 'script is required');
1476
+ }
1477
+
1478
+ const response = await this.tool('javascript_tool', {
1479
+ action: 'javascript_exec',
1480
+ text: String(input.script),
1481
+ tabId: Number(input.tabId),
1482
+ });
1483
+
1484
+ return {
1485
+ source: 'claude-code-native-host',
1486
+ action_taken: 'javascript_exec',
1487
+ tabId: Number(input.tabId),
1488
+ script: String(input.script),
1489
+ result: normalizeToolContent(response.content),
1490
+ warnings: [...this.discovery.warnings],
1491
+ raw_summary: summarizeContent(response.content),
1492
+ };
1493
+ }
1494
+
1495
+ async getPageText(input) {
1496
+ if (!Number.isFinite(input?.tabId)) {
1497
+ throw new BridgeError('tool_call', 'tabId is required');
1498
+ }
1499
+
1500
+ const args = {
1501
+ tabId: Number(input.tabId),
1502
+ };
1503
+
1504
+ if (Number.isFinite(input?.maxChars)) {
1505
+ args.max_chars = Number(input.maxChars);
1506
+ }
1507
+
1508
+ const response = await this.tool('get_page_text', args);
1509
+
1510
+ return {
1511
+ source: 'claude-code-native-host',
1512
+ action_taken: 'get_page_text',
1513
+ tabId: Number(input.tabId),
1514
+ text: extractPrimaryText(response.content),
1515
+ result: normalizeToolContent(response.content),
1516
+ warnings: [...this.discovery.warnings],
1517
+ raw_summary: summarizeContent(response.content),
1518
+ };
1519
+ }
1520
+
1521
+ async readPage(input) {
1522
+ if (!Number.isFinite(input?.tabId)) {
1523
+ throw new BridgeError('tool_call', 'tabId is required');
1524
+ }
1525
+
1526
+ const args = {
1527
+ tabId: Number(input.tabId),
1528
+ };
1529
+
1530
+ if (typeof input?.filter === 'string' && input.filter.length > 0) {
1531
+ args.filter = String(input.filter);
1532
+ }
1533
+
1534
+ if (Number.isFinite(input?.depth)) {
1535
+ args.depth = Number(input.depth);
1536
+ }
1537
+
1538
+ if (typeof input?.refId === 'string' && input.refId.length > 0) {
1539
+ args.ref_id = String(input.refId);
1540
+ }
1541
+
1542
+ if (Number.isFinite(input?.maxChars)) {
1543
+ args.max_chars = Number(input.maxChars);
1544
+ }
1545
+
1546
+ const response = await this.tool('read_page', args);
1547
+
1548
+ return {
1549
+ source: 'claude-code-native-host',
1550
+ action_taken: 'read_page',
1551
+ tabId: Number(input.tabId),
1552
+ filter: args.filter ?? 'all',
1553
+ depth: args.depth ?? 15,
1554
+ refId: args.ref_id ?? null,
1555
+ text: extractPrimaryText(response.content),
1556
+ result: normalizeToolContent(response.content),
1557
+ warnings: [...this.discovery.warnings],
1558
+ raw_summary: summarizeContent(response.content),
1559
+ };
1560
+ }
1561
+
1562
+ async find(input) {
1563
+ if (!Number.isFinite(input?.tabId)) {
1564
+ throw new BridgeError('tool_call', 'tabId is required');
1565
+ }
1566
+
1567
+ if (!input?.query) {
1568
+ throw new BridgeError('tool_call', 'query is required');
1569
+ }
1570
+
1571
+ const response = await this.tool('find', {
1572
+ query: String(input.query),
1573
+ tabId: Number(input.tabId),
1574
+ });
1575
+
1576
+ return {
1577
+ source: 'claude-code-native-host',
1578
+ action_taken: 'find',
1579
+ tabId: Number(input.tabId),
1580
+ query: String(input.query),
1581
+ result: normalizeToolContent(response.content),
1582
+ warnings: [...this.discovery.warnings],
1583
+ raw_summary: summarizeContent(response.content),
1584
+ };
1585
+ }
1586
+
1587
+ async formInput(input) {
1588
+ if (!Number.isFinite(input?.tabId)) {
1589
+ throw new BridgeError('tool_call', 'tabId is required');
1590
+ }
1591
+
1592
+ if (!input?.ref) {
1593
+ throw new BridgeError('tool_call', 'ref is required');
1594
+ }
1595
+
1596
+ if (input.value === undefined) {
1597
+ throw new BridgeError('tool_call', 'value is required');
1598
+ }
1599
+
1600
+ const response = await this.tool('form_input', {
1601
+ ref: String(input.ref),
1602
+ value: input.value,
1603
+ tabId: Number(input.tabId),
1604
+ });
1605
+
1606
+ return {
1607
+ source: 'claude-code-native-host',
1608
+ action_taken: 'form_input',
1609
+ tabId: Number(input.tabId),
1610
+ ref: String(input.ref),
1611
+ value: input.value,
1612
+ result: normalizeToolContent(response.content),
1613
+ warnings: [...this.discovery.warnings],
1614
+ raw_summary: summarizeContent(response.content),
1615
+ };
1616
+ }
1617
+
1618
+ async readConsoleMessages(input) {
1619
+ if (!Number.isFinite(input?.tabId)) {
1620
+ throw new BridgeError('tool_call', 'tabId is required');
1621
+ }
1622
+
1623
+ const args = {
1624
+ tabId: Number(input.tabId),
1625
+ };
1626
+
1627
+ if (input.onlyErrors !== undefined) {
1628
+ args.onlyErrors = Boolean(input.onlyErrors);
1629
+ }
1630
+ if (input.clear !== undefined) {
1631
+ args.clear = Boolean(input.clear);
1632
+ }
1633
+ if (typeof input?.pattern === 'string' && input.pattern.length > 0) {
1634
+ args.pattern = String(input.pattern);
1635
+ }
1636
+ if (Number.isFinite(input?.limit)) {
1637
+ args.limit = Number(input.limit);
1638
+ }
1639
+
1640
+ const response = await this.tool('read_console_messages', args);
1641
+ return {
1642
+ source: 'claude-code-native-host',
1643
+ action_taken: 'read_console_messages',
1644
+ tabId: Number(input.tabId),
1645
+ onlyErrors: Boolean(input.onlyErrors),
1646
+ pattern: args.pattern ?? null,
1647
+ clear: Boolean(input.clear),
1648
+ limit: args.limit ?? 100,
1649
+ result: normalizeToolContent(response.content),
1650
+ warnings: [...this.discovery.warnings],
1651
+ raw_summary: summarizeContent(response.content),
1652
+ };
1653
+ }
1654
+
1655
+ async readNetworkRequests(input) {
1656
+ if (!Number.isFinite(input?.tabId)) {
1657
+ throw new BridgeError('tool_call', 'tabId is required');
1658
+ }
1659
+
1660
+ const args = {
1661
+ tabId: Number(input.tabId),
1662
+ };
1663
+
1664
+ if (typeof input?.urlPattern === 'string' && input.urlPattern.length > 0) {
1665
+ args.urlPattern = String(input.urlPattern);
1666
+ }
1667
+ if (input.clear !== undefined) {
1668
+ args.clear = Boolean(input.clear);
1669
+ }
1670
+ if (Number.isFinite(input?.limit)) {
1671
+ args.limit = Number(input.limit);
1672
+ }
1673
+
1674
+ const response = await this.tool('read_network_requests', args);
1675
+ return {
1676
+ source: 'claude-code-native-host',
1677
+ action_taken: 'read_network_requests',
1678
+ tabId: Number(input.tabId),
1679
+ urlPattern: args.urlPattern ?? null,
1680
+ clear: Boolean(input.clear),
1681
+ limit: args.limit ?? 100,
1682
+ result: normalizeToolContent(response.content),
1683
+ warnings: [...this.discovery.warnings],
1684
+ raw_summary: summarizeContent(response.content),
1685
+ };
1686
+ }
1687
+
1688
+ async uploadFile(input) {
1689
+ if (!Number.isFinite(input?.tabId)) {
1690
+ throw new BridgeError('tool_call', 'tabId is required');
1691
+ }
1692
+ if (!Array.isArray(input?.paths) || input.paths.length === 0) {
1693
+ throw new BridgeError(
1694
+ 'tool_call',
1695
+ 'paths is required and must be a non-empty array',
1696
+ );
1697
+ }
1698
+ const hasRef = typeof input?.ref === 'string' && input.ref.length > 0;
1699
+ const hasSelector =
1700
+ typeof input?.selector === 'string' && input.selector.length > 0;
1701
+ if (!hasRef && !hasSelector) {
1702
+ throw new BridgeError('tool_call', 'ref or selector is required');
1703
+ }
1704
+
1705
+ let result = null;
1706
+ let rawSummary = null;
1707
+ let warnings = [...this.discovery.warnings];
1708
+
1709
+ if (hasSelector) {
1710
+ const files = input.paths.map((entry) => encodeFileArtifact(String(entry)));
1711
+ result = await this.runUploadScript({
1712
+ tabId: Number(input.tabId),
1713
+ selector: String(input.selector),
1714
+ files,
1715
+ allowDrop: false,
1716
+ });
1717
+ rawSummary = result;
1718
+ warnings = [...warnings, 'selector_upload_path_used'];
1719
+ } else {
1720
+ const response = await this.tool('file_upload', {
1721
+ tabId: Number(input.tabId),
1722
+ ref: String(input.ref),
1723
+ paths: input.paths.map((entry) => String(entry)),
1724
+ });
1725
+ result = normalizeToolContent(response.content);
1726
+ rawSummary = summarizeContent(response.content);
1727
+ }
1728
+
1729
+ return {
1730
+ source: 'claude-code-native-host',
1731
+ action_taken: 'file_upload',
1732
+ tabId: Number(input.tabId),
1733
+ ref: hasRef ? String(input.ref) : null,
1734
+ selector: hasSelector ? String(input.selector) : null,
1735
+ paths: input.paths.map((entry) => String(entry)),
1736
+ result,
1737
+ warnings,
1738
+ raw_summary: rawSummary,
1739
+ };
1740
+ }
1741
+
1742
+ async uploadImage(input) {
1743
+ if (!Number.isFinite(input?.tabId)) {
1744
+ throw new BridgeError('tool_call', 'tabId is required');
1745
+ }
1746
+ if (!input?.imageId && !input?.path) {
1747
+ throw new BridgeError('tool_call', 'imageId or path is required');
1748
+ }
1749
+ const hasRef = typeof input?.ref === 'string' && input.ref.length > 0;
1750
+ const hasSelector =
1751
+ typeof input?.selector === 'string' && input.selector.length > 0;
1752
+ const hasCoordinate =
1753
+ Array.isArray(input?.coordinate) ||
1754
+ (Number.isFinite(input?.x) && Number.isFinite(input?.y));
1755
+ const targetCount = [hasRef, hasSelector, hasCoordinate].filter(Boolean).length;
1756
+ if (targetCount === 0) {
1757
+ throw new BridgeError(
1758
+ 'tool_call',
1759
+ 'one target is required: ref, selector, or coordinate/x/y',
1760
+ );
1761
+ }
1762
+ if (targetCount > 1) {
1763
+ throw new BridgeError(
1764
+ 'tool_call',
1765
+ 'provide exactly one target: ref, selector, or coordinate/x/y',
1766
+ );
1767
+ }
1768
+
1769
+ let fileEntry = null;
1770
+ if (input?.imageId) {
1771
+ fileEntry = this.imageCache.get(String(input.imageId)) ?? null;
1772
+ }
1773
+ if (!fileEntry && input?.path) {
1774
+ fileEntry = encodeFileArtifact(String(input.path));
1775
+ }
1776
+ if (!fileEntry) {
1777
+ const response = await this.tool('upload_image', {
1778
+ tabId: Number(input.tabId),
1779
+ imageId: String(input.imageId),
1780
+ ...(hasRef
1781
+ ? { ref: String(input.ref) }
1782
+ : { coordinate: normalizeCoordinate(input) }),
1783
+ ...(typeof input?.filename === 'string' && input.filename.length > 0
1784
+ ? { filename: String(input.filename) }
1785
+ : {}),
1786
+ });
1787
+ return {
1788
+ source: 'claude-code-native-host',
1789
+ action_taken: 'upload_image',
1790
+ tabId: Number(input.tabId),
1791
+ imageId: input?.imageId ? String(input.imageId) : null,
1792
+ ref: hasRef ? String(input.ref) : null,
1793
+ coordinate: hasCoordinate ? normalizeCoordinate(input) : null,
1794
+ filename: input?.filename ? String(input.filename) : null,
1795
+ result: normalizeToolContent(response.content),
1796
+ warnings: [...this.discovery.warnings, 'image_cache_miss_fallback_to_downstream'],
1797
+ raw_summary: summarizeContent(response.content),
1798
+ };
1799
+ }
1800
+
1801
+ const fileName =
1802
+ typeof input?.filename === 'string' && input.filename.length > 0
1803
+ ? String(input.filename)
1804
+ : fileEntry.name;
1805
+ const coordinate = hasCoordinate ? normalizeCoordinate(input) : null;
1806
+ let uploadResult = null;
1807
+ let warnings = [...this.discovery.warnings];
1808
+
1809
+ if (hasRef) {
1810
+ const tempPath = writeTempArtifact({ ...fileEntry, name: fileName }, fileName);
1811
+ const response = await this.tool('file_upload', {
1812
+ tabId: Number(input.tabId),
1813
+ ref: String(input.ref),
1814
+ paths: [tempPath],
1815
+ });
1816
+ uploadResult = normalizeToolContent(response.content);
1817
+ } else if (hasSelector) {
1818
+ uploadResult = await this.runUploadScript({
1819
+ tabId: Number(input.tabId),
1820
+ selector: String(input.selector),
1821
+ files: [{ ...fileEntry, name: fileName }],
1822
+ allowDrop: true,
1823
+ });
1824
+ warnings = [...warnings, 'selector_upload_path_used'];
1825
+ } else {
1826
+ uploadResult = await this.runUploadScript({
1827
+ tabId: Number(input.tabId),
1828
+ coordinate,
1829
+ files: [{ ...fileEntry, name: fileName }],
1830
+ allowDrop: true,
1831
+ });
1832
+ }
1833
+
1834
+ return {
1835
+ source: 'claude-code-native-host',
1836
+ action_taken: 'upload_image',
1837
+ tabId: Number(input.tabId),
1838
+ imageId: input?.imageId ? String(input.imageId) : null,
1839
+ path: input?.path ? String(input.path) : null,
1840
+ ref: hasRef ? String(input.ref) : null,
1841
+ selector: hasSelector ? String(input.selector) : null,
1842
+ coordinate,
1843
+ filename: fileName,
1844
+ result: uploadResult,
1845
+ warnings,
1846
+ raw_summary: uploadResult,
1847
+ };
1848
+ }
1849
+
1850
+ async screenshot(input) {
1851
+ if (!Number.isFinite(input?.tabId)) {
1852
+ throw new BridgeError('tool_call', 'tabId is required');
1853
+ }
1854
+
1855
+ const response = await this.tool('computer', {
1856
+ action: 'screenshot',
1857
+ tabId: Number(input.tabId),
1858
+ });
1859
+ const metadata = extractScreenshotMetadata(response.content);
1860
+ const image = extractImageItem(response.content);
1861
+ const imageId =
1862
+ metadata?.imageId ??
1863
+ extractImageIdFromContent(response.content) ??
1864
+ `image_${Date.now().toString(36)}`;
1865
+
1866
+ if (image) {
1867
+ cacheImageEntry(
1868
+ imageId,
1869
+ {
1870
+ ...image,
1871
+ name: `screenshot-${imageId}.${metadata?.format === 'jpeg' ? 'jpg' : 'png'}`,
1872
+ },
1873
+ this.imageCache,
1874
+ );
1875
+ }
1876
+
1877
+ return {
1878
+ source: 'claude-code-native-host',
1879
+ action_taken: 'screenshot',
1880
+ tabId: Number(input.tabId),
1881
+ imageId,
1882
+ width: metadata?.width ?? null,
1883
+ height: metadata?.height ?? null,
1884
+ format: metadata?.format ?? image?.mediaType ?? null,
1885
+ cached: Boolean(image),
1886
+ warnings: [...this.discovery.warnings],
1887
+ raw_summary: summarizeContent(response.content),
1888
+ };
1889
+ }
1890
+
1891
+ async resizeWindow(input) {
1892
+ if (!Number.isFinite(input?.tabId)) {
1893
+ throw new BridgeError('tool_call', 'tabId is required');
1894
+ }
1895
+ if (!Number.isFinite(input?.width) || !Number.isFinite(input?.height)) {
1896
+ throw new BridgeError('tool_call', 'width and height are required');
1897
+ }
1898
+
1899
+ const response = await this.tool('resize_window', {
1900
+ tabId: Number(input.tabId),
1901
+ width: Number(input.width),
1902
+ height: Number(input.height),
1903
+ });
1904
+ return {
1905
+ source: 'claude-code-native-host',
1906
+ action_taken: 'resize_window',
1907
+ tabId: Number(input.tabId),
1908
+ width: Number(input.width),
1909
+ height: Number(input.height),
1910
+ result: normalizeToolContent(response.content),
1911
+ warnings: [...this.discovery.warnings],
1912
+ raw_summary: summarizeContent(response.content),
1913
+ };
1914
+ }
1915
+
1916
+ async ensureSessionContext(createIfEmpty = false) {
1917
+ return this.tool('tabs_context_mcp', {
1918
+ createIfEmpty: Boolean(createIfEmpty),
1919
+ });
1920
+ }
1921
+
1922
+ async runUploadScript({
1923
+ tabId,
1924
+ ref = null,
1925
+ selector = null,
1926
+ coordinate = null,
1927
+ files,
1928
+ allowDrop,
1929
+ }) {
1930
+ const script = buildUploadScript({
1931
+ files,
1932
+ ref,
1933
+ selector,
1934
+ coordinate,
1935
+ allowDrop,
1936
+ });
1937
+ const result = await this.javascriptExec({
1938
+ tabId,
1939
+ script,
1940
+ });
1941
+ const parsed =
1942
+ typeof result.result === 'string' ? safeJsonParse(result.result) : result.result;
1943
+
1944
+ if (!parsed?.ok) {
1945
+ throw new BridgeError(
1946
+ 'tool_call',
1947
+ parsed?.error ?? 'upload script failed',
1948
+ parsed ?? result,
1949
+ );
1950
+ }
1951
+
1952
+ return parsed;
1953
+ }
1954
+ }
1955
+
1956
+ async function createAdapter() {
1957
+ const discovery = await discoverEnvironment();
1958
+ const resolved = await resolveUsableSocket(discovery);
1959
+ return new ClaudeChromeAdapter(resolved.discovery, {
1960
+ candidateAttempts: resolved.attempts,
1961
+ initialProbe: resolved.probe,
1962
+ sessionScope: SESSION_SCOPE,
1963
+ imageCache: SHARED_IMAGE_CACHE,
1964
+ });
1965
+ }
1966
+
1967
+ async function runProbe() {
1968
+ const discovery = await discoverEnvironment();
1969
+
1970
+ if (!discovery.processes.some((entry) => entry.socketExists)) {
1971
+ return {
1972
+ source: 'claude-code-native-host',
1973
+ host_process: discovery.selectedProcess,
1974
+ launcher_target: discovery.launcherTarget,
1975
+ socket_path: discovery.selectedProcess?.socketPath ?? null,
1976
+ connect_ok: false,
1977
+ status_ok: false,
1978
+ warnings: [...discovery.warnings],
1979
+ failure_stage: 'discover',
1980
+ failure_reason: 'no live native-host socket found',
1981
+ };
1982
+ }
1983
+
1984
+ try {
1985
+ const resolved = await resolveUsableSocket(discovery);
1986
+ const adapter = new ClaudeChromeAdapter(resolved.discovery, {
1987
+ candidateAttempts: resolved.attempts,
1988
+ initialProbe: resolved.probe,
1989
+ });
1990
+ return await adapter.health();
1991
+ } catch (error) {
1992
+ return {
1993
+ source: 'claude-code-native-host',
1994
+ host_process: discovery.selectedProcess,
1995
+ launcher_target: discovery.launcherTarget,
1996
+ socket_path: discovery.selectedProcess?.socketPath ?? null,
1997
+ connect_ok: error.stage !== 'discover',
1998
+ status_ok: false,
1999
+ warnings: [...discovery.warnings],
2000
+ failure_stage: error.stage ?? 'unknown',
2001
+ failure_reason: error.message,
2002
+ };
2003
+ }
2004
+ }
2005
+
2006
+ function toolDefinitions() {
2007
+ return [
2008
+ {
2009
+ name: 'browser_health',
2010
+ description:
2011
+ 'Discover the live Claude Code Chrome native-host socket and report bridge health.',
2012
+ inputSchema: {
2013
+ type: 'object',
2014
+ additionalProperties: false,
2015
+ properties: {},
2016
+ },
2017
+ },
2018
+ {
2019
+ name: 'browser_snapshot',
2020
+ description:
2021
+ 'Return the current browser/tab context exposed by the local Claude in Chrome bridge.',
2022
+ inputSchema: {
2023
+ type: 'object',
2024
+ additionalProperties: false,
2025
+ properties: {},
2026
+ },
2027
+ },
2028
+ {
2029
+ name: 'browser_tabs_context',
2030
+ description:
2031
+ 'Return the current MCP tab-group context, optionally creating it if no session tab group exists.',
2032
+ inputSchema: {
2033
+ type: 'object',
2034
+ additionalProperties: false,
2035
+ properties: {
2036
+ createIfEmpty: { type: 'boolean' },
2037
+ },
2038
+ },
2039
+ },
2040
+ {
2041
+ name: 'browser_create_tab',
2042
+ description:
2043
+ 'Create a new empty tab in the MCP tab group, creating the group first when needed.',
2044
+ inputSchema: {
2045
+ type: 'object',
2046
+ additionalProperties: false,
2047
+ properties: {},
2048
+ },
2049
+ },
2050
+ {
2051
+ name: 'browser_navigate_tab',
2052
+ description:
2053
+ 'Navigate a specific tab to a URL through the local Claude in Chrome bridge.',
2054
+ inputSchema: {
2055
+ type: 'object',
2056
+ additionalProperties: false,
2057
+ required: ['tabId', 'url'],
2058
+ properties: {
2059
+ tabId: { type: 'integer' },
2060
+ url: { type: 'string' },
2061
+ force: { type: 'boolean' },
2062
+ },
2063
+ },
2064
+ },
2065
+ {
2066
+ name: 'browser_open_or_focus',
2067
+ description:
2068
+ 'Open a new tab for a URL via the local bridge, or return a snapshot for an existing tab when downstream focus is unavailable.',
2069
+ inputSchema: {
2070
+ type: 'object',
2071
+ additionalProperties: false,
2072
+ properties: {
2073
+ url: { type: 'string' },
2074
+ tabId: { type: 'integer' },
2075
+ },
2076
+ },
2077
+ },
2078
+ {
2079
+ name: 'browser_reuse_tab',
2080
+ description:
2081
+ 'Confirm that an existing visible tab can be reused by tabId or exact URL, and return the current browser context.',
2082
+ inputSchema: {
2083
+ type: 'object',
2084
+ additionalProperties: false,
2085
+ properties: {
2086
+ tabId: { type: 'integer' },
2087
+ url: { type: 'string' },
2088
+ },
2089
+ },
2090
+ },
2091
+ {
2092
+ name: 'browser_close_tab',
2093
+ description:
2094
+ 'Close a specific tab by tabId through the local Claude in Chrome bridge and return the remaining browser context.',
2095
+ inputSchema: {
2096
+ type: 'object',
2097
+ additionalProperties: false,
2098
+ required: ['tabId'],
2099
+ properties: {
2100
+ tabId: { type: 'integer' },
2101
+ },
2102
+ },
2103
+ },
2104
+ {
2105
+ name: 'browser_javascript_exec',
2106
+ description:
2107
+ 'Execute JavaScript in a specific tab through the local Claude in Chrome bridge.',
2108
+ inputSchema: {
2109
+ type: 'object',
2110
+ additionalProperties: false,
2111
+ required: ['tabId', 'script'],
2112
+ properties: {
2113
+ tabId: { type: 'integer' },
2114
+ script: { type: 'string' },
2115
+ },
2116
+ },
2117
+ },
2118
+ {
2119
+ name: 'browser_get_page_text',
2120
+ description:
2121
+ 'Extract plain text from a specific tab through the local Claude in Chrome bridge.',
2122
+ inputSchema: {
2123
+ type: 'object',
2124
+ additionalProperties: false,
2125
+ required: ['tabId'],
2126
+ properties: {
2127
+ tabId: { type: 'integer' },
2128
+ maxChars: { type: 'integer' },
2129
+ },
2130
+ },
2131
+ },
2132
+ {
2133
+ name: 'browser_read_page',
2134
+ description:
2135
+ 'Read an accessibility-tree style view of a specific tab or subtree through the local Claude in Chrome bridge.',
2136
+ inputSchema: {
2137
+ type: 'object',
2138
+ additionalProperties: false,
2139
+ required: ['tabId'],
2140
+ properties: {
2141
+ tabId: { type: 'integer' },
2142
+ filter: { type: 'string', enum: ['interactive', 'all'] },
2143
+ depth: { type: 'integer' },
2144
+ refId: { type: 'string' },
2145
+ maxChars: { type: 'integer' },
2146
+ },
2147
+ },
2148
+ },
2149
+ {
2150
+ name: 'browser_find',
2151
+ description:
2152
+ 'Find an element in a specific tab using natural language through the local Claude in Chrome bridge.',
2153
+ inputSchema: {
2154
+ type: 'object',
2155
+ additionalProperties: false,
2156
+ required: ['tabId', 'query'],
2157
+ properties: {
2158
+ tabId: { type: 'integer' },
2159
+ query: { type: 'string' },
2160
+ },
2161
+ },
2162
+ },
2163
+ {
2164
+ name: 'browser_form_input',
2165
+ description:
2166
+ 'Set a form control value by ref in a specific tab through the local Claude in Chrome bridge.',
2167
+ inputSchema: {
2168
+ type: 'object',
2169
+ additionalProperties: false,
2170
+ required: ['tabId', 'ref', 'value'],
2171
+ properties: {
2172
+ tabId: { type: 'integer' },
2173
+ ref: { type: 'string' },
2174
+ value: {},
2175
+ },
2176
+ },
2177
+ },
2178
+ {
2179
+ name: 'browser_console_messages',
2180
+ description:
2181
+ 'Read tracked browser console messages from a specific tab through the local Claude in Chrome bridge.',
2182
+ inputSchema: {
2183
+ type: 'object',
2184
+ additionalProperties: false,
2185
+ required: ['tabId'],
2186
+ properties: {
2187
+ tabId: { type: 'integer' },
2188
+ onlyErrors: { type: 'boolean' },
2189
+ clear: { type: 'boolean' },
2190
+ pattern: { type: 'string' },
2191
+ limit: { type: 'integer' },
2192
+ },
2193
+ },
2194
+ },
2195
+ {
2196
+ name: 'browser_network_requests',
2197
+ description:
2198
+ 'Read tracked network requests from a specific tab through the local Claude in Chrome bridge.',
2199
+ inputSchema: {
2200
+ type: 'object',
2201
+ additionalProperties: false,
2202
+ required: ['tabId'],
2203
+ properties: {
2204
+ tabId: { type: 'integer' },
2205
+ urlPattern: { type: 'string' },
2206
+ clear: { type: 'boolean' },
2207
+ limit: { type: 'integer' },
2208
+ },
2209
+ },
2210
+ },
2211
+ {
2212
+ name: 'browser_computer',
2213
+ description:
2214
+ 'Run the underlying CiC computer tool directly for browser-facing actions such as hover, key, scroll, drag, right-click, double-click, wait, zoom, and scroll_to.',
2215
+ inputSchema: {
2216
+ type: 'object',
2217
+ additionalProperties: false,
2218
+ required: ['tabId', 'action'],
2219
+ properties: {
2220
+ tabId: { type: 'integer' },
2221
+ action: {
2222
+ type: 'string',
2223
+ enum: [
2224
+ 'left_click',
2225
+ 'right_click',
2226
+ 'type',
2227
+ 'screenshot',
2228
+ 'wait',
2229
+ 'scroll',
2230
+ 'key',
2231
+ 'left_click_drag',
2232
+ 'double_click',
2233
+ 'triple_click',
2234
+ 'zoom',
2235
+ 'scroll_to',
2236
+ 'hover',
2237
+ ],
2238
+ },
2239
+ ref: { type: 'string' },
2240
+ coordinate: {
2241
+ type: 'array',
2242
+ minItems: 2,
2243
+ maxItems: 2,
2244
+ items: { type: 'number' },
2245
+ },
2246
+ x: { type: 'number' },
2247
+ y: { type: 'number' },
2248
+ text: { type: 'string' },
2249
+ duration: { type: 'number' },
2250
+ scrollDirection: {
2251
+ type: 'string',
2252
+ enum: ['up', 'down', 'left', 'right'],
2253
+ },
2254
+ scroll_direction: {
2255
+ type: 'string',
2256
+ enum: ['up', 'down', 'left', 'right'],
2257
+ },
2258
+ scrollAmount: { type: 'number' },
2259
+ scroll_amount: { type: 'number' },
2260
+ startCoordinate: {
2261
+ type: 'array',
2262
+ minItems: 2,
2263
+ maxItems: 2,
2264
+ items: { type: 'number' },
2265
+ },
2266
+ start_coordinate: {
2267
+ type: 'array',
2268
+ minItems: 2,
2269
+ maxItems: 2,
2270
+ items: { type: 'number' },
2271
+ },
2272
+ startX: { type: 'number' },
2273
+ startY: { type: 'number' },
2274
+ region: {
2275
+ type: 'array',
2276
+ minItems: 4,
2277
+ maxItems: 4,
2278
+ items: { type: 'number' },
2279
+ },
2280
+ repeat: { type: 'integer' },
2281
+ modifiers: { type: 'string' },
2282
+ },
2283
+ },
2284
+ },
2285
+ {
2286
+ name: 'browser_click',
2287
+ description:
2288
+ 'Click at viewport coordinates in a specific tab through the local Claude in Chrome bridge.',
2289
+ inputSchema: {
2290
+ type: 'object',
2291
+ additionalProperties: false,
2292
+ required: ['tabId'],
2293
+ properties: {
2294
+ tabId: { type: 'integer' },
2295
+ coordinate: {
2296
+ type: 'array',
2297
+ minItems: 2,
2298
+ maxItems: 2,
2299
+ items: { type: 'number' },
2300
+ },
2301
+ x: { type: 'number' },
2302
+ y: { type: 'number' },
2303
+ },
2304
+ },
2305
+ },
2306
+ {
2307
+ name: 'browser_type',
2308
+ description:
2309
+ 'Type text into the current focused target in a specific tab. Optional coordinates will click first.',
2310
+ inputSchema: {
2311
+ type: 'object',
2312
+ additionalProperties: false,
2313
+ required: ['tabId', 'text'],
2314
+ properties: {
2315
+ tabId: { type: 'integer' },
2316
+ text: { type: 'string' },
2317
+ coordinate: {
2318
+ type: 'array',
2319
+ minItems: 2,
2320
+ maxItems: 2,
2321
+ items: { type: 'number' },
2322
+ },
2323
+ x: { type: 'number' },
2324
+ y: { type: 'number' },
2325
+ },
2326
+ },
2327
+ },
2328
+ {
2329
+ name: 'browser_screenshot',
2330
+ description:
2331
+ 'Capture a screenshot for a specific tab and cache the resulting image for later browser_upload_image calls.',
2332
+ inputSchema: {
2333
+ type: 'object',
2334
+ additionalProperties: false,
2335
+ required: ['tabId'],
2336
+ properties: {
2337
+ tabId: { type: 'integer' },
2338
+ },
2339
+ },
2340
+ },
2341
+ {
2342
+ name: 'browser_upload_file',
2343
+ description:
2344
+ 'Upload one or more local files to a file input ref or CSS selector in a specific tab through the local Claude in Chrome bridge.',
2345
+ inputSchema: {
2346
+ type: 'object',
2347
+ additionalProperties: false,
2348
+ required: ['tabId', 'paths'],
2349
+ properties: {
2350
+ tabId: { type: 'integer' },
2351
+ ref: { type: 'string' },
2352
+ selector: { type: 'string' },
2353
+ paths: {
2354
+ type: 'array',
2355
+ minItems: 1,
2356
+ items: { type: 'string' },
2357
+ },
2358
+ },
2359
+ },
2360
+ },
2361
+ {
2362
+ name: 'browser_upload_image',
2363
+ description:
2364
+ 'Upload an image from browser_screenshot(imageId) or a local image path to a file input ref, CSS selector, or viewport coordinate in a specific tab.',
2365
+ inputSchema: {
2366
+ type: 'object',
2367
+ additionalProperties: false,
2368
+ required: ['tabId'],
2369
+ properties: {
2370
+ tabId: { type: 'integer' },
2371
+ imageId: { type: 'string' },
2372
+ path: { type: 'string' },
2373
+ ref: { type: 'string' },
2374
+ selector: { type: 'string' },
2375
+ coordinate: {
2376
+ type: 'array',
2377
+ minItems: 2,
2378
+ maxItems: 2,
2379
+ items: { type: 'number' },
2380
+ },
2381
+ x: { type: 'number' },
2382
+ y: { type: 'number' },
2383
+ filename: { type: 'string' },
2384
+ },
2385
+ },
2386
+ },
2387
+ {
2388
+ name: 'browser_resize_window',
2389
+ description:
2390
+ 'Resize the browser window that contains a specific tab through the local Claude in Chrome bridge.',
2391
+ inputSchema: {
2392
+ type: 'object',
2393
+ additionalProperties: false,
2394
+ required: ['tabId', 'width', 'height'],
2395
+ properties: {
2396
+ tabId: { type: 'integer' },
2397
+ width: { type: 'number' },
2398
+ height: { type: 'number' },
2399
+ },
2400
+ },
2401
+ },
2402
+ ];
2403
+ }
2404
+
2405
+ async function handleToolCall(name, args) {
2406
+ const adapter = await createAdapter();
2407
+
2408
+ switch (name) {
2409
+ case 'browser_health':
2410
+ return adapter.health();
2411
+ case 'browser_snapshot':
2412
+ return adapter.snapshot();
2413
+ case 'browser_tabs_context':
2414
+ return adapter.tabsContext(args ?? {});
2415
+ case 'browser_create_tab':
2416
+ return adapter.createTab(args ?? {});
2417
+ case 'browser_navigate_tab':
2418
+ return adapter.navigateTab(args ?? {});
2419
+ case 'browser_open_or_focus':
2420
+ return adapter.openOrFocus(args ?? {});
2421
+ case 'browser_reuse_tab':
2422
+ return adapter.reuseTab(args ?? {});
2423
+ case 'browser_close_tab':
2424
+ return adapter.closeTab(args ?? {});
2425
+ case 'browser_javascript_exec':
2426
+ return adapter.javascriptExec(args ?? {});
2427
+ case 'browser_get_page_text':
2428
+ return adapter.getPageText(args ?? {});
2429
+ case 'browser_read_page':
2430
+ return adapter.readPage(args ?? {});
2431
+ case 'browser_find':
2432
+ return adapter.find(args ?? {});
2433
+ case 'browser_form_input':
2434
+ return adapter.formInput(args ?? {});
2435
+ case 'browser_console_messages':
2436
+ return adapter.readConsoleMessages(args ?? {});
2437
+ case 'browser_network_requests':
2438
+ return adapter.readNetworkRequests(args ?? {});
2439
+ case 'browser_computer':
2440
+ return adapter.computer(args ?? {});
2441
+ case 'browser_click':
2442
+ return adapter.click(args ?? {});
2443
+ case 'browser_type':
2444
+ return adapter.type(args ?? {});
2445
+ case 'browser_screenshot':
2446
+ return adapter.screenshot(args ?? {});
2447
+ case 'browser_upload_file':
2448
+ return adapter.uploadFile(args ?? {});
2449
+ case 'browser_upload_image':
2450
+ return adapter.uploadImage(args ?? {});
2451
+ case 'browser_resize_window':
2452
+ return adapter.resizeWindow(args ?? {});
2453
+ default:
2454
+ throw new BridgeError('tool_call', `unknown tool: ${name}`);
2455
+ }
2456
+ }
2457
+
2458
+ async function withMcpToolCallTimeout(name, args, action) {
2459
+ return new Promise((resolve, reject) => {
2460
+ let settled = false;
2461
+ const timer = setTimeout(() => {
2462
+ if (settled) {
2463
+ return;
2464
+ }
2465
+ settled = true;
2466
+ reject(
2467
+ new BridgeError(
2468
+ 'tool_timeout',
2469
+ `MCP tool call timed out waiting for ${name}`,
2470
+ {
2471
+ tool: name,
2472
+ timeoutMs: MCP_TOOL_CALL_TIMEOUT_MS,
2473
+ arguments: args,
2474
+ },
2475
+ ),
2476
+ );
2477
+ }, MCP_TOOL_CALL_TIMEOUT_MS);
2478
+
2479
+ Promise.resolve()
2480
+ .then(action)
2481
+ .then((result) => {
2482
+ if (settled) {
2483
+ return;
2484
+ }
2485
+ settled = true;
2486
+ clearTimeout(timer);
2487
+ resolve(result);
2488
+ })
2489
+ .catch((error) => {
2490
+ if (settled) {
2491
+ return;
2492
+ }
2493
+ settled = true;
2494
+ clearTimeout(timer);
2495
+ reject(error);
2496
+ });
2497
+ });
2498
+ }
2499
+
2500
+ function rpcSuccess(id, result) {
2501
+ return {
2502
+ jsonrpc: '2.0',
2503
+ id,
2504
+ result,
2505
+ };
2506
+ }
2507
+
2508
+ function rpcError(id, code, message, data = undefined) {
2509
+ return {
2510
+ jsonrpc: '2.0',
2511
+ id,
2512
+ error: {
2513
+ code,
2514
+ message,
2515
+ data,
2516
+ },
2517
+ };
2518
+ }
2519
+
2520
+ function negotiateProtocolVersion(requestedVersion) {
2521
+ if (
2522
+ typeof requestedVersion === 'string' &&
2523
+ SUPPORTED_PROTOCOL_VERSIONS.includes(requestedVersion)
2524
+ ) {
2525
+ return requestedVersion;
2526
+ }
2527
+
2528
+ return DEFAULT_PROTOCOL_VERSION;
2529
+ }
2530
+
2531
+ function encodeRpc(message) {
2532
+ const json = JSON.stringify(message);
2533
+ return `Content-Length: ${Buffer.byteLength(json, 'utf8')}\r\n\r\n${json}`;
2534
+ }
2535
+
2536
+ function encodeRawRpc(message) {
2537
+ return `${JSON.stringify(message)}\n`;
2538
+ }
2539
+
2540
+ function findHeaderBoundary(buffer) {
2541
+ const crlf = buffer.indexOf('\r\n\r\n');
2542
+ if (crlf !== -1) {
2543
+ return { index: crlf, length: 4 };
2544
+ }
2545
+
2546
+ const lf = buffer.indexOf('\n\n');
2547
+ if (lf !== -1) {
2548
+ return { index: lf, length: 2 };
2549
+ }
2550
+
2551
+ return null;
2552
+ }
2553
+
2554
+ function detectTransportMode(buffer) {
2555
+ const preview = buffer.subarray(0, 64).toString('utf8').trimStart();
2556
+
2557
+ if (preview.startsWith('Content-Length:')) {
2558
+ return 'content-length';
2559
+ }
2560
+
2561
+ if (preview.startsWith('{')) {
2562
+ return 'raw-json';
2563
+ }
2564
+
2565
+ return null;
2566
+ }
2567
+
2568
+ function tryConsumeRawJsonMessage(buffer) {
2569
+ const text = buffer.toString('utf8');
2570
+ const newlineIndex = text.indexOf('\n');
2571
+
2572
+ if (newlineIndex !== -1) {
2573
+ const rawLine = text.slice(0, newlineIndex).trim();
2574
+ const consumedBytes = Buffer.byteLength(text.slice(0, newlineIndex + 1), 'utf8');
2575
+
2576
+ if (!rawLine) {
2577
+ return {
2578
+ message: null,
2579
+ consumedBytes,
2580
+ };
2581
+ }
2582
+
2583
+ const message = safeJsonParse(rawLine);
2584
+ if (!message) {
2585
+ throw new BridgeError('response_parse', 'invalid raw JSON-RPC body');
2586
+ }
2587
+
2588
+ return {
2589
+ message,
2590
+ consumedBytes,
2591
+ };
2592
+ }
2593
+
2594
+ const trimmed = text.trim();
2595
+ if (!trimmed) {
2596
+ return null;
2597
+ }
2598
+
2599
+ const message = safeJsonParse(trimmed);
2600
+ if (!message) {
2601
+ return null;
2602
+ }
2603
+
2604
+ return {
2605
+ message,
2606
+ consumedBytes: buffer.length,
2607
+ };
2608
+ }
2609
+
2610
+ function writeRpc(message, transportMode = 'content-length') {
2611
+ traceMcp('outgoing', message);
2612
+ process.stdout.write(
2613
+ transportMode === 'raw-json' ? encodeRawRpc(message) : encodeRpc(message),
2614
+ );
2615
+ }
2616
+
2617
+ function toolResultPayload(value, isError = false) {
2618
+ return {
2619
+ content: [
2620
+ {
2621
+ type: 'text',
2622
+ text: JSON.stringify(value, null, 2),
2623
+ },
2624
+ ],
2625
+ structuredContent: value,
2626
+ isError,
2627
+ };
2628
+ }
2629
+
2630
+ async function runMcpServer() {
2631
+ const stdin = process.stdin;
2632
+ let buffer = Buffer.alloc(0);
2633
+ let transportMode = null;
2634
+ const emitRpc = (message) => writeRpc(message, transportMode ?? 'content-length');
2635
+
2636
+ traceMcp('startup', {
2637
+ argv: process.argv,
2638
+ cwd: process.cwd(),
2639
+ trace_path: MCP_TRACE_PATH,
2640
+ });
2641
+
2642
+ stdin.on('data', async (chunk) => {
2643
+ traceMcp('stdin_chunk', {
2644
+ bytes: chunk.length,
2645
+ preview: chunk
2646
+ .subarray(0, 120)
2647
+ .toString('utf8')
2648
+ .replace(/\r/g, '\\r')
2649
+ .replace(/\n/g, '\\n'),
2650
+ });
2651
+ buffer = Buffer.concat([buffer, chunk]);
2652
+
2653
+ while (true) {
2654
+ if (!transportMode) {
2655
+ transportMode = detectTransportMode(buffer);
2656
+
2657
+ if (transportMode) {
2658
+ traceMcp('transport_mode', { transportMode });
2659
+ }
2660
+ }
2661
+
2662
+ if (transportMode === 'raw-json') {
2663
+ const rawFrame = tryConsumeRawJsonMessage(buffer);
2664
+ if (!rawFrame) {
2665
+ return;
2666
+ }
2667
+
2668
+ buffer = buffer.subarray(rawFrame.consumedBytes);
2669
+
2670
+ if (!rawFrame.message) {
2671
+ continue;
2672
+ }
2673
+
2674
+ const message = rawFrame.message;
2675
+ traceMcp('incoming', message);
2676
+
2677
+ try {
2678
+ if (message.method === 'initialize') {
2679
+ const protocolVersion = negotiateProtocolVersion(
2680
+ message.params?.protocolVersion,
2681
+ );
2682
+ emitRpc(
2683
+ rpcSuccess(message.id, {
2684
+ protocolVersion,
2685
+ capabilities: {
2686
+ tools: {
2687
+ listChanged: false,
2688
+ },
2689
+ },
2690
+ serverInfo: {
2691
+ name: 'codex-chrome-bridge',
2692
+ version: '0.1.0',
2693
+ },
2694
+ instructions:
2695
+ 'Repo-local MCP wrapper around the Claude Code Chrome native-host bridge.',
2696
+ }),
2697
+ );
2698
+ continue;
2699
+ }
2700
+
2701
+ if (message.method === 'notifications/initialized') {
2702
+ continue;
2703
+ }
2704
+
2705
+ if (message.method === 'notifications/cancelled') {
2706
+ continue;
2707
+ }
2708
+
2709
+ if (message.method === 'ping') {
2710
+ emitRpc(rpcSuccess(message.id, {}));
2711
+ continue;
2712
+ }
2713
+
2714
+ if (message.method === 'tools/list') {
2715
+ emitRpc(
2716
+ rpcSuccess(message.id, {
2717
+ tools: toolDefinitions(),
2718
+ }),
2719
+ );
2720
+ continue;
2721
+ }
2722
+
2723
+ if (message.method === 'tools/call') {
2724
+ const toolName = message.params?.name;
2725
+ const args = message.params?.arguments ?? {};
2726
+
2727
+ try {
2728
+ const result = await withMcpToolCallTimeout(toolName, args, () =>
2729
+ handleToolCall(toolName, args),
2730
+ );
2731
+ emitRpc(rpcSuccess(message.id, toolResultPayload(result, false)));
2732
+ } catch (error) {
2733
+ const payload = {
2734
+ ok: false,
2735
+ stage: error.stage ?? 'unknown',
2736
+ error: {
2737
+ code: error.name ?? 'Error',
2738
+ message: error.message,
2739
+ detail: error.detail,
2740
+ },
2741
+ };
2742
+ emitRpc(rpcSuccess(message.id, toolResultPayload(payload, true)));
2743
+ }
2744
+ continue;
2745
+ }
2746
+
2747
+ emitRpc(
2748
+ rpcError(message.id ?? null, -32601, `unknown method: ${message.method}`),
2749
+ );
2750
+ } catch (error) {
2751
+ emitRpc(
2752
+ rpcError(message.id ?? null, -32000, error.message, {
2753
+ stage: error.stage,
2754
+ detail: error.detail,
2755
+ }),
2756
+ );
2757
+ }
2758
+ continue;
2759
+ }
2760
+
2761
+ const boundary = findHeaderBoundary(buffer);
2762
+ if (!boundary) {
2763
+ return;
2764
+ }
2765
+
2766
+ const headerEnd = boundary.index;
2767
+ const headerText = buffer.subarray(0, headerEnd).toString('utf8');
2768
+ const contentLengthMatch = headerText.match(/Content-Length:\s*(\d+)/i);
2769
+ if (!contentLengthMatch) {
2770
+ buffer = Buffer.alloc(0);
2771
+ emitRpc(rpcError(null, -32600, 'missing Content-Length header'));
2772
+ return;
2773
+ }
2774
+
2775
+ const bodyLength = Number.parseInt(contentLengthMatch[1], 10);
2776
+ const frameLength = headerEnd + boundary.length + bodyLength;
2777
+ if (buffer.length < frameLength) {
2778
+ return;
2779
+ }
2780
+
2781
+ const body = buffer
2782
+ .subarray(headerEnd + boundary.length, frameLength)
2783
+ .toString('utf8');
2784
+ buffer = buffer.subarray(frameLength);
2785
+ const message = safeJsonParse(body);
2786
+
2787
+ if (!message) {
2788
+ emitRpc(rpcError(null, -32700, 'invalid JSON-RPC body'));
2789
+ continue;
2790
+ }
2791
+
2792
+ traceMcp('incoming', message);
2793
+
2794
+ try {
2795
+ if (message.method === 'initialize') {
2796
+ const protocolVersion = negotiateProtocolVersion(
2797
+ message.params?.protocolVersion,
2798
+ );
2799
+ emitRpc(
2800
+ rpcSuccess(message.id, {
2801
+ protocolVersion,
2802
+ capabilities: {
2803
+ tools: {
2804
+ listChanged: false,
2805
+ },
2806
+ },
2807
+ serverInfo: {
2808
+ name: 'codex-chrome-bridge',
2809
+ version: '0.1.0',
2810
+ },
2811
+ instructions:
2812
+ 'Repo-local MCP wrapper around the Claude Code Chrome native-host bridge.',
2813
+ }),
2814
+ );
2815
+ continue;
2816
+ }
2817
+
2818
+ if (message.method === 'notifications/initialized') {
2819
+ continue;
2820
+ }
2821
+
2822
+ if (message.method === 'notifications/cancelled') {
2823
+ continue;
2824
+ }
2825
+
2826
+ if (message.method === 'ping') {
2827
+ emitRpc(rpcSuccess(message.id, {}));
2828
+ continue;
2829
+ }
2830
+
2831
+ if (message.method === 'tools/list') {
2832
+ emitRpc(
2833
+ rpcSuccess(message.id, {
2834
+ tools: toolDefinitions(),
2835
+ }),
2836
+ );
2837
+ continue;
2838
+ }
2839
+
2840
+ if (message.method === 'tools/call') {
2841
+ const toolName = message.params?.name;
2842
+ const args = message.params?.arguments ?? {};
2843
+
2844
+ try {
2845
+ const result = await withMcpToolCallTimeout(toolName, args, () =>
2846
+ handleToolCall(toolName, args),
2847
+ );
2848
+ emitRpc(rpcSuccess(message.id, toolResultPayload(result, false)));
2849
+ } catch (error) {
2850
+ const payload = {
2851
+ ok: false,
2852
+ stage: error.stage ?? 'unknown',
2853
+ error: {
2854
+ code: error.name ?? 'Error',
2855
+ message: error.message,
2856
+ detail: error.detail,
2857
+ },
2858
+ };
2859
+ emitRpc(rpcSuccess(message.id, toolResultPayload(payload, true)));
2860
+ }
2861
+ continue;
2862
+ }
2863
+
2864
+ emitRpc(rpcError(message.id ?? null, -32601, `unknown method: ${message.method}`));
2865
+ } catch (error) {
2866
+ emitRpc(
2867
+ rpcError(message.id ?? null, -32000, error.message, {
2868
+ stage: error.stage,
2869
+ detail: error.detail,
2870
+ }),
2871
+ );
2872
+ }
2873
+ }
2874
+ });
2875
+
2876
+ stdin.resume();
2877
+ }
2878
+
2879
+ async function main() {
2880
+ const mode = process.argv[2];
2881
+
2882
+ if (mode === 'probe') {
2883
+ const result = await runProbe();
2884
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
2885
+ return;
2886
+ }
2887
+
2888
+ if (mode === 'mcp') {
2889
+ await runMcpServer();
2890
+ return;
2891
+ }
2892
+
2893
+ process.stderr.write('Usage: node ./src/bridge.js <probe|mcp>\n');
2894
+ process.exitCode = 1;
2895
+ }
2896
+
2897
+ const isMainModule =
2898
+ typeof process.argv[1] === 'string' &&
2899
+ path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
2900
+
2901
+ if (isMainModule) {
2902
+ main().catch((error) => {
2903
+ process.stderr.write(`${error.stack ?? error.message}\n`);
2904
+ process.exitCode = 1;
2905
+ });
2906
+ }
2907
+
2908
+ export const __test__ = {
2909
+ parseLauncherTarget,
2910
+ parseVersionFromBinary,
2911
+ mimeTypeFromPath,
2912
+ summarizeContent,
2913
+ findStructuredJson,
2914
+ extractTextItems,
2915
+ extractPrimaryText,
2916
+ normalizeToolContent,
2917
+ extractImageItem,
2918
+ extractImageIdFromContent,
2919
+ extractTabGroupIdFromContent,
2920
+ contentSignalsMissingTabGroup,
2921
+ toolSupportsSessionContextRecovery,
2922
+ extractScreenshotMetadata,
2923
+ extractTabIdFromContent,
2924
+ findTabInContext,
2925
+ selectTabsInContext,
2926
+ normalizeCoordinate,
2927
+ normalizeOptionalCoordinate,
2928
+ normalizeStartCoordinate,
2929
+ normalizeRegion,
2930
+ BridgeError,
2931
+ };