@browserbridge/bbx 1.7.8 → 1.9.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 (44) hide show
  1. package/PRIVACY.md +57 -12
  2. package/README.md +19 -8
  3. package/SECURITY.md +12 -0
  4. package/package.json +15 -6
  5. package/packages/agent-client/src/cli-args.js +59 -0
  6. package/packages/agent-client/src/cli-batch.js +121 -58
  7. package/packages/agent-client/src/cli-output.js +8 -1
  8. package/packages/agent-client/src/cli.js +272 -70
  9. package/packages/agent-client/src/client.js +296 -8
  10. package/packages/agent-client/src/command-registry.js +64 -55
  11. package/packages/agent-client/src/config.js +195 -0
  12. package/packages/agent-client/src/content-extract.js +198 -0
  13. package/packages/agent-client/src/npm-self-update.js +305 -0
  14. package/packages/agent-client/src/remotes.js +4 -2
  15. package/packages/agent-client/src/runtime.js +864 -39
  16. package/packages/agent-client/src/types.ts +169 -6
  17. package/packages/mcp-server/src/guidance.js +1 -1
  18. package/packages/mcp-server/src/handlers-capture.js +69 -6
  19. package/packages/mcp-server/src/handlers-dom.js +61 -4
  20. package/packages/mcp-server/src/handlers-navigation.js +2 -1
  21. package/packages/mcp-server/src/handlers-page.js +211 -54
  22. package/packages/mcp-server/src/handlers-utils.js +101 -10
  23. package/packages/mcp-server/src/handlers.js +14 -7
  24. package/packages/mcp-server/src/server.js +176 -11
  25. package/packages/native-host/src/artifact-store.js +353 -0
  26. package/packages/native-host/src/config.js +9 -1
  27. package/packages/native-host/src/daemon-logger.js +3 -1
  28. package/packages/native-host/src/daemon.js +701 -33
  29. package/packages/native-host/src/native-host.js +28 -3
  30. package/packages/protocol/src/capability-values.js +2 -0
  31. package/packages/protocol/src/defaults.js +31 -0
  32. package/packages/protocol/src/errors.js +102 -6
  33. package/packages/protocol/src/incidental-sanitizer.js +197 -0
  34. package/packages/protocol/src/index.js +2 -0
  35. package/packages/protocol/src/protocol.js +612 -6
  36. package/packages/protocol/src/recovery-telemetry.js +302 -0
  37. package/packages/protocol/src/registry.js +206 -38
  38. package/packages/protocol/src/summary.js +203 -30
  39. package/packages/protocol/src/types.ts +721 -2
  40. package/skills/browser-bridge/SKILL.md +54 -19
  41. package/skills/browser-bridge/references/interaction.md +55 -11
  42. package/skills/browser-bridge/references/patch-workflow.md +2 -0
  43. package/skills/browser-bridge/references/protocol.md +265 -86
  44. package/skills/browser-bridge/references/token-efficiency.md +29 -13
@@ -24,11 +24,16 @@ import {
24
24
  DAEMON_RECENT_LOG_LIMIT,
25
25
  DEFAULT_DAEMON_PENDING_TIMEOUT_MS,
26
26
  DEFAULT_LOG_TAIL_LIMIT,
27
+ DOM_BASELINE_TTL_MS,
27
28
  ERROR_CODES,
28
29
  getProtocolVersion,
29
30
  getSupportedProtocolVersions,
30
31
  MAX_DAEMON_PENDING_TIMEOUT_MS,
32
+ MAX_DOM_BASELINES_GLOBAL,
31
33
  parseJsonLines,
34
+ RecoveryTelemetryCollector,
35
+ normalizeRecoveryTelemetrySummary,
36
+ sanitizeIncidentalValue,
32
37
  setProtocolPackageVersion,
33
38
  validateBridgeRequest,
34
39
  } from '../../protocol/src/index.js';
@@ -37,8 +42,10 @@ import {
37
42
  formatBridgeTransport,
38
43
  getBridgeListenTarget,
39
44
  getBridgeTransport,
45
+ getArtifactStorePath,
40
46
  getSocketPath,
41
47
  } from './config.js';
48
+ import { ArtifactStore } from './artifact-store.js';
42
49
  import { bridgeAuthTokensEqual, ensureBridgeAuthToken, readBridgeAuthToken } from './auth-token.js';
43
50
  import { normalizeDaemonLogger } from './daemon-logger.js';
44
51
  import { writeJsonLine } from './framing.js';
@@ -53,8 +60,8 @@ setProtocolPackageVersion(DAEMON_VERSION);
53
60
  /** @typedef {import('./config.js').BridgeTransport} BridgeTransport */
54
61
  /** @typedef {import('./daemon-logger.js').DaemonLoggerLike} DaemonLoggerLike */
55
62
  /** @typedef {'agent' | 'extension'} SocketRole */
