@browserbridge/bbx 1.1.0 → 1.3.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 (31) hide show
  1. package/README.md +6 -5
  2. package/package.json +1 -1
  3. package/packages/agent-client/src/cli.js +30 -20
  4. package/packages/agent-client/src/client.js +105 -42
  5. package/packages/agent-client/src/command-registry.js +4 -14
  6. package/packages/agent-client/src/detect.js +3 -3
  7. package/packages/agent-client/src/install.js +3 -7
  8. package/packages/agent-client/src/mcp-config.js +1 -3
  9. package/packages/agent-client/src/runtime.js +7 -41
  10. package/packages/agent-client/src/setup-status.js +3 -13
  11. package/packages/agent-client/src/types.ts +131 -0
  12. package/packages/mcp-server/src/handlers-capture.js +291 -0
  13. package/packages/mcp-server/src/handlers-dom.js +203 -0
  14. package/packages/mcp-server/src/handlers-navigation.js +79 -0
  15. package/packages/mcp-server/src/handlers-page.js +365 -0
  16. package/packages/mcp-server/src/handlers-utils.js +318 -0
  17. package/packages/mcp-server/src/handlers.js +59 -1176
  18. package/packages/mcp-server/src/server.js +2 -1
  19. package/packages/native-host/bin/bridge-daemon.js +2 -1
  20. package/packages/native-host/bin/install-manifest.js +8 -0
  21. package/packages/native-host/bin/postinstall.js +46 -9
  22. package/packages/native-host/src/daemon-logger.js +157 -0
  23. package/packages/native-host/src/daemon-process.js +43 -18
  24. package/packages/native-host/src/daemon.js +133 -12
  25. package/packages/native-host/src/framing.js +13 -0
  26. package/packages/native-host/src/native-host.js +7 -5
  27. package/packages/protocol/src/capabilities.js +1 -0
  28. package/packages/protocol/src/protocol.js +40 -0
  29. package/packages/protocol/src/registry.js +5 -9
  30. package/packages/protocol/src/types.ts +572 -0
  31. package/packages/protocol/src/types.js +0 -626
