baa-conductor


baa-conductor / apps / conductor-daemon / src
codex@macbookpro  ·  2026-04-01

firefox-bridge.ts

   1import { randomUUID } from "node:crypto";
   2
   3import type {
   4  BrowserBridgeActionDispatch,
   5  BrowserBridgeActionResultSnapshot
   6} from "./browser-types.js";
   7import {
   8  DEFAULT_BAA_DELIVERY_ACTION_RESULT_TIMEOUT_MS,
   9  DEFAULT_FIREFOX_ACTION_RESULT_TIMEOUT_MS,
  10  DEFAULT_FIREFOX_API_REQUEST_TIMEOUT_MS,
  11  DEFAULT_FIREFOX_STREAM_IDLE_TIMEOUT_MS,
  12  DEFAULT_FIREFOX_STREAM_MAX_BUFFERED_BYTES,
  13  DEFAULT_FIREFOX_STREAM_MAX_BUFFERED_EVENTS,
  14  DEFAULT_FIREFOX_STREAM_OPEN_TIMEOUT_MS,
  15  resolveDeliveryActionResultTimeoutMs
  16} from "./execution-timeouts.js";
  17
  18type TimeoutHandle = ReturnType<typeof globalThis.setTimeout>;
  19
  20export type FirefoxBridgeResponseMode = "buffered" | "sse";
  21export type FirefoxBridgeOutboundCommandType =
  22  | "api_request"
  23  | "browser.inject_message"
  24  | "browser.proxy_delivery"
  25  | "browser.send_message"
  26  | "controller_reload"
  27  | "open_tab"
  28  | "plugin_status"
  29  | "reload"
  30  | "request_cancel"
  31  | "request_credentials"
  32  | "tab_focus"
  33  | "tab_restore"
  34  | "ws_reconnect";
  35
  36export type FirefoxBridgeErrorCode =
  37  | "action_timeout"
  38  | "client_disconnected"
  39  | "client_not_found"
  40  | "client_replaced"
  41  | "duplicate_request_id"
  42  | "no_active_client"
  43  | "request_not_found"
  44  | "request_timeout"
  45  | "send_failed"
  46  | "service_stopped";
  47
  48export interface FirefoxBridgeRegisteredClient {
  49  clientId: string;
  50  connectedAt: number;
  51  connectionId: string;
  52  lastMessageAt: number;
  53  sendJson(payload: Record<string, unknown>): boolean;
  54}
  55
  56export interface FirefoxBridgeCommandTarget {
  57  clientId?: string | null;
  58}
  59
  60export interface FirefoxBridgeDispatchReceipt {
  61  clientId: string;
  62  connectionId: string;
  63  dispatchedAt: number;
  64  type: FirefoxBridgeOutboundCommandType;
  65}
  66
  67export interface FirefoxOpenTabCommandInput extends FirefoxBridgeCommandTarget {
  68  platform?: string | null;
  69}
  70
  71export interface FirefoxRequestCredentialsCommandInput extends FirefoxBridgeCommandTarget {
  72  platform?: string | null;
  73  reason?: string | null;
  74}
  75
  76export interface FirefoxReloadCommandInput extends FirefoxBridgeCommandTarget {
  77  platform?: string | null;
  78  reason?: string | null;
  79}
  80
  81export interface FirefoxPluginActionCommandInput extends FirefoxBridgeCommandTarget {
  82  action: "controller_reload" | "plugin_status" | "tab_focus" | "tab_restore" | "ws_reconnect";
  83  disconnectMs?: number | null;
  84  platform?: string | null;
  85  reason?: string | null;
  86  repeatCount?: number | null;
  87  repeatIntervalMs?: number | null;
  88}
  89
  90export interface FirefoxApiRequestCommandInput extends FirefoxBridgeCommandTarget {
  91  body?: unknown;
  92  conversationId?: string | null;
  93  headers?: Record<string, string> | null;
  94  id?: string | null;
  95  idleTimeoutMs?: number | null;
  96  maxBufferedBytes?: number | null;
  97  maxBufferedEvents?: number | null;
  98  method?: string | null;
  99  openTimeoutMs?: number | null;
 100  path: string;
 101  platform: string;
 102  responseMode?: FirefoxBridgeResponseMode | null;
 103  streamId?: string | null;
 104  timeoutMs?: number | null;
 105}
 106
 107export interface FirefoxInjectMessageCommandInput extends FirefoxBridgeCommandTarget {
 108  conversationId?: string | null;
 109  messageText: string;
 110  pageTitle?: string | null;
 111  pageUrl?: string | null;
 112  planId: string;
 113  pollIntervalMs?: number | null;
 114  platform: string;
 115  retryAttempts?: number | null;
 116  retryDelayMs?: number | null;
 117  shellPage?: boolean | null;
 118  tabId?: number | null;
 119  timeoutMs?: number | null;
 120}
 121
 122export interface FirefoxProxyDeliveryCommandInput extends FirefoxBridgeCommandTarget {
 123  assistantMessageId: string;
 124  conversationId?: string | null;
 125  messageText: string;
 126  organizationId?: string | null;
 127  pageTitle?: string | null;
 128  pageUrl?: string | null;
 129  planId: string;
 130  platform: string;
 131  shellPage?: boolean | null;
 132  tabId?: number | null;
 133  timeoutMs?: number | null;
 134}
 135
 136export interface FirefoxSendMessageCommandInput extends FirefoxBridgeCommandTarget {
 137  conversationId?: string | null;
 138  pageTitle?: string | null;
 139  pageUrl?: string | null;
 140  planId: string;
 141  pollIntervalMs?: number | null;
 142  platform: string;
 143  retryAttempts?: number | null;
 144  retryDelayMs?: number | null;
 145  shellPage?: boolean | null;
 146  tabId?: number | null;
 147  timeoutMs?: number | null;
 148}
 149
 150export interface FirefoxRequestCancelCommandInput extends FirefoxBridgeCommandTarget {
 151  platform?: string | null;
 152  reason?: string | null;
 153  requestId: string;
 154  streamId?: string | null;
 155}
 156
 157export interface FirefoxBridgeCancelReceipt extends FirefoxBridgeDispatchReceipt {
 158  reason: string | null;
 159  requestId: string;
 160  streamId: string | null;
 161}
 162
 163export interface FirefoxApiResponsePayload {
 164  body: unknown;
 165  error: string | null;
 166  id: string;
 167  ok: boolean;
 168  status: number | null;
 169}
 170
 171export interface FirefoxBridgeApiResponse extends FirefoxApiResponsePayload {
 172  clientId: string;
 173  connectionId: string;
 174  respondedAt: number;
 175}
 176
 177export interface FirefoxBridgeConnectionClosedEvent {
 178  clientId: string | null;
 179  code?: number | null;
 180  connectionId: string;
 181  reason?: string | null;
 182}
 183
 184export interface FirefoxBridgeStreamPartialState {
 185  buffered_bytes: number;
 186  event_count: number;
 187  last_seq: number;
 188  opened: boolean;
 189}
 190
 191export interface FirefoxBridgeStreamOpenEvent {
 192  clientId: string;
 193  connectionId: string;
 194  meta: unknown;
 195  openedAt: number;
 196  requestId: string;
 197  status: number | null;
 198  streamId: string;
 199  type: "stream_open";
 200}
 201
 202export interface FirefoxBridgeStreamDataEvent {
 203  clientId: string;
 204  connectionId: string;
 205  data: unknown;
 206  event: string | null;
 207  raw: string | null;
 208  receivedAt: number;
 209  requestId: string;
 210  seq: number;
 211  streamId: string;
 212  type: "stream_event";
 213}
 214
 215export interface FirefoxBridgeStreamEndEvent {
 216  clientId: string;
 217  connectionId: string;
 218  endedAt: number;
 219  partial: FirefoxBridgeStreamPartialState;
 220  requestId: string;
 221  status: number | null;
 222  streamId: string;
 223  type: "stream_end";
 224}
 225
 226export interface FirefoxBridgeStreamErrorEvent {
 227  clientId: string;
 228  code: string;
 229  connectionId: string;
 230  erroredAt: number;
 231  message: string;
 232  partial: FirefoxBridgeStreamPartialState;
 233  requestId: string;
 234  status: number | null;
 235  streamId: string;
 236  type: "stream_error";
 237}
 238
 239export type FirefoxBridgeStreamEvent =
 240  | FirefoxBridgeStreamDataEvent
 241  | FirefoxBridgeStreamEndEvent
 242  | FirefoxBridgeStreamErrorEvent
 243  | FirefoxBridgeStreamOpenEvent;
 244
 245export interface FirefoxBridgeApiStream extends AsyncIterable<FirefoxBridgeStreamEvent> {
 246  readonly clientId: string;
 247  readonly connectionId: string;
 248  readonly requestId: string;
 249  readonly streamId: string;
 250  cancel(reason?: string | null): void;
 251}
 252
 253export interface FirefoxStreamOpenPayload {
 254  id: string;
 255  meta?: unknown;
 256  status: number | null;
 257  streamId?: string | null;
 258}
 259
 260export interface FirefoxStreamEventPayload {
 261  data?: unknown;
 262  event?: string | null;
 263  id: string;
 264  raw?: string | null;
 265  seq: number;
 266  streamId?: string | null;
 267}
 268
 269export interface FirefoxStreamEndPayload {
 270  id: string;
 271  status: number | null;
 272  streamId?: string | null;
 273}
 274
 275export interface FirefoxStreamErrorPayload {
 276  code?: string | null;
 277  id: string;
 278  message?: string | null;
 279  status: number | null;
 280  streamId?: string | null;
 281}
 282
 283interface FirefoxPendingApiRequest {
 284  clientId: string;
 285  connectionId: string;
 286  reject: (error: FirefoxBridgeError) => void;
 287  requestId: string;
 288  resolve: (response: FirefoxBridgeApiResponse) => void;
 289  timer: TimeoutHandle;
 290}
 291
 292interface FirefoxPendingActionRequest {
 293  clientId: string;
 294  connectionId: string;
 295  reject: (error: FirefoxBridgeError) => void;
 296  requestId: string;
 297  resolve: (response: BrowserBridgeActionResultSnapshot) => void;
 298  timer: TimeoutHandle;
 299}
 300
 301interface FirefoxCommandBrokerOptions {
 302  clearTimeoutImpl?: (handle: TimeoutHandle) => void;
 303  now?: () => number;
 304  resolveActiveClient: () => FirefoxBridgeRegisteredClient | null;
 305  resolveClientById: (clientId: string) => FirefoxBridgeRegisteredClient | null;
 306  setTimeoutImpl?: (handler: () => void, timeoutMs: number) => TimeoutHandle;
 307}
 308
 309interface FirefoxStreamSessionOptions {
 310  clearTimeoutImpl: (handle: TimeoutHandle) => void;
 311  clientId: string;
 312  connectionId: string;
 313  idleTimeoutMs: number;
 314  maxBufferedBytes: number;
 315  maxBufferedEvents: number;
 316  now: () => number;
 317  onCancel: (reason?: string | null) => void;
 318  onClose: (requestId: string) => void;
 319  openTimeoutMs: number;
 320  requestId: string;
 321  setTimeoutImpl: (handler: () => void, timeoutMs: number) => TimeoutHandle;
 322  streamId: string;
 323}
 324
 325interface FirefoxQueuedStreamEvent {
 326  event: FirefoxBridgeStreamEvent;
 327  size: number;
 328}
 329
 330function normalizeOptionalString(value: unknown): string | null {
 331  if (typeof value !== "string") {
 332    return null;
 333  }
 334
 335  const normalized = value.trim();
 336  return normalized === "" ? null : normalized;
 337}
 338
 339function normalizeHeaderRecord(
 340  headers: Record<string, string> | null | undefined
 341): Record<string, string> | undefined {
 342  if (headers == null) {
 343    return undefined;
 344  }
 345
 346  const normalized: Record<string, string> = {};
 347
 348  for (const [name, value] of Object.entries(headers)) {
 349    const normalizedName = normalizeOptionalString(name);
 350
 351    if (normalizedName == null || typeof value !== "string") {
 352      continue;
 353    }
 354
 355    normalized[normalizedName] = value;
 356  }
 357
 358  return Object.keys(normalized).length > 0 ? normalized : undefined;
 359}
 360
 361function normalizeTimeoutMs(value: number | null | undefined, fallback: number): number {
 362  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
 363    return fallback;
 364  }
 365
 366  return Math.round(value);
 367}
 368
 369function normalizePositiveInteger(value: number | null | undefined, fallback: number): number {
 370  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
 371    return fallback;
 372  }
 373
 374  return Math.max(1, Math.round(value));
 375}
 376
 377function normalizeOptionalNonNegativeInteger(value: number | null | undefined): number | undefined {
 378  if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
 379    return undefined;
 380  }
 381
 382  return Math.round(value);
 383}
 384
 385function normalizeOptionalPositiveInteger(value: number | null | undefined): number | undefined {
 386  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
 387    return undefined;
 388  }
 389
 390  return Math.max(1, Math.round(value));
 391}
 392
 393function normalizeStatus(value: number | null | undefined): number | null {
 394  if (typeof value !== "number" || !Number.isFinite(value)) {
 395    return null;
 396  }
 397
 398  return Math.round(value);
 399}
 400
 401function compactRecord(input: Record<string, unknown>): Record<string, unknown> {
 402  const output: Record<string, unknown> = {};
 403
 404  for (const [key, value] of Object.entries(input)) {
 405    if (value !== undefined) {
 406      output[key] = value;
 407    }
 408  }
 409
 410  return output;
 411}
 412
 413function estimateEventSize(event: FirefoxBridgeStreamEvent): number {
 414  try {
 415    return Buffer.from(JSON.stringify(event), "utf8").length;
 416  } catch {
 417    return 0;
 418  }
 419}
 420
 421function buildPartialState(
 422  opened: boolean,
 423  eventCount: number,
 424  lastSeq: number,
 425  bufferedBytes: number
 426): FirefoxBridgeStreamPartialState {
 427  return {
 428    buffered_bytes: bufferedBytes,
 429    event_count: eventCount,
 430    last_seq: lastSeq,
 431    opened
 432  };
 433}
 434
 435export class FirefoxBridgeError extends Error {
 436  readonly clientId: string | null;
 437  readonly code: FirefoxBridgeErrorCode;
 438  readonly connectionId: string | null;
 439  readonly requestId: string | null;
 440  readonly timeoutMs: number | null;
 441
 442  constructor(
 443    code: FirefoxBridgeErrorCode,
 444    message: string,
 445    options: {
 446      clientId?: string | null;
 447      connectionId?: string | null;
 448      requestId?: string | null;
 449      timeoutMs?: number | null;
 450    } = {}
 451  ) {
 452    super(message);
 453    this.name = "FirefoxBridgeError";
 454    this.clientId = options.clientId ?? null;
 455    this.code = code;
 456    this.connectionId = options.connectionId ?? null;
 457    this.requestId = options.requestId ?? null;
 458    this.timeoutMs = normalizeOptionalPositiveInteger(options.timeoutMs ?? undefined) ?? null;
 459  }
 460}
 461
 462class FirefoxBridgeApiStreamSession implements FirefoxBridgeApiStream {
 463  readonly clientId: string;
 464  readonly connectionId: string;
 465  readonly requestId: string;
 466
 467  private readonly clearTimeoutImpl: (handle: TimeoutHandle) => void;
 468  private readonly idleTimeoutMs: number;
 469  private readonly maxBufferedBytes: number;
 470  private readonly maxBufferedEvents: number;
 471  private readonly now: () => number;
 472  private readonly onCancel: (reason?: string | null) => void;
 473  private readonly onClose: (requestId: string) => void;
 474  private readonly openTimeoutMs: number;
 475  private readonly queue: FirefoxQueuedStreamEvent[] = [];
 476  private readonly setTimeoutImpl: (handler: () => void, timeoutMs: number) => TimeoutHandle;
 477  private readonly waiters: Array<(result: IteratorResult<FirefoxBridgeStreamEvent>) => void> = [];
 478  private bufferedBytes = 0;
 479  private cancelSent = false;
 480  private closed = false;
 481  private eventCount = 0;
 482  private idleTimer: TimeoutHandle | null = null;
 483  private lastSeq = 0;
 484  private openTimer: TimeoutHandle | null = null;
 485  private opened = false;
 486  private streamIdValue: string;
 487
 488  constructor(options: FirefoxStreamSessionOptions) {
 489    this.clearTimeoutImpl = options.clearTimeoutImpl;
 490    this.clientId = options.clientId;
 491    this.connectionId = options.connectionId;
 492    this.idleTimeoutMs = options.idleTimeoutMs;
 493    this.maxBufferedBytes = options.maxBufferedBytes;
 494    this.maxBufferedEvents = options.maxBufferedEvents;
 495    this.now = options.now;
 496    this.onCancel = options.onCancel;
 497    this.onClose = options.onClose;
 498    this.openTimeoutMs = options.openTimeoutMs;
 499    this.requestId = options.requestId;
 500    this.setTimeoutImpl = options.setTimeoutImpl;
 501    this.streamIdValue = options.streamId;
 502    this.resetOpenTimer();
 503  }
 504
 505  get streamId(): string {
 506    return this.streamIdValue;
 507  }
 508
 509  [Symbol.asyncIterator](): AsyncIterableIterator<FirefoxBridgeStreamEvent> {
 510    return {
 511      [Symbol.asyncIterator]: () => this[Symbol.asyncIterator](),
 512      next: async () => await this.next()
 513    };
 514  }
 515
 516  cancel(reason?: string | null): void {
 517    this.dispatchCancel(reason);
 518    this.fail("request_cancelled", normalizeOptionalString(reason) ?? "Stream request was cancelled.");
 519  }
 520
 521  next(): Promise<IteratorResult<FirefoxBridgeStreamEvent>> {
 522    const queued = this.queue.shift();
 523
 524    if (queued != null) {
 525      this.bufferedBytes = Math.max(0, this.bufferedBytes - queued.size);
 526      return Promise.resolve({
 527        done: false,
 528        value: queued.event
 529      });
 530    }
 531
 532    if (this.closed) {
 533      return Promise.resolve({
 534        done: true,
 535        value: undefined
 536      });
 537    }
 538
 539    return new Promise((resolve) => {
 540      this.waiters.push(resolve);
 541    });
 542  }
 543
 544  markOpen(payload: FirefoxStreamOpenPayload): boolean {
 545    if (this.closed) {
 546      return false;
 547    }
 548
 549    if (normalizeOptionalString(payload.streamId) != null) {
 550      this.streamIdValue = normalizeOptionalString(payload.streamId) ?? this.streamIdValue;
 551    }
 552
 553    this.opened = true;
 554    this.resetIdleTimer();
 555    return this.enqueue({
 556      clientId: this.clientId,
 557      connectionId: this.connectionId,
 558      meta: payload.meta ?? null,
 559      openedAt: this.now(),
 560      requestId: this.requestId,
 561      status: normalizeStatus(payload.status),
 562      streamId: this.streamIdValue,
 563      type: "stream_open"
 564    });
 565  }
 566
 567  markEvent(payload: FirefoxStreamEventPayload): boolean {
 568    if (this.closed) {
 569      return false;
 570    }
 571
 572    if (!this.opened) {
 573      this.markOpen({
 574        id: this.requestId,
 575        status: null,
 576        streamId: payload.streamId ?? this.streamIdValue
 577      });
 578    }
 579
 580    if (normalizeOptionalString(payload.streamId) != null) {
 581      this.streamIdValue = normalizeOptionalString(payload.streamId) ?? this.streamIdValue;
 582    }
 583
 584    const seq = normalizePositiveInteger(payload.seq, this.lastSeq + 1);
 585    this.eventCount += 1;
 586    this.lastSeq = seq;
 587    this.resetIdleTimer();
 588    return this.enqueue({
 589      clientId: this.clientId,
 590      connectionId: this.connectionId,
 591      data: payload.data ?? null,
 592      event: normalizeOptionalString(payload.event),
 593      raw: normalizeOptionalString(payload.raw),
 594      receivedAt: this.now(),
 595      requestId: this.requestId,
 596      seq,
 597      streamId: this.streamIdValue,
 598      type: "stream_event"
 599    });
 600  }
 601
 602  markEnd(payload: FirefoxStreamEndPayload): boolean {
 603    if (this.closed) {
 604      return false;
 605    }
 606
 607    return this.finishWithEvent({
 608      clientId: this.clientId,
 609      connectionId: this.connectionId,
 610      endedAt: this.now(),
 611      partial: buildPartialState(
 612        this.opened,
 613        this.eventCount,
 614        this.lastSeq,
 615        this.bufferedBytes
 616      ),
 617      requestId: this.requestId,
 618      status: normalizeStatus(payload.status),
 619      streamId:
 620        normalizeOptionalString(payload.streamId) ?? this.streamIdValue,
 621      type: "stream_end"
 622    });
 623  }
 624
 625  markError(payload: FirefoxStreamErrorPayload): boolean {
 626    if (this.closed) {
 627      return false;
 628    }
 629
 630    return this.finishWithEvent({
 631      clientId: this.clientId,
 632      code: normalizeOptionalString(payload.code) ?? "stream_error",
 633      connectionId: this.connectionId,
 634      erroredAt: this.now(),
 635      message: normalizeOptionalString(payload.message) ?? "Browser stream failed.",
 636      partial: buildPartialState(
 637        this.opened,
 638        this.eventCount,
 639        this.lastSeq,
 640        this.bufferedBytes
 641      ),
 642      requestId: this.requestId,
 643      status: normalizeStatus(payload.status),
 644      streamId:
 645        normalizeOptionalString(payload.streamId) ?? this.streamIdValue,
 646      type: "stream_error"
 647    });
 648  }
 649
 650  fail(code: string, message: string, status: number | null = null): boolean {
 651    return this.markError({
 652      code,
 653      id: this.requestId,
 654      message,
 655      status,
 656      streamId: this.streamIdValue
 657    });
 658  }
 659
 660  private enqueue(event: FirefoxBridgeStreamEvent): boolean {
 661    if (this.waiters.length > 0) {
 662      const waiter = this.waiters.shift();
 663
 664      waiter?.({
 665        done: false,
 666        value: event
 667      });
 668      return true;
 669    }
 670
 671    const size = estimateEventSize(event);
 672
 673    if (
 674      event.type === "stream_event"
 675      && (
 676        this.queue.length + 1 > this.maxBufferedEvents
 677        || this.bufferedBytes + size > this.maxBufferedBytes
 678      )
 679    ) {
 680      this.dispatchCancel("stream_buffer_overflow");
 681      return this.finishWithEvent({
 682        clientId: this.clientId,
 683        code: "stream_buffer_overflow",
 684        connectionId: this.connectionId,
 685        erroredAt: this.now(),
 686        message: "Browser stream exceeded the conductor buffering limit.",
 687        partial: buildPartialState(
 688          this.opened,
 689          this.eventCount,
 690          this.lastSeq,
 691          this.bufferedBytes
 692        ),
 693        requestId: this.requestId,
 694        status: null,
 695        streamId: this.streamIdValue,
 696        type: "stream_error"
 697      });
 698    }
 699
 700    this.queue.push({
 701      event,
 702      size
 703    });
 704    this.bufferedBytes += size;
 705    return true;
 706  }
 707
 708  private finishWithEvent(event: FirefoxBridgeStreamEndEvent | FirefoxBridgeStreamErrorEvent): boolean {
 709    if (this.closed) {
 710      return false;
 711    }
 712
 713    if (this.waiters.length > 0) {
 714      const waiter = this.waiters.shift();
 715      waiter?.({
 716        done: false,
 717        value: event
 718      });
 719    } else {
 720      const size = estimateEventSize(event);
 721      this.queue.push({
 722        event,
 723        size
 724      });
 725      this.bufferedBytes += size;
 726    }
 727
 728    this.close();
 729    return true;
 730  }
 731
 732  private close(): void {
 733    if (this.closed) {
 734      return;
 735    }
 736
 737    this.closed = true;
 738    this.clearTimers();
 739    this.onClose(this.requestId);
 740
 741    if (this.queue.length === 0) {
 742      while (this.waiters.length > 0) {
 743        const waiter = this.waiters.shift();
 744        waiter?.({
 745          done: true,
 746          value: undefined
 747        });
 748      }
 749    }
 750  }
 751
 752  private dispatchCancel(reason?: string | null): void {
 753    if (this.cancelSent) {
 754      return;
 755    }
 756
 757    this.cancelSent = true;
 758    this.onCancel(reason);
 759  }
 760
 761  private clearTimers(): void {
 762    if (this.openTimer != null) {
 763      this.clearTimeoutImpl(this.openTimer);
 764      this.openTimer = null;
 765    }
 766
 767    if (this.idleTimer != null) {
 768      this.clearTimeoutImpl(this.idleTimer);
 769      this.idleTimer = null;
 770    }
 771  }
 772
 773  private resetOpenTimer(): void {
 774    if (this.openTimeoutMs <= 0 || this.closed) {
 775      return;
 776    }
 777
 778    if (this.openTimer != null) {
 779      this.clearTimeoutImpl(this.openTimer);
 780    }
 781
 782    this.openTimer = this.setTimeoutImpl(() => {
 783      this.dispatchCancel("stream_open_timeout");
 784      this.fail(
 785        "stream_open_timeout",
 786        `Browser stream "${this.requestId}" did not open within ${this.openTimeoutMs}ms.`
 787      );
 788    }, this.openTimeoutMs);
 789  }
 790
 791  private resetIdleTimer(): void {
 792    if (this.openTimer != null) {
 793      this.clearTimeoutImpl(this.openTimer);
 794      this.openTimer = null;
 795    }
 796
 797    if (this.idleTimeoutMs <= 0 || this.closed) {
 798      return;
 799    }
 800
 801    if (this.idleTimer != null) {
 802      this.clearTimeoutImpl(this.idleTimer);
 803    }
 804
 805    this.idleTimer = this.setTimeoutImpl(() => {
 806      this.dispatchCancel("stream_idle_timeout");
 807      this.fail(
 808        "stream_idle_timeout",
 809        `Browser stream "${this.requestId}" was idle for more than ${this.idleTimeoutMs}ms.`
 810      );
 811    }, this.idleTimeoutMs);
 812  }
 813}
 814
 815export class FirefoxCommandBroker {
 816  private readonly clearTimeoutImpl: (handle: TimeoutHandle) => void;
 817  private readonly now: () => number;
 818  private readonly pendingActionRequests = new Map<string, FirefoxPendingActionRequest>();
 819  private readonly pendingApiRequests = new Map<string, FirefoxPendingApiRequest>();
 820  private readonly pendingStreamRequests = new Map<string, FirefoxBridgeApiStreamSession>();
 821  private readonly resolveActiveClient: () => FirefoxBridgeRegisteredClient | null;
 822  private readonly resolveClientById: (clientId: string) => FirefoxBridgeRegisteredClient | null;
 823  private readonly setTimeoutImpl: (handler: () => void, timeoutMs: number) => TimeoutHandle;
 824
 825  constructor(options: FirefoxCommandBrokerOptions) {
 826    this.clearTimeoutImpl = options.clearTimeoutImpl ?? ((handle) => globalThis.clearTimeout(handle));
 827    this.now = options.now ?? (() => Date.now());
 828    this.resolveActiveClient = options.resolveActiveClient;
 829    this.resolveClientById = options.resolveClientById;
 830    this.setTimeoutImpl = options.setTimeoutImpl ?? ((handler, timeoutMs) => globalThis.setTimeout(handler, timeoutMs));
 831  }
 832
 833  dispatch(
 834    type: Exclude<FirefoxBridgeOutboundCommandType, "api_request">,
 835    payload: Record<string, unknown>,
 836    target: FirefoxBridgeCommandTarget = {}
 837  ): FirefoxBridgeDispatchReceipt {
 838    const client = this.selectClient(target);
 839    const dispatchedAt = this.now();
 840    const envelope = compactRecord({
 841      ...payload,
 842      type
 843    });
 844
 845    if (!client.sendJson(envelope)) {
 846      throw new FirefoxBridgeError(
 847        "send_failed",
 848        `Failed to send ${type} to Firefox client "${client.clientId}".`,
 849        {
 850          clientId: client.clientId,
 851          connectionId: client.connectionId
 852        }
 853      );
 854    }
 855
 856    return {
 857      clientId: client.clientId,
 858      connectionId: client.connectionId,
 859      dispatchedAt,
 860      type
 861    };
 862  }
 863
 864  dispatchWithActionResult(
 865    type: Exclude<FirefoxBridgeOutboundCommandType, "api_request" | "request_cancel">,
 866    payload: Record<string, unknown>,
 867    target: FirefoxBridgeCommandTarget = {},
 868    timeoutMs: number = DEFAULT_FIREFOX_ACTION_RESULT_TIMEOUT_MS
 869  ): BrowserBridgeActionDispatch {
 870    const client = this.selectClient(target);
 871    const dispatchedAt = this.now();
 872    const requestId = randomUUID();
 873    const envelope = compactRecord({
 874      ...payload,
 875      requestId,
 876      type
 877    });
 878
 879    let resolveResult!: (response: BrowserBridgeActionResultSnapshot) => void;
 880    let rejectResult!: (error: FirefoxBridgeError) => void;
 881    const result = new Promise<BrowserBridgeActionResultSnapshot>((resolve, reject) => {
 882      resolveResult = resolve;
 883      rejectResult = reject;
 884    });
 885
 886    // Low-level callers may only care about the dispatch side-effect.
 887    void result.catch(() => {});
 888
 889    const timer = this.setTimeoutImpl(() => {
 890      this.pendingActionRequests.delete(requestId);
 891      rejectResult(
 892          new FirefoxBridgeError(
 893            "action_timeout",
 894            `Firefox client "${client.clientId}" did not report action_result "${requestId}" within ${timeoutMs}ms.`,
 895            {
 896              clientId: client.clientId,
 897              connectionId: client.connectionId,
 898              requestId,
 899              timeoutMs
 900            }
 901          )
 902        );
 903    }, normalizeTimeoutMs(timeoutMs, DEFAULT_FIREFOX_ACTION_RESULT_TIMEOUT_MS));
 904
 905    this.pendingActionRequests.set(requestId, {
 906      clientId: client.clientId,
 907      connectionId: client.connectionId,
 908      reject: rejectResult,
 909      requestId,
 910      resolve: resolveResult,
 911      timer
 912    });
 913
 914    if (!client.sendJson(envelope)) {
 915      const pending = this.clearPendingActionRequest(requestId);
 916      const error = new FirefoxBridgeError(
 917        "send_failed",
 918        `Failed to send ${type} to Firefox client "${client.clientId}".`,
 919        {
 920          clientId: client.clientId,
 921          connectionId: client.connectionId,
 922          requestId
 923        }
 924      );
 925
 926      pending?.reject(error);
 927      throw error;
 928    }
 929
 930    return {
 931      clientId: client.clientId,
 932      connectionId: client.connectionId,
 933      dispatchedAt,
 934      requestId,
 935      result,
 936      type
 937    };
 938  }
 939
 940  sendApiRequest(
 941    payload: Record<string, unknown>,
 942    options: FirefoxBridgeCommandTarget & {
 943      requestId: string;
 944      timeoutMs?: number | null;
 945    }
 946  ): Promise<FirefoxBridgeApiResponse> {
 947    if (this.pendingApiRequests.has(options.requestId) || this.pendingStreamRequests.has(options.requestId)) {
 948      throw new FirefoxBridgeError(
 949        "duplicate_request_id",
 950        `Firefox bridge request id "${options.requestId}" is already in flight.`,
 951        {
 952          requestId: options.requestId
 953        }
 954      );
 955    }
 956
 957    const client = this.selectClient(options);
 958    const timeoutMs = normalizeTimeoutMs(options.timeoutMs, DEFAULT_FIREFOX_API_REQUEST_TIMEOUT_MS);
 959    const envelope = compactRecord({
 960      ...payload,
 961      id: options.requestId,
 962      response_mode: "buffered",
 963      type: "api_request"
 964    });
 965
 966    return new Promise<FirefoxBridgeApiResponse>((resolve, reject) => {
 967      const timer = this.setTimeoutImpl(() => {
 968        this.pendingApiRequests.delete(options.requestId);
 969        reject(
 970          new FirefoxBridgeError(
 971            "request_timeout",
 972            `Firefox client "${client.clientId}" did not respond to api_request "${options.requestId}" within ${timeoutMs}ms.`,
 973            {
 974              clientId: client.clientId,
 975              connectionId: client.connectionId,
 976              requestId: options.requestId,
 977              timeoutMs
 978            }
 979          )
 980        );
 981      }, timeoutMs);
 982
 983      this.pendingApiRequests.set(options.requestId, {
 984        clientId: client.clientId,
 985        connectionId: client.connectionId,
 986        reject,
 987        requestId: options.requestId,
 988        resolve,
 989        timer
 990      });
 991
 992      if (!client.sendJson(envelope)) {
 993        this.clearPendingRequest(options.requestId);
 994        reject(
 995          new FirefoxBridgeError(
 996            "send_failed",
 997            `Failed to send api_request "${options.requestId}" to Firefox client "${client.clientId}".`,
 998            {
 999              clientId: client.clientId,
1000              connectionId: client.connectionId,
1001              requestId: options.requestId
1002            }
1003          )
1004        );
1005      }
1006    });
1007  }
1008
1009  openApiStream(
1010    payload: Record<string, unknown>,
1011    options: FirefoxBridgeCommandTarget & {
1012      idleTimeoutMs?: number | null;
1013      maxBufferedBytes?: number | null;
1014      maxBufferedEvents?: number | null;
1015      openTimeoutMs?: number | null;
1016      requestId: string;
1017      streamId: string;
1018    }
1019  ): FirefoxBridgeApiStream {
1020    if (this.pendingApiRequests.has(options.requestId) || this.pendingStreamRequests.has(options.requestId)) {
1021      throw new FirefoxBridgeError(
1022        "duplicate_request_id",
1023        `Firefox bridge request id "${options.requestId}" is already in flight.`,
1024        {
1025          requestId: options.requestId
1026        }
1027      );
1028    }
1029
1030    const client = this.selectClient(options);
1031    const streamSession = new FirefoxBridgeApiStreamSession({
1032      clearTimeoutImpl: this.clearTimeoutImpl,
1033      clientId: client.clientId,
1034      connectionId: client.connectionId,
1035      idleTimeoutMs: normalizeTimeoutMs(
1036        options.idleTimeoutMs,
1037        DEFAULT_FIREFOX_STREAM_IDLE_TIMEOUT_MS
1038      ),
1039      maxBufferedBytes: normalizePositiveInteger(
1040        options.maxBufferedBytes,
1041        DEFAULT_FIREFOX_STREAM_MAX_BUFFERED_BYTES
1042      ),
1043      maxBufferedEvents: normalizePositiveInteger(
1044        options.maxBufferedEvents,
1045        DEFAULT_FIREFOX_STREAM_MAX_BUFFERED_EVENTS
1046      ),
1047      now: this.now,
1048      onCancel: (reason) => {
1049        try {
1050          this.dispatch("request_cancel", {
1051            id: options.requestId,
1052            reason: normalizeOptionalString(reason) ?? undefined,
1053            stream_id: options.streamId
1054          }, {
1055            clientId: client.clientId
1056          });
1057        } catch {
1058          // Best-effort remote cancel.
1059        }
1060      },
1061      onClose: (requestId) => {
1062        this.pendingStreamRequests.delete(requestId);
1063      },
1064      openTimeoutMs: normalizeTimeoutMs(
1065        options.openTimeoutMs,
1066        DEFAULT_FIREFOX_STREAM_OPEN_TIMEOUT_MS
1067      ),
1068      requestId: options.requestId,
1069      setTimeoutImpl: this.setTimeoutImpl,
1070      streamId: options.streamId
1071    });
1072    this.pendingStreamRequests.set(options.requestId, streamSession);
1073
1074    const envelope = compactRecord({
1075      ...payload,
1076      id: options.requestId,
1077      response_mode: "sse",
1078      stream_id: options.streamId,
1079      type: "api_request"
1080    });
1081
1082    if (!client.sendJson(envelope)) {
1083      this.pendingStreamRequests.delete(options.requestId);
1084      streamSession.fail(
1085        "send_failed",
1086        `Failed to send api_request "${options.requestId}" to Firefox client "${client.clientId}".`
1087      );
1088      throw new FirefoxBridgeError(
1089        "send_failed",
1090        `Failed to send api_request "${options.requestId}" to Firefox client "${client.clientId}".`,
1091        {
1092          clientId: client.clientId,
1093          connectionId: client.connectionId,
1094          requestId: options.requestId
1095        }
1096      );
1097    }
1098
1099    return streamSession;
1100  }
1101
1102  cancelRequest(input: FirefoxRequestCancelCommandInput): FirefoxBridgeCancelReceipt {
1103    const pendingApiRequest = this.pendingApiRequests.get(input.requestId) ?? null;
1104    const pendingStreamRequest = this.pendingStreamRequests.get(input.requestId) ?? null;
1105    const requestedClientId = normalizeOptionalString(input.clientId);
1106
1107    if (pendingApiRequest == null && pendingStreamRequest == null) {
1108      throw new FirefoxBridgeError(
1109        "request_not_found",
1110        `Firefox bridge request "${input.requestId}" is not in flight.`,
1111        {
1112          requestId: input.requestId
1113        }
1114      );
1115    }
1116
1117    const targetClientId =
1118      pendingApiRequest?.clientId
1119      ?? pendingStreamRequest?.clientId
1120      ?? null;
1121
1122    if (requestedClientId != null && targetClientId != null && requestedClientId !== targetClientId) {
1123      throw new FirefoxBridgeError(
1124        "client_not_found",
1125        `Firefox bridge request "${input.requestId}" is not running on client "${requestedClientId}".`,
1126        {
1127          clientId: requestedClientId,
1128          requestId: input.requestId
1129        }
1130      );
1131    }
1132
1133    const receipt = this.dispatch(
1134      "request_cancel",
1135      compactRecord({
1136        id: input.requestId,
1137        platform: normalizeOptionalString(input.platform) ?? undefined,
1138        reason: normalizeOptionalString(input.reason) ?? undefined,
1139        stream_id:
1140          normalizeOptionalString(input.streamId)
1141          ?? pendingStreamRequest?.streamId
1142          ?? undefined
1143      }),
1144      {
1145        clientId: targetClientId ?? requestedClientId
1146      }
1147    );
1148
1149    return {
1150      ...receipt,
1151      reason: normalizeOptionalString(input.reason),
1152      requestId: input.requestId,
1153      streamId:
1154        normalizeOptionalString(input.streamId)
1155        ?? pendingStreamRequest?.streamId
1156        ?? null
1157    };
1158  }
1159
1160  handleApiResponse(connectionId: string, payload: FirefoxApiResponsePayload): boolean {
1161    const pending = this.pendingApiRequests.get(payload.id);
1162
1163    if (pending == null || pending.connectionId !== connectionId) {
1164      return false;
1165    }
1166
1167    this.clearPendingRequest(payload.id);
1168    pending.resolve({
1169      body: payload.body,
1170      clientId: pending.clientId,
1171      connectionId,
1172      error: payload.error,
1173      id: payload.id,
1174      ok: payload.ok,
1175      respondedAt: this.now(),
1176      status: payload.status
1177    });
1178    return true;
1179  }
1180
1181  handleActionResult(connectionId: string, payload: BrowserBridgeActionResultSnapshot): boolean {
1182    const pending = this.pendingActionRequests.get(payload.request_id);
1183
1184    if (pending == null || pending.connectionId !== connectionId) {
1185      return false;
1186    }
1187
1188    this.clearPendingActionRequest(payload.request_id);
1189    pending.resolve(payload);
1190    return true;
1191  }
1192
1193  handleStreamOpen(connectionId: string, payload: FirefoxStreamOpenPayload): boolean {
1194    const pending = this.pendingStreamRequests.get(payload.id);
1195
1196    if (pending == null || pending.connectionId !== connectionId) {
1197      return false;
1198    }
1199
1200    return pending.markOpen(payload);
1201  }
1202
1203  handleStreamEvent(connectionId: string, payload: FirefoxStreamEventPayload): boolean {
1204    const pending = this.pendingStreamRequests.get(payload.id);
1205
1206    if (pending == null || pending.connectionId !== connectionId) {
1207      return false;
1208    }
1209
1210    return pending.markEvent(payload);
1211  }
1212
1213  handleStreamEnd(connectionId: string, payload: FirefoxStreamEndPayload): boolean {
1214    const pending = this.pendingStreamRequests.get(payload.id);
1215
1216    if (pending == null || pending.connectionId !== connectionId) {
1217      return false;
1218    }
1219
1220    return pending.markEnd(payload);
1221  }
1222
1223  handleStreamError(connectionId: string, payload: FirefoxStreamErrorPayload): boolean {
1224    const pending = this.pendingStreamRequests.get(payload.id);
1225
1226    if (pending == null || pending.connectionId !== connectionId) {
1227      return false;
1228    }
1229
1230    return pending.markError(payload);
1231  }
1232
1233  handleConnectionClosed(event: FirefoxBridgeConnectionClosedEvent): void {
1234    const actionRequestIds = [...this.pendingActionRequests.values()]
1235      .filter((entry) => entry.connectionId === event.connectionId)
1236      .map((entry) => entry.requestId);
1237    const requestIds = [...this.pendingApiRequests.values()]
1238      .filter((entry) => entry.connectionId === event.connectionId)
1239      .map((entry) => entry.requestId);
1240    const streamIds = [...this.pendingStreamRequests.values()]
1241      .filter((entry) => entry.connectionId === event.connectionId)
1242      .map((entry) => entry.requestId);
1243
1244    const errorCode: FirefoxBridgeErrorCode =
1245      event.code === 4001 ? "client_replaced" : "client_disconnected";
1246    const clientLabel = event.clientId ?? "unknown";
1247    const reasonSuffix =
1248      normalizeOptionalString(event.reason) == null ? "" : ` (${normalizeOptionalString(event.reason)})`;
1249
1250    for (const requestId of actionRequestIds) {
1251      const pending = this.clearPendingActionRequest(requestId);
1252
1253      if (pending == null) {
1254        continue;
1255      }
1256
1257      pending.reject(
1258        new FirefoxBridgeError(
1259          errorCode,
1260          errorCode === "client_replaced"
1261            ? `Firefox client "${clientLabel}" was replaced before action "${requestId}" completed${reasonSuffix}.`
1262            : `Firefox client "${clientLabel}" disconnected before action "${requestId}" completed${reasonSuffix}.`,
1263          {
1264            clientId: pending.clientId,
1265            connectionId: pending.connectionId,
1266            requestId
1267          }
1268        )
1269      );
1270    }
1271
1272    for (const requestId of requestIds) {
1273      const pending = this.clearPendingRequest(requestId);
1274
1275      if (pending == null) {
1276        continue;
1277      }
1278
1279      pending.reject(
1280        new FirefoxBridgeError(
1281          errorCode,
1282          errorCode === "client_replaced"
1283            ? `Firefox client "${clientLabel}" was replaced before api_request "${requestId}" completed${reasonSuffix}.`
1284            : `Firefox client "${clientLabel}" disconnected before api_request "${requestId}" completed${reasonSuffix}.`,
1285          {
1286            clientId: pending.clientId,
1287            connectionId: pending.connectionId,
1288            requestId
1289          }
1290        )
1291      );
1292    }
1293
1294    for (const requestId of streamIds) {
1295      const pending = this.pendingStreamRequests.get(requestId);
1296
1297      if (pending == null) {
1298        continue;
1299      }
1300
1301      pending.fail(
1302        errorCode,
1303        errorCode === "client_replaced"
1304          ? `Firefox client "${clientLabel}" was replaced before stream "${requestId}" completed${reasonSuffix}.`
1305          : `Firefox client "${clientLabel}" disconnected before stream "${requestId}" completed${reasonSuffix}.`
1306      );
1307    }
1308  }
1309
1310  stop(): void {
1311    const actionRequestIds = [...this.pendingActionRequests.keys()];
1312    const requestIds = [...this.pendingApiRequests.keys()];
1313    const streamIds = [...this.pendingStreamRequests.keys()];
1314
1315    for (const requestId of actionRequestIds) {
1316      const pending = this.clearPendingActionRequest(requestId);
1317
1318      if (pending == null) {
1319        continue;
1320      }
1321
1322      pending.reject(
1323        new FirefoxBridgeError(
1324          "service_stopped",
1325          `Firefox bridge stopped before action "${requestId}" completed.`,
1326          {
1327            clientId: pending.clientId,
1328            connectionId: pending.connectionId,
1329            requestId
1330          }
1331        )
1332      );
1333    }
1334
1335    for (const requestId of requestIds) {
1336      const pending = this.clearPendingRequest(requestId);
1337
1338      if (pending == null) {
1339        continue;
1340      }
1341
1342      pending.reject(
1343        new FirefoxBridgeError(
1344          "service_stopped",
1345          `Firefox bridge stopped before api_request "${requestId}" completed.`,
1346          {
1347            clientId: pending.clientId,
1348            connectionId: pending.connectionId,
1349            requestId
1350          }
1351        )
1352      );
1353    }
1354
1355    for (const requestId of streamIds) {
1356      const pending = this.pendingStreamRequests.get(requestId);
1357
1358      if (pending == null) {
1359        continue;
1360      }
1361
1362      pending.fail(
1363        "service_stopped",
1364        `Firefox bridge stopped before stream "${requestId}" completed.`
1365      );
1366    }
1367  }
1368
1369  private clearPendingRequest(requestId: string): FirefoxPendingApiRequest | null {
1370    const pending = this.pendingApiRequests.get(requestId);
1371
1372    if (pending == null) {
1373      return null;
1374    }
1375
1376    this.pendingApiRequests.delete(requestId);
1377    this.clearTimeoutImpl(pending.timer);
1378    return pending;
1379  }
1380
1381  private clearPendingActionRequest(requestId: string): FirefoxPendingActionRequest | null {
1382    const pending = this.pendingActionRequests.get(requestId);
1383
1384    if (pending == null) {
1385      return null;
1386    }
1387
1388    this.pendingActionRequests.delete(requestId);
1389    this.clearTimeoutImpl(pending.timer);
1390    return pending;
1391  }
1392
1393  private selectClient(target: FirefoxBridgeCommandTarget): FirefoxBridgeRegisteredClient {
1394    const normalizedClientId = normalizeOptionalString(target.clientId);
1395    const client =
1396      normalizedClientId == null
1397        ? this.resolveActiveClient()
1398        : this.resolveClientById(normalizedClientId);
1399
1400    if (client != null) {
1401      return client;
1402    }
1403
1404    if (normalizedClientId == null) {
1405      throw new FirefoxBridgeError(
1406        "no_active_client",
1407        "No active Firefox bridge client is connected."
1408      );
1409    }
1410
1411    throw new FirefoxBridgeError(
1412      "client_not_found",
1413      `Firefox bridge client "${normalizedClientId}" is not connected.`,
1414      {
1415        clientId: normalizedClientId
1416      }
1417    );
1418  }
1419}
1420
1421export class FirefoxBridgeService {
1422  constructor(private readonly broker: FirefoxCommandBroker) {}
1423
1424  dispatchPluginAction(
1425    input: FirefoxPluginActionCommandInput
1426  ): BrowserBridgeActionDispatch {
1427    return this.broker.dispatchWithActionResult(
1428      input.action,
1429      compactRecord({
1430        action: input.action,
1431        disconnect_ms: normalizeOptionalNonNegativeInteger(input.disconnectMs),
1432        platform: normalizeOptionalString(input.platform) ?? undefined,
1433        reason: normalizeOptionalString(input.reason) ?? undefined,
1434        repeat_count: normalizeOptionalPositiveInteger(input.repeatCount),
1435        repeat_interval_ms: normalizeOptionalNonNegativeInteger(input.repeatIntervalMs)
1436      }),
1437      input
1438    );
1439  }
1440
1441  openTab(input: FirefoxOpenTabCommandInput = {}): BrowserBridgeActionDispatch {
1442    const platform = normalizeOptionalString(input.platform);
1443
1444    return this.broker.dispatchWithActionResult(
1445      "open_tab",
1446      compactRecord({
1447        action: "tab_open",
1448        platform: platform ?? undefined
1449      }),
1450      input
1451    );
1452  }
1453
1454  requestCredentials(
1455    input: FirefoxRequestCredentialsCommandInput = {}
1456  ): BrowserBridgeActionDispatch {
1457    return this.broker.dispatchWithActionResult(
1458      "request_credentials",
1459      compactRecord({
1460        action: "request_credentials",
1461        platform: normalizeOptionalString(input.platform) ?? undefined,
1462        reason: normalizeOptionalString(input.reason) ?? undefined
1463      }),
1464      input
1465    );
1466  }
1467
1468  injectMessage(
1469    input: FirefoxInjectMessageCommandInput
1470  ): BrowserBridgeActionDispatch {
1471    return this.broker.dispatchWithActionResult(
1472      "browser.inject_message",
1473      compactRecord({
1474        action: "inject_message",
1475        conversation_id: normalizeOptionalString(input.conversationId) ?? undefined,
1476        message_text: input.messageText,
1477        page_title: normalizeOptionalString(input.pageTitle) ?? undefined,
1478        page_url: normalizeOptionalString(input.pageUrl) ?? undefined,
1479        poll_interval_ms: normalizeOptionalNonNegativeInteger(input.pollIntervalMs),
1480        plan_id: input.planId,
1481        platform: input.platform,
1482        retry_attempts: normalizeOptionalPositiveInteger(input.retryAttempts),
1483        retry_delay_ms: normalizeOptionalNonNegativeInteger(input.retryDelayMs),
1484        shell_page: typeof input.shellPage === "boolean" ? input.shellPage : undefined,
1485        target_tab_id: normalizeOptionalPositiveInteger(input.tabId),
1486        timeout_ms: normalizeOptionalPositiveInteger(input.timeoutMs)
1487      }),
1488      compactRecord({
1489        clientId: normalizeOptionalString(input.clientId) ?? undefined
1490      }),
1491      normalizeTimeoutMs(
1492        resolveDeliveryActionResultTimeoutMs(input.timeoutMs),
1493        DEFAULT_BAA_DELIVERY_ACTION_RESULT_TIMEOUT_MS
1494      )
1495    );
1496  }
1497
1498  proxyDelivery(
1499    input: FirefoxProxyDeliveryCommandInput
1500  ): BrowserBridgeActionDispatch {
1501    return this.broker.dispatchWithActionResult(
1502      "browser.proxy_delivery",
1503      compactRecord({
1504        action: "proxy_delivery",
1505        assistant_message_id: normalizeOptionalString(input.assistantMessageId) ?? input.assistantMessageId,
1506        conversation_id: normalizeOptionalString(input.conversationId) ?? undefined,
1507        message_text: input.messageText,
1508        organization_id: normalizeOptionalString(input.organizationId) ?? undefined,
1509        page_title: normalizeOptionalString(input.pageTitle) ?? undefined,
1510        page_url: normalizeOptionalString(input.pageUrl) ?? undefined,
1511        plan_id: input.planId,
1512        platform: input.platform,
1513        shell_page: typeof input.shellPage === "boolean" ? input.shellPage : undefined,
1514        target_tab_id: normalizeOptionalPositiveInteger(input.tabId),
1515        timeout_ms: normalizeOptionalPositiveInteger(input.timeoutMs)
1516      }),
1517      compactRecord({
1518        clientId: normalizeOptionalString(input.clientId) ?? undefined
1519      }),
1520      normalizeTimeoutMs(
1521        resolveDeliveryActionResultTimeoutMs(input.timeoutMs),
1522        DEFAULT_BAA_DELIVERY_ACTION_RESULT_TIMEOUT_MS
1523      )
1524    );
1525  }
1526
1527  sendMessage(
1528    input: FirefoxSendMessageCommandInput
1529  ): BrowserBridgeActionDispatch {
1530    return this.broker.dispatchWithActionResult(
1531      "browser.send_message",
1532      compactRecord({
1533        action: "send_message",
1534        conversation_id: normalizeOptionalString(input.conversationId) ?? undefined,
1535        page_title: normalizeOptionalString(input.pageTitle) ?? undefined,
1536        page_url: normalizeOptionalString(input.pageUrl) ?? undefined,
1537        poll_interval_ms: normalizeOptionalNonNegativeInteger(input.pollIntervalMs),
1538        plan_id: input.planId,
1539        platform: input.platform,
1540        retry_attempts: normalizeOptionalPositiveInteger(input.retryAttempts),
1541        retry_delay_ms: normalizeOptionalNonNegativeInteger(input.retryDelayMs),
1542        shell_page: typeof input.shellPage === "boolean" ? input.shellPage : undefined,
1543        target_tab_id: normalizeOptionalPositiveInteger(input.tabId),
1544        timeout_ms: normalizeOptionalPositiveInteger(input.timeoutMs)
1545      }),
1546      compactRecord({
1547        clientId: normalizeOptionalString(input.clientId) ?? undefined
1548      }),
1549      normalizeTimeoutMs(
1550        resolveDeliveryActionResultTimeoutMs(input.timeoutMs),
1551        DEFAULT_BAA_DELIVERY_ACTION_RESULT_TIMEOUT_MS
1552      )
1553    );
1554  }
1555
1556  reload(input: FirefoxReloadCommandInput = {}): BrowserBridgeActionDispatch {
1557    return this.broker.dispatchWithActionResult(
1558      "reload",
1559      compactRecord({
1560        action: normalizeOptionalString(input.platform) == null ? "controller_reload" : "tab_reload",
1561        platform: normalizeOptionalString(input.platform) ?? undefined,
1562        reason: normalizeOptionalString(input.reason) ?? undefined
1563      }),
1564      input
1565    );
1566  }
1567
1568  async apiRequest(input: FirefoxApiRequestCommandInput): Promise<FirefoxBridgeApiResponse> {
1569    const platform = normalizeOptionalString(input.platform);
1570    const path = normalizeOptionalString(input.path);
1571
1572    if (platform == null) {
1573      throw new Error("Firefox bridge api_request requires a non-empty platform.");
1574    }
1575
1576    if (path == null) {
1577      throw new Error("Firefox bridge api_request requires a non-empty path.");
1578    }
1579
1580    const requestId = normalizeOptionalString(input.id) ?? randomUUID();
1581
1582    return await this.broker.sendApiRequest(
1583      compactRecord({
1584        body: input.body ?? null,
1585        conversation_id: normalizeOptionalString(input.conversationId) ?? undefined,
1586        headers: normalizeHeaderRecord(input.headers),
1587        method: normalizeOptionalString(input.method)?.toUpperCase() ?? "GET",
1588        path,
1589        platform,
1590        response_mode: "buffered",
1591        timeout_ms: normalizeTimeoutMs(
1592          input.timeoutMs,
1593          DEFAULT_FIREFOX_API_REQUEST_TIMEOUT_MS
1594        )
1595      }),
1596      {
1597        clientId: input.clientId,
1598        requestId,
1599        timeoutMs: input.timeoutMs
1600      }
1601    );
1602  }
1603
1604  streamRequest(input: FirefoxApiRequestCommandInput): FirefoxBridgeApiStream {
1605    const platform = normalizeOptionalString(input.platform);
1606    const path = normalizeOptionalString(input.path);
1607
1608    if (platform == null) {
1609      throw new Error("Firefox bridge stream_request requires a non-empty platform.");
1610    }
1611
1612    if (path == null) {
1613      throw new Error("Firefox bridge stream_request requires a non-empty path.");
1614    }
1615
1616    const requestId = normalizeOptionalString(input.id) ?? randomUUID();
1617    const streamId = normalizeOptionalString(input.streamId) ?? requestId;
1618
1619    return this.broker.openApiStream(
1620      compactRecord({
1621        body: input.body ?? null,
1622        conversation_id: normalizeOptionalString(input.conversationId) ?? undefined,
1623        headers: normalizeHeaderRecord(input.headers),
1624        method: normalizeOptionalString(input.method)?.toUpperCase() ?? "GET",
1625        path,
1626        platform,
1627        response_mode: "sse",
1628        stream_id: streamId,
1629        timeout_ms: normalizeTimeoutMs(
1630          input.timeoutMs,
1631          DEFAULT_FIREFOX_API_REQUEST_TIMEOUT_MS
1632        )
1633      }),
1634      {
1635        clientId: input.clientId,
1636        idleTimeoutMs: input.idleTimeoutMs,
1637        maxBufferedBytes: input.maxBufferedBytes,
1638        maxBufferedEvents: input.maxBufferedEvents,
1639        openTimeoutMs: input.openTimeoutMs,
1640        requestId,
1641        streamId
1642      }
1643    );
1644  }
1645
1646  cancelApiRequest(input: FirefoxRequestCancelCommandInput): FirefoxBridgeCancelReceipt {
1647    return this.cancelRequest(input);
1648  }
1649
1650  cancelRequest(input: FirefoxRequestCancelCommandInput): FirefoxBridgeCancelReceipt {
1651    const requestId = normalizeOptionalString(input.requestId);
1652
1653    if (requestId == null) {
1654      throw new Error("Firefox bridge cancel_request requires a non-empty requestId.");
1655    }
1656
1657    return this.broker.cancelRequest({
1658      ...input,
1659      requestId
1660    });
1661  }
1662
1663  handleApiResponse(connectionId: string, payload: FirefoxApiResponsePayload): boolean {
1664    return this.broker.handleApiResponse(connectionId, {
1665      body: payload.body,
1666      error: payload.error,
1667      id: payload.id,
1668      ok: payload.ok,
1669      status: normalizeStatus(payload.status)
1670    });
1671  }
1672
1673  handleStreamOpen(connectionId: string, payload: FirefoxStreamOpenPayload): boolean {
1674    return this.broker.handleStreamOpen(connectionId, {
1675      ...payload,
1676      status: normalizeStatus(payload.status)
1677    });
1678  }
1679
1680  handleStreamEvent(connectionId: string, payload: FirefoxStreamEventPayload): boolean {
1681    return this.broker.handleStreamEvent(connectionId, payload);
1682  }
1683
1684  handleStreamEnd(connectionId: string, payload: FirefoxStreamEndPayload): boolean {
1685    return this.broker.handleStreamEnd(connectionId, {
1686      ...payload,
1687      status: normalizeStatus(payload.status)
1688    });
1689  }
1690
1691  handleStreamError(connectionId: string, payload: FirefoxStreamErrorPayload): boolean {
1692    return this.broker.handleStreamError(connectionId, {
1693      ...payload,
1694      status: normalizeStatus(payload.status)
1695    });
1696  }
1697
1698  handleActionResult(connectionId: string, payload: BrowserBridgeActionResultSnapshot): boolean {
1699    return this.broker.handleActionResult(connectionId, payload);
1700  }
1701
1702  handleConnectionClosed(event: FirefoxBridgeConnectionClosedEvent): void {
1703    this.broker.handleConnectionClosed(event);
1704  }
1705
1706  stop(): void {
1707    this.broker.stop();
1708  }
1709}