56
- /** @typedef {import('node:net').Socket & { readonly __role?: SocketRole, __clientId?: string, __extensionId?: string, __browserName?: string, __profileLabel?: string, __accessEnabled?: boolean, __lastActiveAt?: number }} ClientSocket */
57
- /** @typedef {{ socket: ClientSocket, timeoutId: NodeJS.Timeout, source?: string, method?: string, protocolVersion?: string, targets: Set<ClientSocket>, lastErrorResponse?: import('../../protocol/src/types.js').BridgeResponse }} PendingEntry */
63
+ /** @typedef {import('node:net').Socket & { readonly __role?: SocketRole, __clientId?: string, __extensionId?: string, __browserName?: string, __profileLabel?: string, __browserExtensionId?: string, __accessEnabled?: boolean, __lastActiveAt?: number }} ClientSocket */
64
+ /** @typedef {{ socket: ClientSocket, timeoutId: NodeJS.Timeout, source?: string, method?: string, protocolVersion?: string, baselineId?: string | null, automaticMcpRetry?: boolean, targets: Set<ClientSocket>, lastErrorResponse?: import('../../protocol/src/types.js').BridgeResponse }} PendingEntry */
58
65
  /**
59
66
  * @typedef {{
60
67
  * installAgentFiles: typeof import('../../agent-client/src/install.js').installAgentFiles,
@@ -85,6 +92,220 @@ function compareProtocolVersions(left, right) {
85
92
  return 0;
86
93
  }
87
94
 
95
+ /**
96
+ * @param {unknown} value
97
+ * @returns {string | undefined}
98
+ */
99
+ function normalizeBrowserExtensionId(value) {
100
+ return typeof value === 'string' && /^[a-p]{32}$/u.test(value) ? value : undefined;
101
+ }
102
+
103
+ const HEALTH_DIAGNOSTIC_COUNT_LIMIT = 10_000;
104
+ const HEALTH_ACCESS_REASONS = new Set([
105
+ 'enabled',
106
+ 'access_disabled',
107
+ 'enabled_window_missing',
108
+ 'no_routable_active_tab',
109
+ 'restricted_page',
110
+ ]);
111
+ const HEALTH_DEBUGGER_STATUSES = new Set(['idle', 'active']);
112
+ const HEALTH_DEBUGGER_REASONS = new Set([
113
+ 'debugger_conflict',
114
+ 'debugger_detached',
115
+ 'debugger_replaced',
116
+ 'debugger_canceled',
117
+ 'target_closed',
118
+ ]);
119
+ const HEALTH_CAPTURE_STATES = new Set(['stopped', 'armed', 'active', 'stop_failed']);
120
+ const DIAGNOSTIC_METHODS = new Set([
121
+ 'health.ping',
122
+ 'daemon.metrics',
123
+ 'log.tail',
124
+ 'setup.get_status',
125
+ 'setup.install',
126
+ ]);
127
+
128
+ /**
129
+ * @param {unknown} value
130
+ * @returns {Record<string, unknown> | null}
131
+ */
132
+ function asHealthRecord(value) {
133
+ return value && typeof value === 'object' && !Array.isArray(value)
134
+ ? /** @type {Record<string, unknown>} */ (value)
135
+ : null;
136
+ }
137
+
138
+ /**
139
+ * @param {unknown} value
140
+ * @returns {number | undefined}
141
+ */
142
+ function normalizeHealthCount(value) {
143
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0
144
+ ? Math.min(HEALTH_DIAGNOSTIC_COUNT_LIMIT, Math.trunc(value))
145
+ : undefined;
146
+ }
147
+
148
+ /**
149
+ * Copy only extension-owned health fields into the daemon response. This keeps
150
+ * transport, connectivity, daemon identity, and daemon protocol negotiation
151
+ * authoritative even if an extension response is malformed or hostile.
152
+ *
153
+ * @param {unknown} value
154
+ * @returns {Record<string, unknown>}
155
+ */
156
+ function normalizeExtensionHealthResult(value) {
157
+ const result = asHealthRecord(value);
158
+ if (!result) return {};
159
+
160
+ /** @type {Record<string, unknown>} */
161
+ const normalized = {};
162
+ if (result.extension === 'ok') normalized.extension = 'ok';
163
+ if (
164
+ typeof result.extensionVersion === 'string' &&
165
+ /^\d+\.\d+\.\d+$/u.test(result.extensionVersion)
166
+ ) {
167
+ normalized.extensionVersion = result.extensionVersion;
168
+ }
169
+
170
+ const access = asHealthRecord(result.access);
171
+ if (access) {
172
+ /** @type {Record<string, unknown>} */
173
+ const normalizedAccess = {};
174
+ if (typeof access.enabled === 'boolean') normalizedAccess.enabled = access.enabled;
175
+ if (
176
+ access.windowId === null ||
177
+ (Number.isSafeInteger(access.windowId) && Number(access.windowId) > 0)
178
+ ) {
179
+ normalizedAccess.windowId = access.windowId;
180
+ }
181
+ if (
182
+ access.routeTabId === null ||
183
+ (Number.isSafeInteger(access.routeTabId) && Number(access.routeTabId) > 0)
184
+ ) {
185
+ normalizedAccess.routeTabId = access.routeTabId;
186
+ }
187
+ if (typeof access.routeReady === 'boolean') normalizedAccess.routeReady = access.routeReady;
188
+ if (typeof access.reason === 'string' && HEALTH_ACCESS_REASONS.has(access.reason)) {
189
+ normalizedAccess.reason = access.reason;
190
+ }
191
+ normalized.access = normalizedAccess;
192
+ }
193
+
194
+ const debuggerDiagnostics = asHealthRecord(result.debugger);
195
+ if (debuggerDiagnostics) {
196
+ /** @type {Record<string, unknown>} */
197
+ const normalizedDebugger = {};
198
+ if (
199
+ typeof debuggerDiagnostics.status === 'string' &&
200
+ HEALTH_DEBUGGER_STATUSES.has(debuggerDiagnostics.status)
201
+ ) {
202
+ normalizedDebugger.status = debuggerDiagnostics.status;
203
+ }
204
+ for (const key of ['attachedTabCount', 'heldTabCount', 'pendingTabCount']) {
205
+ const count = normalizeHealthCount(debuggerDiagnostics[key]);
206
+ if (count !== undefined) normalizedDebugger[key] = count;
207
+ }
208
+ if (
209
+ typeof debuggerDiagnostics.recentReason === 'string' &&
210
+ HEALTH_DEBUGGER_REASONS.has(debuggerDiagnostics.recentReason)
211
+ ) {
212
+ normalizedDebugger.recentReason = debuggerDiagnostics.recentReason;
213
+ } else if (debuggerDiagnostics.recentReason === null) {
214
+ normalizedDebugger.recentReason = null;
215
+ }
216
+ normalized.debugger = normalizedDebugger;
217
+ }
218
+
219
+ const capture = asHealthRecord(result.capture);
220
+ if (capture) {
221
+ /** @type {Record<string, unknown>} */
222
+ const normalizedCapture = {};
223
+ if (typeof capture.state === 'string' && HEALTH_CAPTURE_STATES.has(capture.state)) {
224
+ normalizedCapture.state = capture.state;
225
+ }
226
+ for (const key of [
227
+ 'activeTabCount',
228
+ 'ownershipCount',
229
+ 'inflightCount',
230
+ 'interceptionActiveTabCount',
231
+ 'interceptionRuleCount',
232
+ ]) {
233
+ const count = normalizeHealthCount(capture[key]);
234
+ if (count !== undefined) normalizedCapture[key] = count;
235
+ }
236
+ normalized.capture = normalizedCapture;
237
+ }
238
+
239
+ const domBaselines = asHealthRecord(result.domBaselines);
240
+ if (domBaselines) {
241
+ /** @type {Record<string, unknown>} */
242
+ const normalizedBaselines = {};
243
+ for (const key of ['baselineCount', 'bytes', 'tabCount']) {
244
+ const count = normalizeHealthCount(domBaselines[key]);
245
+ if (count !== undefined) normalizedBaselines[key] = count;
246
+ }
247
+ const operationSource = asHealthRecord(domBaselines.operations);
248
+ if (operationSource) {
249
+ /** @type {Record<string, unknown>} */
250
+ const operations = {};
251
+ for (const name of [
252
+ 'create',
253
+ 'get',
254
+ 'compare',
255
+ 'release',
256
+ 'invalidate',
257
+ 'clearTab',
258
+ 'clearWindow',
259
+ 'clearAll',
260
+ 'invalidateNavigation',
261
+ 'pruneExpired',
262
+ ]) {
263
+ const source = asHealthRecord(operationSource[name]);
264
+ if (!source) continue;
265
+ const calls = normalizeHealthCount(source.calls);
266
+ const totalLatencyMs = normalizeHealthCount(source.totalLatencyMs);
267
+ const maxLatencyMs = normalizeHealthCount(source.maxLatencyMs);
268
+ operations[name] = {
269
+ ...(calls === undefined ? {} : { calls }),
270
+ ...(totalLatencyMs === undefined ? {} : { totalLatencyMs }),
271
+ ...(maxLatencyMs === undefined ? {} : { maxLatencyMs }),
272
+ };
273
+ }
274
+ normalizedBaselines.operations = operations;
275
+ }
276
+ normalized.domBaselines = normalizedBaselines;
277
+ }
278
+
279
+ const recovery = normalizeRecoveryTelemetrySummary(result.recovery, 'routedExtension');
280
+ if (recovery) normalized.recovery = recovery;
281
+
282
+ const extensionSupportedVersions = Array.isArray(result.supported_versions)
283
+ ? result.supported_versions
284
+ .filter(
285
+ (version) =>
286
+ typeof version === 'string' &&
287
+ version.length <= 32 &&
288
+ /^\d+\.\d+(?:\.\d+)?(?:[-+][A-Za-z0-9.-]+)?$/u.test(version)
289
+ )
290
+ .slice(0, 10)
291
+ : [];
292
+ normalized.extension_supported_versions = extensionSupportedVersions;
293
+ return normalized;
294
+ }
295
+
296
+ /**
297
+ * @param {import('../../protocol/src/types.js').RecoveryTelemetrySummary} daemon
298
+ * @param {unknown} extensionResult
299
+ */
300
+ function createHealthRecoveryPayload(daemon, extensionResult) {
301
+ const source = asHealthRecord(extensionResult);
302
+ const routedExtension = normalizeRecoveryTelemetrySummary(source?.recovery, 'routedExtension');
303
+ return {
304
+ daemon,
305
+ ...(routedExtension ? { routedExtension } : {}),
306
+ };
307
+ }
308
+
88
309
  /**
89
310
  * @param {string | undefined} requestedVersion
90
311
  * @returns {{ supported_versions: readonly string[], deprecated_since?: string, migration_hint?: string }}
@@ -140,12 +361,19 @@ export function isWindowsNamedPipePath(socketPath) {
140
361
  * response?: import('../../protocol/src/types.js').BridgeResponse,
141
362
  * browserName?: string,
142
363
  * profileLabel?: string,
364
+ * browserExtensionId?: string,
143
365
  * accessEnabled?: boolean,
144
366
  * authToken?: string,
145
367
  * at?: number
368
+ * artifactId?: string,
369
+ * artifact?: Record<string, unknown>,
370
+ * chunkIndex?: number,
371
+ * data?: string
146
372
  * }} DaemonMessage
147
373
  */