@@ -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
@@ -527,6 +527,7 @@ export function createBridgeMcpServer() {
527
527
  .optional()
528
528
  .describe('Element reference (for element action, preferred)'),
529
529
  selector: z.string().optional().describe('CSS selector (used if no elementRef)'),
530
+ nodeId: z.number().optional().describe('CDP node id for cdp_box_model/cdp_computed_styles'),
530
531
  rect: z
531
532
  .object({
532
533
  x: z.number().describe('Region left edge (viewport pixels)'),
@@ -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,18 +10,55 @@
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));
16
17
  const repoRoot = path.resolve(__dirname, '../../..');
17
18
 
18
- try {
19
- await installNativeManifest({ repoRoot, preserveCustomExtensionId: true });
20
- process.stdout.write('Browser Bridge: native host installed. Run `bbx doctor` to verify.\n');
21
- } catch (err) {
22
- // Non-fatal - user can run `bbx install` manually.
23
- const message = err instanceof Error ? err.message : String(err);
24
- process.stderr.write(
25
- `Browser Bridge: native host auto-install skipped (${message}).\nRun \`bbx install\` manually if needed.\n`
26
- );
19
+ /**
20
+ * @param {{
21
+ * installNativeManifestFn?: typeof installNativeManifest,
22
+ * restartBridgeDaemonIfRunningFn?: typeof restartBridgeDaemonIfRunning,
23
+ * stdout?: Pick<NodeJS.WriteStream, 'write'>,
24
+ * stderr?: Pick<NodeJS.WriteStream, 'write'>,
25
+ * exit?: (code?: number) => void,
26
+ * }} [deps]
27
+ * @returns {Promise<void>}
28
+ */
29
+ export async function runPostinstall({
30
+ installNativeManifestFn = installNativeManifest,
31
+ restartBridgeDaemonIfRunningFn = restartBridgeDaemonIfRunning,
32
+ stdout = process.stdout,
33
+ stderr = process.stderr,
34
+ exit = (code) => process.exit(code),
35
+ } = {}) {
36
+ try {
37
+ await installNativeManifestFn({ repoRoot, preserveCustomExtensionId: true });
38
+ stdout.write('Browser Bridge: native host installed. Run `bbx doctor` to verify.\n');
39
+ } catch (err) {
40
+ // Non-fatal - user can run `bbx install` manually.
41
+ const message = err instanceof Error ? err.message : String(err);
42
+ stderr.write(
43
+ `Browser Bridge: native host auto-install skipped (${message}).\nRun \`bbx install\` manually if needed.\n`
44
+ );
45
+ exit(0);
46
+ return;
47
+ }
48
+
49
+ try {
50
+ const restartResult = await restartBridgeDaemonIfRunningFn();
51
+ if (restartResult) {
52
+ stdout.write('Browser Bridge: restarted the local daemon to use the updated install.\n');
53
+ }
54
+ } catch (err) {
55
+ const message = err instanceof Error ? err.message : String(err);
56
+ stderr.write(
57
+ `Browser Bridge: native host installed, but daemon restart failed (${message}).\nRun \`bbx restart\` if needed.\n`
58
+ );
59
+ }
60
+ }
61
+
62
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
63
+ await runPostinstall();
27
64
  }
@@ -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
+ }
@@ -10,7 +10,6 @@ import { pingExistingDaemon } from './daemon.js';
10
10
  import {
11
11
  createSocketBridgeTransport,
12
12
  formatBridgeTransport,
13
- getBridgeDir,
14
13
  getBridgeTransport,
15
14
  getDaemonPidPath,
16
15
  } from './config.js';
@@ -74,7 +73,7 @@ export function spawnBridgeDaemonProcess() {
74
73
  * @returns {Promise<void>}
75
74
  */
76
75
  export async function writeDaemonPidFile(pid = process.pid, pidPath = getDaemonPidPath()) {
77
- await fs.promises.mkdir(getBridgeDir(), { recursive: true });
76
+ await fs.promises.mkdir(path.dirname(pidPath), { recursive: true });
78
77
  await fs.promises.writeFile(pidPath, `${pid}\n`, 'utf8');
79
78
  }
80
79
 
@@ -196,6 +195,48 @@ export async function stopBridgeDaemon(options = {}) {
196
195
  * }>}
197
196
  */
