@browserbridge/bbx 1.1.0 → 1.2.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.
@@ -401,7 +401,7 @@ export function createBridgeMcpServer() {
401
401
  .optional()
402
402
  .describe('Mouse button for click (default: left)'),
403
403
  clickCount: z.number().optional().describe('Click count (1=single, 2=double)'),
404
- text: z.string().optional().describe('Text to type (for type action)'),
404
+ text: z.string().max(100000).optional().describe('Text to type (for type action)'),
405
405
  clear: z.boolean().optional().describe('Clear field before typing (default: false)'),
406
406
  submit: z.boolean().optional().describe('Press Enter after typing (default: false)'),
407
407
  key: z
@@ -6,11 +6,12 @@ import {
6
6
  formatBridgeTransport,
7
7
  getBridgeTransport,
8
8
  } from '../src/config.js';
9
+ import { DaemonLogger } from '../src/daemon-logger.js';
9
10
  import { clearDaemonPidFile, writeDaemonPidFile } from '../src/daemon-process.js';
10
11
 
11
12
  applyWindowsTcpTransportDefaults();
12
13
  const transport = getBridgeTransport();
13
- const daemon = new BridgeDaemon({ transport });
14
+ const daemon = new BridgeDaemon({ transport, logger: new DaemonLogger() });
14
15
 
15
16
  /**
16
17
  * @param {unknown} error
@@ -3,6 +3,7 @@
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
 
6
+ import { restartBridgeDaemonIfRunning } from '../src/daemon-process.js';
6
7
  import {
7
8
  installNativeManifest,
8
9
  parseExtensionId,
@@ -77,3 +78,10 @@ for (const [index, target] of targets.entries()) {
77
78
  browser: target,
78
79
  });
79
80
  }
81
+
82
+ if (!uninstall) {
83
+ const restartResult = await restartBridgeDaemonIfRunning();
84
+ if (restartResult) {
85
+ process.stdout.write('Restarted Browser Bridge daemon to use the updated install.\n');
86
+ }
87
+ }
@@ -10,6 +10,7 @@
10
10
  import path from 'node:path';
11
11
  import { fileURLToPath } from 'node:url';
12
12
 
13
+ import { restartBridgeDaemonIfRunning } from '../src/daemon-process.js';
13
14
  import { installNativeManifest } from '../src/install-manifest.js';
14
15
 
15
16
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -24,4 +25,19 @@ try {
24
25
  process.stderr.write(
25
26
  `Browser Bridge: native host auto-install skipped (${message}).\nRun \`bbx install\` manually if needed.\n`
26
27
  );
28
+ process.exit(0);
29
+ }
30
+
31
+ try {
32
+ const restartResult = await restartBridgeDaemonIfRunning();
33
+ if (restartResult) {
34
+ process.stdout.write(
35
+ 'Browser Bridge: restarted the local daemon to use the updated install.\n'
36
+ );
37
+ }
38
+ } catch (err) {
39
+ const message = err instanceof Error ? err.message : String(err);
40
+ process.stderr.write(
41
+ `Browser Bridge: native host installed, but daemon restart failed (${message}).\nRun \`bbx restart\` if needed.\n`
42
+ );
27
43
  }
@@ -0,0 +1,157 @@
1
+ // @ts-check
2
+
3
+ /** @typedef {{ write: (chunk: string) => void }} LogStream */
4
+
5
+ /**
6
+ * @enum {number}
7
+ * @private
8
+ */
9
+ const LOG_LEVELS = /** @type {const} */ ({
10
+ debug: 0,
11
+ info: 1,
12
+ warn: 2,
13
+ error: 3,
14
+ });
15
+
16
+ /** @typedef {keyof typeof LOG_LEVELS} LogLevel */
17
+
18
+ /**
19
+ * @typedef {{
20
+ * debug(message: string, extra?: Record<string, unknown>): void;
21
+ * info(message: string, extra?: Record<string, unknown>): void;
22
+ * warn(message: string, extra?: Record<string, unknown>): void;
23
+ * error(message: string, extra?: Record<string, unknown>): void;
24
+ * }} DaemonLoggerLike
25
+ */
26
+
27
+ /**
28
+ * Structured JSON logger for the daemon. Each call emits a single NDJSON line
29
+ * with a timestamp, level, message, and optional structured fields.
30
+ */
31
+ export class DaemonLogger {
32
+ /** @type {LogStream} */
33
+ #stream;
34
+ /** @type {LogLevel} */
35
+ #minLevel;
36
+ /** @type {Record<string, unknown>} */
37
+ #defaults;
38
+
39
+ /**
40
+ * @param {{ stream?: LogStream, minLevel?: LogLevel, defaults?: Record<string, unknown> }} [options]
41
+ */
42
+ constructor({ stream, minLevel = 'info', defaults = {} } = {}) {
43
+ this.#stream = stream ?? /** @type {LogStream} */ (process.stderr);
44
+ this.#minLevel = minLevel;
45
+ this.#defaults = defaults;
46
+ }
47
+
48
+ /**
49
+ * @param {LogLevel} level
50
+ * @param {string} message
51
+ * @param {Record<string, unknown>} [extra]
52
+ * @returns {void}
53
+ */
54
+ #write(level, message, extra) {
55
+ if (LOG_LEVELS[level] < LOG_LEVELS[this.#minLevel]) {
56
+ return;
57
+ }
58
+ const entry = {
59
+ ...this.#defaults,
60
+ timestamp: new Date().toISOString(),
61
+ level,
62
+ message,
63
+ ...extra,
64
+ };
65
+ this.#stream.write(`${JSON.stringify(entry)}\n`);
66
+ }
67
+
68
+ /**
69
+ * @param {string} message
70
+ * @param {Record<string, unknown>} [extra]
71
+ * @returns {void}
72
+ */
73
+ debug(message, extra) {
74
+ this.#write('debug', message, extra);
75
+ }
76
+
77
+ /**
78
+ * @param {string} message
79
+ * @param {Record<string, unknown>} [extra]
80
+ * @returns {void}
81
+ */
82
+ info(message, extra) {
83
+ this.#write('info', message, extra);
84
+ }
85
+
86
+ /**
87
+ * @param {string} message
88
+ * @param {Record<string, unknown>} [extra]
89
+ * @returns {void}
90
+ */
91
+ warn(message, extra) {
92
+ this.#write('warn', message, extra);
93
+ }
94
+
95
+ /**
96
+ * @param {string} message
97
+ * @param {Record<string, unknown>} [extra]
98
+ * @returns {void}
99
+ */
100
+ error(message, extra) {
101
+ this.#write('error', message, extra);
102
+ }
103
+ }
104
+
105
+ /**
106
+ * A silent logger that discards all output. Useful in tests.
107
+ * @type {DaemonLoggerLike}
108
+ */
109
+ export const silentLogger = {
110
+ debug() {},
111
+ info() {},
112
+ warn() {},
113
+ error() {},
114
+ };
115
+
116
+ /**
117
+ * Wrap a legacy `Pick<Console, 'log' | 'error'>` logger into the new
118
+ * `DaemonLoggerLike` interface. Used for backward compatibility with
119
+ * callers that still pass `{ log() {}, error() {} }`.
120
+ *
121
+ * @param {Pick<Console, 'log' | 'error'>} legacy
122
+ * @returns {DaemonLoggerLike}
123
+ */
124
+ export function wrapLegacyLogger(legacy) {
125
+ return {
126
+ debug(message, extra) {
127
+ legacy.log?.(`[debug] ${message}`, extra ?? '');
128
+ },
129
+ info(message, extra) {
130
+ legacy.log?.(`[info] ${message}`, extra ?? '');
131
+ },
132
+ warn(message, extra) {
133
+ legacy.log?.(`[warn] ${message}`, extra ?? '');
134
+ },
135
+ error(message, extra) {
136
+ legacy.error?.(`[error] ${message}`, extra ?? '');
137
+ },
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Accept either a `DaemonLoggerLike` or a legacy `Pick<Console, 'log' | 'error'>`
143
+ * and return a normalized `DaemonLoggerLike`. If the input already has an `info`
144
+ * method it is returned as-is; otherwise it is wrapped via `wrapLegacyLogger`.
145
+ *
146
+ * @param {DaemonLoggerLike | Pick<Console, 'log' | 'error'> | undefined} logger
147
+ * @returns {DaemonLoggerLike}
148
+ */
149
+ export function normalizeDaemonLogger(logger) {
150
+ if (logger === undefined) {
151
+ return new DaemonLogger();
152
+ }
153
+ if (typeof (/** @type {any} */ (logger).info) === 'function') {
154
+ return /** @type {DaemonLoggerLike} */ (logger);
155
+ }
156
+ return wrapLegacyLogger(/** @type {Pick<Console, 'log' | 'error'>} */ (logger));
157
+ }
@@ -196,6 +196,48 @@ export async function stopBridgeDaemon(options = {}) {
196
196
  * }>}
197
197
  */
198
198
  export async function restartBridgeDaemon(options = {}) {
199
+ const stopResult = await stopBridgeDaemon(options);
200
+ return restartBridgeDaemonAfterStop(stopResult, options);
201
+ }
202
+
203
+ /**
204
+ * Restart the daemon only when one is already running. This is useful during
205
+ * package upgrades where the launcher changed and the in-memory daemon should
206
+ * pick up the new install, without eagerly starting a fresh background process.
207
+ *
208
+ * @param {RestartBridgeDaemonOptions} [options={}]
209
+ * @returns {Promise<{
210
+ * transport: string,
211
+ * socketPath: string,
212
+ * pidPath: string,
213
+ * pid: number | null,
214
+ * previouslyRunning: boolean,
215
+ * previousPid: number | null,
216
+ * removedStaleSocket: boolean,
217
+ * } | null>}
218
+ */
219
+ export async function restartBridgeDaemonIfRunning(options = {}) {
220
+ const stopResult = await stopBridgeDaemon(options);
221
+ if (!stopResult.previouslyRunning) {
222
+ return null;
223
+ }
224
+ return restartBridgeDaemonAfterStop(stopResult, options);
225
+ }
226
+
227
+ /**
228
+ * @param {Awaited<ReturnType<typeof stopBridgeDaemon>>} stopResult
229
+ * @param {RestartBridgeDaemonOptions} [options={}]
230
+ * @returns {Promise<{
231
+ * transport: string,
232
+ * socketPath: string,
233
+ * pidPath: string,
234
+ * pid: number | null,
235
+ * previouslyRunning: boolean,
236
+ * previousPid: number | null,
237
+ * removedStaleSocket: boolean,
238
+ * }>}
239
+ */
240
+ async function restartBridgeDaemonAfterStop(stopResult, options = {}) {
199
241
  const {
200
242
  transport = getBridgeTransport(),
201
243
  socketPath = undefined,
@@ -204,27 +246,11 @@ export async function restartBridgeDaemon(options = {}) {
204
246
  pollIntervalMs = DEFAULT_DAEMON_POLL_INTERVAL_MS,
205
247
  pingDaemonFn = pingExistingDaemon,
206
248
  readPidFn = readDaemonPidFile,
207
- findPidByTransportFn = findDaemonPidByTransport,
208
- killFn = process.kill.bind(process),
209
- rmFn = fs.promises.rm,
210
249
  sleepFn = sleep,
211
250
  spawnDaemonFn = spawnBridgeDaemonProcess,
212
251
  } = options;
213
252
  const resolvedTransport = socketPath ? createSocketBridgeTransport(socketPath) : transport;
214
253
 
215
- const stopResult = await stopBridgeDaemon({
216
- transport: resolvedTransport,
217
- pidPath,
218
- timeoutMs,
219
- pollIntervalMs,
220
- pingDaemonFn,
221
- readPidFn,
222
- findPidByTransportFn,
223
- killFn,
224
- rmFn,
225
- sleepFn,
226
- });
227
-
228
254
  spawnDaemonFn();
229
255
 
230
256
  const started = await waitForDaemonReachability({
@@ -36,13 +36,17 @@ import {
36
36
  getBridgeTransport,
37
37
  getSocketPath,
38
38
  } from './config.js';
39
+ import { normalizeDaemonLogger } from './daemon-logger.js';
39
40
  import { writeJsonLine } from './framing.js';
40
41
 
42
+ const DAEMON_VERSION = loadDaemonVersion();
43
+
41
44
  /** @typedef {import('../../protocol/src/types.js').BridgeRequest} BridgeRequest */
42
45
  /** @typedef {import('../../protocol/src/types.js').SetupInstallParams} SetupInstallParams */
43
46
  /** @typedef {import('../../protocol/src/types.js').SetupInstallResult} SetupInstallResult */
44
47
  /** @typedef {import('../../protocol/src/types.js').SetupStatus} SetupStatus */
45
48
  /** @typedef {import('./config.js').BridgeTransport} BridgeTransport */
49
+ /** @typedef {import('./daemon-logger.js').DaemonLoggerLike} DaemonLoggerLike */
46
50
  /** @typedef {import('node:net').Socket & { __clientId?: string, __extensionId?: string, __browserName?: string, __profileLabel?: string, __accessEnabled?: boolean, __lastActiveAt?: number }} ClientSocket */
47
51
  /** @typedef {{ socket: ClientSocket, timeoutId: NodeJS.Timeout, source?: string, method?: string, targets: Set<ClientSocket>, lastErrorResponse?: import('../../protocol/src/types.js').BridgeResponse }} PendingEntry */
48
52
  /**
@@ -95,11 +99,24 @@ function getVersionNegotiationPayload(requestedVersion) {
95
99
  };
96
100
  }
97
101
 
102
+ /**
103
+ * @returns {string | null}
104
+ */
105
+ function loadDaemonVersion() {
106
+ try {
107
+ const raw = fs.readFileSync(new URL('../../../package.json', import.meta.url), 'utf8');
108
+ const parsed = JSON.parse(raw);
109
+ return parsed && typeof parsed.version === 'string' ? parsed.version : null;
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
98
115
  /**
99
116
  * @param {string} socketPath
100
117
  * @returns {boolean}
101
118
  */
102
- function isWindowsNamedPipePath(socketPath) {
119
+ export function isWindowsNamedPipePath(socketPath) {
103
120
  return socketPath.startsWith('\\\\.\\pipe\\');
104
121
  }
105
122
 
@@ -129,7 +146,7 @@ export class BridgeDaemon {
129
146
  * listenOptions?: import('node:net').ListenOptions | null,
130
147
  * setupStatusLoader?: () => Promise<SetupStatus>,
131
148
  * setupInstaller?: (params: Record<string, unknown>) => Promise<SetupInstallResult>,
132
- * logger?: Pick<Console, 'log' | 'error'>
149
+ * logger?: DaemonLoggerLike | Pick<Console, 'log' | 'error'>
133
150
  * }} [options={}]
134
151
  */
135
152
  constructor({
@@ -138,7 +155,7 @@ export class BridgeDaemon {
138
155
  listenOptions = null,
139
156
  setupStatusLoader = collectSetupStatus,
140
157
  setupInstaller = installSetupTarget,
141
- logger = console,
158
+ logger = undefined,
142
159
  } = {}) {
143
160
  this.transport = socketPath ? createSocketBridgeTransport(socketPath) : transport;
144
161
  this.socketPath =
@@ -146,7 +163,8 @@ export class BridgeDaemon {
146
163
  this.listenOptions = listenOptions ?? getBridgeListenTarget(this.transport);
147
164
  this.setupStatusLoader = setupStatusLoader;
148
165
  this.setupInstaller = setupInstaller;
149
- this.logger = logger;
166
+ /** @type {DaemonLoggerLike} */
167
+ this.logger = normalizeDaemonLogger(logger);
150
168
  /** @type {net.Server | null} */
151
169
  this.server = null;
152
170
  /** @type {net.AddressInfo | string | null} */
@@ -168,6 +186,16 @@ export class BridgeDaemon {
168
186
  this.connectedExtensionsCache = null;
169
187
  /** @type {Promise<void> | null} */
170
188
  this.stopPromise = null;
189
+ /** @type {number} */
190
+ this.startedAt = 0;
191
+ /** @type {number} */
192
+ this.requestsProcessed = 0;
193
+ /** @type {number} */
194
+ this.requestsFailed = 0;
195
+ /** @type {number} */
196
+ this.totalResponseTimeMs = 0;
197
+ /** @type {Map<string, number>} */
198
+ this.requestStartTimes = new Map();
171
199
  }
172
200
 
173
201
  /**
@@ -216,6 +244,7 @@ export class BridgeDaemon {
216
244
  */
217
245
  trackPendingRequest(requestId, pending) {
218
246
  this.pendingRequests.set(requestId, pending);
247
+ this.requestStartTimes.set(requestId, Date.now());
219
248
  this.addPendingRequestIndex(this.pendingRequestsByOwnerSocket, pending.socket, requestId);
220
249
  for (const targetSocket of pending.targets) {
221
250
  this.addPendingRequestIndex(this.pendingRequestsByTargetSocket, targetSocket, requestId);
@@ -232,6 +261,7 @@ export class BridgeDaemon {
232
261
  return undefined;
233
262
  }
234
263
  this.pendingRequests.delete(requestId);
264
+ this.requestStartTimes.delete(requestId);
235
265
  this.removePendingRequestIndex(this.pendingRequestsByOwnerSocket, pending.socket, requestId);
236
266
  for (const targetSocket of pending.targets) {
237
267
  this.removePendingRequestIndex(this.pendingRequestsByTargetSocket, targetSocket, requestId);
@@ -287,6 +317,11 @@ export class BridgeDaemon {
287
317
  socket.__lastActiveAt = Date.now();
288
318
  this.extensionSockets.set(extensionId, socket);
289
319
  this.invalidateConnectedExtensionsCache();
320
+ this.logger.info('extension registered', {
321
+ extensionId,
322
+ browserName: socket.__browserName ?? null,
323
+ profileLabel: socket.__profileLabel ?? null,
324
+ });
290
325
  void writeJsonLine(socket, { type: 'registered', role: 'extension' });
291
326
  return;
292
327
  }
@@ -295,6 +330,7 @@ export class BridgeDaemon {
295
330
  const clientId = message.clientId || randomUUID();
296
331
  this.agentSockets.set(clientId, socket);
297
332
  socket.__clientId = clientId;
333
+ this.logger.info('agent registered', { clientId });
298
334
  void writeJsonLine(socket, {
299
335
  type: 'registered',
300
336
  role: 'agent',
@@ -320,7 +356,9 @@ export class BridgeDaemon {
320
356
  `Another daemon is already running on ${this.socketPath}. Stop it before starting a new one.`
321
357
  );
322
358
  }
323
- this.logger.log('[daemon] Removing stale socket from previous run:', this.socketPath);
359
+ this.logger.info('Removing stale socket from previous run', {
360
+ socketPath: this.socketPath,
361
+ });
324
362
  } catch (error) {
325
363
  if (error instanceof Error && error.message.startsWith('Another daemon')) {
326
364
  throw error;
@@ -333,15 +371,14 @@ export class BridgeDaemon {
333
371
  this.server = net.createServer((socket) => {
334
372
  const typedSocket = /** @type {ClientSocket} */ (socket);
335
373
  typedSocket.on('error', (err) => {
336
- this.logger.error?.('[daemon] socket error:', err.message);
374
+ this.logger.error('socket error', { message: err.message });
337
375
  });
338
376
  parseJsonLines(typedSocket, (raw) => {
339
377
  const message = /** @type {DaemonMessage} */ (raw);
340
378
  void this.handleClientMessage(typedSocket, message).catch((err) => {
341
- this.logger.error?.(
342
- '[daemon] handler error:',
343
- err instanceof Error ? err.message : String(err)
344
- );
379
+ this.logger.error('handler error', {
380
+ message: err instanceof Error ? err.message : String(err),
381
+ });
345
382
  });
346
383
  });
347
384
  typedSocket.on('close', () => this.handleSocketClose(typedSocket));
@@ -362,6 +399,13 @@ export class BridgeDaemon {
362
399
  await fs.promises.chmod(this.socketPath, 0o600);
363
400
  }
364
401
 
402
+ this.logger.info('Daemon listening', {
403
+ transport: formatBridgeTransport(this.transport),
404
+ socketPath: this.socketPath ?? null,
405
+ });
406
+
407
+ this.startedAt = Date.now();
408
+
365
409
  return this;
366
410
  }
367
411
 
@@ -483,10 +527,12 @@ export class BridgeDaemon {
483
527
  if (this.extensionSockets.size === 0) {
484
528
  const response = createSuccess(request.id, {
485
529
  daemon: 'ok',
530
+ daemonVersion: DAEMON_VERSION,
486
531
  extensionConnected: false,
487
532
  socketPath: this.socketPath,
488
533
  transport: formatBridgeTransport(this.transport),
489
534
  connectedExtensions: [],
535
+ daemon_supported_versions: SUPPORTED_VERSIONS,
490
536
  ...getVersionNegotiationPayload(request.meta?.protocol_version),
491
537
  });
492
538
  await writeJsonLine(socket, { type: 'agent.response', response });
@@ -502,6 +548,26 @@ export class BridgeDaemon {
502
548
  return;
503
549
  }
504
550
 
551
+ if (request.method === 'daemon.metrics') {
552
+ const now = Date.now();
553
+ const uptimeMs = this.startedAt > 0 ? now - this.startedAt : 0;
554
+ const avgResponseTimeMs =
555
+ this.requestsProcessed > 0
556
+ ? Math.round(this.totalResponseTimeMs / this.requestsProcessed)
557
+ : 0;
558
+ const response = createSuccess(request.id, {
559
+ uptimeMs,
560
+ activeAgents: this.agentSockets.size,
561
+ activeExtensions: this.extensionSockets.size,
562
+ pendingRequests: this.pendingRequests.size,
563
+ requestsProcessed: this.requestsProcessed,
564
+ requestsFailed: this.requestsFailed,
565
+ avgResponseTimeMs,
566
+ });
567
+ await writeJsonLine(socket, { type: 'agent.response', response });
568
+ return;
569
+ }
570
+
505
571
  if (request.method === 'setup.get_status') {
506
572
  const response = createSuccess(request.id, await this.setupStatusLoader(), {
507
573
  method: request.method,
@@ -593,6 +659,7 @@ export class BridgeDaemon {
593
659
  const pending = this.pendingRequests.get(request.id);
594
660
  if (!pending) return;
595
661
  this.clearPendingRequest(request.id, pending);
662
+ this.recordRequestCompletion(request.id, false);
596
663
  const response = createFailure(
597
664
  request.id,
598
665
  ERROR_CODES.TIMEOUT,
@@ -604,6 +671,12 @@ export class BridgeDaemon {
604
671
  });
605
672
  }, this.pendingTimeoutMs),
606
673
  });
674
+ this.logger.info('request routed', {
675
+ requestId: request.id,
676
+ method: request.method,
677
+ clientId: socket.__clientId ?? null,
678
+ targetCount: targets.length,
679
+ });
607
680
  const broadcastPayload = { type: 'extension.request', request };
608
681
  await Promise.all(targets.map((extSocket) => writeJsonLine(extSocket, broadcastPayload)));
609
682
  }
@@ -697,6 +770,7 @@ export class BridgeDaemon {
697
770
  if (responseMessage.ok) {
698
771
  clearTimeout(pending.timeoutId);
699
772
  this.clearPendingRequest(responseMessage.id, pending);
773
+ this.recordRequestCompletion(responseMessage.id, true);
700
774
  this.pushLog({
701
775
  at: new Date().toISOString(),
702
776
  method: responseMessage.meta?.method ?? null,
@@ -715,6 +789,8 @@ export class BridgeDaemon {
715
789
  transport: formatBridgeTransport(this.transport),
716
790
  connectedExtensions: this.getConnectedExtensionsSnapshot(),
717
791
  .../** @type {Record<string, unknown>} */ (responseMessage.result),
792
+ daemonVersion: DAEMON_VERSION,
793
+ daemon_supported_versions: SUPPORTED_VERSIONS,
718
794
  },
719
795
  {
720
796
  ...responseMessage.meta,
@@ -742,11 +818,13 @@ export class BridgeDaemon {
742
818
  */
743
819
  handleSocketClose(socket) {
744
820
  if (socket.__extensionId) {
821
+ this.logger.info('extension disconnected', { extensionId: socket.__extensionId });
745
822
  this.extensionSockets.delete(socket.__extensionId);
746
823
  this.invalidateConnectedExtensionsCache();
747
824
  }
748
825
 
749
826
  if (socket.__clientId) {
827
+ this.logger.info('agent disconnected', { clientId: socket.__clientId });
750
828
  this.agentSockets.delete(socket.__clientId);
751
829
  }
752
830
 
@@ -759,6 +837,7 @@ export class BridgeDaemon {
759
837
  }
760
838
  clearTimeout(pending.timeoutId);
761
839
  this.clearPendingRequest(id, pending);
840
+ this.recordRequestCompletion(id, false);
762
841
  }
763
842
  }
764
843
 
@@ -790,6 +869,7 @@ export class BridgeDaemon {
790
869
 
791
870
  clearTimeout(pending.timeoutId);
792
871
  this.clearPendingRequest(requestId, pending);
872
+ this.recordRequestCompletion(requestId, false);
793
873
 
794
874
  const response =
795
875
  pending.lastErrorResponse ??
@@ -815,6 +895,22 @@ export class BridgeDaemon {
815
895
  });
816
896
  }
817
897
 
898
+ /**
899
+ * @param {string} requestId
900
+ * @param {boolean} ok
901
+ * @returns {void}
902
+ */
903
+ recordRequestCompletion(requestId, ok) {
904
+ const startedAt = this.requestStartTimes.get(requestId);
905
+ this.requestsProcessed += 1;
906
+ if (!ok) {
907
+ this.requestsFailed += 1;
908
+ }
909
+ if (typeof startedAt === 'number') {
910
+ this.totalResponseTimeMs += Date.now() - startedAt;
911
+ }
912
+ }
913
+
818
914
  /**
819
915
  * @param {Record<string, unknown>} entry
820
916
  * @returns {void}
@@ -105,6 +105,7 @@ export const METHOD_CAPABILITIES = Object.freeze({
105
105
  'performance.get_metrics': CAPABILITIES.PERFORMANCE_READ,
106
106
  'log.tail': null,
107
107
  'health.ping': null,
108
+ 'daemon.metrics': null,
108
109
  });
109
110
 
110
111
  /**
@@ -82,6 +82,7 @@ const BRIDGE_METHOD_DESCRIPTIONS = Object.freeze({
82
82
  'performance.get_metrics': 'Read browser performance metrics.',
83
83
  'log.tail': 'Tail recent bridge log entries.',
84
84
  'health.ping': 'Check daemon, extension, and access-routing health.',
85
+ 'daemon.metrics': 'Daemon health and performance metrics.',
85
86
  });
86
87
 
87
88
  /**
@@ -124,6 +125,7 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
124
125
  ),
125
126
  'log.tail': createRegistryEntry('log.tail', 'system', false, [], 'trivial'),
126
127
  'health.ping': createRegistryEntry('health.ping', 'system', false, [], 'trivial'),
128
+ 'daemon.metrics': createRegistryEntry('daemon.metrics', 'system', false, [], 'trivial'),
127
129
  // tabs — trivial
128
130
  'tabs.list': createRegistryEntry('tabs.list', 'tabs', false, [], 'trivial'),
129
131
  'tabs.create': createRegistryEntry('tabs.create', 'tabs', false, ['url', 'active'], 'trivial'),
@@ -15,7 +15,7 @@ export {};
15
15
  */
16
16
 
17
17
  /**
18
- * @typedef {'access.request' | 'tabs.list' | 'tabs.create' | 'tabs.close' | 'skill.get_runtime_context' | 'setup.get_status' | 'setup.install' | 'page.get_state' | 'page.evaluate' | 'page.get_console' | 'page.wait_for_load_state' | 'page.get_storage' | 'page.get_text' | 'page.get_network' | 'navigation.navigate' | 'navigation.reload' | 'navigation.go_back' | 'navigation.go_forward' | 'dom.query' | 'dom.describe' | 'dom.get_text' | 'dom.get_attributes' | 'dom.wait_for' | 'dom.find_by_text' | 'dom.find_by_role' | 'dom.get_html' | 'dom.get_accessibility_tree' | 'layout.get_box_model' | 'layout.hit_test' | 'styles.get_computed' | 'styles.get_matched_rules' | 'viewport.scroll' | 'viewport.resize' | 'input.click' | 'input.focus' | 'input.type' | 'input.press_key' | 'input.set_checked' | 'input.select_option' | 'input.hover' | 'input.drag' | 'input.scroll_into_view' | 'screenshot.capture_region' | 'screenshot.capture_element' | 'screenshot.capture_full_page' | 'patch.apply_styles' | 'patch.apply_dom' | 'patch.list' | 'patch.rollback' | 'patch.commit_session_baseline' | 'cdp.get_document' | 'cdp.get_dom_snapshot' | 'cdp.get_box_model' | 'cdp.get_computed_styles_for_node' | 'cdp.dispatch_key_event' | 'performance.get_metrics' | 'log.tail' | 'health.ping'} BridgeMethod
18
+ * @typedef {'access.request' | 'tabs.list' | 'tabs.create' | 'tabs.close' | 'skill.get_runtime_context' | 'setup.get_status' | 'setup.install' | 'page.get_state' | 'page.evaluate' | 'page.get_console' | 'page.wait_for_load_state' | 'page.get_storage' | 'page.get_text' | 'page.get_network' | 'navigation.navigate' | 'navigation.reload' | 'navigation.go_back' | 'navigation.go_forward' | 'dom.query' | 'dom.describe' | 'dom.get_text' | 'dom.get_attributes' | 'dom.wait_for' | 'dom.find_by_text' | 'dom.find_by_role' | 'dom.get_html' | 'dom.get_accessibility_tree' | 'layout.get_box_model' | 'layout.hit_test' | 'styles.get_computed' | 'styles.get_matched_rules' | 'viewport.scroll' | 'viewport.resize' | 'input.click' | 'input.focus' | 'input.type' | 'input.press_key' | 'input.set_checked' | 'input.select_option' | 'input.hover' | 'input.drag' | 'input.scroll_into_view' | 'screenshot.capture_region' | 'screenshot.capture_element' | 'screenshot.capture_full_page' | 'patch.apply_styles' | 'patch.apply_dom' | 'patch.list' | 'patch.rollback' | 'patch.commit_session_baseline' | 'cdp.get_document' | 'cdp.get_dom_snapshot' | 'cdp.get_box_model' | 'cdp.get_computed_styles_for_node' | 'cdp.dispatch_key_event' | 'performance.get_metrics' | 'log.tail' | 'health.ping' | 'daemon.metrics'} BridgeMethod
19
19
  */
20
20
 
21
21
  /**