148
374
 
375
+ /** @typedef {import('../../protocol/src/types.js').ArtifactKind} ArtifactKind */
376
+
149
377
  export class BridgeDaemon {
150
378
  /**
151
379
  * @param {{
@@ -155,7 +383,8 @@ export class BridgeDaemon {
155
383
  * setupStatusLoader?: () => Promise<SetupStatus>,
156
384
  * setupInstaller?: (params: Record<string, unknown>) => Promise<SetupInstallResult>,
157
385
  * logger?: DaemonLoggerLike | Pick<Console, 'log' | 'error'>,
158
- * authToken?: string | null
386
+ * authToken?: string | null,
387
+ * artifactStore?: ArtifactStore
159
388
  * }} [options={}]
160
389
  */
161
390
  constructor({
@@ -166,6 +395,7 @@ export class BridgeDaemon {
166
395
  setupInstaller = installSetupTarget,
167
396
  logger = undefined,
168
397
  authToken = undefined,
398
+ artifactStore = new ArtifactStore(getArtifactStorePath()),
169
399
  } = {}) {
170
400
  this.transport = socketPath ? createSocketBridgeTransport(socketPath) : transport;
171
401
  this.socketPath =
@@ -192,7 +422,7 @@ export class BridgeDaemon {
192
422
  this.pendingTimeoutMs = DEFAULT_DAEMON_PENDING_TIMEOUT_MS;
193
423
  /** @type {Record<string, unknown>[]} */
194
424
  this.recentLog = [];
195
- /** @type {Array<{ extensionId: string, browserName: string | null, profileLabel: string | null, accessEnabled: boolean }> | null} */
425
+ /** @type {Array<{ extensionId: string, browserExtensionId: string | null, browserName: string | null, profileLabel: string | null, accessEnabled: boolean }> | null} */
196
426
  this.connectedExtensionsCache = null;
197
427
  /** @type {Promise<void> | null} */
198
428
  this.stopPromise = null;
@@ -208,6 +438,13 @@ export class BridgeDaemon {
208
438
  this.requestStartTimes = new Map();
209
439
  /** @type {string | null | undefined} */
210
440
  this.authToken = authToken;
441
+ this.artifactStore = artifactStore;
442
+ this.artifactStoreInitialized = false;
443
+ /** @type {Map<string, { socket: ClientSocket, expiresAt: number }>} */
444
+ this.domBaselineOwners = new Map();
445
+ /** @type {Map<string, { socket: ClientSocket, expiresAt: number }>} */
446
+ this.abandonedDomBaselineCreates = new Map();
447
+ this.recoveryTelemetry = new RecoveryTelemetryCollector();
211
448
  }
212
449
 
213
450
  /**
@@ -275,11 +512,17 @@ export class BridgeDaemon {
275
512
  * @param {PendingEntry | undefined} [pending]
276
513
  * @returns {PendingEntry | undefined}
277
514
  */
278
- clearPendingRequest(requestId, pending = this.pendingRequests.get(requestId)) {
515
+ clearPendingRequest(
516
+ requestId,
517
+ pending = this.pendingRequests.get(requestId),
518
+ preserveArtifacts = false
519
+ ) {
279
520
  if (!pending) {
280
521
  return undefined;
281
522
  }
282
523
  clearTimeout(pending.timeoutId);
524
+ if (preserveArtifacts) this.artifactStore.abortRequest(requestId);
525
+ else this.artifactStore.deleteRequest(requestId);
283
526
  this.pendingRequests.delete(requestId);
284
527
  this.removePendingRequestIndex(this.pendingRequestsByOwnerSocket, pending.socket, requestId);
285
528
  for (const targetSocket of pending.targets) {
@@ -302,7 +545,7 @@ export class BridgeDaemon {
302
545
  }
303
546
 
304
547
  /**
305
- * @returns {Array<{ extensionId: string, browserName: string | null, profileLabel: string | null, accessEnabled: boolean }>}
548
+ * @returns {Array<{ extensionId: string, browserExtensionId: string | null, browserName: string | null, profileLabel: string | null, accessEnabled: boolean }>}
306
549
  */
307
550
  getConnectedExtensionsSnapshot() {
308
551
  if (this.connectedExtensionsCache) {
@@ -312,6 +555,7 @@ export class BridgeDaemon {
312
555
  this.connectedExtensionsCache = Array.from(this.extensionSockets.entries()).map(
313
556
  ([extensionId, extSocket]) => ({
314
557
  extensionId,
558
+ browserExtensionId: extSocket.__browserExtensionId ?? null,
315
559
  browserName: extSocket.__browserName ?? null,
316
560
  profileLabel: extSocket.__profileLabel ?? null,
317
561
  accessEnabled: extSocket.__accessEnabled ?? false,
@@ -373,6 +617,7 @@ export class BridgeDaemon {
373
617
  typeof message.browserName === 'string' ? message.browserName : undefined;
374
618
  socket.__profileLabel =
375
619
  typeof message.profileLabel === 'string' ? message.profileLabel : undefined;
620
+ socket.__browserExtensionId = normalizeBrowserExtensionId(message.browserExtensionId);
376
621
  socket.__lastActiveAt = Date.now();
377
622
  this.extensionSockets.set(extensionId, socket);
378
623
  this.invalidateConnectedExtensionsCache();
@@ -470,6 +715,9 @@ export class BridgeDaemon {
470
715
  await fs.promises.chmod(this.socketPath, 0o600);
471
716
  }
472
717
 
718
+ this.artifactStore.reset();
719
+ this.artifactStoreInitialized = true;
720
+
473
721
  this.logger.info('Daemon listening', {
474
722
  transport: formatBridgeTransport(this.transport),
475
723
  socketPath: this.socketPath ?? null,
@@ -517,6 +765,12 @@ export class BridgeDaemon {
517
765
  }
518
766
  this.extensionSockets.clear();
519
767
  this.invalidateConnectedExtensionsCache();
768
+ this.domBaselineOwners.clear();
769
+ this.abandonedDomBaselineCreates.clear();
770
+ if (this.artifactStoreInitialized) {
771
+ this.artifactStore.reset();
772
+ this.artifactStoreInitialized = false;
773
+ }
520
774
 
521
775
  if (this.server) {
522
776
  const server = this.server;
@@ -589,6 +843,13 @@ export class BridgeDaemon {
589
843
  return this.handleExtensionActivity(socket, message);
590
844
  }
591
845
 
846
+ if (message?.type?.startsWith('extension.artifact.')) {
847
+ if (socket.__role !== 'extension') {
848
+ return this.rejectMessageForRole(socket, message);
849
+ }
850
+ return this.handleExtensionArtifact(socket, message);
851
+ }
852
+
592
853
  if (message?.type === 'extension.setup_status.request') {
593
854
  if (socket.__role !== 'extension') {
594
855
  return this.rejectMessageForRole(socket, message);
@@ -597,11 +858,12 @@ export class BridgeDaemon {
597
858
  }
598
859
 
599
860
  if (message?.type === 'agent.request') {
600
- const isExtensionSetupRequest =
861
+ const isExtensionLocalRequest =
601
862
  socket.__role === 'extension' &&
602
- (message.request?.method === 'setup.get_status' ||
863
+ (message.request?.method === 'health.ping' ||
864
+ message.request?.method === 'setup.get_status' ||
603
865
  message.request?.method === 'setup.install');
604
- if (socket.__role !== 'agent' && !isExtensionSetupRequest) {
866
+ if (socket.__role !== 'agent' && !isExtensionLocalRequest) {
605
867
  return this.rejectMessageForRole(socket, message);
606
868
  }
607
869
  return this.handleAgentRequest(socket, message);
@@ -700,6 +962,7 @@ export class BridgeDaemon {
700
962
  `Request id ${JSON.stringify(request.id)} is already in flight.`
701
963
  );
702
964
  await writeJsonLine(socket, { type: 'agent.response', response });
965
+ this.recordDaemonRequestOutcome(request, false);
703
966
  return;
704
967
  }
705
968
 
@@ -713,6 +976,9 @@ export class BridgeDaemon {
713
976
  transport: formatBridgeTransport(this.transport),
714
977
  proxy: this.getProxyStatusPayload(),
715
978
  connectedExtensions: [],
979
+ recovery: {
980
+ daemon: this.recoveryTelemetry.snapshot('daemon'),
981
+ },
716
982
  daemon_supported_versions: getSupportedProtocolVersions(),
717
983
  ...getVersionNegotiationPayload(request.meta?.protocol_version),
718
984
  });
@@ -732,6 +998,7 @@ export class BridgeDaemon {
732
998
  }
733
999
 
734
1000
  if (request.method === 'daemon.metrics') {
1001
+ this.pruneDomBaselineOwners();
735
1002
  const now = Date.now();
736
1003
  const uptimeMs = this.startedAt > 0 ? now - this.startedAt : 0;
737
1004
  const avgResponseTimeMs =
@@ -746,11 +1013,62 @@ export class BridgeDaemon {
746
1013
  requestsProcessed: this.requestsProcessed,
747
1014
  requestsFailed: this.requestsFailed,
748
1015
  avgResponseTimeMs,
1016
+ domBaselineOwners: this.domBaselineOwners.size,
1017
+ recovery: this.recoveryTelemetry.snapshot('daemon'),
749
1018
  });
750
1019
  await writeJsonLine(socket, { type: 'agent.response', response });
751
1020
  return;
752
1021
  }
753
1022
 
1023
+ if (request.method === 'artifact.read' || request.method === 'artifact.delete') {
1024
+ const ownerId = socket.__clientId;
1025
+ if (!ownerId) {
1026
+ const response = createFailure(
1027
+ request.id,
1028
+ ERROR_CODES.ACCESS_DENIED,
1029
+ 'Artifact access requires a registered agent client.',
1030
+ null,
1031
+ { method: request.method }
1032
+ );
1033
+ await writeJsonLine(socket, { type: 'agent.response', response });
1034
+ this.recordDaemonRequestOutcome(request, false);
1035
+ return;
1036
+ }
1037
+ try {
1038
+ const result =
1039
+ request.method === 'artifact.read'
1040
+ ? this.artifactStore.read(
1041
+ String(request.params.artifactId),
1042
+ ownerId,
1043
+ Number(request.params.offset),
1044
+ Number(request.params.maxBytes)
1045
+ )
1046
+ : this.artifactStore.delete(String(request.params.artifactId), ownerId);
1047
+ await writeJsonLine(socket, {
1048
+ type: 'agent.response',
1049
+ response: createSuccess(request.id, result, { method: request.method }),
1050
+ });
1051
+ this.recordDaemonRequestOutcome(request, true);
1052
+ } catch (error) {
1053
+ const code =
1054
+ error && typeof error === 'object' && 'code' in error
1055
+ ? /** @type {import('../../protocol/src/types.js').ErrorCode} */ (error.code)
1056
+ : ERROR_CODES.INTERNAL_ERROR;
1057
+ await writeJsonLine(socket, {
1058
+ type: 'agent.response',
1059
+ response: createFailure(
1060
+ request.id,
1061
+ code,
1062
+ error instanceof Error ? error.message : String(error),
1063
+ null,
1064
+ { method: request.method }
1065
+ ),
1066
+ });
1067
+ this.recordDaemonRequestOutcome(request, false);
1068
+ }
1069
+ return;
1070
+ }
1071
+
754
1072
  if (request.method === 'setup.get_status') {
755
1073
  const response = createSuccess(request.id, await this.setupStatusLoader(), {
756
1074
  method: request.method,
@@ -799,17 +1117,48 @@ export class BridgeDaemon {
799
1117
  typeof request.meta?.target_profile === 'string' ? request.meta.target_profile : null;
800
1118
  const hasExplicitTarget = Boolean(targetBrowser || targetProfile);
801
1119
 
802
- const target = this.selectExtensionTarget(targetBrowser, targetProfile);
1120
+ const baselineId =
1121
+ typeof request.params.baselineId === 'string' ? request.params.baselineId : null;
1122
+ const usesBaselineOwner =
1123
+ request.method === 'dom.baseline.compare' ||
1124
+ request.method === 'dom.baseline.describe' ||
1125
+ request.method === 'dom.baseline.release';
1126
+ const baselineOwner = usesBaselineOwner ? this.getDomBaselineOwner(baselineId ?? '') : null;
1127
+ const target = usesBaselineOwner
1128
+ ? baselineOwner &&
1129
+ (!targetBrowser || baselineOwner.__browserName === targetBrowser) &&
1130
+ (!targetProfile || baselineOwner.__profileLabel === targetProfile)
1131
+ ? baselineOwner
1132
+ : null
1133
+ : this.selectExtensionTarget(targetBrowser, targetProfile);
803
1134
 
804
1135
  if (!target) {
1136
+ if (usesBaselineOwner) {
1137
+ const response =
1138
+ request.method === 'dom.baseline.release'
1139
+ ? createSuccess(request.id, { baselineId, released: false }, { method: request.method })
1140
+ : createFailure(
1141
+ request.id,
1142
+ ERROR_CODES.DOM_BASELINE_NOT_FOUND,
1143
+ 'DOM baseline is missing, expired, released, or its browser destination disconnected.',
1144
+ { baselineId },
1145
+ { method: request.method }
1146
+ );
1147
+ await writeJsonLine(socket, { type: 'agent.response', response });
1148
+ this.recordDaemonRequestOutcome(request, response.ok);
1149
+ return;
1150
+ }
805
1151
  const response = createFailure(
806
1152
  request.id,
807
1153
  ERROR_CODES.EXTENSION_DISCONNECTED,
808
1154
  hasExplicitTarget
809
1155
  ? `No connected extension matches target_browser="${targetBrowser ?? '*'}" target_profile="${targetProfile ?? '*'}".`
810
- : 'The Chrome extension is not connected to the local bridge daemon.'
1156
+ : 'The Chrome extension is not connected to the local bridge daemon.',
1157
+ null,
1158
+ { method: request.method }
811
1159
  );
812
1160
  await writeJsonLine(socket, { type: 'agent.response', response });
1161
+ this.recordDaemonRequestOutcome(request, false);
813
1162
  return;
814
1163
  }
815
1164
 
@@ -818,16 +1167,21 @@ export class BridgeDaemon {
818
1167
  method: request.method,
819
1168
  protocolVersion: request.meta?.protocol_version,
820
1169
  source: typeof request.meta?.source === 'string' ? request.meta.source : '',
1170
+ baselineId,
1171
+ automaticMcpRetry: isAutomaticMcpRetry(request.meta),
821
1172
  targets: new Set([target]),
822
1173
  timeoutId: setTimeout(() => {
823
1174
  const pending = this.pendingRequests.get(request.id);
824
1175
  if (!pending) return;
1176
+ this.markAbandonedDomBaselineCreate(request.id, pending);
825
1177
  this.clearPendingRequest(request.id, pending);
826
- this.recordRequestCompletion(request.id, false);
1178
+ this.recordRequestCompletion(request.id, false, pending);
827
1179
  const response = createFailure(
828
1180
  request.id,
829
1181
  ERROR_CODES.TIMEOUT,
830
- 'Extension did not respond in time.'
1182
+ 'Extension did not respond in time.',
1183
+ null,
1184
+ { method: pending.method }
831
1185
  );
832
1186
  void writeJsonLine(pending.socket, {
833
1187
  type: 'agent.response',
@@ -918,6 +1272,113 @@ export class BridgeDaemon {
918
1272
  return candidates[0]?.[1] ?? null;
919
1273
  }
920
1274
 
1275
+ /** @param {string} baselineId */
1276
+ getDomBaselineOwner(baselineId) {
1277
+ this.pruneDomBaselineOwners();
1278
+ const owner = this.domBaselineOwners.get(baselineId);
1279
+ if (!owner || !owner.socket.__extensionId || !owner.socket.__accessEnabled) return null;
1280
+ if (this.extensionSockets.get(owner.socket.__extensionId) !== owner.socket) {
1281
+ this.domBaselineOwners.delete(baselineId);
1282
+ return null;
1283
+ }
1284
+ return owner.socket;
1285
+ }
1286
+
1287
+ /** @param {string} baselineId @param {ClientSocket} socket @param {unknown} expiresAt */
1288
+ registerDomBaselineOwner(baselineId, socket, expiresAt) {
1289
+ this.pruneDomBaselineOwners();
1290
+ const parsedExpiry = typeof expiresAt === 'string' ? Date.parse(expiresAt) : Number.NaN;
1291
+ const now = Date.now();
1292
+ if (
1293
+ !/^baseline_[A-Za-z0-9_-]{32,64}$/u.test(baselineId) ||
1294
+ !socket.__extensionId ||
1295
+ !Number.isFinite(parsedExpiry) ||
1296
+ parsedExpiry <= now ||
1297
+ parsedExpiry > now + DOM_BASELINE_TTL_MS + 10_000
1298
+ ) {
1299
+ return false;
1300
+ }
1301
+ const existing = this.domBaselineOwners.get(baselineId);
1302
+ if (existing && existing.socket !== socket) return false;
1303
+ const maxOwnerMappings = MAX_DOM_BASELINES_GLOBAL * 4;
1304
+ while (
1305
+ !existing &&
1306
+ [...this.domBaselineOwners.values()].filter((owner) => owner.socket === socket).length >=
1307
+ maxOwnerMappings
1308
+ ) {
1309
+ const oldest = [...this.domBaselineOwners].find(([, owner]) => owner.socket === socket);
1310
+ if (!oldest) break;
1311
+ this.domBaselineOwners.delete(oldest[0]);
1312
+ }
1313
+ this.domBaselineOwners.set(baselineId, { socket, expiresAt: parsedExpiry });
1314
+ return true;
1315
+ }
1316
+
1317
+ pruneDomBaselineOwners() {
1318
+ const now = Date.now();
1319
+ for (const [baselineId, owner] of this.domBaselineOwners) {
1320
+ if (owner.expiresAt <= now) this.domBaselineOwners.delete(baselineId);
1321
+ }
1322
+ }
1323
+
1324
+ /** @param {ClientSocket} socket */
1325
+ clearDomBaselineOwnersForSocket(socket) {
1326
+ for (const [baselineId, owner] of this.domBaselineOwners) {
1327
+ if (owner.socket === socket) this.domBaselineOwners.delete(baselineId);
1328
+ }
1329
+ for (const [requestId, abandoned] of this.abandonedDomBaselineCreates) {
1330
+ if (abandoned.socket === socket) this.abandonedDomBaselineCreates.delete(requestId);
1331
+ }
1332
+ }
1333
+
1334
+ /** @param {string} requestId @param {PendingEntry} pending */
1335
+ markAbandonedDomBaselineCreate(requestId, pending) {
1336
+ if (pending.method !== 'dom.baseline.create') return;
1337
+ const target = pending.targets.values().next().value;
1338
+ if (!target) return;
1339
+ const now = Date.now();
1340
+ for (const [id, entry] of this.abandonedDomBaselineCreates) {
1341
+ if (entry.expiresAt <= now) this.abandonedDomBaselineCreates.delete(id);
1342
+ }
1343
+ this.abandonedDomBaselineCreates.set(requestId, {
1344
+ socket: target,
1345
+ expiresAt: now + DOM_BASELINE_TTL_MS,
1346
+ });
1347
+ while (this.abandonedDomBaselineCreates.size > MAX_DOM_BASELINES_GLOBAL * 4) {
1348
+ const oldestId = this.abandonedDomBaselineCreates.keys().next().value;
1349
+ if (typeof oldestId !== 'string') break;
1350
+ this.abandonedDomBaselineCreates.delete(oldestId);
1351
+ }
1352
+ }
1353
+
1354
+ /**
1355
+ * @param {ClientSocket} socket
1356
+ * @param {import('../../protocol/src/types.js').BridgeResponse} response
1357
+ */
1358
+ async releaseOrphanDomBaseline(socket, response) {
1359
+ const result =
1360
+ response.result && typeof response.result === 'object'
1361
+ ? /** @type {Record<string, unknown>} */ (response.result)
1362
+ : {};
1363
+ const baselineId = typeof result.baselineId === 'string' ? result.baselineId : '';
1364
+ if (!/^baseline_[A-Za-z0-9_-]{32,64}$/u.test(baselineId)) return;
1365
+ const scope =
1366
+ result.scope && typeof result.scope === 'object'
1367
+ ? /** @type {Record<string, unknown>} */ (result.scope)
1368
+ : {};
1369
+ const tabId = Number.isSafeInteger(scope.tabId) ? Number(scope.tabId) : null;
1370
+ await writeJsonLine(socket, {
1371
+ type: 'extension.request',
1372
+ request: {
1373
+ id: `orphan_release_${randomUUID().replaceAll('-', '')}`,
1374
+ method: 'dom.baseline.release',
1375
+ tab_id: tabId,
1376
+ params: { baselineId },
1377
+ meta: { protocol_version: getProtocolVersion(), token_budget: null },
1378
+ },
1379
+ });
1380
+ }
1381
+
921
1382
  /**
922
1383
  * @param {BridgeRequest} request
923
1384
  * @returns {number}
@@ -975,6 +1436,11 @@ export class BridgeDaemon {
975
1436
  changed = changed || socket.__profileLabel !== message.profileLabel;
976
1437
  socket.__profileLabel = message.profileLabel;
977
1438
  }
1439
+ if (typeof message.browserExtensionId === 'string') {
1440
+ const browserExtensionId = normalizeBrowserExtensionId(message.browserExtensionId);
1441
+ changed = changed || socket.__browserExtensionId !== browserExtensionId;
1442
+ socket.__browserExtensionId = browserExtensionId;
1443
+ }
978
1444
  if (changed) {
979
1445
  this.invalidateConnectedExtensionsCache();
980
1446
  }
@@ -987,6 +1453,10 @@ export class BridgeDaemon {
987
1453
  */
988
1454
  handleExtensionAccessUpdate(socket, message) {
989
1455
  const accessEnabled = Boolean(message.accessEnabled);
1456
+ if (!accessEnabled && socket.__extensionId) {
1457
+ this.artifactStore.deleteByExtension(socket.__extensionId);
1458
+ this.clearDomBaselineOwnersForSocket(socket);
1459
+ }
990
1460
  if (socket.__accessEnabled !== accessEnabled) {
991
1461
  socket.__accessEnabled = accessEnabled;
992
1462
  this.invalidateConnectedExtensionsCache();
@@ -995,6 +1465,58 @@ export class BridgeDaemon {
995
1465
  socket.__accessEnabled = accessEnabled;
996
1466
  }
997
1467
 
1468
+ /**
1469
+ * @param {ClientSocket} socket
1470
+ * @param {DaemonMessage} message
1471
+ * @returns {void}
1472
+ */
1473
+ handleExtensionArtifact(socket, message) {
1474
+ const artifact =
1475
+ message.artifact && typeof message.artifact === 'object' ? message.artifact : {};
1476
+ const requestId = typeof artifact.requestId === 'string' ? artifact.requestId : '';
1477
+ const pending = this.pendingRequests.get(requestId);
1478
+ if (!pending || !pending.targets.has(socket) || !socket.__extensionId) return;
1479
+ const ownerId = pending.socket.__clientId;
1480
+ if (!ownerId) return;
1481
+ try {
1482
+ if (message.type === 'extension.artifact.begin') {
1483
+ this.artifactStore.begin({
1484
+ artifactId: String(artifact.artifactId ?? ''),
1485
+ requestId,
1486
+ ownerId,
1487
+ extensionId: socket.__extensionId,
1488
+ kind: /** @type {ArtifactKind} */ (artifact.kind),
1489
+ mimeType: String(artifact.mimeType ?? ''),
1490
+ totalBytes: Number(artifact.byteLength),
1491
+ sha256: String(artifact.sha256 ?? ''),
1492
+ chunkCount: Number(artifact.chunkCount),
1493
+ createdAt: String(artifact.createdAt ?? ''),
1494
+ expiresAt: String(artifact.expiresAt ?? ''),
1495
+ });
1496
+ } else if (message.type === 'extension.artifact.chunk') {
1497
+ this.artifactStore.writeChunk(
1498
+ String(message.artifactId ?? ''),
1499
+ Number(message.chunkIndex),
1500
+ String(message.data ?? '')
1501
+ );
1502
+ } else if (message.type === 'extension.artifact.commit') {
1503
+ this.artifactStore.commit(String(message.artifactId ?? ''));
1504
+ }
1505
+ } catch (error) {
1506
+ const code =
1507
+ error && typeof error === 'object' && 'code' in error
1508
+ ? /** @type {import('../../protocol/src/types.js').ErrorCode} */ (error.code)
1509
+ : ERROR_CODES.ARTIFACT_TRANSFER_INVALID;
1510
+ pending.lastErrorResponse = createFailure(
1511
+ requestId,
1512
+ code,
1513
+ error instanceof Error ? error.message : String(error),
1514
+ null,
1515
+ { method: pending.method }
1516
+ );
1517
+ }
1518
+ }
1519
+
998
1520
  /**
999
1521
  * Structured remote-exposure status for health payloads. The proxy counts
1000
1522
  * as enabled only when the daemon listens on a TCP address other than
@@ -1035,22 +1557,88 @@ export class BridgeDaemon {
1035
1557
  }
1036
1558
 
1037
1559
  const pending = this.pendingRequests.get(responseMessage.id);
1038
- if (!pending || !pending.targets.has(socket)) {
1560
+ if (!pending) {
1561
+ const abandoned = this.abandonedDomBaselineCreates.get(responseMessage.id);
1562
+ if (abandoned?.socket === socket) {
1563
+ this.abandonedDomBaselineCreates.delete(responseMessage.id);
1564
+ if (responseMessage.ok) {
1565
+ await this.releaseOrphanDomBaseline(socket, responseMessage).catch((error) => {
1566
+ this.logger.error('orphan DOM baseline release failed', {
1567
+ requestId: responseMessage.id,
1568
+ message: error instanceof Error ? error.message : String(error),
1569
+ });
1570
+ });
1571
+ }
1572
+ }
1573
+ return;
1574
+ }
1575
+ if (!pending.targets.has(socket)) {
1039
1576
  return;
1040
1577
  }
1041
1578
 
1042
1579
  this.removePendingTarget(responseMessage.id, pending, socket);
1043
1580
 
1044
1581
  if (responseMessage.ok) {
1045
- this.clearPendingRequest(responseMessage.id, pending);
1046
- this.recordRequestCompletion(responseMessage.id, true);
1047
- this.pushLog({
1048
- at: new Date().toISOString(),
1049
- method: responseMessage.meta?.method ?? null,
1050
- ok: true,
1051
- id: responseMessage.id,
1052
- source: pending.source || null,
1053
- });
1582
+ const result =
1583
+ responseMessage.result && typeof responseMessage.result === 'object'
1584
+ ? /** @type {Record<string, unknown>} */ (responseMessage.result)
1585
+ : {};
1586
+ const artifact =
1587
+ result.artifact && typeof result.artifact === 'object'
1588
+ ? /** @type {Record<string, unknown>} */ (result.artifact)
1589
+ : null;
1590
+ const expectedArtifactKind = getArtifactKindForMethod(pending.method);
1591
+ if (
1592
+ result.delivery === 'artifact' &&
1593
+ (!artifact ||
1594
+ artifact.kind !== expectedArtifactKind ||
1595
+ !pending.socket.__clientId ||
1596
+ !this.artifactStore.matchesCommitted(
1597
+ String(artifact.artifactId ?? ''),
1598
+ pending.socket.__clientId,
1599
+ responseMessage.id,
1600
+ artifact,
1601
+ expectedArtifactKind ?? undefined
1602
+ ))
1603
+ ) {
1604
+ pending.lastErrorResponse ??= createFailure(
1605
+ responseMessage.id,
1606
+ ERROR_CODES.ARTIFACT_TRANSFER_INVALID,
1607
+ 'Artifact metadata does not match the request or was not committed before the response.',
1608
+ null,
1609
+ { method: pending.method }
1610
+ );
1611
+ }
1612
+ if (pending.lastErrorResponse) {
1613
+ await this.finishPendingRequestIfExhausted(responseMessage.id, pending);
1614
+ return;
1615
+ }
1616
+ if (pending.method === 'dom.baseline.create') {
1617
+ const baselineId = typeof result.baselineId === 'string' ? result.baselineId : '';
1618
+ const evicted = Array.isArray(result.evicted) ? result.evicted : [];
1619
+ for (const item of evicted) {
1620
+ if (item && typeof item === 'object' && 'baselineId' in item) {
1621
+ const evictedId = String(item.baselineId);
1622
+ if (this.domBaselineOwners.get(evictedId)?.socket === socket) {
1623
+ this.domBaselineOwners.delete(evictedId);
1624
+ }
1625
+ }
1626
+ }
1627
+ if (!this.registerDomBaselineOwner(baselineId, socket, result.expiresAt)) {
1628
+ await this.releaseOrphanDomBaseline(socket, responseMessage);
1629
+ pending.lastErrorResponse = createFailure(
1630
+ responseMessage.id,
1631
+ ERROR_CODES.DOM_BASELINE_INVALIDATED,
1632
+ 'DOM baseline owner metadata is invalid.',
1633
+ null,
1634
+ { method: pending.method }
1635
+ );
1636
+ await this.finishPendingRequestIfExhausted(responseMessage.id, pending);
1637
+ return;
1638
+ }
1639
+ } else if (pending.method === 'dom.baseline.release' && pending.baselineId) {
1640
+ this.domBaselineOwners.delete(pending.baselineId);
1641
+ }
1054
1642
  const response =
1055
1643
  pending.method === 'health.ping'
1056
1644
  ? createSuccess(
@@ -1061,27 +1649,54 @@ export class BridgeDaemon {
1061
1649
  socketPath: this.socketPath,
1062
1650
  transport: formatBridgeTransport(this.transport),
1063
1651
  connectedExtensions: this.getConnectedExtensionsSnapshot(),
1652
+ ...normalizeExtensionHealthResult(responseMessage.result),
1064
1653
  ...getVersionNegotiationPayload(pending.protocolVersion),
1065
- .../** @type {Record<string, unknown>} */ (responseMessage.result),
1066
1654
  daemonVersion: DAEMON_VERSION,
1067
1655
  daemon_supported_versions: getSupportedProtocolVersions(),
1068
1656
  proxy: this.getProxyStatusPayload(),
1657
+ recovery: createHealthRecoveryPayload(
1658
+ this.recoveryTelemetry.snapshot('daemon'),
1659
+ responseMessage.result
1660
+ ),
1069
1661
  },
1070
1662
  {
1071
- ...responseMessage.meta,
1072
- method: responseMessage.meta?.method ?? pending.method,
1663
+ method: pending.method,
1073
1664
  }
1074
1665
  )
1075
1666
  : responseMessage;
1076
1667
 
1077
- await writeJsonLine(pending.socket, {
1078
- type: 'agent.response',
1079
- response,
1668
+ try {
1669
+ await writeJsonLine(pending.socket, {
1670
+ type: 'agent.response',
1671
+ response,
1672
+ });
1673
+ } catch (error) {
1674
+ if (pending.method === 'dom.baseline.create') {
1675
+ const baselineId =
1676
+ result && typeof result.baselineId === 'string' ? result.baselineId : null;
1677
+ if (baselineId) this.domBaselineOwners.delete(baselineId);
1678
+ await this.releaseOrphanDomBaseline(socket, responseMessage).catch(() => {});
1679
+ }
1680
+ this.clearPendingRequest(responseMessage.id, pending);
1681
+ this.recordRequestCompletion(responseMessage.id, false, pending);
1682
+ throw error;
1683
+ }
1684
+ this.clearPendingRequest(responseMessage.id, pending, true);
1685
+ this.recordRequestCompletion(responseMessage.id, true, pending);
1686
+ this.pushLog({
1687
+ at: new Date().toISOString(),
1688
+ method: responseMessage.meta?.method ?? null,
1689
+ ok: true,
1690
+ id: responseMessage.id,
1691
+ source: pending.source || null,
1080
1692
  });
1081
1693
  return;
1082
1694
  }
1083
1695
 
1084
1696
  // A routed request has one target, so its error is final.
1697
+ if (pending.baselineId && responseMessage.error?.code === ERROR_CODES.DOM_BASELINE_NOT_FOUND) {
1698
+ this.domBaselineOwners.delete(pending.baselineId);
1699
+ }
1085
1700
  pending.lastErrorResponse = responseMessage;
1086
1701
 
1087
1702
  await this.finishPendingRequestIfExhausted(responseMessage.id, pending);
@@ -1093,6 +1708,8 @@ export class BridgeDaemon {
1093
1708
  */
1094
1709
  handleSocketClose(socket) {
1095
1710
  if (socket.__extensionId) {
1711
+ this.artifactStore.deleteByExtension(socket.__extensionId);
1712
+ this.clearDomBaselineOwnersForSocket(socket);
1096
1713
  this.logger.info('extension disconnected', { extensionId: socket.__extensionId });
1097
1714
  if (this.extensionSockets.get(socket.__extensionId) === socket) {
1098
1715
  this.extensionSockets.delete(socket.__extensionId);
@@ -1114,8 +1731,9 @@ export class BridgeDaemon {
1114
1731
  if (!pending) {
1115
1732
  continue;
1116
1733
  }
1734
+ this.markAbandonedDomBaselineCreate(id, pending);
1117
1735
  this.clearPendingRequest(id, pending);
1118
- this.recordRequestCompletion(id, false);
1736
+ this.recordRequestCompletion(id, false, pending);
1119
1737
  }
1120
1738
  }
1121
1739
 
@@ -1152,7 +1770,7 @@ export class BridgeDaemon {
1152
1770
  }
1153
1771
 
1154
1772
  this.clearPendingRequest(requestId, pending);
1155
- this.recordRequestCompletion(requestId, false);
1773
+ this.recordRequestCompletion(requestId, false, pending);
1156
1774
 
1157
1775
  const response =
1158
1776
  pending.lastErrorResponse ??
@@ -1181,9 +1799,10 @@ export class BridgeDaemon {
1181
1799
  /**
1182
1800
  * @param {string} requestId
1183
1801
  * @param {boolean} ok
1802
+ * @param {PendingEntry | undefined} [pending]
1184
1803
  * @returns {void}
1185
1804
  */
1186
- recordRequestCompletion(requestId, ok) {
1805
+ recordRequestCompletion(requestId, ok, pending) {
1187
1806
  const startedAt = this.requestStartTimes.get(requestId);
1188
1807
  this.requestStartTimes.delete(requestId);
1189
1808
  this.requestsProcessed += 1;
@@ -1193,6 +1812,34 @@ export class BridgeDaemon {
1193
1812
  if (typeof startedAt === 'number') {
1194
1813
  this.totalResponseTimeMs += Date.now() - startedAt;
1195
1814
  }
1815
+ if (pending?.method && !DIAGNOSTIC_METHODS.has(pending.method)) {
1816
+ this.recordDaemonRequestOutcome(
1817
+ {
1818
+ method: /** @type {BridgeRequest['method']} */ (pending.method),
1819
+ meta: {
1820
+ protocol_version: pending.protocolVersion ?? getProtocolVersion(),
1821
+ token_budget: null,
1822
+ ...(pending.automaticMcpRetry
1823
+ ? { source: 'mcp', automatic_retry: { attempt: 2, reason: 'retryable_error' } }
1824
+ : {}),
1825
+ },
1826
+ },
1827
+ ok
1828
+ );
1829
+ }
1830
+ }
1831
+
1832
+ /**
1833
+ * @param {Pick<BridgeRequest, 'method' | 'meta'>} request
1834
+ * @param {boolean} ok
1835
+ */
1836
+ recordDaemonRequestOutcome(request, ok) {
1837
+ if (DIAGNOSTIC_METHODS.has(request.method)) return;
1838
+ const outcome = ok ? 'success' : 'failure';
1839
+ this.recoveryTelemetry.record('request_outcome', outcome, request.method);
1840
+ if (isAutomaticMcpRetry(request.meta)) {
1841
+ this.recoveryTelemetry.record('automatic_mcp_retry', outcome, request.method);
1842
+ }
1196
1843
  }
1197
1844
 
1198
1845
  /**
@@ -1200,13 +1847,34 @@ export class BridgeDaemon {
1200
1847
  * @returns {void}
1201
1848
  */
1202
1849
  pushLog(entry) {
1203
- this.recentLog.push(entry);
1850
+ this.recentLog.push(/** @type {Record<string, unknown>} */ (sanitizeIncidentalValue(entry)));
1204
1851
  if (this.recentLog.length > DAEMON_RECENT_LOG_LIMIT) {
1205
1852
  this.recentLog.shift();
1206
1853
  }
1207
1854
  }
1208
1855
  }
1209
1856
 
1857
+ /** @param {Record<string, unknown>} meta */
1858
+ function isAutomaticMcpRetry(meta) {
1859
+ const marker = asHealthRecord(meta.automatic_retry);
1860
+ if (meta.source !== 'mcp' || !marker) return false;
1861
+ const keys = Object.keys(marker).sort();
1862
+ return (
1863
+ keys.length === 2 &&
1864
+ keys[0] === 'attempt' &&
1865
+ keys[1] === 'reason' &&
1866
+ marker.attempt === 2 &&
1867
+ marker.reason === 'retryable_error'
1868
+ );
1869
+ }
1870
+
1871
+ /** @param {string | undefined} method @returns {ArtifactKind | null} */
1872
+ function getArtifactKindForMethod(method) {
1873
+ if (method?.startsWith('screenshot.')) return 'screenshot';
1874
+ if (method === 'network.export_har') return 'har';
1875
+ return null;
1876
+ }
1877
+
1210
1878
  /**
1211
1879
  * Check whether a daemon is already listening on the given transport.
1212
1880
  * Connects, sends a health.ping, and waits up to 500 ms for a response.