198
197
  export async function restartBridgeDaemon(options = {}) {
198
+ const stopResult = await stopBridgeDaemon(options);
199
+ return restartBridgeDaemonAfterStop(stopResult, options);
200
+ }
201
+
202
+ /**
203
+ * Restart the daemon only when one is already running. This is useful during
204
+ * package upgrades where the launcher changed and the in-memory daemon should
205
+ * pick up the new install, without eagerly starting a fresh background process.
206
+ *
207
+ * @param {RestartBridgeDaemonOptions} [options={}]
208
+ * @returns {Promise<{
209
+ * transport: string,
210
+ * socketPath: string,
211
+ * pidPath: string,
212
+ * pid: number | null,
213
+ * previouslyRunning: boolean,
214
+ * previousPid: number | null,
215
+ * removedStaleSocket: boolean,
216
+ * } | null>}
217
+ */
218
+ export async function restartBridgeDaemonIfRunning(options = {}) {
219
+ const stopResult = await stopBridgeDaemon(options);
220
+ if (!stopResult.previouslyRunning) {
221
+ return null;
222
+ }
223
+ return restartBridgeDaemonAfterStop(stopResult, options);
224
+ }
225
+
226
+ /**
227
+ * @param {Awaited<ReturnType<typeof stopBridgeDaemon>>} stopResult
228
+ * @param {RestartBridgeDaemonOptions} [options={}]
229
+ * @returns {Promise<{
230
+ * transport: string,
231
+ * socketPath: string,
232
+ * pidPath: string,
233
+ * pid: number | null,
234
+ * previouslyRunning: boolean,
235
+ * previousPid: number | null,
236
+ * removedStaleSocket: boolean,
237
+ * }>}
238
+ */
239
+ async function restartBridgeDaemonAfterStop(stopResult, options = {}) {
199
240
  const {
200
241
  transport = getBridgeTransport(),
201
242
  socketPath = undefined,
@@ -204,27 +245,11 @@ export async function restartBridgeDaemon(options = {}) {
204
245
  pollIntervalMs = DEFAULT_DAEMON_POLL_INTERVAL_MS,
205
246
  pingDaemonFn = pingExistingDaemon,
206
247
  readPidFn = readDaemonPidFile,
207
- findPidByTransportFn = findDaemonPidByTransport,
208
- killFn = process.kill.bind(process),
209
- rmFn = fs.promises.rm,
210
248
  sleepFn = sleep,
211
249
  spawnDaemonFn = spawnBridgeDaemonProcess,
212
250
  } = options;
213
251
  const resolvedTransport = socketPath ? createSocketBridgeTransport(socketPath) : transport;
214
252
 
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
253
  spawnDaemonFn();
229
254
 
230
255
  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);
@@ -287,6 +316,11 @@ export class BridgeDaemon {
287
316
  socket.__lastActiveAt = Date.now();
288
317
  this.extensionSockets.set(extensionId, socket);
289
318
  this.invalidateConnectedExtensionsCache();
319
+ this.logger.info('extension registered', {
320
+ extensionId,
321
+ browserName: socket.__browserName ?? null,
322
+ profileLabel: socket.__profileLabel ?? null,
323
+ });
290
324
  void writeJsonLine(socket, { type: 'registered', role: 'extension' });
291
325
  return;
292
326
  }
@@ -295,6 +329,7 @@ export class BridgeDaemon {
295
329
  const clientId = message.clientId || randomUUID();
296
330
  this.agentSockets.set(clientId, socket);
297
331
  socket.__clientId = clientId;
332
+ this.logger.info('agent registered', { clientId });
298
333
  void writeJsonLine(socket, {
299
334
  type: 'registered',
300
335
  role: 'agent',
@@ -320,7 +355,9 @@ export class BridgeDaemon {
320
355
  `Another daemon is already running on ${this.socketPath}. Stop it before starting a new one.`
321
356
  );
322
357
  }
323
- this.logger.log('[daemon] Removing stale socket from previous run:', this.socketPath);
358
+ this.logger.info('Removing stale socket from previous run', {
359
+ socketPath: this.socketPath,
360
+ });
324
361
  } catch (error) {
325
362
  if (error instanceof Error && error.message.startsWith('Another daemon')) {
326
363
  throw error;
@@ -333,15 +370,14 @@ export class BridgeDaemon {
333
370
  this.server = net.createServer((socket) => {
334
371
  const typedSocket = /** @type {ClientSocket} */ (socket);
335
372
  typedSocket.on('error', (err) => {
336
- this.logger.error?.('[daemon] socket error:', err.message);
373
+ this.logger.error('socket error', { message: err.message });
337
374
  });
338
375
  parseJsonLines(typedSocket, (raw) => {
339
376
  const message = /** @type {DaemonMessage} */ (raw);
340
377
  void this.handleClientMessage(typedSocket, message).catch((err) => {
341
- this.logger.error?.(
342
- '[daemon] handler error:',
343
- err instanceof Error ? err.message : String(err)
344
- );
378
+ this.logger.error('handler error', {
379
+ message: err instanceof Error ? err.message : String(err),
380
+ });
345
381
  });
346
382
  });
347
383
  typedSocket.on('close', () => this.handleSocketClose(typedSocket));
@@ -362,6 +398,13 @@ export class BridgeDaemon {
362
398
  await fs.promises.chmod(this.socketPath, 0o600);
363
399
  }
364
400
 
401
+ this.logger.info('Daemon listening', {
402
+ transport: formatBridgeTransport(this.transport),
403
+ socketPath: this.socketPath ?? null,
404
+ });
405
+
406
+ this.startedAt = Date.now();
407
+
365
408
  return this;
366
409
  }
367
410
 
@@ -483,10 +526,12 @@ export class BridgeDaemon {
483
526
  if (this.extensionSockets.size === 0) {
484
527
  const response = createSuccess(request.id, {
485
528
  daemon: 'ok',
529
+ daemonVersion: DAEMON_VERSION,
486
530
  extensionConnected: false,
487
531
  socketPath: this.socketPath,
488
532
  transport: formatBridgeTransport(this.transport),
489
533
  connectedExtensions: [],
534
+ daemon_supported_versions: SUPPORTED_VERSIONS,
490
535
  ...getVersionNegotiationPayload(request.meta?.protocol_version),
491
536
  });
492
537
  await writeJsonLine(socket, { type: 'agent.response', response });
@@ -495,8 +540,30 @@ export class BridgeDaemon {
495
540
  }
496
541
 
497
542
  if (request.method === 'log.tail') {
543
+ const limit =
544
+ typeof request.params.limit === 'number' ? request.params.limit : DEFAULT_LOG_TAIL_LIMIT;
498
545
  const response = createSuccess(request.id, {
499
- entries: this.recentLog.slice(-DEFAULT_LOG_TAIL_LIMIT),
546
+ entries: this.recentLog.slice(-limit),
547
+ });
548
+ await writeJsonLine(socket, { type: 'agent.response', response });
549
+ return;
550
+ }
551
+
552
+ if (request.method === 'daemon.metrics') {
553
+ const now = Date.now();
554
+ const uptimeMs = this.startedAt > 0 ? now - this.startedAt : 0;
555
+ const avgResponseTimeMs =
556
+ this.requestsProcessed > 0
557
+ ? Math.round(this.totalResponseTimeMs / this.requestsProcessed)
558
+ : 0;
559
+ const response = createSuccess(request.id, {
560
+ uptimeMs,
561
+ activeAgents: this.agentSockets.size,
562
+ activeExtensions: this.extensionSockets.size,
563
+ pendingRequests: this.pendingRequests.size,
564
+ requestsProcessed: this.requestsProcessed,
565
+ requestsFailed: this.requestsFailed,
566
+ avgResponseTimeMs,
500
567
  });
501
568
  await writeJsonLine(socket, { type: 'agent.response', response });
502
569
  return;
@@ -593,6 +660,7 @@ export class BridgeDaemon {
593
660
  const pending = this.pendingRequests.get(request.id);
594
661
  if (!pending) return;
595
662
  this.clearPendingRequest(request.id, pending);
663
+ this.recordRequestCompletion(request.id, false);
596
664
  const response = createFailure(
597
665
  request.id,
598
666
  ERROR_CODES.TIMEOUT,
@@ -604,8 +672,37 @@ export class BridgeDaemon {
604
672
  });
605
673
  }, this.pendingTimeoutMs),
606
674
  });
675
+ this.logger.info('request routed', {
676
+ requestId: request.id,
677
+ method: request.method,
678
+ clientId: socket.__clientId ?? null,
679
+ targetCount: targets.length,
680
+ });
607
681
  const broadcastPayload = { type: 'extension.request', request };
608
- await Promise.all(targets.map((extSocket) => writeJsonLine(extSocket, broadcastPayload)));
682
+ await Promise.all(
683
+ targets.map(async (extSocket) => {
684
+ try {
685
+ await writeJsonLine(extSocket, broadcastPayload);
686
+ } catch (error) {
687
+ this.logger.error('request route failed', {
688
+ requestId: request.id,
689
+ method: request.method,
690
+ message: error instanceof Error ? error.message : String(error),
691
+ });
692
+ const pending = this.pendingRequests.get(request.id);
693
+ if (!pending) {
694
+ return;
695
+ }
696
+ this.removePendingTarget(request.id, pending, extSocket);
697
+ if (extSocket.__extensionId) {
698
+ this.extensionSockets.delete(extSocket.__extensionId);
699
+ this.invalidateConnectedExtensionsCache();
700
+ }
701
+ extSocket.destroy(error instanceof Error ? error : undefined);
702
+ await this.finishPendingRequestIfExhausted(request.id, pending);
703
+ }
704
+ })
705
+ );
609
706
  }
610
707
 
611
708
  /**
@@ -697,6 +794,7 @@ export class BridgeDaemon {
697
794
  if (responseMessage.ok) {
698
795
  clearTimeout(pending.timeoutId);
699
796
  this.clearPendingRequest(responseMessage.id, pending);
797
+ this.recordRequestCompletion(responseMessage.id, true);
700
798
  this.pushLog({
701
799
  at: new Date().toISOString(),
702
800
  method: responseMessage.meta?.method ?? null,
@@ -715,6 +813,8 @@ export class BridgeDaemon {
715
813
  transport: formatBridgeTransport(this.transport),
716
814
  connectedExtensions: this.getConnectedExtensionsSnapshot(),
717
815
  .../** @type {Record<string, unknown>} */ (responseMessage.result),
816
+ daemonVersion: DAEMON_VERSION,
817
+ daemon_supported_versions: SUPPORTED_VERSIONS,
718
818
  },
719
819
  {
720
820
  ...responseMessage.meta,
@@ -742,11 +842,13 @@ export class BridgeDaemon {
742
842
  */
743
843
  handleSocketClose(socket) {
744
844
  if (socket.__extensionId) {
845
+ this.logger.info('extension disconnected', { extensionId: socket.__extensionId });
745
846
  this.extensionSockets.delete(socket.__extensionId);
746
847
  this.invalidateConnectedExtensionsCache();
747
848
  }
748
849
 
749
850
  if (socket.__clientId) {
851
+ this.logger.info('agent disconnected', { clientId: socket.__clientId });
750
852
  this.agentSockets.delete(socket.__clientId);
751
853
  }
752
854
 
@@ -759,6 +861,7 @@ export class BridgeDaemon {
759
861
  }
760
862
  clearTimeout(pending.timeoutId);
761
863
  this.clearPendingRequest(id, pending);
864
+ this.recordRequestCompletion(id, false);
762
865
  }
763
866
  }
764
867
 
@@ -790,6 +893,7 @@ export class BridgeDaemon {
790
893
 
791
894
  clearTimeout(pending.timeoutId);
792
895
  this.clearPendingRequest(requestId, pending);
896
+ this.recordRequestCompletion(requestId, false);
793
897
 
794
898
  const response =
795
899
  pending.lastErrorResponse ??
@@ -815,6 +919,23 @@ export class BridgeDaemon {
815
919
  });
816
920
  }
817
921
 
922
+ /**
923
+ * @param {string} requestId
924
+ * @param {boolean} ok
925
+ * @returns {void}
926
+ */
927
+ recordRequestCompletion(requestId, ok) {
928
+ const startedAt = this.requestStartTimes.get(requestId);
929
+ this.requestStartTimes.delete(requestId);
930
+ this.requestsProcessed += 1;
931
+ if (!ok) {
932
+ this.requestsFailed += 1;
933
+ }
934
+ if (typeof startedAt === 'number') {
935
+ this.totalResponseTimeMs += Date.now() - startedAt;
936
+ }
937
+ }
938
+
818
939
  /**
819
940
  * @param {Record<string, unknown>} entry
820
941
  * @returns {void}