baa-conductor

git clone 

commit
07895cd
parent
f684414
author
codex@macbookpro
date
2026-03-26 19:49:59 +0800 CST
feat: complete browser relay control path
26 files changed,  +3724, -291
M README.md
+4, -4
 1@@ -42,7 +42,7 @@
 2    - `GET /describe/business`
 3    - 或 `GET /describe/control`
 4 4. 如有需要,再调 `GET /v1/capabilities`
 5-5. 完成能力感知后,再执行业务查询、Claude 浏览器动作或控制动作;如果要调用 `/v1/exec` 或 `/v1/files/*`,必须带 `Authorization: Bearer <BAA_SHARED_TOKEN>`
 6+5. 完成能力感知后,再执行业务查询、通用 browser request、browser/plugin 管理动作或控制动作;如果要调用 `/v1/exec` 或 `/v1/files/*`,必须带 `Authorization: Bearer <BAA_SHARED_TOKEN>`
 7 
 8 ## 当前目录结构
 9 
10@@ -94,7 +94,7 @@ docs/
11 - 运行中的浏览器插件代码以 [`plugins/baa-firefox`](./plugins/baa-firefox) 为准
12 - 浏览器桥接正式模型已经固定为“登录态元数据持久化 + 单平台单空壳页 + 浏览器本地代发”;页面对话 UI 不是正式主能力
13 - `GET /v1/browser` 是当前正式浏览器桥接读面:返回活跃 bridge 和持久化登录态记录,只暴露 `account`、凭证指纹、端点元数据与 `fresh/stale/lost`
14-- `POST` / `GET /v1/browser/claude/*` 是当前唯一正式浏览器代发面:请求经本地 `/ws/firefox` 转发到 Firefox 插件在浏览器本地代发,`conductor` 不直接持有原始凭证
15+- 正式浏览器写接口已经收口到 `POST /v1/browser/request`、`POST /v1/browser/request/cancel` 和 `POST /v1/browser/actions`;`/v1/browser/claude/*` 只保留为 Claude legacy 包装与辅助读
16 - `codexd` 目前还是半成品,不是已上线组件
17 - `codexd` 必须作为独立常驻进程存在,不接受长期内嵌到 `conductor-daemon`
18 - `codexd` 后续默认以 `app-server` 为主,不以 TUI 或 `exec` 作为主双工接口
19@@ -103,7 +103,7 @@ docs/
20 
21 | 面 | 地址 | 定位 | 说明 |
22 | --- | --- | --- | --- |
23-| local API | `http://100.71.210.78:4317` | 唯一主接口、内网真相源 | 当前已承接 `/describe`、`/health`、`/version`、`/v1/capabilities`、`/v1/status`、`/v1/status/ui`、`/v1/browser/*`、`/v1/system/state`、`/v1/controllers`、`/v1/tasks`、`/v1/runs`、`pause/resume/drain`、`/v1/exec`、`/v1/files/read` 和 `/v1/files/write`;其中 `GET /v1/browser` 返回浏览器登录态元数据与持久化状态,Claude 专用 `/v1/browser/claude/*` 则走本地插件代发,host-ops 统一要求 `Authorization: Bearer <BAA_SHARED_TOKEN>` |
24+| local API | `http://100.71.210.78:4317` | 唯一主接口、内网真相源 | 当前已承接 `/describe`、`/health`、`/version`、`/v1/capabilities`、`/v1/status`、`/v1/status/ui`、`/v1/browser/*`、`/v1/system/state`、`/v1/controllers`、`/v1/tasks`、`/v1/runs`、`pause/resume/drain`、`/v1/exec`、`/v1/files/read` 和 `/v1/files/write`;其中 `GET /v1/browser` 返回浏览器登录态元数据与持久化状态,`POST /v1/browser/request` / `cancel` / `actions` 走本地插件代发,Claude 专用 `/v1/browser/claude/*` 只保留 legacy 包装,host-ops 统一要求 `Authorization: Bearer <BAA_SHARED_TOKEN>` |
25 | public host | `https://conductor.makefile.so` | 唯一公网域名 | 由 VPS Nginx 回源到 `100.71.210.78:4317`;`/v1/exec` 和 `/v1/files/*` 不再允许匿名调用 |
26 | local status view | `http://100.71.210.78:4318` | 本地只读观察兼容层 | 显式 opt-in 保留,继续提供 `/describe`、`/v1/status`、`/v1/status/ui` 和 `/ui` 等 legacy 合同,不是主控制面 |
27 
28@@ -124,7 +124,7 @@ legacy 兼容说明:
29 
30 - 保持 `mini` launchd、自启动和本地探针稳定
31 - 保持 `conductor.makefile.so -> 100.71.210.78:4317` 的链路稳定
32-- 保持 `/v1/browser`、浏览器登录态持久化与 Claude 本地代发合同稳定
33+- 保持 `/v1/browser`、通用 browser request/SSE、浏览器登录态持久化与 Claude legacy 包装合同稳定
34 - 保持 `pnpm smoke` 和 browser-control e2e smoke 对浏览器链路持续可回归
35 
36 ## 当前已知 gap
A apps/conductor-daemon/src/browser-request-policy.ts
+608, -0
  1@@ -0,0 +1,608 @@
  2+export type BrowserRequestCircuitState = "closed" | "half_open" | "open";
  3+export type BrowserRequestLeaseStatus = "cancelled" | "failure" | "success";
  4+
  5+type TimeoutHandle = ReturnType<typeof globalThis.setTimeout>;
  6+
  7+export interface BrowserRequestTarget {
  8+  clientId: string;
  9+  platform: string;
 10+}
 11+
 12+export interface BrowserRequestStreamPolicyConfig {
 13+  idleTimeoutMs: number;
 14+  maxBufferedBytes: number;
 15+  maxBufferedEvents: number;
 16+  openTimeoutMs: number;
 17+}
 18+
 19+export interface BrowserRequestPolicyConfig {
 20+  backoff: {
 21+    baseMs: number;
 22+    maxMs: number;
 23+  };
 24+  circuitBreaker: {
 25+    failureThreshold: number;
 26+    openMs: number;
 27+  };
 28+  concurrency: {
 29+    maxInFlightPerClientPlatform: number;
 30+  };
 31+  jitter: {
 32+    maxMs: number;
 33+    minMs: number;
 34+    muMs: number;
 35+    sigmaMs: number;
 36+  };
 37+  rateLimit: {
 38+    requestsPerMinutePerPlatform: number;
 39+    windowMs: number;
 40+  };
 41+  stream: BrowserRequestStreamPolicyConfig;
 42+}
 43+
 44+export interface BrowserRequestLeaseOutcome {
 45+  code?: string | null;
 46+  message?: string | null;
 47+  status: BrowserRequestLeaseStatus;
 48+}
 49+
 50+export interface BrowserRequestAdmission {
 51+  admittedAt: number;
 52+  backoffDelayMs: number;
 53+  circuitState: BrowserRequestCircuitState;
 54+  jitterDelayMs: number;
 55+  platform: string;
 56+  queueDelayMs: number;
 57+  rateLimitDelayMs: number;
 58+  requestId: string;
 59+  requestedAt: number;
 60+  targetClientId: string;
 61+}
 62+
 63+export interface BrowserRequestPolicySnapshot {
 64+  defaults: BrowserRequestPolicyConfig;
 65+  platforms: Array<{
 66+    lastDispatchedAt: number | null;
 67+    platform: string;
 68+    recentDispatchCount: number;
 69+    waiting: number;
 70+  }>;
 71+  targets: Array<{
 72+    backoffUntil: number | null;
 73+    circuitRetryAt: number | null;
 74+    circuitState: BrowserRequestCircuitState;
 75+    clientId: string;
 76+    consecutiveFailures: number;
 77+    inFlight: number;
 78+    lastError: string | null;
 79+    lastFailureAt: number | null;
 80+    lastSuccessAt: number | null;
 81+    platform: string;
 82+    waiting: number;
 83+  }>;
 84+}
 85+
 86+export interface BrowserRequestPolicyLease {
 87+  readonly admission: BrowserRequestAdmission;
 88+  readonly target: BrowserRequestTarget;
 89+  complete(outcome: BrowserRequestLeaseOutcome): void;
 90+}
 91+
 92+export interface BrowserRequestPolicyControllerOptions {
 93+  config?: Partial<BrowserRequestPolicyConfig>;
 94+  now?: () => number;
 95+  random?: () => number;
 96+  setTimeoutImpl?: (handler: () => void, timeoutMs: number) => TimeoutHandle;
 97+}
 98+
 99+interface BrowserRequestPlatformState {
100+  busy: boolean;
101+  dispatches: number[];
102+  lastDispatchedAt: number | null;
103+  waiters: Array<() => void>;
104+}
105+
106+interface BrowserRequestTargetState {
107+  backoffUntil: number | null;
108+  circuitRetryAt: number | null;
109+  circuitState: BrowserRequestCircuitState;
110+  consecutiveFailures: number;
111+  inFlight: number;
112+  lastError: string | null;
113+  lastFailureAt: number | null;
114+  lastSuccessAt: number | null;
115+  waiters: Array<() => void>;
116+}
117+
118+const DEFAULT_BROWSER_REQUEST_POLICY: BrowserRequestPolicyConfig = {
119+  backoff: {
120+    baseMs: 1_000,
121+    maxMs: 60_000
122+  },
123+  circuitBreaker: {
124+    failureThreshold: 5,
125+    openMs: 60_000
126+  },
127+  concurrency: {
128+    maxInFlightPerClientPlatform: 1
129+  },
130+  jitter: {
131+    maxMs: 5_000,
132+    minMs: 1_000,
133+    muMs: 2_000,
134+    sigmaMs: 500
135+  },
136+  rateLimit: {
137+    requestsPerMinutePerPlatform: 10,
138+    windowMs: 60_000
139+  },
140+  stream: {
141+    idleTimeoutMs: 30_000,
142+    maxBufferedBytes: 512 * 1024,
143+    maxBufferedEvents: 256,
144+    openTimeoutMs: 10_000
145+  }
146+};
147+
148+function clonePolicyConfig(input: BrowserRequestPolicyConfig): BrowserRequestPolicyConfig {
149+  return {
150+    backoff: {
151+      baseMs: input.backoff.baseMs,
152+      maxMs: input.backoff.maxMs
153+    },
154+    circuitBreaker: {
155+      failureThreshold: input.circuitBreaker.failureThreshold,
156+      openMs: input.circuitBreaker.openMs
157+    },
158+    concurrency: {
159+      maxInFlightPerClientPlatform: input.concurrency.maxInFlightPerClientPlatform
160+    },
161+    jitter: {
162+      maxMs: input.jitter.maxMs,
163+      minMs: input.jitter.minMs,
164+      muMs: input.jitter.muMs,
165+      sigmaMs: input.jitter.sigmaMs
166+    },
167+    rateLimit: {
168+      requestsPerMinutePerPlatform: input.rateLimit.requestsPerMinutePerPlatform,
169+      windowMs: input.rateLimit.windowMs
170+    },
171+    stream: {
172+      idleTimeoutMs: input.stream.idleTimeoutMs,
173+      maxBufferedBytes: input.stream.maxBufferedBytes,
174+      maxBufferedEvents: input.stream.maxBufferedEvents,
175+      openTimeoutMs: input.stream.openTimeoutMs
176+    }
177+  };
178+}
179+
180+function mergePolicyConfig(
181+  defaults: BrowserRequestPolicyConfig,
182+  overrides: Partial<BrowserRequestPolicyConfig>
183+): BrowserRequestPolicyConfig {
184+  return {
185+    backoff: {
186+      ...defaults.backoff,
187+      ...(overrides.backoff ?? {})
188+    },
189+    circuitBreaker: {
190+      ...defaults.circuitBreaker,
191+      ...(overrides.circuitBreaker ?? {})
192+    },
193+    concurrency: {
194+      ...defaults.concurrency,
195+      ...(overrides.concurrency ?? {})
196+    },
197+    jitter: {
198+      ...defaults.jitter,
199+      ...(overrides.jitter ?? {})
200+    },
201+    rateLimit: {
202+      ...defaults.rateLimit,
203+      ...(overrides.rateLimit ?? {})
204+    },
205+    stream: {
206+      ...defaults.stream,
207+      ...(overrides.stream ?? {})
208+    }
209+  };
210+}
211+
212+function normalizeOptionalString(value: string | null | undefined): string | null {
213+  if (value == null) {
214+    return null;
215+  }
216+
217+  const normalized = value.trim();
218+  return normalized === "" ? null : normalized;
219+}
220+
221+function buildTargetKey(target: BrowserRequestTarget): string {
222+  return `${target.clientId}\u0000${target.platform}`;
223+}
224+
225+function prunePlatformDispatches(
226+  state: BrowserRequestPlatformState,
227+  now: number,
228+  windowMs: number
229+): void {
230+  const cutoff = now - windowMs;
231+  state.dispatches = state.dispatches.filter((timestamp) => timestamp > cutoff);
232+}
233+
234+function clamp(value: number, min: number, max: number): number {
235+  return Math.min(max, Math.max(min, value));
236+}
237+
238+function buildTimeoutPromise(
239+  setTimeoutImpl: (handler: () => void, timeoutMs: number) => TimeoutHandle,
240+  timeoutMs: number
241+): Promise<void> {
242+  return new Promise((resolve) => {
243+    setTimeoutImpl(resolve, Math.max(0, timeoutMs));
244+  });
245+}
246+
247+class BrowserRequestPolicyLeaseImpl implements BrowserRequestPolicyLease {
248+  private completed = false;
249+
250+  constructor(
251+    readonly admission: BrowserRequestAdmission,
252+    readonly target: BrowserRequestTarget,
253+    private readonly onComplete: (outcome: BrowserRequestLeaseOutcome) => void
254+  ) {}
255+
256+  complete(outcome: BrowserRequestLeaseOutcome): void {
257+    if (this.completed) {
258+      return;
259+    }
260+
261+    this.completed = true;
262+    this.onComplete(outcome);
263+  }
264+}
265+
266+export class BrowserRequestPolicyError extends Error {
267+  constructor(
268+    readonly code: string,
269+    message: string,
270+    readonly details: Record<string, unknown> = {}
271+  ) {
272+    super(message);
273+    this.name = "BrowserRequestPolicyError";
274+  }
275+}
276+
277+export class BrowserRequestPolicyController {
278+  private readonly config: BrowserRequestPolicyConfig;
279+  private readonly now: () => number;
280+  private readonly platforms = new Map<string, BrowserRequestPlatformState>();
281+  private readonly random: () => number;
282+  private readonly setTimeoutImpl: (handler: () => void, timeoutMs: number) => TimeoutHandle;
283+  private readonly targets = new Map<string, BrowserRequestTargetState>();
284+
285+  constructor(options: BrowserRequestPolicyControllerOptions = {}) {
286+    this.config = clonePolicyConfig(
287+      mergePolicyConfig(DEFAULT_BROWSER_REQUEST_POLICY, options.config ?? {})
288+    );
289+    this.now = options.now ?? (() => Date.now());
290+    this.random = options.random ?? (() => Math.random());
291+    this.setTimeoutImpl = options.setTimeoutImpl ?? ((handler, timeoutMs) => globalThis.setTimeout(handler, timeoutMs));
292+  }
293+
294+  getConfig(): BrowserRequestPolicyConfig {
295+    return clonePolicyConfig(this.config);
296+  }
297+
298+  getSnapshot(): BrowserRequestPolicySnapshot {
299+    const now = this.now();
300+    const platforms = [...this.platforms.entries()]
301+      .map(([platform, state]) => {
302+        prunePlatformDispatches(state, now, this.config.rateLimit.windowMs);
303+        return {
304+          lastDispatchedAt: state.lastDispatchedAt,
305+          platform,
306+          recentDispatchCount: state.dispatches.length,
307+          waiting: state.waiters.length
308+        };
309+      })
310+      .sort((left, right) => left.platform.localeCompare(right.platform));
311+    const targets = [...this.targets.entries()]
312+      .map(([key, state]) => {
313+        const [clientId, platform] = key.split("\u0000");
314+        return {
315+          backoffUntil: state.backoffUntil,
316+          circuitRetryAt: state.circuitRetryAt,
317+          circuitState: state.circuitState,
318+          clientId: clientId ?? "",
319+          consecutiveFailures: state.consecutiveFailures,
320+          inFlight: state.inFlight,
321+          lastError: state.lastError,
322+          lastFailureAt: state.lastFailureAt,
323+          lastSuccessAt: state.lastSuccessAt,
324+          platform: platform ?? "",
325+          waiting: state.waiters.length
326+        };
327+      })
328+      .sort((left, right) => {
329+        const clientCompare = left.clientId.localeCompare(right.clientId);
330+        return clientCompare === 0 ? left.platform.localeCompare(right.platform) : clientCompare;
331+      });
332+
333+    return {
334+      defaults: this.getConfig(),
335+      platforms,
336+      targets
337+    };
338+  }
339+
340+  async beginRequest(
341+    target: BrowserRequestTarget,
342+    requestId: string
343+  ): Promise<BrowserRequestPolicyLease> {
344+    const normalizedTarget = this.normalizeTarget(target);
345+    const requestedAt = this.now();
346+    const targetState = this.getTargetState(normalizedTarget);
347+    await this.acquireTargetSlot(targetState);
348+    let admission: BrowserRequestAdmission | null = null;
349+
350+    try {
351+      admission = await this.admitRequest(normalizedTarget, targetState, requestId, requestedAt);
352+    } catch (error) {
353+      this.releaseTargetSlot(targetState);
354+      throw error;
355+    }
356+
357+    return new BrowserRequestPolicyLeaseImpl(admission, normalizedTarget, (outcome) => {
358+      this.completeRequest(normalizedTarget, targetState, outcome);
359+    });
360+  }
361+
362+  private normalizeTarget(target: BrowserRequestTarget): BrowserRequestTarget {
363+    const clientId = normalizeOptionalString(target.clientId);
364+    const platform = normalizeOptionalString(target.platform);
365+
366+    if (clientId == null || platform == null) {
367+      throw new Error("Browser request policy requires non-empty clientId and platform.");
368+    }
369+
370+    return {
371+      clientId,
372+      platform
373+    };
374+  }
375+
376+  private getPlatformState(platform: string): BrowserRequestPlatformState {
377+    const existing = this.platforms.get(platform);
378+
379+    if (existing != null) {
380+      return existing;
381+    }
382+
383+    const created: BrowserRequestPlatformState = {
384+      busy: false,
385+      dispatches: [],
386+      lastDispatchedAt: null,
387+      waiters: []
388+    };
389+    this.platforms.set(platform, created);
390+    return created;
391+  }
392+
393+  private getTargetState(target: BrowserRequestTarget): BrowserRequestTargetState {
394+    const key = buildTargetKey(target);
395+    const existing = this.targets.get(key);
396+
397+    if (existing != null) {
398+      return existing;
399+    }
400+
401+    const created: BrowserRequestTargetState = {
402+      backoffUntil: null,
403+      circuitRetryAt: null,
404+      circuitState: "closed",
405+      consecutiveFailures: 0,
406+      inFlight: 0,
407+      lastError: null,
408+      lastFailureAt: null,
409+      lastSuccessAt: null,
410+      waiters: []
411+    };
412+    this.targets.set(key, created);
413+    return created;
414+  }
415+
416+  private async acquireTargetSlot(state: BrowserRequestTargetState): Promise<void> {
417+    if (
418+      state.inFlight < this.config.concurrency.maxInFlightPerClientPlatform
419+      && state.waiters.length === 0
420+    ) {
421+      state.inFlight += 1;
422+      return;
423+    }
424+
425+    await new Promise<void>((resolve) => {
426+      state.waiters.push(resolve);
427+    });
428+  }
429+
430+  private releaseTargetSlot(state: BrowserRequestTargetState): void {
431+    const waiter = state.waiters.shift();
432+
433+    if (waiter != null) {
434+      waiter();
435+      return;
436+    }
437+
438+    state.inFlight = Math.max(0, state.inFlight - 1);
439+  }
440+
441+  private async acquirePlatformAdmission(state: BrowserRequestPlatformState): Promise<void> {
442+    if (!state.busy) {
443+      state.busy = true;
444+      return;
445+    }
446+
447+    await new Promise<void>((resolve) => {
448+      state.waiters.push(resolve);
449+    });
450+  }
451+
452+  private releasePlatformAdmission(state: BrowserRequestPlatformState): void {
453+    const waiter = state.waiters.shift();
454+
455+    if (waiter != null) {
456+      waiter();
457+      return;
458+    }
459+
460+    state.busy = false;
461+  }
462+
463+  private async admitRequest(
464+    target: BrowserRequestTarget,
465+    state: BrowserRequestTargetState,
466+    requestId: string,
467+    requestedAt: number
468+  ): Promise<BrowserRequestAdmission> {
469+    const platformState = this.getPlatformState(target.platform);
470+    await this.acquirePlatformAdmission(platformState);
471+    let backoffDelayMs = 0;
472+    let jitterDelayMs = 0;
473+    let rateLimitDelayMs = 0;
474+
475+    try {
476+      this.assertCircuitAllowsRequest(target, state);
477+
478+      const backoffUntil = state.backoffUntil ?? 0;
479+      const now = this.now();
480+      if (backoffUntil > now) {
481+        backoffDelayMs = backoffUntil - now;
482+        await buildTimeoutPromise(this.setTimeoutImpl, backoffDelayMs);
483+      }
484+
485+      const nowAfterBackoff = this.now();
486+      prunePlatformDispatches(platformState, nowAfterBackoff, this.config.rateLimit.windowMs);
487+
488+      if (
489+        platformState.dispatches.length >= this.config.rateLimit.requestsPerMinutePerPlatform
490+      ) {
491+        const earliest = platformState.dispatches[0] ?? nowAfterBackoff;
492+        rateLimitDelayMs = Math.max(
493+          0,
494+          earliest + this.config.rateLimit.windowMs - nowAfterBackoff
495+        );
496+
497+        if (rateLimitDelayMs > 0) {
498+          await buildTimeoutPromise(this.setTimeoutImpl, rateLimitDelayMs);
499+        }
500+      }
501+
502+      jitterDelayMs = this.sampleJitterDelayMs();
503+      if (jitterDelayMs > 0) {
504+        await buildTimeoutPromise(this.setTimeoutImpl, jitterDelayMs);
505+      }
506+
507+      const admittedAt = this.now();
508+      prunePlatformDispatches(platformState, admittedAt, this.config.rateLimit.windowMs);
509+      platformState.dispatches.push(admittedAt);
510+      platformState.lastDispatchedAt = admittedAt;
511+
512+      return {
513+        admittedAt,
514+        backoffDelayMs,
515+        circuitState: state.circuitState,
516+        jitterDelayMs,
517+        platform: target.platform,
518+        queueDelayMs: Math.max(0, admittedAt - requestedAt - backoffDelayMs - rateLimitDelayMs - jitterDelayMs),
519+        rateLimitDelayMs,
520+        requestId,
521+        requestedAt,
522+        targetClientId: target.clientId
523+      };
524+    } finally {
525+      this.releasePlatformAdmission(platformState);
526+    }
527+  }
528+
529+  private assertCircuitAllowsRequest(
530+    target: BrowserRequestTarget,
531+    state: BrowserRequestTargetState
532+  ): void {
533+    if (state.circuitState !== "open") {
534+      return;
535+    }
536+
537+    const now = this.now();
538+    const retryAt = state.circuitRetryAt ?? 0;
539+
540+    if (retryAt > now) {
541+      throw new BrowserRequestPolicyError(
542+        "circuit_open",
543+        `Browser request circuit is open for ${target.platform} on client "${target.clientId}".`,
544+        {
545+          client_id: target.clientId,
546+          platform: target.platform,
547+          retry_after_ms: retryAt - now
548+        }
549+      );
550+    }
551+
552+    state.circuitState = "half_open";
553+  }
554+
555+  private sampleJitterDelayMs(): number {
556+    const { maxMs, minMs, muMs, sigmaMs } = this.config.jitter;
557+    const u1 = Math.max(Number.EPSILON, this.random());
558+    const u2 = Math.max(Number.EPSILON, this.random());
559+    const gaussian = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
560+    return Math.round(clamp(muMs + gaussian * sigmaMs, minMs, maxMs));
561+  }
562+
563+  private completeRequest(
564+    target: BrowserRequestTarget,
565+    state: BrowserRequestTargetState,
566+    outcome: BrowserRequestLeaseOutcome
567+  ): void {
568+    const now = this.now();
569+
570+    if (outcome.status === "success") {
571+      state.backoffUntil = null;
572+      state.circuitRetryAt = null;
573+      state.circuitState = "closed";
574+      state.consecutiveFailures = 0;
575+      state.lastError = null;
576+      state.lastSuccessAt = now;
577+      this.releaseTargetSlot(state);
578+      return;
579+    }
580+
581+    if (outcome.status === "cancelled") {
582+      this.releaseTargetSlot(state);
583+      return;
584+    }
585+
586+    state.consecutiveFailures += 1;
587+    state.lastError = normalizeOptionalString(outcome.code) ?? normalizeOptionalString(outcome.message);
588+    state.lastFailureAt = now;
589+    const backoffDelay = Math.min(
590+      this.config.backoff.maxMs,
591+      this.config.backoff.baseMs * Math.pow(2, Math.max(0, state.consecutiveFailures - 1))
592+    );
593+    state.backoffUntil = now + backoffDelay;
594+
595+    if (
596+      state.circuitState === "half_open"
597+      || state.consecutiveFailures >= this.config.circuitBreaker.failureThreshold
598+    ) {
599+      state.circuitState = "open";
600+      state.circuitRetryAt = now + this.config.circuitBreaker.openMs;
601+    }
602+
603+    this.releaseTargetSlot(state);
604+  }
605+}
606+
607+export function createDefaultBrowserRequestPolicyConfig(): BrowserRequestPolicyConfig {
608+  return clonePolicyConfig(DEFAULT_BROWSER_REQUEST_POLICY);
609+}
M apps/conductor-daemon/src/browser-types.ts
+101, -0
  1@@ -58,6 +58,12 @@ export interface BrowserBridgeDispatchReceipt {
  2   type: string;
  3 }
  4 
  5+export interface BrowserBridgeRequestCancelReceipt extends BrowserBridgeDispatchReceipt {
  6+  reason?: string | null;
  7+  requestId: string;
  8+  streamId?: string | null;
  9+}
 10+
 11 export interface BrowserBridgeApiResponse {
 12   body: unknown;
 13   clientId: string;
 14@@ -69,17 +75,97 @@ export interface BrowserBridgeApiResponse {
 15   status: number | null;
 16 }
 17 
 18+export interface BrowserBridgeStreamPartialState {
 19+  buffered_bytes: number;
 20+  event_count: number;
 21+  last_seq: number;
 22+  opened: boolean;
 23+}
 24+
 25+export type BrowserBridgeStreamEvent =
 26+  | {
 27+      clientId: string;
 28+      connectionId: string;
 29+      meta: unknown;
 30+      openedAt: number;
 31+      requestId: string;
 32+      status: number | null;
 33+      streamId: string;
 34+      type: "stream_open";
 35+    }
 36+  | {
 37+      clientId: string;
 38+      connectionId: string;
 39+      data: unknown;
 40+      event: string | null;
 41+      raw: string | null;
 42+      receivedAt: number;
 43+      requestId: string;
 44+      seq: number;
 45+      streamId: string;
 46+      type: "stream_event";
 47+    }
 48+  | {
 49+      clientId: string;
 50+      connectionId: string;
 51+      endedAt: number;
 52+      partial: BrowserBridgeStreamPartialState;
 53+      requestId: string;
 54+      status: number | null;
 55+      streamId: string;
 56+      type: "stream_end";
 57+    }
 58+  | {
 59+      clientId: string;
 60+      code: string;
 61+      connectionId: string;
 62+      erroredAt: number;
 63+      message: string;
 64+      partial: BrowserBridgeStreamPartialState;
 65+      requestId: string;
 66+      status: number | null;
 67+      streamId: string;
 68+      type: "stream_error";
 69+    };
 70+
 71+export interface BrowserBridgeApiStream extends AsyncIterable<BrowserBridgeStreamEvent> {
 72+  readonly clientId: string;
 73+  readonly connectionId: string;
 74+  readonly requestId: string;
 75+  readonly streamId: string;
 76+  cancel(reason?: string | null): void;
 77+}
 78+
 79 export interface BrowserBridgeController {
 80   apiRequest(input: {
 81     body?: unknown;
 82     clientId?: string | null;
 83     headers?: Record<string, string> | null;
 84     id?: string | null;
 85+    idleTimeoutMs?: number | null;
 86+    maxBufferedBytes?: number | null;
 87+    maxBufferedEvents?: number | null;
 88     method?: string | null;
 89+    openTimeoutMs?: number | null;
 90     path: string;
 91     platform: string;
 92+    responseMode?: "buffered" | "sse" | null;
 93+    streamId?: string | null;
 94     timeoutMs?: number | null;
 95   }): Promise<BrowserBridgeApiResponse>;
 96+  cancelApiRequest(input: {
 97+    clientId?: string | null;
 98+    platform?: string | null;
 99+    reason?: string | null;
100+    requestId: string;
101+    streamId?: string | null;
102+  }): BrowserBridgeRequestCancelReceipt;
103+  dispatchPluginAction(input: {
104+    action: string;
105+    clientId?: string | null;
106+    platform?: string | null;
107+    reason?: string | null;
108+  }): BrowserBridgeDispatchReceipt;
109   openTab(input?: {
110     clientId?: string | null;
111     platform?: string | null;
112@@ -88,6 +174,21 @@ export interface BrowserBridgeController {
113     clientId?: string | null;
114     reason?: string | null;
115   }): BrowserBridgeDispatchReceipt;
116+  streamRequest(input: {
117+    body?: unknown;
118+    clientId?: string | null;
119+    headers?: Record<string, string> | null;
120+    id?: string | null;
121+    idleTimeoutMs?: number | null;
122+    maxBufferedBytes?: number | null;
123+    maxBufferedEvents?: number | null;
124+    method?: string | null;
125+    openTimeoutMs?: number | null;
126+    path: string;
127+    platform: string;
128+    streamId?: string | null;
129+    timeoutMs?: number | null;
130+  }): BrowserBridgeApiStream;
131   requestCredentials(input?: {
132     clientId?: string | null;
133     platform?: string | null;
M apps/conductor-daemon/src/firefox-bridge.ts
+876, -17
   1@@ -1,23 +1,35 @@
   2 import { randomUUID } from "node:crypto";
   3 
   4 const DEFAULT_FIREFOX_API_REQUEST_TIMEOUT_MS = 15_000;
   5+const DEFAULT_FIREFOX_STREAM_IDLE_TIMEOUT_MS = 30_000;
   6+const DEFAULT_FIREFOX_STREAM_MAX_BUFFERED_BYTES = 512 * 1024;
   7+const DEFAULT_FIREFOX_STREAM_MAX_BUFFERED_EVENTS = 256;
   8+const DEFAULT_FIREFOX_STREAM_OPEN_TIMEOUT_MS = 10_000;
   9 
  10 type TimeoutHandle = ReturnType<typeof globalThis.setTimeout>;
  11 
  12+export type FirefoxBridgeResponseMode = "buffered" | "sse";
  13 export type FirefoxBridgeOutboundCommandType =
  14+  | "api_request"
  15+  | "controller_reload"
  16   | "open_tab"
  17-  | "request_credentials"
  18+  | "plugin_status"
  19   | "reload"
  20-  | "api_request";
  21+  | "request_cancel"
  22+  | "request_credentials"
  23+  | "tab_focus"
  24+  | "tab_restore"
  25+  | "ws_reconnect";
  26 
  27 export type FirefoxBridgeErrorCode =
  28+  | "client_disconnected"
  29   | "client_not_found"
  30+  | "client_replaced"
  31   | "duplicate_request_id"
  32   | "no_active_client"
  33+  | "request_not_found"
  34   | "request_timeout"
  35   | "send_failed"
  36-  | "client_disconnected"
  37-  | "client_replaced"
  38   | "service_stopped";
  39 
  40 export interface FirefoxBridgeRegisteredClient {
  41@@ -52,16 +64,41 @@ export interface FirefoxReloadCommandInput extends FirefoxBridgeCommandTarget {
  42   reason?: string | null;
  43 }
  44 
  45+export interface FirefoxPluginActionCommandInput extends FirefoxBridgeCommandTarget {
  46+  action: "controller_reload" | "plugin_status" | "tab_focus" | "tab_restore" | "ws_reconnect";
  47+  platform?: string | null;
  48+  reason?: string | null;
  49+}
  50+
  51 export interface FirefoxApiRequestCommandInput extends FirefoxBridgeCommandTarget {
  52   body?: unknown;
  53   headers?: Record<string, string> | null;
  54   id?: string | null;
  55+  idleTimeoutMs?: number | null;
  56+  maxBufferedBytes?: number | null;
  57+  maxBufferedEvents?: number | null;
  58   method?: string | null;
  59+  openTimeoutMs?: number | null;
  60   path: string;
  61   platform: string;
  62+  responseMode?: FirefoxBridgeResponseMode | null;
  63+  streamId?: string | null;
  64   timeoutMs?: number | null;
  65 }
  66 
  67+export interface FirefoxRequestCancelCommandInput extends FirefoxBridgeCommandTarget {
  68+  platform?: string | null;
  69+  reason?: string | null;
  70+  requestId: string;
  71+  streamId?: string | null;
  72+}
  73+
  74+export interface FirefoxBridgeCancelReceipt extends FirefoxBridgeDispatchReceipt {
  75+  reason: string | null;
  76+  requestId: string;
  77+  streamId: string | null;
  78+}
  79+
  80 export interface FirefoxApiResponsePayload {
  81   body: unknown;
  82   error: string | null;
  83@@ -83,6 +120,105 @@ export interface FirefoxBridgeConnectionClosedEvent {
  84   reason?: string | null;
  85 }
  86 
  87+export interface FirefoxBridgeStreamPartialState {
  88+  buffered_bytes: number;
  89+  event_count: number;
  90+  last_seq: number;
  91+  opened: boolean;
  92+}
  93+
  94+export interface FirefoxBridgeStreamOpenEvent {
  95+  clientId: string;
  96+  connectionId: string;
  97+  meta: unknown;
  98+  openedAt: number;
  99+  requestId: string;
 100+  status: number | null;
 101+  streamId: string;
 102+  type: "stream_open";
 103+}
 104+
 105+export interface FirefoxBridgeStreamDataEvent {
 106+  clientId: string;
 107+  connectionId: string;
 108+  data: unknown;
 109+  event: string | null;
 110+  raw: string | null;
 111+  receivedAt: number;
 112+  requestId: string;
 113+  seq: number;
 114+  streamId: string;
 115+  type: "stream_event";
 116+}
 117+
 118+export interface FirefoxBridgeStreamEndEvent {
 119+  clientId: string;
 120+  connectionId: string;
 121+  endedAt: number;
 122+  partial: FirefoxBridgeStreamPartialState;
 123+  requestId: string;
 124+  status: number | null;
 125+  streamId: string;
 126+  type: "stream_end";
 127+}
 128+
 129+export interface FirefoxBridgeStreamErrorEvent {
 130+  clientId: string;
 131+  code: string;
 132+  connectionId: string;
 133+  erroredAt: number;
 134+  message: string;
 135+  partial: FirefoxBridgeStreamPartialState;
 136+  requestId: string;
 137+  status: number | null;
 138+  streamId: string;
 139+  type: "stream_error";
 140+}
 141+
 142+export type FirefoxBridgeStreamEvent =
 143+  | FirefoxBridgeStreamDataEvent
 144+  | FirefoxBridgeStreamEndEvent
 145+  | FirefoxBridgeStreamErrorEvent
 146+  | FirefoxBridgeStreamOpenEvent;
 147+
 148+export interface FirefoxBridgeApiStream extends AsyncIterable<FirefoxBridgeStreamEvent> {
 149+  readonly clientId: string;
 150+  readonly connectionId: string;
 151+  readonly requestId: string;
 152+  readonly streamId: string;
 153+  cancel(reason?: string | null): void;
 154+}
 155+
 156+export interface FirefoxStreamOpenPayload {
 157+  id: string;
 158+  meta?: unknown;
 159+  status: number | null;
 160+  streamId?: string | null;
 161+}
 162+
 163+export interface FirefoxStreamEventPayload {
 164+  data?: unknown;
 165+  event?: string | null;
 166+  id: string;
 167+  raw?: string | null;
 168+  seq: number;
 169+  streamId?: string | null;
 170+}
 171+
 172+export interface FirefoxStreamEndPayload {
 173+  id: string;
 174+  status: number | null;
 175+  streamId?: string | null;
 176+}
 177+
 178+export interface FirefoxStreamErrorPayload {
 179+  code?: string | null;
 180+  id: string;
 181+  message?: string | null;
 182+  status: number | null;
 183+  streamId?: string | null;
 184+}
 185+
 186 interface FirefoxPendingApiRequest {
 187   clientId: string;
 188   connectionId: string;
 189@@ -100,6 +236,27 @@ interface FirefoxCommandBrokerOptions {
 190   setTimeoutImpl?: (handler: () => void, timeoutMs: number) => TimeoutHandle;
 191 }
 192 
 193+interface FirefoxStreamSessionOptions {
 194+  clearTimeoutImpl: (handle: TimeoutHandle) => void;
 195+  clientId: string;
 196+  connectionId: string;
 197+  idleTimeoutMs: number;
 198+  maxBufferedBytes: number;
 199+  maxBufferedEvents: number;
 200+  now: () => number;
 201+  onCancel: (reason?: string | null) => void;
 202+  onClose: (requestId: string) => void;
 203+  openTimeoutMs: number;
 204+  requestId: string;
 205+  setTimeoutImpl: (handler: () => void, timeoutMs: number) => TimeoutHandle;
 206+  streamId: string;
 207+}
 208+
 209+interface FirefoxQueuedStreamEvent {
 210+  event: FirefoxBridgeStreamEvent;
 211+  size: number;
 212+}
 213+
 214 function normalizeOptionalString(value: unknown): string | null {
 215   if (typeof value !== "string") {
 216     return null;
 217@@ -131,14 +288,22 @@ function normalizeHeaderRecord(
 218   return Object.keys(normalized).length > 0 ? normalized : undefined;
 219 }
 220 
 221-function normalizeTimeoutMs(value: number | null | undefined): number {
 222+function normalizeTimeoutMs(value: number | null | undefined, fallback: number): number {
 223   if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
 224-    return DEFAULT_FIREFOX_API_REQUEST_TIMEOUT_MS;
 225+    return fallback;
 226   }
 227 
 228   return Math.round(value);
 229 }
 230 
 231+function normalizePositiveInteger(value: number | null | undefined, fallback: number): number {
 232+  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
 233+    return fallback;
 234+  }
 235+
 236+  return Math.max(1, Math.round(value));
 237+}
 238+
 239 function normalizeStatus(value: number | null | undefined): number | null {
 240   if (typeof value !== "number" || !Number.isFinite(value)) {
 241     return null;
 242@@ -159,6 +324,28 @@ function compactRecord(input: Record<string, unknown>): Record<string, unknown>
 243   return output;
 244 }
 245 
 246+function estimateEventSize(event: FirefoxBridgeStreamEvent): number {
 247+  try {
 248+    return Buffer.from(JSON.stringify(event), "utf8").length;
 249+  } catch {
 250+    return 0;
 251+  }
 252+}
 253+
 254+function buildPartialState(
 255+  opened: boolean,
 256+  eventCount: number,
 257+  lastSeq: number,
 258+  bufferedBytes: number
 259+): FirefoxBridgeStreamPartialState {
 260+  return {
 261+    buffered_bytes: bufferedBytes,
 262+    event_count: eventCount,
 263+    last_seq: lastSeq,
 264+    opened
 265+  };
 266+}
 267+
 268 export class FirefoxBridgeError extends Error {
 269   readonly clientId: string | null;
 270   readonly code: FirefoxBridgeErrorCode;
 271@@ -183,10 +370,364 @@ export class FirefoxBridgeError extends Error {
 272   }
 273 }
 274 
 275+class FirefoxBridgeApiStreamSession implements FirefoxBridgeApiStream {
 276+  readonly clientId: string;
 277+  readonly connectionId: string;
 278+  readonly requestId: string;
 279+
 280+  private readonly clearTimeoutImpl: (handle: TimeoutHandle) => void;
 281+  private readonly idleTimeoutMs: number;
 282+  private readonly maxBufferedBytes: number;
 283+  private readonly maxBufferedEvents: number;
 284+  private readonly now: () => number;
 285+  private readonly onCancel: (reason?: string | null) => void;
 286+  private readonly onClose: (requestId: string) => void;
 287+  private readonly openTimeoutMs: number;
 288+  private readonly queue: FirefoxQueuedStreamEvent[] = [];
 289+  private readonly setTimeoutImpl: (handler: () => void, timeoutMs: number) => TimeoutHandle;
 290+  private readonly waiters: Array<(result: IteratorResult<FirefoxBridgeStreamEvent>) => void> = [];
 291+  private bufferedBytes = 0;
 292+  private cancelSent = false;
 293+  private closed = false;
 294+  private eventCount = 0;
 295+  private idleTimer: TimeoutHandle | null = null;
 296+  private lastSeq = 0;
 297+  private openTimer: TimeoutHandle | null = null;
 298+  private opened = false;
 299+  private streamIdValue: string;
 300+
 301+  constructor(options: FirefoxStreamSessionOptions) {
 302+    this.clearTimeoutImpl = options.clearTimeoutImpl;
 303+    this.clientId = options.clientId;
 304+    this.connectionId = options.connectionId;
 305+    this.idleTimeoutMs = options.idleTimeoutMs;
 306+    this.maxBufferedBytes = options.maxBufferedBytes;
 307+    this.maxBufferedEvents = options.maxBufferedEvents;
 308+    this.now = options.now;
 309+    this.onCancel = options.onCancel;
 310+    this.onClose = options.onClose;
 311+    this.openTimeoutMs = options.openTimeoutMs;
 312+    this.requestId = options.requestId;
 313+    this.setTimeoutImpl = options.setTimeoutImpl;
 314+    this.streamIdValue = options.streamId;
 315+    this.resetOpenTimer();
 316+  }
 317+
 318+  get streamId(): string {
 319+    return this.streamIdValue;
 320+  }
 321+
 322+  [Symbol.asyncIterator](): AsyncIterableIterator<FirefoxBridgeStreamEvent> {
 323+    return {
 324+      [Symbol.asyncIterator]: () => this[Symbol.asyncIterator](),
 325+      next: async () => await this.next()
 326+    };
 327+  }
 328+
 329+  cancel(reason?: string | null): void {
 330+    this.dispatchCancel(reason);
 331+    this.fail("request_cancelled", normalizeOptionalString(reason) ?? "Stream request was cancelled.");
 332+  }
 333+
 334+  next(): Promise<IteratorResult<FirefoxBridgeStreamEvent>> {
 335+    const queued = this.queue.shift();
 336+
 337+    if (queued != null) {
 338+      this.bufferedBytes = Math.max(0, this.bufferedBytes - queued.size);
 339+      return Promise.resolve({
 340+        done: false,
 341+        value: queued.event
 342+      });
 343+    }
 344+
 345+    if (this.closed) {
 346+      return Promise.resolve({
 347+        done: true,
 348+        value: undefined
 349+      });
 350+    }
 351+
 352+    return new Promise((resolve) => {
 353+      this.waiters.push(resolve);
 354+    });
 355+  }
 356+
 357+  markOpen(payload: FirefoxStreamOpenPayload): boolean {
 358+    if (this.closed) {
 359+      return false;
 360+    }
 361+
 362+    if (normalizeOptionalString(payload.streamId) != null) {
 363+      this.streamIdValue = normalizeOptionalString(payload.streamId) ?? this.streamIdValue;
 364+    }
 365+
 366+    this.opened = true;
 367+    this.resetIdleTimer();
 368+    return this.enqueue({
 369+      clientId: this.clientId,
 370+      connectionId: this.connectionId,
 371+      meta: payload.meta ?? null,
 372+      openedAt: this.now(),
 373+      requestId: this.requestId,
 374+      status: normalizeStatus(payload.status),
 375+      streamId: this.streamIdValue,
 376+      type: "stream_open"
 377+    });
 378+  }
 379+
 380+  markEvent(payload: FirefoxStreamEventPayload): boolean {
 381+    if (this.closed) {
 382+      return false;
 383+    }
 384+
 385+    if (!this.opened) {
 386+      this.markOpen({
 387+        id: this.requestId,
 388+        status: null,
 389+        streamId: payload.streamId ?? this.streamIdValue
 390+      });
 391+    }
 392+
 393+    if (normalizeOptionalString(payload.streamId) != null) {
 394+      this.streamIdValue = normalizeOptionalString(payload.streamId) ?? this.streamIdValue;
 395+    }
 396+
 397+    const seq = normalizePositiveInteger(payload.seq, this.lastSeq + 1);
 398+    this.eventCount += 1;
 399+    this.lastSeq = seq;
 400+    this.resetIdleTimer();
 401+    return this.enqueue({
 402+      clientId: this.clientId,
 403+      connectionId: this.connectionId,
 404+      data: payload.data ?? null,
 405+      event: normalizeOptionalString(payload.event),
 406+      raw: normalizeOptionalString(payload.raw),
 407+      receivedAt: this.now(),
 408+      requestId: this.requestId,
 409+      seq,
 410+      streamId: this.streamIdValue,
 411+      type: "stream_event"
 412+    });
 413+  }
 414+
 415+  markEnd(payload: FirefoxStreamEndPayload): boolean {
 416+    if (this.closed) {
 417+      return false;
 418+    }
 419+
 420+    return this.finishWithEvent({
 421+      clientId: this.clientId,
 422+      connectionId: this.connectionId,
 423+      endedAt: this.now(),
 424+      partial: buildPartialState(
 425+        this.opened,
 426+        this.eventCount,
 427+        this.lastSeq,
 428+        this.bufferedBytes
 429+      ),
 430+      requestId: this.requestId,
 431+      status: normalizeStatus(payload.status),
 432+      streamId:
 433+        normalizeOptionalString(payload.streamId) ?? this.streamIdValue,
 434+      type: "stream_end"
 435+    });
 436+  }
 437+
 438+  markError(payload: FirefoxStreamErrorPayload): boolean {
 439+    if (this.closed) {
 440+      return false;
 441+    }
 442+
 443+    return this.finishWithEvent({
 444+      clientId: this.clientId,
 445+      code: normalizeOptionalString(payload.code) ?? "stream_error",
 446+      connectionId: this.connectionId,
 447+      erroredAt: this.now(),
 448+      message: normalizeOptionalString(payload.message) ?? "Browser stream failed.",
 449+      partial: buildPartialState(
 450+        this.opened,
 451+        this.eventCount,
 452+        this.lastSeq,
 453+        this.bufferedBytes
 454+      ),
 455+      requestId: this.requestId,
 456+      status: normalizeStatus(payload.status),
 457+      streamId:
 458+        normalizeOptionalString(payload.streamId) ?? this.streamIdValue,
 459+      type: "stream_error"
 460+    });
 461+  }
 462+
 463+  fail(code: string, message: string, status: number | null = null): boolean {
 464+    return this.markError({
 465+      code,
 466+      id: this.requestId,
 467+      message,
 468+      status,
 469+      streamId: this.streamIdValue
 470+    });
 471+  }
 472+
 473+  private enqueue(event: FirefoxBridgeStreamEvent): boolean {
 474+    if (this.waiters.length > 0) {
 475+      const waiter = this.waiters.shift();
 476+
 477+      waiter?.({
 478+        done: false,
 479+        value: event
 480+      });
 481+      return true;
 482+    }
 483+
 484+    const size = estimateEventSize(event);
 485+
 486+    if (
 487+      event.type === "stream_event"
 488+      && (
 489+        this.queue.length + 1 > this.maxBufferedEvents
 490+        || this.bufferedBytes + size > this.maxBufferedBytes
 491+      )
 492+    ) {
 493+      this.dispatchCancel("stream_buffer_overflow");
 494+      return this.finishWithEvent({
 495+        clientId: this.clientId,
 496+        code: "stream_buffer_overflow",
 497+        connectionId: this.connectionId,
 498+        erroredAt: this.now(),
 499+        message: "Browser stream exceeded the conductor buffering limit.",
 500+        partial: buildPartialState(
 501+          this.opened,
 502+          this.eventCount,
 503+          this.lastSeq,
 504+          this.bufferedBytes
 505+        ),
 506+        requestId: this.requestId,
 507+        status: null,
 508+        streamId: this.streamIdValue,
 509+        type: "stream_error"
 510+      });
 511+    }
 512+
 513+    this.queue.push({
 514+      event,
 515+      size
 516+    });
 517+    this.bufferedBytes += size;
 518+    return true;
 519+  }
 520+
 521+  private finishWithEvent(event: FirefoxBridgeStreamEndEvent | FirefoxBridgeStreamErrorEvent): boolean {
 522+    if (this.closed) {
 523+      return false;
 524+    }
 525+
 526+    if (this.waiters.length > 0) {
 527+      const waiter = this.waiters.shift();
 528+      waiter?.({
 529+        done: false,
 530+        value: event
 531+      });
 532+    } else {
 533+      const size = estimateEventSize(event);
 534+      this.queue.push({
 535+        event,
 536+        size
 537+      });
 538+      this.bufferedBytes += size;
 539+    }
 540+
 541+    this.close();
 542+    return true;
 543+  }
 544+
 545+  private close(): void {
 546+    if (this.closed) {
 547+      return;
 548+    }
 549+
 550+    this.closed = true;
 551+    this.clearTimers();
 552+    this.onClose(this.requestId);
 553+
 554+    if (this.queue.length === 0) {
 555+      while (this.waiters.length > 0) {
 556+        const waiter = this.waiters.shift();
 557+        waiter?.({
 558+          done: true,
 559+          value: undefined
 560+        });
 561+      }
 562+    }
 563+  }
 564+
 565+  private dispatchCancel(reason?: string | null): void {
 566+    if (this.cancelSent) {
 567+      return;
 568+    }
 569+
 570+    this.cancelSent = true;
 571+    this.onCancel(reason);
 572+  }
 573+
 574+  private clearTimers(): void {
 575+    if (this.openTimer != null) {
 576+      this.clearTimeoutImpl(this.openTimer);
 577+      this.openTimer = null;
 578+    }
 579+
 580+    if (this.idleTimer != null) {
 581+      this.clearTimeoutImpl(this.idleTimer);
 582+      this.idleTimer = null;
 583+    }
 584+  }
 585+
 586+  private resetOpenTimer(): void {
 587+    if (this.openTimeoutMs <= 0 || this.closed) {
 588+      return;
 589+    }
 590+
 591+    if (this.openTimer != null) {
 592+      this.clearTimeoutImpl(this.openTimer);
 593+    }
 594+
 595+    this.openTimer = this.setTimeoutImpl(() => {
 596+      this.dispatchCancel("stream_open_timeout");
 597+      this.fail(
 598+        "stream_open_timeout",
 599+        `Browser stream "${this.requestId}" did not open within ${this.openTimeoutMs}ms.`
 600+      );
 601+    }, this.openTimeoutMs);
 602+  }
 603+
 604+  private resetIdleTimer(): void {
 605+    if (this.openTimer != null) {
 606+      this.clearTimeoutImpl(this.openTimer);
 607+      this.openTimer = null;
 608+    }
 609+
 610+    if (this.idleTimeoutMs <= 0 || this.closed) {
 611+      return;
 612+    }
 613+
 614+    if (this.idleTimer != null) {
 615+      this.clearTimeoutImpl(this.idleTimer);
 616+    }
 617+
 618+    this.idleTimer = this.setTimeoutImpl(() => {
 619+      this.dispatchCancel("stream_idle_timeout");
 620+      this.fail(
 621+        "stream_idle_timeout",
 622+        `Browser stream "${this.requestId}" was idle for more than ${this.idleTimeoutMs}ms.`
 623+      );
 624+    }, this.idleTimeoutMs);
 625+  }
 626+}
 627+
 628 export class FirefoxCommandBroker {
 629   private readonly clearTimeoutImpl: (handle: TimeoutHandle) => void;
 630   private readonly now: () => number;
 631   private readonly pendingApiRequests = new Map<string, FirefoxPendingApiRequest>();
 632+  private readonly pendingStreamRequests = new Map<string, FirefoxBridgeApiStreamSession>();
 633   private readonly resolveActiveClient: () => FirefoxBridgeRegisteredClient | null;
 634   private readonly resolveClientById: (clientId: string) => FirefoxBridgeRegisteredClient | null;
 635   private readonly setTimeoutImpl: (handler: () => void, timeoutMs: number) => TimeoutHandle;
 636@@ -237,7 +778,7 @@ export class FirefoxCommandBroker {
 637       timeoutMs?: number | null;
 638     }
 639   ): Promise<FirefoxBridgeApiResponse> {
 640-    if (this.pendingApiRequests.has(options.requestId)) {
 641+    if (this.pendingApiRequests.has(options.requestId) || this.pendingStreamRequests.has(options.requestId)) {
 642       throw new FirefoxBridgeError(
 643         "duplicate_request_id",
 644         `Firefox bridge request id "${options.requestId}" is already in flight.`,
 645@@ -248,10 +789,11 @@ export class FirefoxCommandBroker {
 646     }
 647 
 648     const client = this.selectClient(options);
 649-    const timeoutMs = normalizeTimeoutMs(options.timeoutMs);
 650+    const timeoutMs = normalizeTimeoutMs(options.timeoutMs, DEFAULT_FIREFOX_API_REQUEST_TIMEOUT_MS);
 651     const envelope = compactRecord({
 652       ...payload,
 653       id: options.requestId,
 654+      response_mode: "buffered",
 655       type: "api_request"
 656     });
 657 
 658@@ -297,10 +839,158 @@ export class FirefoxCommandBroker {
 659     });
 660   }
 661 
 662-  handleApiResponse(
 663-    connectionId: string,
 664-    payload: FirefoxApiResponsePayload
 665-  ): boolean {
 666+  openApiStream(
 667+    payload: Record<string, unknown>,
 668+    options: FirefoxBridgeCommandTarget & {
 669+      idleTimeoutMs?: number | null;
 670+      maxBufferedBytes?: number | null;
 671+      maxBufferedEvents?: number | null;
 672+      openTimeoutMs?: number | null;
 673+      requestId: string;
 674+      streamId: string;
 675+    }
 676+  ): FirefoxBridgeApiStream {
 677+    if (this.pendingApiRequests.has(options.requestId) || this.pendingStreamRequests.has(options.requestId)) {
 678+      throw new FirefoxBridgeError(
 679+        "duplicate_request_id",
 680+        `Firefox bridge request id "${options.requestId}" is already in flight.`,
 681+        {
 682+          requestId: options.requestId
 683+        }
 684+      );
 685+    }
 686+
 687+    const client = this.selectClient(options);
 688+    const streamSession = new FirefoxBridgeApiStreamSession({
 689+      clearTimeoutImpl: this.clearTimeoutImpl,
 690+      clientId: client.clientId,
 691+      connectionId: client.connectionId,
 692+      idleTimeoutMs: normalizeTimeoutMs(
 693+        options.idleTimeoutMs,
 694+        DEFAULT_FIREFOX_STREAM_IDLE_TIMEOUT_MS
 695+      ),
 696+      maxBufferedBytes: normalizePositiveInteger(
 697+        options.maxBufferedBytes,
 698+        DEFAULT_FIREFOX_STREAM_MAX_BUFFERED_BYTES
 699+      ),
 700+      maxBufferedEvents: normalizePositiveInteger(
 701+        options.maxBufferedEvents,
 702+        DEFAULT_FIREFOX_STREAM_MAX_BUFFERED_EVENTS
 703+      ),
 704+      now: this.now,
 705+      onCancel: (reason) => {
 706+        try {
 707+          this.dispatch("request_cancel", {
 708+            id: options.requestId,
 709+            reason: normalizeOptionalString(reason) ?? undefined,
 710+            stream_id: options.streamId
 711+          }, {
 712+            clientId: client.clientId
 713+          });
 714+        } catch {
 715+          // Best-effort remote cancel.
 716+        }
 717+      },
 718+      onClose: (requestId) => {
 719+        this.pendingStreamRequests.delete(requestId);
 720+      },
 721+      openTimeoutMs: normalizeTimeoutMs(
 722+        options.openTimeoutMs,
 723+        DEFAULT_FIREFOX_STREAM_OPEN_TIMEOUT_MS
 724+      ),
 725+      requestId: options.requestId,
 726+      setTimeoutImpl: this.setTimeoutImpl,
 727+      streamId: options.streamId
 728+    });
 729+    this.pendingStreamRequests.set(options.requestId, streamSession);
 730+
 731+    const envelope = compactRecord({
 732+      ...payload,
 733+      id: options.requestId,
 734+      response_mode: "sse",
 735+      stream_id: options.streamId,
 736+      type: "api_request"
 737+    });
 738+
 739+    if (!client.sendJson(envelope)) {
 740+      this.pendingStreamRequests.delete(options.requestId);
 741+      streamSession.fail(
 742+        "send_failed",
 743+        `Failed to send api_request "${options.requestId}" to Firefox client "${client.clientId}".`
 744+      );
 745+      throw new FirefoxBridgeError(
 746+        "send_failed",
 747+        `Failed to send api_request "${options.requestId}" to Firefox client "${client.clientId}".`,
 748+        {
 749+          clientId: client.clientId,
 750+          connectionId: client.connectionId,
 751+          requestId: options.requestId
 752+        }
 753+      );
 754+    }
 755+
 756+    return streamSession;
 757+  }
 758+
 759+  cancelRequest(input: FirefoxRequestCancelCommandInput): FirefoxBridgeCancelReceipt {
 760+    const pendingApiRequest = this.pendingApiRequests.get(input.requestId) ?? null;
 761+    const pendingStreamRequest = this.pendingStreamRequests.get(input.requestId) ?? null;
 762+    const requestedClientId = normalizeOptionalString(input.clientId);
 763+
 764+    if (pendingApiRequest == null && pendingStreamRequest == null) {
 765+      throw new FirefoxBridgeError(
 766+        "request_not_found",
 767+        `Firefox bridge request "${input.requestId}" is not in flight.`,
 768+        {
 769+          requestId: input.requestId
 770+        }
 771+      );
 772+    }
 773+
 774+    const targetClientId =
 775+      pendingApiRequest?.clientId
 776+      ?? pendingStreamRequest?.clientId
 777+      ?? null;
 778+
 779+    if (requestedClientId != null && targetClientId != null && requestedClientId !== targetClientId) {
 780+      throw new FirefoxBridgeError(
 781+        "client_not_found",
 782+        `Firefox bridge request "${input.requestId}" is not running on client "${requestedClientId}".`,
 783+        {
 784+          clientId: requestedClientId,
 785+          requestId: input.requestId
 786+        }
 787+      );
 788+    }
 789+
 790+    const receipt = this.dispatch(
 791+      "request_cancel",
 792+      compactRecord({
 793+        id: input.requestId,
 794+        platform: normalizeOptionalString(input.platform) ?? undefined,
 795+        reason: normalizeOptionalString(input.reason) ?? undefined,
 796+        stream_id:
 797+          normalizeOptionalString(input.streamId)
 798+          ?? pendingStreamRequest?.streamId
 799+          ?? undefined
 800+      }),
 801+      {
 802+        clientId: targetClientId ?? requestedClientId
 803+      }
 804+    );
 805+
 806+    return {
 807+      ...receipt,
 808+      reason: normalizeOptionalString(input.reason),
 809+      requestId: input.requestId,
 810+      streamId:
 811+        normalizeOptionalString(input.streamId)
 812+        ?? pendingStreamRequest?.streamId
 813+        ?? null
 814+    };
 815+  }
 816+
 817+  handleApiResponse(connectionId: string, payload: FirefoxApiResponsePayload): boolean {
 818     const pending = this.pendingApiRequests.get(payload.id);
 819 
 820     if (pending == null || pending.connectionId !== connectionId) {
 821@@ -321,14 +1011,53 @@ export class FirefoxCommandBroker {
 822     return true;
 823   }
 824 
 825+  handleStreamOpen(connectionId: string, payload: FirefoxStreamOpenPayload): boolean {
 826+    const pending = this.pendingStreamRequests.get(payload.id);
 827+
 828+    if (pending == null || pending.connectionId !== connectionId) {
 829+      return false;
 830+    }
 831+
 832+    return pending.markOpen(payload);
 833+  }
 834+
 835+  handleStreamEvent(connectionId: string, payload: FirefoxStreamEventPayload): boolean {
 836+    const pending = this.pendingStreamRequests.get(payload.id);
 837+
 838+    if (pending == null || pending.connectionId !== connectionId) {
 839+      return false;
 840+    }
 841+
 842+    return pending.markEvent(payload);
 843+  }
 844+
 845+  handleStreamEnd(connectionId: string, payload: FirefoxStreamEndPayload): boolean {
 846+    const pending = this.pendingStreamRequests.get(payload.id);
 847+
 848+    if (pending == null || pending.connectionId !== connectionId) {
 849+      return false;
 850+    }
 851+
 852+    return pending.markEnd(payload);
 853+  }
 854+
 855+  handleStreamError(connectionId: string, payload: FirefoxStreamErrorPayload): boolean {
 856+    const pending = this.pendingStreamRequests.get(payload.id);
 857+
 858+    if (pending == null || pending.connectionId !== connectionId) {
 859+      return false;
 860+    }
 861+
 862+    return pending.markError(payload);
 863+  }
 864+
 865   handleConnectionClosed(event: FirefoxBridgeConnectionClosedEvent): void {
 866     const requestIds = [...this.pendingApiRequests.values()]
 867       .filter((entry) => entry.connectionId === event.connectionId)
 868       .map((entry) => entry.requestId);
 869-
 870-    if (requestIds.length === 0) {
 871-      return;
 872-    }
 873+    const streamIds = [...this.pendingStreamRequests.values()]
 874+      .filter((entry) => entry.connectionId === event.connectionId)
 875+      .map((entry) => entry.requestId);
 876 
 877     const errorCode: FirefoxBridgeErrorCode =
 878       event.code === 4001 ? "client_replaced" : "client_disconnected";
 879@@ -357,10 +1086,26 @@ export class FirefoxCommandBroker {
 880         )
 881       );
 882     }
 883+
 884+    for (const requestId of streamIds) {
 885+      const pending = this.pendingStreamRequests.get(requestId);
 886+
 887+      if (pending == null) {
 888+        continue;
 889+      }
 890+
 891+      pending.fail(
 892+        errorCode,
 893+        errorCode === "client_replaced"
 894+          ? `Firefox client "${clientLabel}" was replaced before stream "${requestId}" completed${reasonSuffix}.`
 895+          : `Firefox client "${clientLabel}" disconnected before stream "${requestId}" completed${reasonSuffix}.`
 896+      );
 897+    }
 898   }
 899 
 900   stop(): void {
 901     const requestIds = [...this.pendingApiRequests.keys()];
 902+    const streamIds = [...this.pendingStreamRequests.keys()];
 903 
 904     for (const requestId of requestIds) {
 905       const pending = this.clearPendingRequest(requestId);
 906@@ -381,6 +1126,19 @@ export class FirefoxCommandBroker {
 907         )
 908       );
 909     }
 910+
 911+    for (const requestId of streamIds) {
 912+      const pending = this.pendingStreamRequests.get(requestId);
 913+
 914+      if (pending == null) {
 915+        continue;
 916+      }
 917+
 918+      pending.fail(
 919+        "service_stopped",
 920+        `Firefox bridge stopped before stream "${requestId}" completed.`
 921+      );
 922+    }
 923   }
 924 
 925   private clearPendingRequest(requestId: string): FirefoxPendingApiRequest | null {
 926@@ -426,6 +1184,19 @@ export class FirefoxCommandBroker {
 927 export class FirefoxBridgeService {
 928   constructor(private readonly broker: FirefoxCommandBroker) {}
 929 
 930+  dispatchPluginAction(
 931+    input: FirefoxPluginActionCommandInput
 932+  ): FirefoxBridgeDispatchReceipt {
 933+    return this.broker.dispatch(
 934+      input.action,
 935+      compactRecord({
 936+        platform: normalizeOptionalString(input.platform) ?? undefined,
 937+        reason: normalizeOptionalString(input.reason) ?? undefined
 938+      }),
 939+      input
 940+    );
 941+  }
 942+
 943   openTab(input: FirefoxOpenTabCommandInput = {}): FirefoxBridgeDispatchReceipt {
 944     const platform = normalizeOptionalString(input.platform);
 945 
 946@@ -481,7 +1252,12 @@ export class FirefoxBridgeService {
 947         headers: normalizeHeaderRecord(input.headers),
 948         method: normalizeOptionalString(input.method)?.toUpperCase() ?? "GET",
 949         path,
 950-        platform
 951+        platform,
 952+        response_mode: "buffered",
 953+        timeout_ms: normalizeTimeoutMs(
 954+          input.timeoutMs,
 955+          DEFAULT_FIREFOX_API_REQUEST_TIMEOUT_MS
 956+        )
 957       }),
 958       {
 959         clientId: input.clientId,
 960@@ -491,6 +1267,64 @@ export class FirefoxBridgeService {
 961     );
 962   }
 963 
 964+  streamRequest(input: FirefoxApiRequestCommandInput): FirefoxBridgeApiStream {
 965+    const platform = normalizeOptionalString(input.platform);
 966+    const path = normalizeOptionalString(input.path);
 967+
 968+    if (platform == null) {
 969+      throw new Error("Firefox bridge stream_request requires a non-empty platform.");
 970+    }
 971+
 972+    if (path == null) {
 973+      throw new Error("Firefox bridge stream_request requires a non-empty path.");
 974+    }
 975+
 976+    const requestId = normalizeOptionalString(input.id) ?? randomUUID();
 977+    const streamId = normalizeOptionalString(input.streamId) ?? requestId;
 978+
 979+    return this.broker.openApiStream(
 980+      compactRecord({
 981+        body: input.body ?? null,
 982+        headers: normalizeHeaderRecord(input.headers),
 983+        method: normalizeOptionalString(input.method)?.toUpperCase() ?? "GET",
 984+        path,
 985+        platform,
 986+        response_mode: "sse",
 987+        stream_id: streamId,
 988+        timeout_ms: normalizeTimeoutMs(
 989+          input.timeoutMs,
 990+          DEFAULT_FIREFOX_API_REQUEST_TIMEOUT_MS
 991+        )
 992+      }),
 993+      {
 994+        clientId: input.clientId,
 995+        idleTimeoutMs: input.idleTimeoutMs,
 996+        maxBufferedBytes: input.maxBufferedBytes,
 997+        maxBufferedEvents: input.maxBufferedEvents,
 998+        openTimeoutMs: input.openTimeoutMs,
 999+        requestId,
1000+        streamId
1001+      }
1002+    );
1003+  }
1004+
1005+  cancelApiRequest(input: FirefoxRequestCancelCommandInput): FirefoxBridgeCancelReceipt {
1006+    return this.cancelRequest(input);
1007+  }
1008+
1009+  cancelRequest(input: FirefoxRequestCancelCommandInput): FirefoxBridgeCancelReceipt {
1010+    const requestId = normalizeOptionalString(input.requestId);
1011+
1012+    if (requestId == null) {
1013+      throw new Error("Firefox bridge cancel_request requires a non-empty requestId.");
1014+    }
1015+
1016+    return this.broker.cancelRequest({
1017+      ...input,
1018+      requestId
1019+    });
1020+  }
1021+
1022   handleApiResponse(connectionId: string, payload: FirefoxApiResponsePayload): boolean {
1023     return this.broker.handleApiResponse(connectionId, {
1024       body: payload.body,
1025@@ -501,6 +1335,31 @@ export class FirefoxBridgeService {
1026     });
1027   }
1028 
1029+  handleStreamOpen(connectionId: string, payload: FirefoxStreamOpenPayload): boolean {
1030+    return this.broker.handleStreamOpen(connectionId, {
1031+      ...payload,
1032+      status: normalizeStatus(payload.status)
1033+    });
1034+  }
1035+
1036+  handleStreamEvent(connectionId: string, payload: FirefoxStreamEventPayload): boolean {
1037+    return this.broker.handleStreamEvent(connectionId, payload);
1038+  }
1039+
1040+  handleStreamEnd(connectionId: string, payload: FirefoxStreamEndPayload): boolean {
1041+    return this.broker.handleStreamEnd(connectionId, {
1042+      ...payload,
1043+      status: normalizeStatus(payload.status)
1044+    });
1045+  }
1046+
1047+  handleStreamError(connectionId: string, payload: FirefoxStreamErrorPayload): boolean {
1048+    return this.broker.handleStreamError(connectionId, {
1049+      ...payload,
1050+      status: normalizeStatus(payload.status)
1051+    });
1052+  }
1053+
1054   handleConnectionClosed(event: FirefoxBridgeConnectionClosedEvent): void {
1055     this.broker.handleConnectionClosed(event);
1056   }
M apps/conductor-daemon/src/firefox-ws.ts
+115, -1
  1@@ -859,6 +859,18 @@ export class ConductorFirefoxWebSocketServer {
  2       case "api_response":
  3         this.handleApiResponse(connection, message);
  4         return;
  5+      case "stream_open":
  6+        this.handleStreamOpen(connection, message);
  7+        return;
  8+      case "stream_event":
  9+        this.handleStreamEvent(connection, message);
 10+        return;
 11+      case "stream_end":
 12+        this.handleStreamEnd(connection, message);
 13+        return;
 14+      case "stream_error":
 15+        this.handleStreamError(connection, message);
 16+        return;
 17       default:
 18         this.sendError(connection, "unsupported_message_type", `Unsupported WS message type: ${type}.`);
 19     }
 20@@ -899,7 +911,11 @@ export class ConductorFirefoxWebSocketServer {
 21           "credentials",
 22           "api_endpoints",
 23           "client_log",
 24-          "api_response"
 25+          "api_response",
 26+          "stream_open",
 27+          "stream_event",
 28+          "stream_end",
 29+          "stream_error"
 30         ],
 31         outbound: [
 32           "hello_ack",
 33@@ -907,8 +923,13 @@ export class ConductorFirefoxWebSocketServer {
 34           "action_result",
 35           "request_credentials",
 36           "open_tab",
 37+          "plugin_status",
 38+          "ws_reconnect",
 39+          "controller_reload",
 40+          "tab_restore",
 41           "reload",
 42           "api_request",
 43+          "request_cancel",
 44           "error"
 45         ]
 46       }
 47@@ -1153,6 +1174,99 @@ export class ConductorFirefoxWebSocketServer {
 48     }
 49   }
 50 
 51+  private handleStreamOpen(
 52+    connection: FirefoxWebSocketConnection,
 53+    message: Record<string, unknown>
 54+  ): void {
 55+    const id = readFirstString(message, ["id", "requestId", "request_id"]);
 56+
 57+    if (id == null) {
 58+      this.sendError(connection, "invalid_message", "stream_open requires a non-empty id field.");
 59+      return;
 60+    }
 61+
 62+    this.bridgeService.handleStreamOpen(connection.getConnectionId(), {
 63+      id,
 64+      meta: message.meta ?? null,
 65+      status:
 66+        typeof message.status === "number" && Number.isFinite(message.status)
 67+          ? Math.round(message.status)
 68+          : null,
 69+      streamId: readFirstString(message, ["streamId", "stream_id"])
 70+    });
 71+  }
 72+
 73+  private handleStreamEvent(
 74+    connection: FirefoxWebSocketConnection,
 75+    message: Record<string, unknown>
 76+  ): void {
 77+    const id = readFirstString(message, ["id", "requestId", "request_id"]);
 78+    const rawSeq = message.seq;
 79+
 80+    if (id == null) {
 81+      this.sendError(connection, "invalid_message", "stream_event requires a non-empty id field.");
 82+      return;
 83+    }
 84+
 85+    if (typeof rawSeq !== "number" || !Number.isFinite(rawSeq) || rawSeq <= 0) {
 86+      this.sendError(connection, "invalid_message", "stream_event requires a positive numeric seq field.");
 87+      return;
 88+    }
 89+
 90+    this.bridgeService.handleStreamEvent(connection.getConnectionId(), {
 91+      data: message.data ?? null,
 92+      event: readFirstString(message, ["event"]),
 93+      id,
 94+      raw: readFirstString(message, ["raw", "chunk"]),
 95+      seq: Math.round(rawSeq),
 96+      streamId: readFirstString(message, ["streamId", "stream_id"])
 97+    });
 98+  }
 99+
100+  private handleStreamEnd(
101+    connection: FirefoxWebSocketConnection,
102+    message: Record<string, unknown>
103+  ): void {
104+    const id = readFirstString(message, ["id", "requestId", "request_id"]);
105+
106+    if (id == null) {
107+      this.sendError(connection, "invalid_message", "stream_end requires a non-empty id field.");
108+      return;
109+    }
110+
111+    this.bridgeService.handleStreamEnd(connection.getConnectionId(), {
112+      id,
113+      status:
114+        typeof message.status === "number" && Number.isFinite(message.status)
115+          ? Math.round(message.status)
116+          : null,
117+      streamId: readFirstString(message, ["streamId", "stream_id"])
118+    });
119+  }
120+
121+  private handleStreamError(
122+    connection: FirefoxWebSocketConnection,
123+    message: Record<string, unknown>
124+  ): void {
125+    const id = readFirstString(message, ["id", "requestId", "request_id"]);
126+
127+    if (id == null) {
128+      this.sendError(connection, "invalid_message", "stream_error requires a non-empty id field.");
129+      return;
130+    }
131+
132+    this.bridgeService.handleStreamError(connection.getConnectionId(), {
133+      code: readFirstString(message, ["code", "error"]),
134+      id,
135+      message: readFirstString(message, ["message"]),
136+      status:
137+        typeof message.status === "number" && Number.isFinite(message.status)
138+          ? Math.round(message.status)
139+          : null,
140+      streamId: readFirstString(message, ["streamId", "stream_id"])
141+    });
142+  }
143+
144   private async refreshBrowserLoginStateStatuses(): Promise<void> {
145     await this.repository.markBrowserLoginStatesLost(
146       this.now() - Math.ceil(FIREFOX_WS_LOGIN_STATE_LOST_AFTER_MS / 1000)
M apps/conductor-daemon/src/http-types.ts
+2, -0
 1@@ -3,12 +3,14 @@ export interface ConductorHttpRequest {
 2   headers?: Record<string, string | undefined>;
 3   method: string;
 4   path: string;
 5+  signal?: AbortSignal;
 6 }
 7 
 8 export interface ConductorHttpResponse {
 9   body: string;
10   headers: Record<string, string>;
11   status: number;
12+  streamBody?: AsyncIterable<string> | null;
13 }
14 
15 export const JSON_RESPONSE_HEADERS = {
M apps/conductor-daemon/src/index.test.js
+189, -3
  1@@ -467,6 +467,37 @@ function parseJsonBody(response) {
  2   return JSON.parse(response.body);
  3 }
  4 
  5+async function readResponseBodyText(response) {
  6+  let text = response.body;
  7+
  8+  if (response.streamBody != null) {
  9+    for await (const chunk of response.streamBody) {
 10+      text += chunk;
 11+    }
 12+  }
 13+
 14+  return text;
 15+}
 16+
 17+function parseSseFrames(text) {
 18+  return String(text || "")
 19+    .split(/\n\n+/u)
 20+    .map((chunk) => chunk.trim())
 21+    .filter(Boolean)
 22+    .map((chunk) => {
 23+      const lines = chunk.split("\n");
 24+      const eventLine = lines.find((line) => line.startsWith("event:"));
 25+      const dataLines = lines
 26+        .filter((line) => line.startsWith("data:"))
 27+        .map((line) => line.slice(5).trimStart());
 28+
 29+      return {
 30+        data: JSON.parse(dataLines.join("\n")),
 31+        event: eventLine ? eventLine.slice(6).trim() : null
 32+      };
 33+    });
 34+}
 35+
 36 function createBrowserBridgeStub() {
 37   const calls = [];
 38   const browserState = {
 39@@ -535,6 +566,25 @@ function createBrowserBridgeStub() {
 40     status
 41   });
 42 
 43+  const buildApiStream = ({ events, input }) => ({
 44+    clientId: input.clientId || "firefox-claude",
 45+    connectionId: "conn-firefox-claude",
 46+    requestId: input.id || `browser-stream-${calls.length + 1}`,
 47+    streamId: input.streamId || input.id || `browser-stream-${calls.length + 1}`,
 48+    cancel(reason = null) {
 49+      calls.push({
 50+        id: input.id,
 51+        kind: "streamCancel",
 52+        reason
 53+      });
 54+    },
 55+    async *[Symbol.asyncIterator]() {
 56+      for (const event of events) {
 57+        yield event;
 58+      }
 59+    }
 60+  });
 61+
 62   return {
 63     calls,
 64     context: {
 65@@ -614,6 +664,35 @@ function createBrowserBridgeStub() {
 66 
 67           throw new Error(`unexpected browser proxy path: ${input.path}`);
 68         },
 69+        cancelApiRequest(input = {}) {
 70+          calls.push({
 71+            ...input,
 72+            kind: "cancelApiRequest"
 73+          });
 74+
 75+          return {
 76+            clientId: input.clientId || "firefox-claude",
 77+            connectionId: "conn-firefox-claude",
 78+            dispatchedAt: 1710000004500,
 79+            reason: input.reason || null,
 80+            requestId: input.requestId || "browser-cancel",
 81+            streamId: input.streamId || null,
 82+            type: "request_cancel"
 83+          };
 84+        },
 85+        dispatchPluginAction(input = {}) {
 86+          calls.push({
 87+            ...input,
 88+            kind: "dispatchPluginAction"
 89+          });
 90+
 91+          return {
 92+            clientId: input.clientId || "firefox-claude",
 93+            connectionId: "conn-firefox-claude",
 94+            dispatchedAt: 1710000004750,
 95+            type: input.action || "plugin_status"
 96+          };
 97+        },
 98         openTab(input = {}) {
 99           calls.push({
100             ...input,
101@@ -652,6 +731,63 @@ function createBrowserBridgeStub() {
102             dispatchedAt: 1710000007000,
103             type: "request_credentials"
104           };
105+        },
106+        streamRequest(input) {
107+          calls.push({
108+            ...input,
109+            kind: "streamRequest"
110+          });
111+
112+          return buildApiStream({
113+            input,
114+            events: [
115+              {
116+                clientId: input.clientId || "firefox-claude",
117+                connectionId: "conn-firefox-claude",
118+                meta: {
119+                  path: input.path
120+                },
121+                openedAt: 1710000008000,
122+                requestId: input.id || "browser-stream-1",
123+                status: 200,
124+                streamId: input.streamId || input.id || "browser-stream-1",
125+                type: "stream_open"
126+              },
127+              {
128+                clientId: input.clientId || "firefox-claude",
129+                connectionId: "conn-firefox-claude",
130+                data: {
131+                  type: "content_block_delta",
132+                  delta: {
133+                    type: "text_delta",
134+                    text: "hello from claude stream"
135+                  }
136+                },
137+                event: "message",
138+                raw: 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"hello from claude stream"}}',
139+                receivedAt: 1710000008001,
140+                requestId: input.id || "browser-stream-1",
141+                seq: 1,
142+                streamId: input.streamId || input.id || "browser-stream-1",
143+                type: "stream_event"
144+              },
145+              {
146+                clientId: input.clientId || "firefox-claude",
147+                connectionId: "conn-firefox-claude",
148+                endedAt: 1710000008002,
149+                partial: {
150+                  buffered_bytes: 120,
151+                  event_count: 1,
152+                  last_seq: 1,
153+                  opened: true
154+                },
155+                requestId: input.id || "browser-stream-1",
156+                status: 200,
157+                streamId: input.streamId || input.id || "browser-stream-1",
158+                type: "stream_end"
159+              }
160+            ]
161+          });
162         }
163       },
164       browserStateLoader: () => browserState
165@@ -1428,6 +1564,10 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
166     assert.doesNotMatch(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/runs/u);
167     assert.equal(businessDescribePayload.data.codex.backend, "independent_codexd");
168     assert.equal(businessDescribePayload.data.browser.request_contract.route.path, "/v1/browser/request");
169+    assert.match(
170+      JSON.stringify(businessDescribePayload.data.browser.request_contract.supported_response_modes),
171+      /"sse"/u
172+    );
173 
174     const controlDescribeResponse = await handleConductorHttpRequest(
175       {
176@@ -1506,6 +1646,20 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
177     assert.equal(browserActionsResponse.status, 200);
178     assert.equal(parseJsonBody(browserActionsResponse).data.action, "tab_open");
179 
180+    const browserPluginActionResponse = await handleConductorHttpRequest(
181+      {
182+        body: JSON.stringify({
183+          action: "plugin_status",
184+          client_id: "firefox-claude"
185+        }),
186+        method: "POST",
187+        path: "/v1/browser/actions"
188+      },
189+      localApiContext
190+    );
191+    assert.equal(browserPluginActionResponse.status, 200);
192+    assert.equal(parseJsonBody(browserPluginActionResponse).data.action, "plugin_status");
193+
194     const browserRequestResponse = await handleConductorHttpRequest(
195       {
196         body: JSON.stringify({
197@@ -1526,21 +1680,48 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
198       browserRequestPayload.data.proxy.path,
199       "/api/organizations/org-1/chat_conversations/conv-1/completion"
200     );
201+    assert.equal(browserRequestPayload.data.policy.target_client_id, "firefox-claude");
202+
203+    const browserStreamResponse = await handleConductorHttpRequest(
204+      {
205+        body: JSON.stringify({
206+          platform: "claude",
207+          prompt: "hello browser stream",
208+          responseMode: "sse",
209+          requestId: "browser-stream-123"
210+        }),
211+        method: "POST",
212+        path: "/v1/browser/request"
213+      },
214+      localApiContext
215+    );
216+    assert.equal(browserStreamResponse.status, 200);
217+    assert.equal(browserStreamResponse.headers["content-type"], "text/event-stream; charset=utf-8");
218+    const browserStreamText = await readResponseBodyText(browserStreamResponse);
219+    const browserStreamFrames = parseSseFrames(browserStreamText);
220+    assert.deepEqual(
221+      browserStreamFrames.map((frame) => frame.event),
222+      ["stream_open", "stream_event", "stream_end"]
223+    );
224+    assert.equal(browserStreamFrames[0].data.request_id, "browser-stream-123");
225+    assert.equal(browserStreamFrames[1].data.seq, 1);
226+    assert.equal(browserStreamFrames[2].data.stream_id, "browser-stream-123");
227 
228     const browserRequestCancelResponse = await handleConductorHttpRequest(
229       {
230         body: JSON.stringify({
231           platform: "claude",
232-          request_id: "browser-123"
233+          request_id: "browser-stream-123"
234         }),
235         method: "POST",
236         path: "/v1/browser/request/cancel"
237       },
238       localApiContext
239     );
240-    assert.equal(browserRequestCancelResponse.status, 501);
241+    assert.equal(browserRequestCancelResponse.status, 200);
242     const browserRequestCancelPayload = parseJsonBody(browserRequestCancelResponse);
243-    assert.equal(browserRequestCancelPayload.error, "browser_request_cancel_not_supported");
244+    assert.equal(browserRequestCancelPayload.data.status, "cancel_requested");
245+    assert.equal(browserRequestCancelPayload.data.type, "request_cancel");
246 
247     const browserOpenResponse = await handleConductorHttpRequest(
248       {
249@@ -1935,9 +2116,14 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
250     browser.calls.map((entry) => entry.kind === "apiRequest" ? `${entry.kind}:${entry.method}:${entry.path}` : `${entry.kind}:${entry.platform || "-"}`),
251     [
252       "openTab:claude",
253+      "dispatchPluginAction:-",
254       "apiRequest:GET:/api/organizations",
255       "apiRequest:GET:/api/organizations/org-1/chat_conversations",
256       "apiRequest:POST:/api/organizations/org-1/chat_conversations/conv-1/completion",
257+      "apiRequest:GET:/api/organizations",
258+      "apiRequest:GET:/api/organizations/org-1/chat_conversations",
259+      "streamRequest:claude",
260+      "cancelApiRequest:claude",
261       "openTab:claude",
262       "apiRequest:GET:/api/organizations",
263       "apiRequest:GET:/api/organizations/org-1/chat_conversations",
M apps/conductor-daemon/src/index.ts
+76, -8
  1@@ -15,6 +15,10 @@ import {
  2   ConductorFirefoxWebSocketServer,
  3   buildFirefoxWebSocketUrl
  4 } from "./firefox-ws.js";
  5+import {
  6+  BrowserRequestPolicyController,
  7+  type BrowserRequestPolicyControllerOptions
  8+} from "./browser-request-policy.js";
  9 import type { FirefoxBridgeService } from "./firefox-bridge.js";
 10 import { handleConductorHttpRequest as handleConductorLocalHttpRequest } from "./local-api.js";
 11 import { ConductorLocalControlPlane } from "./local-control-plane.js";
 12@@ -31,6 +35,10 @@ export {
 13   type FirefoxReloadCommandInput,
 14   type FirefoxRequestCredentialsCommandInput
 15 } from "./firefox-bridge.js";
 16+export {
 17+  BrowserRequestPolicyController,
 18+  type BrowserRequestPolicyControllerOptions
 19+} from "./browser-request-policy.js";
 20 export { handleConductorHttpRequest } from "./local-api.js";
 21 
 22 export type ConductorRole = "primary" | "standby";
 23@@ -229,7 +237,9 @@ export interface ConductorDaemonOptions {
 24   setIntervalImpl?: (handler: () => void, intervalMs: number) => TimerHandle;
 25 }
 26 
 27-export interface ConductorRuntimeOptions extends ConductorDaemonOptions {}
 28+export interface ConductorRuntimeOptions extends ConductorDaemonOptions {
 29+  browserRequestPolicyOptions?: BrowserRequestPolicyControllerOptions;
 30+}
 31 
 32 export type ConductorEnvironment = Record<string, string | undefined>;
 33 
 34@@ -523,17 +533,54 @@ function resolveLocalApiListenConfig(localApiBase: string): LocalApiListenConfig
 35   };
 36 }
 37 
 38-function writeHttpResponse(
 39+async function writeHttpResponse(
 40   response: ServerResponse<IncomingMessage>,
 41   payload: ConductorHttpResponse
 42-): void {
 43+): Promise<void> {
 44+  const writableResponse = response as ServerResponse<IncomingMessage> & {
 45+    destroyed?: boolean;
 46+    on?(event: string, listener: () => void): unknown;
 47+    writableEnded?: boolean;
 48+    write?(chunk: string): boolean;
 49+  };
 50   response.statusCode = payload.status;
 51 
 52   for (const [name, value] of Object.entries(payload.headers)) {
 53     response.setHeader(name, value);
 54   }
 55 
 56-  response.end(payload.body);
 57+  if (payload.streamBody == null) {
 58+    response.end(payload.body);
 59+    return;
 60+  }
 61+
 62+  if (payload.body !== "" && typeof writableResponse.write === "function") {
 63+    if (!writableResponse.write(payload.body)) {
 64+      await new Promise<void>((resolve) => {
 65+        writableResponse.on?.("drain", resolve);
 66+      });
 67+    }
 68+  }
 69+
 70+  for await (const chunk of payload.streamBody) {
 71+    if (writableResponse.destroyed === true) {
 72+      break;
 73+    }
 74+
 75+    if (typeof writableResponse.write !== "function") {
 76+      continue;
 77+    }
 78+
 79+    if (!writableResponse.write(chunk)) {
 80+      await new Promise<void>((resolve) => {
 81+        writableResponse.on?.("drain", resolve);
 82+      });
 83+    }
 84+  }
 85+
 86+  if (writableResponse.writableEnded !== true) {
 87+    response.end();
 88+  }
 89 }
 90 
 91 async function readIncomingRequestBody(request: IncomingMessage): Promise<string | null> {
 92@@ -576,6 +623,7 @@ function normalizeIncomingRequestHeaders(
 93 }
 94 
 95 class ConductorLocalHttpServer {
 96+  private readonly browserRequestPolicy: BrowserRequestPolicyController;
 97   private readonly codexdLocalApiBase: string | null;
 98   private readonly fetchImpl: typeof fetch;
 99   private readonly firefoxWebSocketServer: ConductorFirefoxWebSocketServer;
100@@ -596,8 +644,10 @@ class ConductorLocalHttpServer {
101     fetchImpl: typeof fetch,
102     sharedToken: string | null,
103     version: string | null,
104-    now: () => number
105+    now: () => number,
106+    browserRequestPolicyOptions: BrowserRequestPolicyControllerOptions = {}
107   ) {
108+    this.browserRequestPolicy = new BrowserRequestPolicyController(browserRequestPolicyOptions);
109     this.codexdLocalApiBase = codexdLocalApiBase;
110     this.fetchImpl = fetchImpl;
111     this.localApiBase = localApiBase;
112@@ -635,15 +685,32 @@ class ConductorLocalHttpServer {
113     const listenConfig = resolveLocalApiListenConfig(this.localApiBase);
114     const server = createServer((request, response) => {
115       void (async () => {
116+        const requestAbortController = new AbortController();
117+        const abortRequest = () => {
118+          if (!requestAbortController.signal.aborted) {
119+            requestAbortController.abort();
120+          }
121+        };
122+        const requestWithExtendedEvents = request as IncomingMessage & {
123+          on?(event: string, listener: () => void): unknown;
124+        };
125+        const responseWithExtendedEvents = response as ServerResponse<IncomingMessage> & {
126+          on?(event: string, listener: () => void): unknown;
127+        };
128+        requestWithExtendedEvents.on?.("aborted", abortRequest);
129+        responseWithExtendedEvents.on?.("close", abortRequest);
130+
131         const payload = await handleConductorLocalHttpRequest(
132           {
133             body: await readIncomingRequestBody(request),
134             headers: normalizeIncomingRequestHeaders(request.headers),
135             method: request.method ?? "GET",
136-            path: request.url ?? "/"
137+            path: request.url ?? "/",
138+            signal: requestAbortController.signal
139           },
140           {
141             browserBridge: this.firefoxWebSocketServer.getBridgeService(),
142+            browserRequestPolicy: this.browserRequestPolicy,
143             browserStateLoader: () => this.firefoxWebSocketServer.getStateSnapshot(),
144             codexdLocalApiBase: this.codexdLocalApiBase,
145             fetchImpl: this.fetchImpl,
146@@ -654,7 +721,7 @@ class ConductorLocalHttpServer {
147           }
148         );
149 
150-        writeHttpResponse(response, payload);
151+        await writeHttpResponse(response, payload);
152       })().catch((error: unknown) => {
153         response.statusCode = 500;
154         response.setHeader("cache-control", "no-store");
155@@ -1899,7 +1966,8 @@ export class ConductorRuntime {
156             options.fetchImpl ?? globalThis.fetch,
157             this.config.sharedToken,
158             this.config.version,
159-            this.now
160+            this.now,
161+            options.browserRequestPolicyOptions
162           );
163   }
164 
M apps/conductor-daemon/src/local-api.ts
+899, -173
   1@@ -1,3 +1,4 @@
   2+import { randomUUID } from "node:crypto";
   3 import {
   4   AUTOMATION_STATE_KEY,
   5   DEFAULT_AUTOMATION_MODE,
   6@@ -40,19 +41,29 @@ import {
   7   type ConductorHttpResponse
   8 } from "./http-types.js";
   9 import type {
  10+  BrowserBridgeApiStream,
  11   BrowserBridgeApiResponse,
  12   BrowserBridgeClientSnapshot,
  13   BrowserBridgeController,
  14   BrowserBridgeCredentialSnapshot,
  15   BrowserBridgeRequestHookSnapshot,
  16+  BrowserBridgeStreamEvent,
  17   BrowserBridgeStateSnapshot
  18 } from "./browser-types.js";
  19+import {
  20+  BrowserRequestPolicyController,
  21+  BrowserRequestPolicyError,
  22+  createDefaultBrowserRequestPolicyConfig,
  23+  type BrowserRequestAdmission,
  24+  type BrowserRequestPolicyLease
  25+} from "./browser-request-policy.js";
  26 
  27 const DEFAULT_LIST_LIMIT = 20;
  28 const DEFAULT_LOG_LIMIT = 200;
  29 const MAX_LIST_LIMIT = 100;
  30 const MAX_LOG_LIMIT = 500;
  31 const DEFAULT_BROWSER_PROXY_TIMEOUT_MS = 20_000;
  32+const DEFAULT_BROWSER_REQUEST_POLICY_CONFIG = createDefaultBrowserRequestPolicyConfig();
  33 const TASK_STATUS_SET = new Set<TaskStatus>(TASK_STATUS_VALUES);
  34 const BROWSER_LOGIN_STATUS_SET = new Set<BrowserLoginStateStatus>(["fresh", "stale", "lost"]);
  35 const CODEXD_LOCAL_API_ENV = "BAA_CODEXD_LOCAL_API_BASE";
  36@@ -70,6 +81,10 @@ const STATUS_VIEW_HTML_HEADERS = {
  37   "cache-control": "no-store",
  38   "content-type": "text/html; charset=utf-8"
  39 } as const;
  40+const SSE_RESPONSE_HEADERS = {
  41+  "cache-control": "no-store",
  42+  "content-type": "text/event-stream; charset=utf-8"
  43+} as const;
  44 const BROWSER_CLAUDE_PLATFORM = "claude";
  45 const BROWSER_CLAUDE_ROOT_URL = "https://claude.ai/";
  46 const BROWSER_CLAUDE_ORGANIZATIONS_PATH = "/api/organizations";
  47@@ -77,19 +92,18 @@ const BROWSER_CLAUDE_CONVERSATIONS_PATH = "/api/organizations/{id}/chat_conversa
  48 const BROWSER_CLAUDE_CONVERSATION_PATH = "/api/organizations/{id}/chat_conversations/{id}";
  49 const BROWSER_CLAUDE_COMPLETION_PATH = "/api/organizations/{id}/chat_conversations/{id}/completion";
  50 const SUPPORTED_BROWSER_ACTIONS = [
  51+  "controller_reload",
  52+  "plugin_status",
  53   "request_credentials",
  54   "tab_focus",
  55   "tab_open",
  56-  "tab_reload"
  57-] as const;
  58-const RESERVED_BROWSER_ACTIONS = [
  59-  "controller_reload",
  60-  "plugin_status",
  61+  "tab_reload",
  62   "tab_restore",
  63   "ws_reconnect"
  64 ] as const;
  65-const SUPPORTED_BROWSER_REQUEST_RESPONSE_MODES = ["buffered"] as const;
  66-const RESERVED_BROWSER_REQUEST_RESPONSE_MODES = ["sse"] as const;
  67+const RESERVED_BROWSER_ACTIONS = [] as const;
  68+const SUPPORTED_BROWSER_REQUEST_RESPONSE_MODES = ["buffered", "sse"] as const;
  69+const RESERVED_BROWSER_REQUEST_RESPONSE_MODES = [] as const;
  70 
  71 type LocalApiRouteMethod = "GET" | "POST";
  72 type LocalApiRouteKind = "probe" | "read" | "write";
  73@@ -166,6 +180,7 @@ type UpstreamErrorEnvelope = JsonObject & {
  74 
  75 interface LocalApiRequestContext {
  76   browserBridge: BrowserBridgeController | null;
  77+  browserRequestPolicy: BrowserRequestPolicyController | null;
  78   browserStateLoader: () => BrowserBridgeStateSnapshot | null;
  79   codexdLocalApiBase: string | null;
  80   fetchImpl: typeof fetch;
  81@@ -212,6 +227,7 @@ export interface ConductorRuntimeApiSnapshot {
  82 
  83 export interface ConductorLocalApiContext {
  84   browserBridge?: BrowserBridgeController | null;
  85+  browserRequestPolicy?: BrowserRequestPolicyController | null;
  86   browserStateLoader?: (() => BrowserBridgeStateSnapshot | null) | null;
  87   codexdLocalApiBase?: string | null;
  88   fetchImpl?: typeof fetch;
  89@@ -1145,8 +1161,10 @@ interface BrowserActionDispatchResult {
  90 interface BrowserRequestExecutionResult {
  91   client_id: string;
  92   conversation: ClaudeConversationSummary | null;
  93+  lease: BrowserRequestPolicyLease | null;
  94   organization: ClaudeOrganizationSummary | null;
  95   platform: string;
  96+  policy: BrowserRequestAdmission;
  97   request_body: JsonValue | null;
  98   request_id: string;
  99   request_method: string;
 100@@ -1155,6 +1173,7 @@ interface BrowserRequestExecutionResult {
 101   response: JsonValue | string | null;
 102   response_mode: BrowserRequestResponseMode;
 103   status: number | null;
 104+  stream: BrowserBridgeApiStream | null;
 105 }
 106 
 107 function asUnknownRecord(value: unknown): Record<string, unknown> | null {
 108@@ -1263,6 +1282,71 @@ function parseBrowserProxyBody(body: unknown): JsonValue | string | null {
 109   return String(body);
 110 }
 111 
 112+function serializeSseFrame(event: string, data: JsonValue): string {
 113+  const serialized = JSON.stringify(data);
 114+  return `event: ${event}\ndata: ${serialized}\n\n`;
 115+}
 116+
 117+function parseSseJsonValue(value: string): JsonValue | string {
 118+  try {
 119+    return JSON.parse(value) as JsonValue;
 120+  } catch {
 121+    return value;
 122+  }
 123+}
 124+
 125+function parseBrowserSseChunks(
 126+  rawBody: string
 127+): Array<{
 128+  data: JsonValue | string;
 129+  event: string | null;
 130+  raw: string;
 131+}> {
 132+  const chunks = String(rawBody || "")
 133+    .split(/\r?\n\r?\n/gu)
 134+    .map((chunk) => chunk.trim())
 135+    .filter((chunk) => chunk !== "");
 136+
 137+  return chunks.map((chunk) => {
 138+    const lines = chunk.split(/\r?\n/gu);
 139+    let event: string | null = null;
 140+    const dataLines: string[] = [];
 141+
 142+    for (const line of lines) {
 143+      if (line.startsWith("event:")) {
 144+        event = line.slice("event:".length).trim() || null;
 145+        continue;
 146+      }
 147+
 148+      if (line.startsWith("data:")) {
 149+        dataLines.push(line.slice("data:".length).trimStart());
 150+      }
 151+    }
 152+
 153+    const joinedData = dataLines.join("\n");
 154+
 155+    return {
 156+      data: parseSseJsonValue(joinedData === "" ? chunk : joinedData),
 157+      event,
 158+      raw: chunk
 159+    };
 160+  });
 161+}
 162+
 163+function createSseResponse(
 164+  body: string,
 165+  streamBody: AsyncIterable<string> | null = null
 166+): ConductorHttpResponse {
 167+  return {
 168+    status: 200,
 169+    headers: {
 170+      ...SSE_RESPONSE_HEADERS
 171+    },
 172+    body,
 173+    streamBody
 174+  };
 175+}
 176+
 177 function readOptionalQueryString(url: URL, ...fieldNames: string[]): string | undefined {
 178   for (const fieldName of fieldNames) {
 179     const value = url.searchParams.get(fieldName);
 180@@ -1793,6 +1877,13 @@ function createBrowserBridgeHttpError(action: string, error: unknown): LocalApiH
 181         "The requested browser proxy request id is already in flight.",
 182         details
 183       );
 184+    case "request_not_found":
 185+      return new LocalApiHttpError(
 186+        404,
 187+        "browser_request_not_found",
 188+        `The requested browser proxy request is not in flight for ${action}.`,
 189+        details
 190+      );
 191     case "request_timeout":
 192       return new LocalApiHttpError(
 193         504,
 194@@ -1820,6 +1911,33 @@ function createBrowserBridgeHttpError(action: string, error: unknown): LocalApiH
 195   }
 196 }
 197 
 198+function createBrowserPolicyHttpError(action: string, error: BrowserRequestPolicyError): LocalApiHttpError {
 199+  switch (error.code) {
 200+    case "circuit_open":
 201+      return new LocalApiHttpError(
 202+        429,
 203+        "browser_risk_limited",
 204+        `Browser request risk controls rejected ${action}.`,
 205+        compactJsonObject({
 206+          ...error.details,
 207+          action,
 208+          error_code: error.code
 209+        })
 210+      );
 211+    default:
 212+      return new LocalApiHttpError(
 213+        503,
 214+        "browser_risk_limited",
 215+        `Browser request risk controls could not schedule ${action}.`,
 216+        compactJsonObject({
 217+          ...error.details,
 218+          action,
 219+          error_code: error.code
 220+        })
 221+      );
 222+  }
 223+}
 224+
 225 function requireBrowserBridge(context: LocalApiRequestContext): BrowserBridgeController {
 226   if (context.browserBridge == null) {
 227     throw new LocalApiHttpError(
 228@@ -1832,6 +1950,59 @@ function requireBrowserBridge(context: LocalApiRequestContext): BrowserBridgeCon
 229   return context.browserBridge;
 230 }
 231 
 232+function resolveBrowserRequestPolicy(context: LocalApiRequestContext): BrowserRequestPolicyController {
 233+  return context.browserRequestPolicy ?? new BrowserRequestPolicyController({
 234+    config: DEFAULT_BROWSER_REQUEST_POLICY_CONFIG
 235+  });
 236+}
 237+
 238+function selectBrowserClient(
 239+  state: BrowserBridgeStateSnapshot,
 240+  requestedClientId?: string | null
 241+): BrowserBridgeClientSnapshot | null {
 242+  const normalizedRequestedClientId = normalizeOptionalString(requestedClientId);
 243+
 244+  if (normalizedRequestedClientId != null) {
 245+    return state.clients.find((entry) => entry.client_id === normalizedRequestedClientId) ?? null;
 246+  }
 247+
 248+  return (
 249+    state.clients.find((entry) => entry.client_id === state.active_client_id)
 250+    ?? [...state.clients].sort((left, right) => right.last_message_at - left.last_message_at)[0]
 251+    ?? null
 252+  );
 253+}
 254+
 255+function ensureBrowserClientReady(
 256+  client: BrowserBridgeClientSnapshot | null,
 257+  platform: string,
 258+  requestedClientId?: string | null
 259+): BrowserBridgeClientSnapshot {
 260+  if (client != null) {
 261+    return client;
 262+  }
 263+
 264+  const normalizedRequestedClientId = normalizeOptionalString(requestedClientId);
 265+
 266+  if (normalizedRequestedClientId == null) {
 267+    throw new LocalApiHttpError(
 268+      503,
 269+      "browser_bridge_unavailable",
 270+      `No active Firefox bridge client is connected for ${platform} requests.`
 271+    );
 272+  }
 273+
 274+  throw new LocalApiHttpError(
 275+    409,
 276+    "browser_client_not_found",
 277+    `Firefox bridge client "${normalizedRequestedClientId}" is not connected.`,
 278+    compactJsonObject({
 279+      client_id: normalizedRequestedClientId,
 280+      platform
 281+    })
 282+  );
 283+}
 284+
 285 function ensureClaudeBridgeReady(
 286   selection: ClaudeBrowserSelection,
 287   requestedClientId?: string | null
 288@@ -2255,6 +2426,29 @@ async function resolveClaudeOrganization(
 289   return currentOrganization ?? organizations[0]!;
 290 }
 291 
 292+function resolveBrowserRequestId(requestId?: string | null): string {
 293+  return normalizeOptionalString(requestId) ?? randomUUID();
 294+}
 295+
 296+async function beginBrowserRequestLease(
 297+  context: LocalApiRequestContext,
 298+  target: {
 299+    clientId: string;
 300+    platform: string;
 301+  },
 302+  requestId: string
 303+): Promise<BrowserRequestPolicyLease> {
 304+  try {
 305+    return await resolveBrowserRequestPolicy(context).beginRequest(target, requestId);
 306+  } catch (error) {
 307+    if (error instanceof BrowserRequestPolicyError) {
 308+      throw createBrowserPolicyHttpError("browser request", error);
 309+    }
 310+
 311+    throw error;
 312+  }
 313+}
 314+
 315 async function listClaudeConversations(
 316   context: LocalApiRequestContext,
 317   selection: ClaudeBrowserSelection,
 318@@ -2419,6 +2613,7 @@ async function readClaudeConversationCurrentData(
 319 
 320 async function buildBrowserStatusData(context: LocalApiRequestContext): Promise<JsonObject> {
 321   const browserState = loadBrowserState(context);
 322+  const policySnapshot = resolveBrowserRequestPolicy(context).getSnapshot();
 323   const filters = readBrowserStatusFilters(context.url);
 324   const records = await listBrowserMergedRecords(context, browserState, filters);
 325   const currentClient =
 326@@ -2468,6 +2663,40 @@ async function buildBrowserStatusData(context: LocalApiRequestContext): Promise<
 327       supported: true
 328     },
 329     filters: summarizeBrowserFilters(filters),
 330+    policy: {
 331+      defaults: {
 332+        backoff: policySnapshot.defaults.backoff,
 333+        circuit_breaker: policySnapshot.defaults.circuitBreaker,
 334+        concurrency: policySnapshot.defaults.concurrency,
 335+        jitter: policySnapshot.defaults.jitter,
 336+        rate_limit: policySnapshot.defaults.rateLimit,
 337+        stream: {
 338+          idle_timeout_ms: policySnapshot.defaults.stream.idleTimeoutMs,
 339+          max_buffered_bytes: policySnapshot.defaults.stream.maxBufferedBytes,
 340+          max_buffered_events: policySnapshot.defaults.stream.maxBufferedEvents,
 341+          open_timeout_ms: policySnapshot.defaults.stream.openTimeoutMs
 342+        }
 343+      },
 344+      platforms: policySnapshot.platforms.map((entry) => compactJsonObject({
 345+        last_dispatched_at: entry.lastDispatchedAt ?? undefined,
 346+        platform: entry.platform,
 347+        recent_dispatch_count: entry.recentDispatchCount,
 348+        waiting: entry.waiting
 349+      })),
 350+      targets: policySnapshot.targets.map((entry) => compactJsonObject({
 351+        backoff_until: entry.backoffUntil ?? undefined,
 352+        circuit_retry_at: entry.circuitRetryAt ?? undefined,
 353+        circuit_state: entry.circuitState,
 354+        client_id: entry.clientId,
 355+        consecutive_failures: entry.consecutiveFailures,
 356+        in_flight: entry.inFlight,
 357+        last_error: entry.lastError ?? undefined,
 358+        last_failure_at: entry.lastFailureAt ?? undefined,
 359+        last_success_at: entry.lastSuccessAt ?? undefined,
 360+        platform: entry.platform,
 361+        waiting: entry.waiting
 362+      }))
 363+    },
 364     records: records.map((record) =>
 365       serializeBrowserMergedRecord(record, browserState.active_client_id)
 366     ),
 367@@ -2693,14 +2922,23 @@ function buildFirefoxWebSocketData(snapshot: ConductorRuntimeApiSnapshot): JsonO
 368       "credentials",
 369       "api_endpoints",
 370       "client_log",
 371-      "api_response"
 372+      "api_response",
 373+      "stream_open",
 374+      "stream_event",
 375+      "stream_end",
 376+      "stream_error"
 377     ],
 378     outbound_messages: [
 379       "hello_ack",
 380       "state_snapshot",
 381       "action_result",
 382       "open_tab",
 383+      "plugin_status",
 384+      "ws_reconnect",
 385+      "controller_reload",
 386+      "tab_restore",
 387       "api_request",
 388+      "request_cancel",
 389       "request_credentials",
 390       "reload",
 391       "error"
 392@@ -2717,10 +2955,10 @@ function buildBrowserActionContract(origin: string): JsonObject {
 393     route: describeRoute(requireRouteDefinition("browser.actions")),
 394     request_body: {
 395       action:
 396-        "必填字符串。当前正式支持 request_credentials、tab_open、tab_focus、tab_reload;plugin_status、ws_reconnect、controller_reload、tab_restore 已保留进合同但当前返回 501。",
 397-      platform: "tab_open、tab_focus、request_credentials 建议带非空平台字符串;当前正式平台仍是 claude。",
 398+        "必填字符串。当前正式支持 plugin_status、request_credentials、tab_open、tab_focus、tab_reload、tab_restore、ws_reconnect、controller_reload。",
 399+      platform: "tab_open、tab_focus、request_credentials、tab_restore 建议带非空平台字符串;当前正式平台仍是 claude。",
 400       clientId: "可选字符串;指定目标 Firefox bridge client。",
 401-      reason: "可选字符串;tab_reload 和 request_credentials 会原样透传给浏览器侧。"
 402+      reason: "可选字符串;request_credentials、tab_reload、tab_restore、ws_reconnect、controller_reload 会原样透传给浏览器侧。"
 403     },
 404     supported_actions: [...SUPPORTED_BROWSER_ACTIONS],
 405     reserved_actions: [...RESERVED_BROWSER_ACTIONS],
 406@@ -2743,8 +2981,7 @@ function buildBrowserActionContract(origin: string): JsonObject {
 407     ],
 408     error_semantics: [
 409       "503 browser_bridge_unavailable: 当前没有可用 Firefox bridge client。",
 410-      "409 browser_client_not_found: 指定的 clientId 当前未连接。",
 411-      "501 browser_action_not_supported: 合同已预留,但当前 bridge 还没有实现该管理动作。"
 412+      "409 browser_client_not_found: 指定的 clientId 当前未连接。"
 413     ]
 414   };
 415 }
 416@@ -2764,7 +3001,7 @@ function buildBrowserRequestContract(origin: string): JsonObject {
 417       organizationId: "可选 Claude 字段;覆盖自动选择的 organization。",
 418       conversationId: "可选 Claude 字段;覆盖自动选择的 conversation。",
 419       responseMode:
 420-        '可选字符串 buffered 或 sse;当前正式只实现 buffered,sse 会返回 501。',
 421+        '可选字符串 buffered 或 sse;buffered 返回 JSON,sse 返回 text/event-stream,并按 stream_open / stream_event / stream_end / stream_error 编码。',
 422       timeoutMs: `可选整数 > 0;默认 ${DEFAULT_BROWSER_PROXY_TIMEOUT_MS}。`
 423     },
 424     supported_response_modes: [...SUPPORTED_BROWSER_REQUEST_RESPONSE_MODES],
 425@@ -2790,8 +3027,7 @@ function buildBrowserRequestContract(origin: string): JsonObject {
 426       "400 invalid_request: 缺字段或组合不合法;例如既没有 path 也没有 Claude prompt。",
 427       "409 claude_credentials_unavailable: Claude prompt 模式还没有捕获到可用凭证。",
 428       "503 browser_bridge_unavailable: 当前没有活跃 Firefox bridge client。",
 429-      "4xx/5xx browser_upstream_error: 浏览器本地代理已返回上游 HTTP 错误。",
 430-      "501 browser_streaming_not_supported: responseMode=sse 已入合同,但当前 HTTP 面还没有实现。"
 431+      "4xx/5xx browser_upstream_error: 浏览器本地代理已返回上游 HTTP 错误;responseMode=sse 时会在事件流里交付 stream_error。"
 432     ]
 433   };
 434 }
 435@@ -2802,14 +3038,16 @@ function buildBrowserRequestCancelContract(): JsonObject {
 436     request_body: {
 437       requestId: "必填字符串;对应 /v1/browser/request 的 requestId 或响应里返回的 proxy.request_id。",
 438       platform: "必填字符串;与原始请求平台保持一致。",
 439-      clientId: "可选字符串;用于未来精确定位执行侧。",
 440+      clientId: "可选字符串;优先用于校验调用方期望的执行侧。",
 441       reason: "可选字符串;取消原因。"
 442     },
 443-    current_state: "reserved",
 444-    implementation_status: "当前返回 501,等待 browser bridge 增加 request_cancel plumbing。",
 445+    current_state: "active",
 446+    implementation_status: "会把 cancel 请求转发给当前执行中的 Firefox bridge client,并让原始 request 尽快以取消错误结束。",
 447     error_semantics: [
 448       "400 invalid_request: requestId 或 platform 缺失。",
 449-      "501 browser_request_cancel_not_supported: 合同已固定,但本轮不实现真实取消链路。"
 450+      "404 browser_request_not_found: 对应 requestId 当前不在执行中。",
 451+      "409 browser_request_client_mismatch: 指定了 clientId,但与实际执行中的 client 不一致。",
 452+      "503 browser_bridge_unavailable: 当前执行中的 Firefox bridge client 已断开。"
 453     ]
 454   };
 455 }
 456@@ -2846,7 +3084,7 @@ function buildBrowserHttpData(snapshot: ConductorRuntimeApiSnapshot, origin: str
 457       "Business-facing browser work now lands on POST /v1/browser/request; browser/plugin management lands on POST /v1/browser/actions.",
 458       "GET /v1/browser remains the shared read model for login-state metadata and plugin connectivity.",
 459       "The generic browser HTTP surface currently supports Claude only and expects a local Firefox bridge client.",
 460-      "POST /v1/browser/request with responseMode=sse and POST /v1/browser/request/cancel are already reserved in the contract but intentionally return 501 in this task.",
 461+      "POST /v1/browser/request now supports buffered JSON and formal SSE event envelopes; POST /v1/browser/request/cancel cancels an in-flight browser request by requestId.",
 462       "The /v1/browser/claude/* routes remain available as legacy wrappers during the migration window."
 463     ]
 464   };
 465@@ -3430,7 +3668,7 @@ async function handleScopedDescribeRead(
 466       notes: [
 467         "This surface is intended to be enough for business-query discovery without reading external docs.",
 468         "Use GET /v1/status for the narrow read-only compatibility snapshot and GET /v1/status/ui for the matching HTML panel.",
 469-        "Business-facing browser work now lands on POST /v1/browser/request; POST /v1/browser/request/cancel is already reserved in the contract but currently returns 501.",
 470+        "Business-facing browser work now lands on POST /v1/browser/request; POST /v1/browser/request/cancel cancels an in-flight request by requestId.",
 471         "GET /v1/browser/claude/current and POST /v1/browser/claude/send remain available as legacy Claude helpers during migration.",
 472         "All /v1/codex routes proxy the independent codexd daemon instead of an in-process bridge.",
 473         "If you pivot to /describe/control for /v1/exec or /v1/files/*, those host-ops routes require Authorization: Bearer <BAA_SHARED_TOKEN>.",
 474@@ -3653,6 +3891,278 @@ function serializeClaudeConversationSummary(summary: ClaudeConversationSummary |
 475       };
 476 }
 477 
 478+function serializeBrowserRequestPolicyAdmission(admission: BrowserRequestAdmission): JsonObject {
 479+  return compactJsonObject({
 480+    admitted_at: admission.admittedAt,
 481+    backoff_delay_ms: admission.backoffDelayMs || undefined,
 482+    circuit_state: admission.circuitState,
 483+    jitter_delay_ms: admission.jitterDelayMs || undefined,
 484+    platform: admission.platform,
 485+    queue_delay_ms: admission.queueDelayMs || undefined,
 486+    rate_limit_delay_ms: admission.rateLimitDelayMs || undefined,
 487+    request_id: admission.requestId,
 488+    requested_at: admission.requestedAt,
 489+    target_client_id: admission.targetClientId
 490+  });
 491+}
 492+
 493+function serializeBrowserStreamPartialState(
 494+  partial: {
 495+    buffered_bytes: number;
 496+    event_count: number;
 497+    last_seq: number;
 498+    opened: boolean;
 499+  }
 500+): JsonObject {
 501+  return {
 502+    buffered_bytes: partial.buffered_bytes,
 503+    event_count: partial.event_count,
 504+    last_seq: partial.last_seq,
 505+    opened: partial.opened
 506+  };
 507+}
 508+
 509+function isBrowserRequestCancelledError(error: unknown): boolean {
 510+  if (!(error instanceof Error)) {
 511+    return false;
 512+  }
 513+
 514+  const bridgeCode = readBridgeErrorCode(error);
 515+  return bridgeCode === "request_cancelled" || bridgeCode === "downstream_disconnected";
 516+}
 517+
 518+function buildBrowserRequestLeaseOutcome(error: unknown): {
 519+  code?: string | null;
 520+  message?: string | null;
 521+  status: "cancelled" | "failure";
 522+} {
 523+  if (isBrowserRequestCancelledError(error)) {
 524+    return {
 525+      code: readBridgeErrorCode(error),
 526+      message: error instanceof Error ? error.message : String(error),
 527+      status: "cancelled"
 528+    };
 529+  }
 530+
 531+  return {
 532+    code:
 533+      readBridgeErrorCode(error)
 534+      ?? (error instanceof LocalApiHttpError ? error.error : null),
 535+    message: error instanceof Error ? error.message : String(error),
 536+    status: "failure"
 537+  };
 538+}
 539+
 540+async function nextBrowserStreamResult(
 541+  iterator: AsyncIterator<BrowserBridgeStreamEvent>,
 542+  signal?: AbortSignal
 543+): Promise<IteratorResult<BrowserBridgeStreamEvent> | null> {
 544+  if (signal == null) {
 545+    return await iterator.next();
 546+  }
 547+
 548+  if (signal.aborted) {
 549+    return null;
 550+  }
 551+
 552+  return await new Promise<IteratorResult<BrowserBridgeStreamEvent> | null>((resolve, reject) => {
 553+    const onAbort = () => {
 554+      signal.removeEventListener("abort", onAbort);
 555+      resolve(null);
 556+    };
 557+
 558+    signal.addEventListener("abort", onAbort, {
 559+      once: true
 560+    });
 561+
 562+    iterator.next().then(
 563+      (result) => {
 564+        signal.removeEventListener("abort", onAbort);
 565+        resolve(result);
 566+      },
 567+      (error) => {
 568+        signal.removeEventListener("abort", onAbort);
 569+        reject(error);
 570+      }
 571+    );
 572+  });
 573+}
 574+
 575+function buildBrowserSseSuccessResponse(
 576+  execution: BrowserRequestExecutionResult,
 577+  signal?: AbortSignal
 578+): ConductorHttpResponse {
 579+  if (execution.stream == null || execution.lease == null) {
 580+    return createSseResponse(
 581+      serializeSseFrame(
 582+        "stream_error",
 583+        compactJsonObject({
 584+          error: "browser_stream_unavailable",
 585+          message: "Browser stream execution did not return an active stream.",
 586+          platform: execution.platform,
 587+          request_id: execution.request_id,
 588+          stream_id: execution.request_id
 589+        })
 590+      )
 591+    );
 592+  }
 593+
 594+  const stream = execution.stream;
 595+  const iterator = stream[Symbol.asyncIterator]();
 596+  let leaseCompleted = false;
 597+
 598+  const completeLease = (outcome: {
 599+    code?: string | null;
 600+    message?: string | null;
 601+    status: "cancelled" | "failure" | "success";
 602+  }) => {
 603+    if (leaseCompleted) {
 604+      return;
 605+    }
 606+
 607+    leaseCompleted = true;
 608+    execution.lease?.complete(outcome);
 609+  };
 610+
 611+  const streamBody = (async function* (): AsyncGenerator<string> {
 612+    try {
 613+      while (true) {
 614+        const next = await nextBrowserStreamResult(iterator, signal);
 615+
 616+        if (next == null) {
 617+          stream.cancel("downstream_disconnected");
 618+          completeLease({
 619+            code: "downstream_disconnected",
 620+            message: "The HTTP SSE client disconnected before the browser stream completed.",
 621+            status: "cancelled"
 622+          });
 623+          return;
 624+        }
 625+
 626+        if (next.done) {
 627+          completeLease({
 628+            status: "success"
 629+          });
 630+          return;
 631+        }
 632+
 633+        const event = next.value;
 634+
 635+        switch (event.type) {
 636+          case "stream_open":
 637+            yield serializeSseFrame(
 638+              "stream_open",
 639+              compactJsonObject({
 640+                client_id: execution.client_id,
 641+                conversation: serializeClaudeConversationSummary(execution.conversation) ?? undefined,
 642+                meta: parseBrowserProxyBody(event.meta) ?? undefined,
 643+                organization: serializeClaudeOrganizationSummary(execution.organization) ?? undefined,
 644+                platform: execution.platform,
 645+                policy: serializeBrowserRequestPolicyAdmission(execution.policy),
 646+                request_id: execution.request_id,
 647+                request_method: execution.request_method,
 648+                request_mode: execution.request_mode,
 649+                request_path: execution.request_path,
 650+                response_mode: execution.response_mode,
 651+                status: event.status ?? undefined,
 652+                stream_id: event.streamId
 653+              })
 654+            );
 655+            break;
 656+          case "stream_event":
 657+            yield serializeSseFrame(
 658+              "stream_event",
 659+              compactJsonObject({
 660+                data: parseBrowserProxyBody(event.data) ?? null,
 661+                event: event.event ?? undefined,
 662+                raw: event.raw ?? undefined,
 663+                request_id: execution.request_id,
 664+                seq: event.seq,
 665+                stream_id: event.streamId
 666+              })
 667+            );
 668+            break;
 669+          case "stream_end":
 670+            yield serializeSseFrame(
 671+              "stream_end",
 672+              compactJsonObject({
 673+                partial: serializeBrowserStreamPartialState(event.partial),
 674+                request_id: execution.request_id,
 675+                status: event.status ?? undefined,
 676+                stream_id: event.streamId
 677+              })
 678+            );
 679+            completeLease({
 680+              status: "success"
 681+            });
 682+            return;
 683+          case "stream_error":
 684+            yield serializeSseFrame(
 685+              "stream_error",
 686+              compactJsonObject({
 687+                error: event.code,
 688+                message: event.message,
 689+                partial: serializeBrowserStreamPartialState(event.partial),
 690+                request_id: execution.request_id,
 691+                status: event.status ?? undefined,
 692+                stream_id: event.streamId
 693+              })
 694+            );
 695+            completeLease(
 696+              event.code === "request_cancelled" || event.code === "downstream_disconnected"
 697+                ? {
 698+                    code: event.code,
 699+                    message: event.message,
 700+                    status: "cancelled"
 701+                  }
 702+                : {
 703+                    code: event.code,
 704+                    message: event.message,
 705+                    status: "failure"
 706+                  }
 707+            );
 708+            return;
 709+        }
 710+      }
 711+    } finally {
 712+      if (!leaseCompleted) {
 713+        stream.cancel("stream_closed");
 714+        completeLease({
 715+          code: "stream_closed",
 716+          message: "Browser stream was closed before the conductor completed the SSE relay.",
 717+          status: "cancelled"
 718+        });
 719+      }
 720+    }
 721+  })();
 722+
 723+  return createSseResponse("", streamBody);
 724+}
 725+
 726+function buildBrowserSseErrorResponse(
 727+  input: {
 728+    error: string;
 729+    message: string;
 730+    platform: string;
 731+    requestId: string;
 732+    status?: number | null;
 733+  }
 734+): ConductorHttpResponse {
 735+  return createSseResponse(
 736+    serializeSseFrame(
 737+      "stream_error",
 738+      compactJsonObject({
 739+        error: input.error,
 740+        message: input.message,
 741+        platform: input.platform,
 742+        request_id: input.requestId,
 743+        status: input.status ?? undefined,
 744+        stream_id: input.requestId
 745+      })
 746+    )
 747+  );
 748+}
 749+
 750 function dispatchBrowserAction(
 751   context: LocalApiRequestContext,
 752   input: {
 753@@ -3719,18 +4229,25 @@ function dispatchBrowserAction(
 754       case "plugin_status":
 755       case "ws_reconnect":
 756       case "controller_reload":
 757-      case "tab_restore":
 758-        throw new LocalApiHttpError(
 759-          501,
 760-          "browser_action_not_supported",
 761-          `Browser action "${input.action}" is reserved in the HTTP contract but not implemented yet.`,
 762-          compactJsonObject({
 763-            action: input.action,
 764-            browser_status_route: input.action === "plugin_status" ? "/v1/browser" : undefined,
 765-            route: "/v1/browser/actions",
 766-            supported_actions: [...SUPPORTED_BROWSER_ACTIONS]
 767-          })
 768-        );
 769+      case "tab_restore": {
 770+        const receipt = requireBrowserBridge(context).dispatchPluginAction({
 771+          action: input.action,
 772+          clientId: input.clientId,
 773+          platform: input.platform,
 774+          reason: input.reason
 775+        });
 776+
 777+        return {
 778+          action: input.action,
 779+          client_id: receipt.clientId,
 780+          connection_id: receipt.connectionId,
 781+          dispatched_at: receipt.dispatchedAt,
 782+          platform: input.platform ?? null,
 783+          reason: input.reason ?? null,
 784+          status: "dispatched",
 785+          type: receipt.type
 786+        };
 787+      }
 788     }
 789   } catch (error) {
 790     if (error instanceof LocalApiHttpError) {
 791@@ -3759,18 +4276,7 @@ async function executeBrowserRequest(
 792   }
 793 ): Promise<BrowserRequestExecutionResult> {
 794   const responseMode = input.responseMode ?? "buffered";
 795-
 796-  if (responseMode === "sse") {
 797-    throw new LocalApiHttpError(
 798-      501,
 799-      "browser_streaming_not_supported",
 800-      "Browser SSE relay is reserved in the HTTP contract but not implemented on this surface yet.",
 801-      {
 802-        platform: input.platform,
 803-        route: "/v1/browser/request"
 804-      }
 805-    );
 806-  }
 807+  const requestId = resolveBrowserRequestId(input.requestId);
 808 
 809   const explicitPath = normalizeOptionalString(input.path);
 810   const prompt = normalizeOptionalString(input.prompt);
 811@@ -3801,81 +4307,205 @@ async function executeBrowserRequest(
 812       selectClaudeBrowserClient(loadBrowserState(context), input.clientId),
 813       input.clientId
 814     );
 815-    const organization = await resolveClaudeOrganization(
 816-      context,
 817-      selection,
 818-      input.organizationId,
 819-      input.timeoutMs
 820-    );
 821-    const conversation = await resolveClaudeConversation(
 822+    const lease = await beginBrowserRequestLease(
 823       context,
 824-      selection,
 825-      organization.id,
 826       {
 827-        conversationId: input.conversationId,
 828-        createIfMissing: true,
 829-        timeoutMs: input.timeoutMs
 830-      }
 831-    );
 832-    const requestPath = buildClaudeRequestPath(
 833-      BROWSER_CLAUDE_COMPLETION_PATH,
 834-      organization.id,
 835-      conversation.id
 836+        clientId: selection.client.client_id,
 837+        platform: input.platform
 838+      },
 839+      requestId
 840     );
 841+
 842+    try {
 843+      const organization = await resolveClaudeOrganization(
 844+        context,
 845+        selection,
 846+        input.organizationId,
 847+        input.timeoutMs
 848+      );
 849+      const conversation = await resolveClaudeConversation(
 850+        context,
 851+        selection,
 852+        organization.id,
 853+        {
 854+          conversationId: input.conversationId,
 855+          createIfMissing: true,
 856+          timeoutMs: input.timeoutMs
 857+        }
 858+      );
 859+      const requestPath = buildClaudeRequestPath(
 860+        BROWSER_CLAUDE_COMPLETION_PATH,
 861+        organization.id,
 862+        conversation.id
 863+      );
 864+
 865+      if (responseMode === "sse") {
 866+        const stream = requireBrowserBridge(context).streamRequest({
 867+          body: requestBody ?? { prompt: "" },
 868+          clientId: selection.client.client_id,
 869+          headers: input.headers,
 870+          id: requestId,
 871+          idleTimeoutMs: DEFAULT_BROWSER_REQUEST_POLICY_CONFIG.stream.idleTimeoutMs,
 872+          maxBufferedBytes: DEFAULT_BROWSER_REQUEST_POLICY_CONFIG.stream.maxBufferedBytes,
 873+          maxBufferedEvents: DEFAULT_BROWSER_REQUEST_POLICY_CONFIG.stream.maxBufferedEvents,
 874+          method: requestMethod,
 875+          openTimeoutMs: DEFAULT_BROWSER_REQUEST_POLICY_CONFIG.stream.openTimeoutMs,
 876+          path: requestPath,
 877+          platform: input.platform,
 878+          streamId: requestId,
 879+          timeoutMs: input.timeoutMs
 880+        });
 881+
 882+        return {
 883+          client_id: selection.client.client_id,
 884+          conversation,
 885+          lease,
 886+          organization,
 887+          platform: input.platform,
 888+          policy: lease.admission,
 889+          request_body: requestBody ?? null,
 890+          request_id: requestId,
 891+          request_method: requestMethod,
 892+          request_mode: "claude_prompt",
 893+          request_path: requestPath,
 894+          response: null,
 895+          response_mode: responseMode,
 896+          status: null,
 897+          stream
 898+        };
 899+      }
 900+
 901+      const result = await requestBrowserProxy(context, {
 902+        action: "browser request",
 903+        body: requestBody ?? { prompt: "" },
 904+        clientId: selection.client.client_id,
 905+        headers: input.headers,
 906+        id: requestId,
 907+        method: requestMethod,
 908+        path: requestPath,
 909+        platform: input.platform,
 910+        timeoutMs: input.timeoutMs
 911+      });
 912+      lease.complete({
 913+        status: "success"
 914+      });
 915+
 916+      return {
 917+        client_id: result.apiResponse.clientId,
 918+        conversation,
 919+        lease: null,
 920+        organization,
 921+        platform: input.platform,
 922+        policy: lease.admission,
 923+        request_body: requestBody ?? null,
 924+        request_id: result.apiResponse.id,
 925+        request_method: requestMethod,
 926+        request_mode: "claude_prompt",
 927+        request_path: requestPath,
 928+        response: result.body,
 929+        response_mode: responseMode,
 930+        status: result.apiResponse.status,
 931+        stream: null
 932+      };
 933+    } catch (error) {
 934+      lease.complete(buildBrowserRequestLeaseOutcome(error));
 935+      throw error;
 936+    }
 937+  }
 938+
 939+  const targetClient =
 940+    input.platform === BROWSER_CLAUDE_PLATFORM
 941+      ? ensureClaudeBridgeReady(
 942+          selectClaudeBrowserClient(loadBrowserState(context), input.clientId),
 943+          input.clientId
 944+        ).client
 945+      : ensureBrowserClientReady(
 946+          selectBrowserClient(loadBrowserState(context), input.clientId),
 947+          input.platform,
 948+          input.clientId
 949+        );
 950+  const lease = await beginBrowserRequestLease(
 951+    context,
 952+    {
 953+      clientId: targetClient.client_id,
 954+      platform: input.platform
 955+    },
 956+    requestId
 957+  );
 958+
 959+  try {
 960+    if (responseMode === "sse") {
 961+      const stream = requireBrowserBridge(context).streamRequest({
 962+        body: requestBody,
 963+        clientId: targetClient.client_id,
 964+        headers: input.headers,
 965+        id: requestId,
 966+        idleTimeoutMs: DEFAULT_BROWSER_REQUEST_POLICY_CONFIG.stream.idleTimeoutMs,
 967+        maxBufferedBytes: DEFAULT_BROWSER_REQUEST_POLICY_CONFIG.stream.maxBufferedBytes,
 968+        maxBufferedEvents: DEFAULT_BROWSER_REQUEST_POLICY_CONFIG.stream.maxBufferedEvents,
 969+        method: requestMethod,
 970+        openTimeoutMs: DEFAULT_BROWSER_REQUEST_POLICY_CONFIG.stream.openTimeoutMs,
 971+        path: explicitPath,
 972+        platform: input.platform,
 973+        streamId: requestId,
 974+        timeoutMs: input.timeoutMs
 975+      });
 976+
 977+      return {
 978+        client_id: targetClient.client_id,
 979+        conversation: null,
 980+        lease,
 981+        organization: null,
 982+        platform: input.platform,
 983+        policy: lease.admission,
 984+        request_body: requestBody ?? null,
 985+        request_id: requestId,
 986+        request_method: requestMethod,
 987+        request_mode: "api_request",
 988+        request_path: explicitPath,
 989+        response: null,
 990+        response_mode: responseMode,
 991+        status: null,
 992+        stream
 993+      };
 994+    }
 995+
 996     const result = await requestBrowserProxy(context, {
 997       action: "browser request",
 998-      body: requestBody ?? { prompt: "" },
 999-      clientId: selection.client.client_id,
1000+      body: requestBody,
1001+      clientId: targetClient.client_id,
1002       headers: input.headers,
1003-      id: input.requestId,
1004+      id: requestId,
1005       method: requestMethod,
1006-      path: requestPath,
1007+      path: explicitPath,
1008       platform: input.platform,
1009       timeoutMs: input.timeoutMs
1010     });
1011+    lease.complete({
1012+      status: "success"
1013+    });
1014 
1015     return {
1016       client_id: result.apiResponse.clientId,
1017-      conversation,
1018-      organization,
1019+      conversation: null,
1020+      lease: null,
1021+      organization: null,
1022       platform: input.platform,
1023+      policy: lease.admission,
1024       request_body: requestBody ?? null,
1025       request_id: result.apiResponse.id,
1026       request_method: requestMethod,
1027-      request_mode: "claude_prompt",
1028-      request_path: requestPath,
1029+      request_mode: "api_request",
1030+      request_path: explicitPath,
1031       response: result.body,
1032       response_mode: responseMode,
1033-      status: result.apiResponse.status
1034+      status: result.apiResponse.status,
1035+      stream: null
1036     };
1037+  } catch (error) {
1038+    lease.complete(buildBrowserRequestLeaseOutcome(error));
1039+    throw error;
1040   }
1041-
1042-  const result = await requestBrowserProxy(context, {
1043-    action: "browser request",
1044-    body: requestBody,
1045-    clientId: input.clientId,
1046-    headers: input.headers,
1047-    id: input.requestId,
1048-    method: requestMethod,
1049-    path: explicitPath,
1050-    platform: input.platform,
1051-    timeoutMs: input.timeoutMs
1052-  });
1053-
1054-  return {
1055-    client_id: result.apiResponse.clientId,
1056-    conversation: null,
1057-    organization: null,
1058-    platform: input.platform,
1059-    request_body: requestBody ?? null,
1060-    request_id: result.apiResponse.id,
1061-    request_method: requestMethod,
1062-    request_mode: "api_request",
1063-    request_path: explicitPath,
1064-    response: result.body,
1065-    response_mode: responseMode,
1066-    status: result.apiResponse.status
1067-  };
1068 }
1069 
1070 async function handleBrowserActions(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
1071@@ -3915,6 +4545,8 @@ async function handleBrowserActions(context: LocalApiRequestContext): Promise<Co
1072 async function handleBrowserRequest(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
1073   const body = readBodyObject(context.request, true);
1074   const platform = readOptionalStringBodyField(body, "platform");
1075+  const responseMode = readBrowserRequestResponseMode(body);
1076+  const requestId = readOptionalStringBodyField(body, "requestId", "request_id", "id");
1077 
1078   if (platform == null) {
1079     throw new LocalApiHttpError(
1080@@ -3927,46 +4559,70 @@ async function handleBrowserRequest(context: LocalApiRequestContext): Promise<Co
1081     );
1082   }
1083 
1084-  const execution = await executeBrowserRequest(context, {
1085-    clientId: readOptionalStringBodyField(body, "clientId", "client_id"),
1086-    conversationId: readOptionalStringBodyField(body, "conversationId", "conversation_id"),
1087-    headers:
1088-      readOptionalStringMap(body, "headers")
1089-      ?? readOptionalStringMap(body, "request_headers")
1090-      ?? undefined,
1091-    method: readOptionalStringBodyField(body, "method"),
1092-    organizationId: readOptionalStringBodyField(body, "organizationId", "organization_id"),
1093-    path: readOptionalStringBodyField(body, "path"),
1094-    platform,
1095-    prompt: readOptionalStringBodyField(body, "prompt", "message", "input"),
1096-    requestBody: readBodyField(body, "requestBody", "request_body", "body", "payload"),
1097-    requestId: readOptionalStringBodyField(body, "requestId", "request_id", "id"),
1098-    responseMode: readBrowserRequestResponseMode(body),
1099-    timeoutMs: readOptionalTimeoutMs(body, context.url)
1100-  });
1101+  try {
1102+    const execution = await executeBrowserRequest(context, {
1103+      clientId: readOptionalStringBodyField(body, "clientId", "client_id"),
1104+      conversationId: readOptionalStringBodyField(body, "conversationId", "conversation_id"),
1105+      headers:
1106+        readOptionalStringMap(body, "headers")
1107+        ?? readOptionalStringMap(body, "request_headers")
1108+        ?? undefined,
1109+      method: readOptionalStringBodyField(body, "method"),
1110+      organizationId: readOptionalStringBodyField(body, "organizationId", "organization_id"),
1111+      path: readOptionalStringBodyField(body, "path"),
1112+      platform,
1113+      prompt: readOptionalStringBodyField(body, "prompt", "message", "input"),
1114+      requestBody: readBodyField(body, "requestBody", "request_body", "body", "payload"),
1115+      requestId,
1116+      responseMode,
1117+      timeoutMs: readOptionalTimeoutMs(body, context.url)
1118+    });
1119 
1120-  return buildSuccessEnvelope(context.requestId, 200, {
1121-    client_id: execution.client_id,
1122-    conversation: serializeClaudeConversationSummary(execution.conversation),
1123-    organization: serializeClaudeOrganizationSummary(execution.organization),
1124-    platform: execution.platform,
1125-    proxy: {
1126-      method: execution.request_method,
1127-      path: execution.request_path,
1128-      request_body: execution.request_body,
1129-      request_id: execution.request_id,
1130-      response_mode: execution.response_mode,
1131-      status: execution.status
1132-    },
1133-    request_mode: execution.request_mode,
1134-    response: execution.response
1135-  });
1136+    if (responseMode === "sse") {
1137+      return buildBrowserSseSuccessResponse(execution, context.request.signal);
1138+    }
1139+
1140+    return buildSuccessEnvelope(context.requestId, 200, {
1141+      client_id: execution.client_id,
1142+      conversation: serializeClaudeConversationSummary(execution.conversation),
1143+      organization: serializeClaudeOrganizationSummary(execution.organization),
1144+      platform: execution.platform,
1145+      policy: serializeBrowserRequestPolicyAdmission(execution.policy),
1146+      proxy: {
1147+        method: execution.request_method,
1148+        path: execution.request_path,
1149+        request_body: execution.request_body,
1150+        request_id: execution.request_id,
1151+        response_mode: execution.response_mode,
1152+        status: execution.status
1153+      },
1154+      request_mode: execution.request_mode,
1155+      response: execution.response
1156+    });
1157+  } catch (error) {
1158+    if (responseMode !== "sse" || !(error instanceof LocalApiHttpError)) {
1159+      throw error;
1160+    }
1161+
1162+    return buildBrowserSseErrorResponse({
1163+      error: error.error,
1164+      message: error.message,
1165+      platform,
1166+      requestId:
1167+        readUnknownString(asUnknownRecord(error.details), ["bridge_request_id"])
1168+        ?? requestId
1169+        ?? context.requestId,
1170+      status: error.status
1171+    });
1172+  }
1173 }
1174 
1175 async function handleBrowserRequestCancel(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
1176   const body = readBodyObject(context.request, true);
1177   const requestId = readOptionalStringBodyField(body, "requestId", "request_id", "id");
1178   const platform = readOptionalStringBodyField(body, "platform");
1179+  const clientId = readOptionalStringBodyField(body, "clientId", "client_id");
1180+  const reason = readOptionalStringBodyField(body, "reason");
1181 
1182   if (requestId == null) {
1183     throw new LocalApiHttpError(
1184@@ -3990,18 +4646,63 @@ async function handleBrowserRequestCancel(context: LocalApiRequestContext): Prom
1185     );
1186   }
1187 
1188-  throw new LocalApiHttpError(
1189-    501,
1190-    "browser_request_cancel_not_supported",
1191-    "Browser request cancel is reserved in the HTTP contract but not implemented yet.",
1192-    compactJsonObject({
1193-      client_id: readOptionalStringBodyField(body, "clientId", "client_id"),
1194+  let dispatch;
1195+
1196+  try {
1197+    dispatch = requireBrowserBridge(context).cancelApiRequest({
1198+      clientId,
1199       platform,
1200-      reason: readOptionalStringBodyField(body, "reason") ?? undefined,
1201-      request_id: requestId,
1202-      route: "/v1/browser/request/cancel"
1203-    })
1204-  );
1205+      reason,
1206+      requestId
1207+    });
1208+  } catch (error) {
1209+    if (error instanceof LocalApiHttpError) {
1210+      throw error;
1211+    }
1212+
1213+    if (readBridgeErrorCode(error) === "request_not_found") {
1214+      throw new LocalApiHttpError(
1215+        404,
1216+        "browser_request_not_found",
1217+        `Browser request "${requestId}" is not in flight.`,
1218+        compactJsonObject({
1219+          client_id: clientId ?? undefined,
1220+          platform,
1221+          reason: reason ?? undefined,
1222+          request_id: requestId,
1223+          route: "/v1/browser/request/cancel"
1224+        })
1225+      );
1226+    }
1227+
1228+    if (readBridgeErrorCode(error) === "client_not_found" && clientId != null) {
1229+      throw new LocalApiHttpError(
1230+        409,
1231+        "browser_request_client_mismatch",
1232+        `Browser request "${requestId}" is not running on the requested Firefox bridge client.`,
1233+        compactJsonObject({
1234+          client_id: clientId,
1235+          platform,
1236+          reason: reason ?? undefined,
1237+          request_id: requestId,
1238+          route: "/v1/browser/request/cancel"
1239+        })
1240+      );
1241+    }
1242+
1243+    throw createBrowserBridgeHttpError(`browser request cancel ${requestId}`, error);
1244+  }
1245+
1246+  return buildSuccessEnvelope(context.requestId, 200, {
1247+    client_id: dispatch.clientId,
1248+    connection_id: dispatch.connectionId,
1249+    dispatched_at: dispatch.dispatchedAt,
1250+    platform,
1251+    reason: reason ?? null,
1252+    request_id: dispatch.requestId,
1253+    status: "cancel_requested",
1254+    type: dispatch.type
1255+  });
1256 }
1257 
1258 async function handleBrowserClaudeOpen(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
1259@@ -4024,36 +4725,60 @@ async function handleBrowserClaudeOpen(context: LocalApiRequestContext): Promise
1260 
1261 async function handleBrowserClaudeSend(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
1262   const body = readBodyObject(context.request, true);
1263-  const execution = await executeBrowserRequest(context, {
1264-    clientId: readOptionalStringBodyField(body, "clientId", "client_id"),
1265-    conversationId: readOptionalStringBodyField(body, "conversationId", "conversation_id"),
1266-    headers:
1267-      readOptionalStringMap(body, "headers")
1268-      ?? readOptionalStringMap(body, "request_headers")
1269-      ?? undefined,
1270-    method: readOptionalStringBodyField(body, "method") ?? "POST",
1271-    organizationId: readOptionalStringBodyField(body, "organizationId", "organization_id"),
1272-    path: readOptionalStringBodyField(body, "path"),
1273-    platform: BROWSER_CLAUDE_PLATFORM,
1274-    prompt: readOptionalStringBodyField(body, "prompt", "message", "input"),
1275-    requestBody: readBodyField(body, "requestBody", "request_body", "body", "payload"),
1276-    requestId: readOptionalStringBodyField(body, "requestId", "request_id", "id"),
1277-    timeoutMs: readOptionalTimeoutMs(body, context.url)
1278-  });
1279+  const responseMode = readBrowserRequestResponseMode(body);
1280+  try {
1281+    const execution = await executeBrowserRequest(context, {
1282+      clientId: readOptionalStringBodyField(body, "clientId", "client_id"),
1283+      conversationId: readOptionalStringBodyField(body, "conversationId", "conversation_id"),
1284+      headers:
1285+        readOptionalStringMap(body, "headers")
1286+        ?? readOptionalStringMap(body, "request_headers")
1287+        ?? undefined,
1288+      method: readOptionalStringBodyField(body, "method") ?? "POST",
1289+      organizationId: readOptionalStringBodyField(body, "organizationId", "organization_id"),
1290+      path: readOptionalStringBodyField(body, "path"),
1291+      platform: BROWSER_CLAUDE_PLATFORM,
1292+      prompt: readOptionalStringBodyField(body, "prompt", "message", "input"),
1293+      requestBody: readBodyField(body, "requestBody", "request_body", "body", "payload"),
1294+      requestId: readOptionalStringBodyField(body, "requestId", "request_id", "id"),
1295+      responseMode,
1296+      timeoutMs: readOptionalTimeoutMs(body, context.url)
1297+    });
1298 
1299-  return buildSuccessEnvelope(context.requestId, 200, {
1300-    client_id: execution.client_id,
1301-    conversation: serializeClaudeConversationSummary(execution.conversation),
1302-    organization: serializeClaudeOrganizationSummary(execution.organization),
1303-    platform: BROWSER_CLAUDE_PLATFORM,
1304-    proxy: {
1305-      path: execution.request_path,
1306-      request_body: execution.request_body,
1307-      request_id: execution.request_id,
1308-      status: execution.status
1309-    },
1310-    response: execution.response
1311-  });
1312+    if (responseMode === "sse") {
1313+      return buildBrowserSseSuccessResponse(execution, context.request.signal);
1314+    }
1315+
1316+    return buildSuccessEnvelope(context.requestId, 200, {
1317+      client_id: execution.client_id,
1318+      conversation: serializeClaudeConversationSummary(execution.conversation),
1319+      organization: serializeClaudeOrganizationSummary(execution.organization),
1320+      platform: BROWSER_CLAUDE_PLATFORM,
1321+      policy: serializeBrowserRequestPolicyAdmission(execution.policy),
1322+      proxy: {
1323+        path: execution.request_path,
1324+        request_body: execution.request_body,
1325+        request_id: execution.request_id,
1326+        status: execution.status
1327+      },
1328+      response: execution.response
1329+    });
1330+  } catch (error) {
1331+    if (responseMode !== "sse" || !(error instanceof LocalApiHttpError)) {
1332+      throw error;
1333+    }
1334+
1335+    return buildBrowserSseErrorResponse({
1336+      error: error.error,
1337+      message: error.message,
1338+      platform: BROWSER_CLAUDE_PLATFORM,
1339+      requestId:
1340+        readUnknownString(asUnknownRecord(error.details), ["bridge_request_id"])
1341+        ?? readOptionalStringBodyField(body, "requestId", "request_id", "id")
1342+        ?? context.requestId,
1343+      status: error.status
1344+    });
1345+  }
1346 }
1347 
1348 async function handleBrowserClaudeCurrent(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
1349@@ -4683,6 +5408,7 @@ export async function handleConductorHttpRequest(
1350       matchedRoute,
1351       {
1352         browserBridge: context.browserBridge ?? null,
1353+        browserRequestPolicy: context.browserRequestPolicy ?? null,
1354         browserStateLoader: context.browserStateLoader ?? (() => null),
1355         codexdLocalApiBase:
1356           normalizeOptionalString(context.codexdLocalApiBase) ?? context.snapshotLoader().codexd.localApiBase,
M docs/api/README.md
+19, -9
 1@@ -129,15 +129,15 @@
 2 - `GET /v1/browser`:浏览器登录态元数据、持久化状态和插件在线状态读面
 3 - `POST /v1/browser/request`:通用 browser 代发入口
 4 - `POST /v1/browser/actions`:通用 browser/plugin 管理动作入口
 5-- `POST /v1/browser/request/cancel`:请求或流取消入口;合同已固定,但当前返回 `501`
 6+- `POST /v1/browser/request/cancel`:请求或流取消入口
 7 - `POST` / `GET /v1/browser/claude/*`:Claude 专用 legacy 兼容包装层
 8 
 9 | 方法 | 路径 | 说明 |
10 | --- | --- | --- |
11 | `GET` | `/v1/browser` | 读取活跃 Firefox bridge、插件在线状态、持久化登录态记录和 `fresh` / `stale` / `lost` 状态 |
12-| `POST` | `/v1/browser/actions` | 派发通用 browser/plugin 管理动作;当前正式支持 `request_credentials`、`tab_open`、`tab_focus`、`tab_reload` |
13-| `POST` | `/v1/browser/request` | 发起通用 browser HTTP 代发请求;当前正式支持 Claude 的 buffered 请求 |
14-| `POST` | `/v1/browser/request/cancel` | 取消请求或流;合同已固定,但当前返回 `501`,等待 bridge 侧补齐 cancel plumbing |
15+| `POST` | `/v1/browser/actions` | 派发通用 browser/plugin 管理动作;当前正式支持 `request_credentials`、`tab_open`、`tab_focus`、`tab_reload`、`plugin_status`、`ws_reconnect`、`controller_reload`、`tab_restore` |
16+| `POST` | `/v1/browser/request` | 发起通用 browser HTTP 代发请求;当前正式支持 Claude 的 buffered 与 SSE 请求 |
17+| `POST` | `/v1/browser/request/cancel` | 取消请求或流;会向对应 Firefox client 派发 `request_cancel` |
18 | `POST` | `/v1/browser/claude/open` | legacy 包装:等价映射到 `POST /v1/browser/actions` |
19 | `POST` | `/v1/browser/claude/send` | legacy 包装:等价映射到 `POST /v1/browser/request` |
20 | `GET` | `/v1/browser/claude/current` | legacy 辅助读:读取当前 Claude 代理回读结果;不是未来通用主模型 |
21@@ -155,9 +155,10 @@ Browser 面约定:
22 - 连接断开或流量老化后,持久化记录仍可读,但状态会从 `fresh` 变成 `stale` / `lost`
23 - 当前浏览器本地代发面只支持 `claude`;ChatGPT / Gemini 目前只有壳页和元数据上报,不在正式 HTTP relay 合同里
24 - `POST /v1/browser/request` 要求 `platform`;若 `platform=claude` 且省略 `path`,可用 `prompt` 走 Claude completion 兼容模式
25-- `POST /v1/browser/request` 当前只正式支持 `responseMode=buffered`;`responseMode=sse` 已纳入合同,但当前返回 `501`
26-- `POST /v1/browser/actions` 当前正式支持 `request_credentials`、`tab_open`、`tab_focus`、`tab_reload`
27-- `plugin_status`、`ws_reconnect`、`controller_reload`、`tab_restore` 已在合同中保留,但当前通过 `POST /v1/browser/actions` 调用会返回 `501`
28+- `POST /v1/browser/request` 支持 `responseMode=buffered` 和 `responseMode=sse`
29+- SSE 响应固定用 `stream_open`、`stream_event`、`stream_end`、`stream_error` 作为 event name;`stream_event` 带递增 `seq`
30+- `POST /v1/browser/actions` 当前正式支持 `request_credentials`、`tab_open`、`tab_focus`、`tab_reload`、`plugin_status`、`ws_reconnect`、`controller_reload`、`tab_restore`
31+- `GET /v1/browser` 会回显当前风控默认值和运行时 target/platform 状态,便于观察抖动、限流、退避和熔断
32 - `/ws/firefox` 只在本地 listener 上可用,不是公网产品接口
33 - `request` 的 Claude prompt 模式和 `current` 辅助读只有在 `mini` 上已有活跃 Firefox bridge client,且 Claude 页面已捕获有效凭证和 endpoint 时才可用
34 - 如果当前没有活跃 Firefox client,会返回清晰的 `503` JSON 错误
35@@ -204,7 +205,16 @@ curl -X POST "${LOCAL_API_BASE}/v1/browser/request" \
36   -d '{"platform":"claude","prompt":"Summarize the current bridge state."}'
37 ```
38 
39-取消合同占位:
40+通过 SSE 模式直连浏览器流:
41+
42+```bash
43+LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
44+curl -N -X POST "${LOCAL_API_BASE}/v1/browser/request" \
45+  -H 'Content-Type: application/json' \
46+  -d '{"platform":"claude","prompt":"Stream the current bridge state.","responseMode":"sse","requestId":"browser-stream-demo"}'
47+```
48+
49+取消运行中的 browser request / stream:
50 
51 ```bash
52 LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
53@@ -313,7 +323,7 @@ host-ops 约定:
54 - 浏览器发来的 `credentials` / `api_endpoints` 会被转换成 `account`、凭证指纹、端点元数据和 `fresh/stale/lost` 持久化记录
55 - `headers` 只保留名称与数量;原始 `cookie` / `token` / header 值既不会入库,也不会在 snapshot 或 `/v1/browser` 中回显
56 - `GET /v1/browser` 会合并当前活跃连接和持久化记录;即使 client 断开或 daemon 重启,最近一次记录仍可读取
57-- `/v1/browser/request` 和 legacy 的 `/v1/browser/claude/current` 会复用这条 WS bridge 的 `api_request` / `api_response` 做 Claude 页面内 HTTP 代理
58+- `/v1/browser/request`、`/v1/browser/request/cancel` 和 legacy 的 `/v1/browser/claude/current` 会复用这条 WS bridge 的 `api_request` / `api_response` / `stream_*` / `request_cancel` 做 Claude 页面内 HTTP 代理
59 
60 详细消息模型和 smoke 示例见:
61 
M docs/api/business-interfaces.md
+7, -4
 1@@ -64,8 +64,8 @@
 2 
 3 | 方法 | 路径 | 作用 |
 4 | --- | --- | --- |
 5-| `POST` | `/v1/browser/request` | 通过 `conductor -> /ws/firefox -> 插件页面内 HTTP 代理` 发起通用 browser request;当前正式支持 Claude buffered 请求 |
 6-| `POST` | `/v1/browser/request/cancel` | 取消 request 或流;合同已保留,但当前返回 `501` |
 7+| `POST` | `/v1/browser/request` | 通过 `conductor -> /ws/firefox -> 插件页面内 HTTP 代理` 发起通用 browser request;当前正式支持 Claude buffered 与 SSE 请求 |
 8+| `POST` | `/v1/browser/request/cancel` | 取消 request 或流;会向对应 Firefox client 下发正式 `request_cancel` |
 9 | `POST` | `/v1/browser/claude/send` | legacy 包装:等价映射到 `POST /v1/browser/request` |
10 
11 说明:
12@@ -75,7 +75,10 @@
13 - `records[].view` 会区分活跃连接与仅持久化记录,`status` 会暴露 `fresh`、`stale`、`lost`
14 - 当前浏览器代发面只支持 `claude`
15 - `POST /v1/browser/request` 要求 `platform`;若 `platform=claude` 且省略 `path`,可用 `prompt` 走 Claude completion 兼容模式
16-- `POST /v1/browser/request` 当前只正式支持 `responseMode=buffered`;`responseMode=sse` 已入合同,但当前返回 `501`
17+- `POST /v1/browser/request` 支持 `responseMode=buffered` 和 `responseMode=sse`
18+- `responseMode=sse` 会返回 `text/event-stream`,事件名固定为 `stream_open`、`stream_event`、`stream_end`、`stream_error`
19+- `stream_event` 都带递增 `seq`;失败、超时或取消时会带着已收到的 partial 状态落到 `stream_error`
20+- `GET /v1/browser` 会返回当前浏览器风控默认值和运行中 target/platform 状态摘要,便于观察限流、退避和熔断
21 - `send` / `current` 不是 DOM 自动化,而是通过插件已有的页面内 HTTP 代理完成
22 - 如果没有活跃 Firefox bridge client,会返回 `503`
23 - 如果 client 还没有 Claude 凭证快照,会返回 `409`
24@@ -208,7 +211,7 @@ curl "${BASE_URL}/v1/tasks/${TASK_ID}/logs?limit=50"
25 - 业务类接口当前以“只读查询”为主
26 - 浏览器业务面当前以“登录态元数据读面 + 通用 browser request 合同”收口,不把页面对话 UI 当成正式能力
27 - 浏览器 relay 当前只正式支持 Claude,且依赖本地 Firefox bridge 已连接
28-- `/v1/browser/request/cancel` 和 `responseMode=sse` 已保留进合同,但当前不在本轮实现范围内
29+- `/v1/browser/request/cancel` 已正式接到 Firefox bridge;`responseMode=sse` 会返回真实 `text/event-stream`
30 - `/v1/codex/*` 是少数已经正式开放的业务写接口,但后端固定代理到独立 `codexd`
31 - 控制动作例如 `pause` / `resume` / `drain` 不在本文件讨论范围内
32 - 本机能力接口 `/v1/exec`、`/v1/files/read`、`/v1/files/write` 也不在本文件讨论范围内
M docs/api/control-interfaces.md
+7, -4
 1@@ -76,7 +76,7 @@
 2 
 3 | 方法 | 路径 | 作用 |
 4 | --- | --- | --- |
 5-| `POST` | `/v1/browser/actions` | 派发通用 browser/plugin 管理动作;当前正式支持 `request_credentials`、`tab_open`、`tab_focus`、`tab_reload` |
 6+| `POST` | `/v1/browser/actions` | 派发通用 browser/plugin 管理动作;当前正式支持 `request_credentials`、`tab_open`、`tab_focus`、`tab_reload`、`plugin_status`、`ws_reconnect`、`controller_reload`、`tab_restore` |
 7 | `POST` | `/v1/browser/claude/open` | legacy 包装:等价映射到 `POST /v1/browser/actions` |
 8 | `POST` | `/v1/browser/claude/reload` | legacy 包装:等价映射到 `POST /v1/browser/actions` |
 9 
10@@ -88,7 +88,10 @@ browser/plugin 管理约定:
11   - `tab_open`
12   - `tab_focus`
13   - `tab_reload`
14-- `plugin_status`、`ws_reconnect`、`controller_reload`、`tab_restore` 已保留进合同,但当前会返回 `501`
15+  - `plugin_status`
16+  - `ws_reconnect`
17+  - `controller_reload`
18+  - `tab_restore`
19 - 当前正式平台仍是 `claude`
20 - 如果没有活跃 Firefox bridge client,会返回 `503`
21 - 如果指定了不存在的 `clientId`,会返回 `409`
22@@ -224,8 +227,8 @@ curl -X POST "${BASE_URL}/v1/files/write" \
23 
24 - 当前控制面是单节点 `mini`
25 - 控制动作默认作用于当前唯一活动节点
26-- browser/plugin 管理动作已经纳入 control,但当前正式只实现 `request_credentials`、`tab_open`、`tab_focus`、`tab_reload`
27-- `plugin_status`、`ws_reconnect`、`controller_reload`、`tab_restore` 已保留在合同里,但当前不在本轮实现范围内
28+- browser/plugin 管理动作已经纳入 control;当前正式支持 `request_credentials`、`tab_open`、`tab_focus`、`tab_reload`、`plugin_status`、`ws_reconnect`、`controller_reload`、`tab_restore`
29+- 当前 `/v1/browser/actions` 返回稳定的 dispatch ack;浏览器端最终 runtime 结果仍以 `GET /v1/browser` 和插件侧状态为准
30 - Codex 会话能力不在本文件主讨论范围;它通过 `/v1/codex/*` 代理到独立 `codexd`
31 - 业务查询不在本文件讨论范围内
32 - 业务类接口见 [`business-interfaces.md`](./business-interfaces.md)
M docs/api/firefox-local-ws.md
+126, -8
  1@@ -43,6 +43,10 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
  2 | `credentials` | 上送账号、凭证指纹、新鲜度和脱敏 header 名称摘要;server 只持久化最小元数据 |
  3 | `api_endpoints` | 上送当前可代发的 endpoint 列表及其 `endpoint_metadata` |
  4 | `api_response` | 对服务端下发的 `api_request` 回包,按 `id` 做 request-response 关联 |
  5+| `stream_open` | 对服务端下发的 SSE `api_request` 回传流已打开,附带 `stream_id` 和上游状态码 |
  6+| `stream_event` | 回传单个流事件;每条都带递增 `seq` |
  7+| `stream_end` | 回传流已正常结束 |
  8+| `stream_error` | 回传流失败、超时、取消或本地执行错误 |
  9 | `client_log` | 可选日志消息;当前 server 只接收,不做业务处理 |
 10 
 11 ### server -> client
 12@@ -53,7 +57,9 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
 13 | `state_snapshot` | 当前 server/system/browser 摘要 |
 14 | `action_result` | `action_request` 的执行结果;成功时直接回传最新 `system` |
 15 | `open_tab` | 指示浏览器打开或激活目标平台标签页 |
 16-| `api_request` | 由 server 发起、浏览器代发的 API 请求;浏览器完成后回 `api_response` |
 17+| `plugin_status` / `ws_reconnect` / `controller_reload` / `tab_restore` | 正式插件管理动作;server 直接用动作名下发消息 |
 18+| `api_request` | 由 server 发起、浏览器代发的 API 请求;buffered 模式回 `api_response`,SSE 模式回 `stream_*` |
 19+| `request_cancel` | 请求浏览器取消当前 `api_request` 或流 |
 20 | `request_credentials` | 提示浏览器重新发送 `credentials` |
 21 | `reload` | 指示插件管理页重载当前 controller 页面 |
 22 | `error` | 非法 JSON、未知消息类型或未实现消息 |
 23@@ -83,8 +89,8 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
 24   "wsUrl": "ws://100.71.210.78:4317/ws/firefox",
 25   "localApiBase": "http://100.71.210.78:4317",
 26   "supports": {
 27-    "inbound": ["hello", "state_request", "action_request", "credentials", "api_endpoints", "client_log", "api_response"],
 28-    "outbound": ["hello_ack", "state_snapshot", "action_result", "open_tab", "api_request", "request_credentials", "reload", "error"]
 29+    "inbound": ["hello", "state_request", "action_request", "credentials", "api_endpoints", "client_log", "api_response", "stream_open", "stream_event", "stream_end", "stream_error"],
 30+    "outbound": ["hello_ack", "state_snapshot", "action_result", "open_tab", "plugin_status", "ws_reconnect", "controller_reload", "tab_restore", "api_request", "request_cancel", "request_credentials", "reload", "error"]
 31   }
 32 }
 33 ```
 34@@ -226,6 +232,117 @@ server 行为:
 35 - `GET /v1/browser` 会把这些持久化记录和当前活跃 WS 连接视图合并
 36 - client 断开或 daemon 重启后,最近一次元数据仍可通过 `/v1/browser` 读取
 37 
 38+### `api_request`
 39+
 40+buffered 请求示例:
 41+
 42+```json
 43+{
 44+  "type": "api_request",
 45+  "id": "browser-request-1",
 46+  "platform": "claude",
 47+  "method": "GET",
 48+  "path": "/api/organizations",
 49+  "response_mode": "buffered"
 50+}
 51+```
 52+
 53+SSE 请求示例:
 54+
 55+```json
 56+{
 57+  "type": "api_request",
 58+  "id": "browser-stream-1",
 59+  "platform": "claude",
 60+  "method": "POST",
 61+  "path": "/api/organizations/org-1/chat_conversations/conv-1/completion",
 62+  "body": {
 63+    "prompt": "Summarize the current bridge state."
 64+  },
 65+  "response_mode": "sse",
 66+  "stream_id": "browser-stream-1"
 67+}
 68+```
 69+
 70+说明:
 71+
 72+- `response_mode=buffered` 时浏览器仍走 `api_response`
 73+- `response_mode=sse` 时浏览器必须改走 `stream_open` / `stream_event` / `stream_end` / `stream_error`
 74+- `stream_id` 首版默认与 `id` 保持一致
 75+
 76+### `request_cancel`
 77+
 78+```json
 79+{
 80+  "type": "request_cancel",
 81+  "id": "browser-stream-1",
 82+  "stream_id": "browser-stream-1",
 83+  "reason": "browser_request_cancelled"
 84+}
 85+```
 86+
 87+说明:
 88+
 89+- 浏览器收到后应尽快中止本地 fetch / SSE reader
 90+- 如果流已经开始,推荐回 `stream_error`
 91+- 如果是 buffered 请求,本地请求 promise 应尽快失败并释放占位
 92+
 93+### `stream_open`
 94+
 95+```json
 96+{
 97+  "type": "stream_open",
 98+  "id": "browser-stream-1",
 99+  "stream_id": "browser-stream-1",
100+  "status": 200,
101+  "meta": {
102+    "method": "POST",
103+    "platform": "claude",
104+    "url": "https://claude.ai/api/organizations/org-1/chat_conversations/conv-1/completion"
105+  }
106+}
107+```
108+
109+### `stream_event`
110+
111+```json
112+{
113+  "type": "stream_event",
114+  "id": "browser-stream-1",
115+  "stream_id": "browser-stream-1",
116+  "seq": 1,
117+  "event": "message",
118+  "data": {
119+    "type": "content_block_delta"
120+  },
121+  "raw": "event: message\ndata: {\"type\":\"content_block_delta\"}"
122+}
123+```
124+
125+### `stream_end`
126+
127+```json
128+{
129+  "type": "stream_end",
130+  "id": "browser-stream-1",
131+  "stream_id": "browser-stream-1",
132+  "status": 200
133+}
134+```
135+
136+### `stream_error`
137+
138+```json
139+{
140+  "type": "stream_error",
141+  "id": "browser-stream-1",
142+  "stream_id": "browser-stream-1",
143+  "status": 499,
144+  "code": "request_cancelled",
145+  "message": "browser_request_cancelled"
146+}
147+```
148+
149 ### `open_tab`
150 
151 ```json
152@@ -310,7 +427,7 @@ server 行为:
153 
154 - 不把 `/ws/firefox` 直接暴露成公网产品接口
155 - 不把页面对话 UI、聊天 DOM 自动化或多标签会话编排写成正式 bridge 能力
156-- Claude 正式 HTTP 面统一收口到 `GET /v1/browser`、`POST /v1/browser/claude/open`、`POST /v1/browser/claude/send`、`GET /v1/browser/claude/current`
157+- 正式 HTTP 面已经收口到 `GET /v1/browser`、`POST /v1/browser/actions`、`POST /v1/browser/request`、`POST /v1/browser/request/cancel`;`/v1/browser/claude/*` 只保留 legacy 包装与 Claude 辅助读
158 - 本文仍只讨论 WS transport、client registry 和 request-response 基础能力
159 
160 ## 最小 smoke
161@@ -358,7 +475,8 @@ EOF
162 - `GET /v1/browser` 持久化记录在断连和重启后的可读性
163 - `GET /v1/browser` 上 `fresh` / `stale` / `lost` 的状态变化
164 - `GET /v1/browser` 不回显原始 `cookie` / `token` / header 值
165-- `POST /v1/browser/claude/open`
166-- `POST /v1/browser/claude/send`
167-- `GET /v1/browser/claude/current`
168-- 以及 `/ws/firefox` 上的 `open_tab` 和 `api_request` / `api_response`
169+- `POST /v1/browser/actions`
170+- `POST /v1/browser/request`
171+- `POST /v1/browser/request/cancel`
172+- 正式 SSE 与 Claude legacy wrapper
173+- 以及 `/ws/firefox` 上的 `open_tab`、`request_cancel`、`api_request` / `api_response` / `stream_*`
M docs/firefox/README.md
+36, -22
  1@@ -23,7 +23,7 @@
  2 
  3 当前插件默认同时使用两条链路:
  4 
  5-- 本地 WS:负责 Firefox bridge 握手、浏览器元数据同步、服务端 `api_request` / `api_response`
  6+- 本地 WS:负责 Firefox bridge 握手、浏览器元数据同步、服务端 `api_request` / `request_cancel` 与 `stream_*`
  7 - 本地 HTTP:负责控制面状态同步,以及 `pause` / `resume` / `drain`
  8 
  9 插件管理页不再允许手工编辑地址。
 10@@ -77,8 +77,8 @@
 11 1. 先读 [`../api/business-interfaces.md`](../api/business-interfaces.md)
 12 2. 再调 `GET /describe/business`
 13 3. 再调 `GET /v1/browser`,确认需要的平台记录、`status` 和 `view`
 14-4. 如果目标是 Claude 浏览器代发,再调 `POST /v1/browser/claude/open`、`POST /v1/browser/claude/send`
 15-5. 只有在需要 Claude 辅助回读时,才调 `GET /v1/browser/claude/current`
 16+4. 如果目标是浏览器代发,先走 `POST /v1/browser/actions`、`POST /v1/browser/request`、`POST /v1/browser/request/cancel`
 17+5. 只有在需要 Claude legacy 包装或 Claude 辅助回读时,才调 `/v1/browser/claude/*`
 18 
 19 不要把这条链路当成通用公网浏览器自动化服务;正式链路依赖 `mini` 本地 Firefox 插件和本地 `/ws/firefox`。
 20 
 21@@ -144,19 +144,23 @@
 22 - 原始凭证值仍只停留在浏览器本地,用于后续同源代发
 23 - client 断开或流量长时间老化后,持久化记录仍可读,但会从 `fresh` 变成 `stale` / `lost`
 24 
 25-## Claude 浏览器本地代发
 26+## 浏览器本地代发
 27 
 28-当前正式对外 HTTP relay 只支持 Claude:
 29+当前正式对外 HTTP relay 已经收口到:
 30 
 31 1. `GET /v1/browser`
 32-2. `POST /v1/browser/claude/open`
 33-3. `POST /v1/browser/claude/send`
 34-4. `GET /v1/browser/claude/current`
 35+2. `POST /v1/browser/actions`
 36+3. `POST /v1/browser/request`
 37+4. `POST /v1/browser/request/cancel`
 38+5. `POST` / `GET /v1/browser/claude/*`(legacy 包装与 Claude 辅助读)
 39 
 40 这条链路的关键边界:
 41 
 42-- `send` / `current` 通过插件已有的页面内 HTTP 代理完成,不是 DOM 自动化
 43+- 当前正式 relay 平台仍只支持 Claude,但合同本身已经是通用 browser request / cancel / SSE
 44+- `request` / `current` 通过插件已有的页面内 HTTP 代理完成,不是 DOM 自动化
 45 - `conductor` 不直接持有原始 Claude 凭证
 46+- `responseMode=sse` 时,浏览器会通过 `stream_open` / `stream_event` / `stream_end` / `stream_error` 回传
 47+- `POST /v1/browser/request/cancel` 会向插件下发正式 `request_cancel`
 48 - 如果没有活跃 Firefox bridge client,会返回 `503`
 49 - 如果 client 还没有 Claude 凭证和 endpoint,会返回 `409`
 50 - ChatGPT / Gemini 当前不在正式 HTTP relay 合同里
 51@@ -189,7 +193,7 @@
 52 - 持久化记录在断连和重启后的可读性
 53 - `fresh` / `stale` / `lost` 状态变化
 54 - 读接口不泄露原始凭证值
 55-- Claude `open` / `send` / `current` 的最小浏览器本地代发闭环
 56+- 通用 browser actions / request / cancel、正式 SSE 和 Claude legacy wrapper 的最小浏览器本地代发闭环
 57 
 58 随后插件会继续上送:
 59 
 60@@ -197,10 +201,14 @@
 61 - `api_endpoints`
 62 - `client_log`
 63 - `api_response`
 64+- `stream_open`
 65+- `stream_event`
 66+- `stream_end`
 67+- `stream_error`
 68 
 69 其中:
 70 
 71-- `credentials` / `api_endpoints` / `api_response` 属于当前正式 bridge 合同
 72+- `credentials` / `api_endpoints` / `api_response` / `stream_*` 属于当前正式 bridge 合同
 73 - `network_log` / `sse_event` 仍是插件侧诊断数据,不属于当前服务端正式承诺的 runtime message
 74 
 75 同时也会消费服务端下发的:
 76@@ -208,13 +216,18 @@
 77 - `hello_ack`
 78 - `state_snapshot`
 79 - `open_tab`
 80+- `plugin_status`
 81+- `ws_reconnect`
 82+- `controller_reload`
 83+- `tab_restore`
 84 - `api_request`
 85+- `request_cancel`
 86 - `request_credentials`
 87 - `reload`
 88 - `action_result`
 89 - `error`
 90 
 91-其中 `api_request` 仍然走统一 proxy 通道;Claude 的 runtime message 只是先在插件本地把这条能力补齐,后续由 `conductor` 的 WS bridge 调用。
 92+其中 `api_request` 仍然走统一 proxy 通道;Claude legacy message 只是兼容包装,正式主模型已经回到通用 browser request / cancel / SSE。
 93 
 94 ## 验收建议
 95 
 96@@ -232,7 +245,7 @@
 97 - 持久化记录在断连和重启后的可读性
 98 - `fresh` / `stale` / `lost` 状态变化
 99 - 读接口不泄露原始凭证值
100-- Claude `open` / `send` / `current` 的最小浏览器本地代发闭环
101+- 通用 browser actions / request / cancel、正式 SSE 和 Claude legacy wrapper 的最小浏览器本地代发闭环
102 
103 ### 1. 元数据与持久化
104 
105@@ -259,14 +272,15 @@
106 4. 继续等待老化窗口
107 5. 确认同一条记录进一步进入 `lost`
108 
109-### 3. Claude relay
110+### 3. Browser relay
111 
112-1. 调 `POST /v1/browser/claude/open`
113-2. 确认 Claude shell tab 被打开或聚焦
114-3. 调 `POST /v1/browser/claude/send`
115-4. 确认请求经 `/ws/firefox` 转给浏览器本地代理,并收到 Claude API 回包
116-5. 如需辅助回读,再调 `GET /v1/browser/claude/current`
117-6. 把这个接口理解为 Claude relay 的辅助读面,不是浏览器桥接持久化主模型
118+1. 调 `POST /v1/browser/actions` 打开或聚焦 Claude shell tab
119+2. 调 `POST /v1/browser/request`
120+3. 确认请求经 `/ws/firefox` 转给浏览器本地代理,并收到 Claude API 回包
121+4. 再调 `POST /v1/browser/request` + `responseMode=sse`
122+5. 确认浏览器回传 `stream_open` / `stream_event` / `stream_end`
123+6. 如需终止 in-flight request 或流,调 `POST /v1/browser/request/cancel`
124+7. 如需 Claude 辅助回读或旧调用方兼容,再调 `/v1/browser/claude/*`
125 
126 ### 4. 控制面
127 
128@@ -276,10 +290,10 @@
129 
130 ## 已知限制
131 
132-- 当前正式浏览器本地代发 HTTP 面只有 Claude
133+- 当前正式浏览器本地代发 HTTP relay 平台只有 Claude
134 - ChatGPT / Gemini 当前只保留空壳页和元数据上报,不在正式 `/v1/browser/*` relay 合同里
135 - 必须先在真实 Claude 页面里产生过请求,插件才能学到可用凭证和 `org-id`
136-- `claude_send` / `claude_current` 走浏览器本地 HTTP 代理,不会驱动 Claude 页面 DOM,也不会把页面会话历史持久化到 `conductor`
137+- `browser/request`、`claude_send` / `claude_current` 走浏览器本地 HTTP 代理,不会驱动 Claude 页面 DOM,也不会把页面会话历史持久化到 `conductor`
138 - 当前 `/v1/browser/claude/current` 只是辅助回读最近一段 Claude 状态,不提供长期历史合同
139 
140 ## 相关文件
M docs/runtime/README.md
+1, -1
1@@ -89,7 +89,7 @@
2   - 会起临时 `codexd` + `conductor`,覆盖 `codexd status`、`GET /v1/codex`、session create/read、turn create/read,以及 `logs/codexd/**`、`state/codexd/**` 落盘
3 - 浏览器控制链路 smoke:
4   - `./scripts/runtime/browser-control-e2e-smoke.sh`
5-  - 会起临时 `conductor` + fake Firefox bridge client,覆盖 `GET /v1/browser` 的元数据上报、持久化读取、`fresh/stale/lost`、敏感值不泄露,以及 `POST /v1/browser/claude/open`、`POST /v1/browser/claude/send`、`GET /v1/browser/claude/current` 和 `/ws/firefox` 上的 `open_tab` 与 `api_request/api_response`
6+  - 会起临时 `conductor` + fake Firefox bridge client,覆盖 `GET /v1/browser` 的元数据上报、持久化读取、`fresh/stale/lost`、敏感值不泄露,以及 `POST /v1/browser/actions`、`POST /v1/browser/request`、`POST /v1/browser/request/cancel`、正式 SSE、legacy Claude wrapper 和 `/ws/firefox` 上的 `open_tab` / `request_cancel` / `api_request` / `stream_*`
7 
8 职责边界:
9 
M plans/BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md
+1, -1
1@@ -2,7 +2,7 @@
2 
3 ## 状态
4 
5-- `TODO`
6+- `已落地(T-S017` 到 `T-S024` 已完成主线收口)`
7 - 优先级:`high`
8 - 记录时间:`2026-03-26`
9 
M plans/FIREFOX_BRIDGE_CONTROL_REQUIREMENTS.md
+1, -1
1@@ -2,7 +2,7 @@
2 
3 ## 状态
4 
5-- `TODO`
6+- `已落地(T-S021` 到 `T-S024` 已收口主线实现)`
7 - 优先级:`high`
8 - 记录时间:`2026-03-26`
9 
M plans/STATUS_SUMMARY.md
+15, -10
 1@@ -9,12 +9,12 @@
 2 - 主线基线:`main@5d4febb`
 3 - 任务文档已统一收口到 `tasks/`
 4 - 当前活动任务见 `tasks/TASK_OVERVIEW.md`
 5-- `T-S001` 到 `T-S022` 已经完成
 6+- `T-S001` 到 `T-S024` 已经完成
 7 
 8 ## 当前状态分类
 9 
10-- `已完成`:`T-S001` 到 `T-S022`
11-- `当前 TODO`:`T-S023`、`T-S024`
12+- `已完成`:`T-S001` 到 `T-S024`
13+- `当前 TODO`:无高优先级主线任务
14 - `低优先级 TODO`:`4318/status-api` 兼容层删旧与解耦
15 
16 当前新的主需求文档:
17@@ -35,6 +35,7 @@
18 - `status-api` 当前默认读 `BAA_CONDUCTOR_LOCAL_API` / `4317`,`BAA_CONTROL_API_BASE` 只保留为兼容覆盖入口
19 - `status-api` 当前结论是继续保留为显式 opt-in 的本地兼容包装层,不立即删除
20 - 浏览器桥接当前正式模型已经收口为“登录态元数据持久化 + 空壳标签页 + 浏览器本地代发请求”
21+- 通用 browser request / cancel / SSE 与插件管理动作已经进入正式主线合同
22 
23 ## 当前在线面
24 
25@@ -55,16 +56,16 @@
26 - 单节点的 Nginx / DNS 计划脚本
27 - 迁移期兼容件:`apps/status-api`、`ops/cloudflare/**`、`tests/control-api/**`、`BAA_CONTROL_API_BASE`
28 
29-## 当前主 TODO
30+## 当前主线收口情况
31 
32-当前主线已切到浏览器桥接第二阶段开发:
33+浏览器桥接第二阶段已经完成主线收口:
34 
35-1. `T-S023`:打通通用 browser request/SSE 链路与 `conductor` 风控策略
36-2. `T-S024`:回写文档、补 smoke 并同步主线状态
37+1. `T-S023`:已完成,通用 browser request / cancel / SSE 链路和首版风控策略已接入主线
38+2. `T-S024`:已完成,README / docs / smoke / 状态视图已同步到正式口径
39 
40-当前开发策略:
41+当前策略:
42 
43-- 先完成 `T-S023`、`T-S024`
44+- 当前没有高优先级主线 blocker
45 - 当前不把大文件拆分当作主线 blocker
46 - 以下重构工作顺延到下一轮专门重构任务:
47   - `apps/conductor-daemon/src/local-api.ts`
48@@ -105,6 +106,8 @@
49 - `T-S020`:浏览器桥接文档、browser smoke 和状态视图已经回写到“登录态元数据持久化 + 空壳标签页 + 浏览器本地代发”正式模型
50 - `T-S021`:`conductor` 的浏览器能力发现已继续收口到 `business` / `control` 两层 describe,并定义了通用 browser HTTP 合同与 legacy Claude 包装位次
51 - `T-S022`:Firefox 插件侧已完成空壳页 runtime、`desired/actual` 状态模型和插件管理类 payload 准备
52+- `T-S023`:通用 browser request / cancel / SSE 链路、`stream_*` 事件模型和首版浏览器风控策略已经接入 `conductor` 与 Firefox bridge
53+- `T-S024`:README、API / Firefox / runtime 文档、browser smoke 和任务状态视图已同步到正式主线口径
54 - 根级 `pnpm smoke` 已进主线,覆盖 runtime public-api compatibility、legacy absence、codexd e2e 和 browser-control e2e smoke
55 
56 ## 4318 依赖盘点与结论
57@@ -134,6 +137,8 @@
58 - runtime smoke 当前仍假定仓库根已经存在 `state/`、`runs/`、`worktrees/`、`logs/launchd/`、`logs/codexd/`、`tmp/` 等本地运行目录;这是现有脚本前提,不是本轮浏览器桥接功能改动本身
59 - `pnpm verify:mini` 只收口 on-node 静态检查和运行态探针,不替代会话级 smoke
60 - `status-api` 的终局已经先收口到“保留为 opt-in 兼容层”;真正删除它之前,还要先清 `4318` 调用方并拆掉当前构建时复用
61+- 风控状态当前仍是进程内内存态;`conductor` 重启后,限流、退避和熔断计数会重置
62+- 正式 browser HTTP relay 当前仍只支持 Claude;其它平台目前只有空壳页和元数据链路,没有接入通用 request / SSE 合同
63 - 这轮还没跑真实 Firefox 手工 smoke,因此“手动关 tab -> tab_restore -> WS 重连后状态回报”的浏览器端闭环仍未实测
64-- `conductor` 还不会消费新增的 `shell_runtime` 字段,也没有正式的插件管理动作回执合同;插件侧 runtime 和 payload 已准备好,后续由 `T-S023` / `T-S024` 接入
65+- `conductor` 还不会消费新增的 `shell_runtime` 字段,也没有 richer 的插件管理动作结果合同;这两项已降为后续增量,不再算当前主线 blocker
66 - 当前多个源码文件已超过常规体量,但拆分重构已明确延后到浏览器桥接主线收口之后再做
M plugins/baa-firefox/README.md
+3, -2
 1@@ -159,10 +159,11 @@ browser.runtime.sendMessage({
 2 - Metadata: account / fingerprint / endpoint panels populate without exposing raw header values
 3 - `GET /v1/browser`: merged record shows `view` / `status` and does not expose raw credential values
 4 - Disconnect / aging: persisted record remains readable after disconnect and later changes `fresh -> stale -> lost`
 5-- Proxying: server-initiated `api_request` still completes through the shell tab
 6+- Proxying: server-initiated `api_request`、`request_cancel` 和 SSE `stream_*` 仍会通过 shell tab 完成
 7 
 8 ## Conductor 对接现状
 9 
10 - `GET /v1/browser` 已经会合并活跃 bridge 和持久化记录,并返回 `view`、`status`、`account`、凭证指纹和端点元数据
11 - client 断连或长时间老化后,持久化记录仍可读取,但状态会转成 `stale` / `lost`
12-- 当前正式浏览器本地代发 HTTP 面只有 Claude:`/v1/browser/claude/*`
13+- 当前正式浏览器写接口已经收口到 `POST /v1/browser/actions`、`POST /v1/browser/request`、`POST /v1/browser/request/cancel`
14+- `/v1/browser/claude/*` 只保留 legacy 包装与 Claude 辅助读
M plugins/baa-firefox/content-script.js
+13, -5
 1@@ -32,11 +32,19 @@ window.addEventListener("__baa_proxy_response__", (event) => {
 2 
 3 browser.runtime.onMessage.addListener((message) => {
 4   if (!message || typeof message !== "object") return undefined;
 5-  if (message.type !== "baa_page_proxy_request") return undefined;
 6-
 7-  window.dispatchEvent(new CustomEvent("__baa_proxy_request__", {
 8-    detail: JSON.stringify(message.data || {})
 9-  }));
10+  if (message.type === "baa_page_proxy_request") {
11+    window.dispatchEvent(new CustomEvent("__baa_proxy_request__", {
12+      detail: JSON.stringify(message.data || {})
13+    }));
14+
15+    return undefined;
16+  }
17+
18+  if (message.type === "baa_page_proxy_cancel") {
19+    window.dispatchEvent(new CustomEvent("__baa_proxy_cancel__", {
20+      detail: JSON.stringify(message.data || {})
21+    }));
22+  }
23 
24   return undefined;
25 });
M plugins/baa-firefox/controller.js
+180, -1
  1@@ -811,6 +811,31 @@ function parseClaudeSseText(text) {
  2   };
  3 }
  4 
  5+function parseStreamChunkPayload(chunk) {
  6+  const source = String(chunk || "");
  7+  const eventMatch = source.match(/(?:^|\n)event:\s*([^\n]+)/u);
  8+  const dataLines = source
  9+    .split("\n")
 10+    .filter((line) => line.startsWith("data:"))
 11+    .map((line) => line.slice(5).trimStart());
 12+  const dataText = dataLines.join("\n");
 13+  const payloadText = dataText || source;
 14+
 15+  try {
 16+    return {
 17+      data: JSON.parse(payloadText),
 18+      event: eventMatch?.[1]?.trim() || null,
 19+      raw: source
 20+    };
 21+  } catch (_) {
 22+    return {
 23+      data: payloadText,
 24+      event: eventMatch?.[1]?.trim() || null,
 25+      raw: source
 26+    };
 27+  }
 28+}
 29+
 30 function createPlatformMap(factory) {
 31   const out = {};
 32   for (const platform of PLATFORM_ORDER) {
 33@@ -3443,6 +3468,29 @@ function createPendingProxyRequest(id, meta = {}) {
 34   return entry;
 35 }
 36 
 37+function cancelPendingProxyRequest(id, reason = "browser_request_cancelled") {
 38+  const pending = pendingProxyRequests.get(id);
 39+
 40+  if (!pending) {
 41+    return false;
 42+  }
 43+
 44+  const tabId = Number.isInteger(pending.tabId) ? pending.tabId : null;
 45+
 46+  if (tabId != null) {
 47+    browser.tabs.sendMessage(tabId, {
 48+      type: "baa_page_proxy_cancel",
 49+      data: {
 50+        id,
 51+        reason
 52+      }
 53+    }).catch(() => {});
 54+  }
 55+
 56+  pending.reject(new Error(reason));
 57+  return true;
 58+}
 59+
 60 async function executeProxyRequest(payload, meta = {}) {
 61   const platform = payload?.platform;
 62   if (!platform || !PLATFORMS[platform]) {
 63@@ -3455,7 +3503,8 @@ async function executeProxyRequest(payload, meta = {}) {
 64     ...meta,
 65     platform,
 66     method: payload.method,
 67-    path: payload.path
 68+    path: payload.path,
 69+    tabId: tab.id
 70   });
 71 
 72   try {
 73@@ -3708,6 +3757,28 @@ function connectWs(options = {}) {
 74         break;
 75       }
 76       case "api_request":
 77+        if (String(message.response_mode || message.responseMode || "buffered").toLowerCase() === "sse") {
 78+          proxyApiRequest(message).catch((error) => {
 79+            if (error?.streamReported === true) {
 80+              addLog("error", `代理流 ${message.platform || "未知"} ${message.method || "GET"} ${message.path || "-"} 失败:${error.message}`);
 81+              return;
 82+            }
 83+
 84+            const streamId = trimToNull(message.stream_id) || trimToNull(message.streamId) || message.id;
 85+            const errorCode = error.message === "browser_request_cancelled" ? "request_cancelled" : "proxy_failed";
 86+            wsSend({
 87+              type: "stream_error",
 88+              id: message.id,
 89+              stream_id: streamId,
 90+              status: null,
 91+              code: errorCode,
 92+              message: error.message
 93+            });
 94+            addLog("error", `代理流 ${message.platform || "未知"} ${message.method || "GET"} ${message.path || "-"} 失败:${error.message}`);
 95+          });
 96+          break;
 97+        }
 98+
 99         proxyApiRequest(message).then((result) => {
100           sendApiResponse(message.id, result.ok, result.status, result.body, result.error);
101           if (!result.ok) {
102@@ -3718,6 +3789,20 @@ function connectWs(options = {}) {
103           addLog("error", `代理 ${message.platform || "未知"} ${message.method || "GET"} ${message.path || "-"} 失败:${error.message}`);
104         });
105         break;
106+      case "api_request_cancel":
107+      case "request_cancel": {
108+        const requestId = trimToNull(message.requestId) || trimToNull(message.id);
109+        const cancelled = requestId
110+          ? cancelPendingProxyRequest(requestId, trimToNull(message.reason) || "browser_request_cancelled")
111+          : false;
112+
113+        if (cancelled) {
114+          addLog("info", `已取消本地代理请求 ${requestId}`, false);
115+        } else if (requestId) {
116+          addLog("warn", `待取消的本地代理请求不存在:${requestId}`, false);
117+        }
118+        break;
119+      }
120       case "request_credentials":
121         sendCredentialSnapshot(message.platform || null, true);
122         break;
123@@ -4182,6 +4267,80 @@ function handlePageSse(data, sender) {
124   if (context.platform === "claude") {
125     applyObservedClaudeSse(data, context.tabId);
126   }
127+
128+  const pending = data.id ? pendingProxyRequests.get(data.id) : null;
129+  if (!pending || pending.responseMode !== "sse") {
130+    return;
131+  }
132+
133+  const streamId = trimToNull(data.stream_id) || pending.streamId || pending.id;
134+  const status = Number.isFinite(data.status) ? data.status : null;
135+
136+  if (!pending.streamOpened || data.open === true) {
137+    pending.streamOpened = true;
138+    pending.streamId = streamId;
139+    wsSend({
140+      type: "stream_open",
141+      id: pending.id,
142+      stream_id: streamId,
143+      status,
144+      meta: {
145+        method: data.method || pending.method || null,
146+        platform: context.platform,
147+        url: data.url || pending.path || null
148+      }
149+    });
150+  }
151+
152+  if (typeof data.chunk === "string" && data.chunk.trim()) {
153+    const parsedChunk = parseStreamChunkPayload(data.chunk);
154+    const seq = Number.isFinite(data.seq) && data.seq > 0
155+      ? data.seq
156+      : (Number(pending.streamSeq) || 0) + 1;
157+    pending.streamSeq = seq;
158+    wsSend({
159+      type: "stream_event",
160+      id: pending.id,
161+      stream_id: streamId,
162+      seq,
163+      event: parsedChunk.event,
164+      raw: parsedChunk.raw,
165+      data: parsedChunk.data
166+    });
167+  }
168+
169+  if (data.done === true) {
170+    wsSend({
171+      type: "stream_end",
172+      id: pending.id,
173+      stream_id: streamId,
174+      status
175+    });
176+    pending.resolve({
177+      body: null,
178+      error: null,
179+      id: pending.id,
180+      method: data.method || pending.method || null,
181+      ok: true,
182+      status,
183+      url: data.url || pending.path || null
184+    });
185+    return;
186+  }
187+
188+  if (data.error) {
189+    const streamError = new Error(data.error);
190+    streamError.streamReported = true;
191+    wsSend({
192+      type: "stream_error",
193+      id: pending.id,
194+      stream_id: streamId,
195+      status,
196+      code: data.error === "browser_request_cancelled" ? "request_cancelled" : "stream_error",
197+      message: data.error
198+    });
199+    pending.reject(streamError);
200+  }
201 }
202 
203 function handlePageProxyResponse(data, sender) {
204@@ -4243,6 +4402,22 @@ function handlePageProxyResponse(data, sender) {
205     }
206   }
207 
208+  if (pending.responseMode === "sse" && (data.error || (Number.isFinite(data.status) && data.status >= 400))) {
209+    const message = data.error || `upstream_status_${data.status}`;
210+    const streamError = new Error(message);
211+    streamError.streamReported = true;
212+    wsSend({
213+      type: "stream_error",
214+      id: pending.id,
215+      stream_id: pending.streamId || pending.id,
216+      status: Number.isFinite(data.status) ? data.status : null,
217+      code: message === "browser_request_cancelled" ? "request_cancelled" : "stream_error",
218+      message
219+    });
220+    pending.reject(streamError);
221+    return;
222+  }
223+
224   pending.resolve({
225     id: data.id,
226     ok: data.ok !== false && !data.error,
227@@ -4389,6 +4564,8 @@ async function proxyApiRequest(message) {
228   if (!platform || !PLATFORMS[platform]) throw new Error(`未知平台:${platform || "-"}`);
229   if (!apiPath) throw new Error("缺少代理请求路径");
230 
231+  const responseMode = String(message?.response_mode || message?.responseMode || "buffered").toLowerCase();
232+  const streamId = trimToNull(message?.stream_id) || trimToNull(message?.streamId) || id;
233   const prompt = platform === "gemini" ? extractPromptFromProxyBody(body) : null;
234   const geminiAutoRequest = platform === "gemini" && prompt && isGeminiStreamGenerateUrl(apiPath)
235     ? buildGeminiAutoRequest(prompt)
236@@ -4404,6 +4581,8 @@ async function proxyApiRequest(message) {
237   }, {
238     platform,
239     prompt,
240+    responseMode,
241+    streamId,
242     attempts: 0
243   });
244 }
M plugins/baa-firefox/page-interceptor.js
+206, -3
  1@@ -7,6 +7,7 @@
  2   const originalXhrOpen = XMLHttpRequest.prototype.open;
  3   const originalXhrSend = XMLHttpRequest.prototype.send;
  4   const originalXhrSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
  5+  const activeProxyControllers = new Map();
  6 
  7   function hostnameMatches(hostname, hosts) {
  8     return hosts.some((host) => hostname === host || hostname.endsWith(`.${host}`));
  9@@ -284,6 +285,140 @@
 10     }
 11   }
 12 
 13+  async function streamProxyResponse(detail, response, startedAt, rule, requestBody) {
 14+    const contentType = response.headers.get("content-type") || "";
 15+    const shouldSplitChunks = contentType.includes("text/event-stream");
 16+    const streamId = detail.stream_id || detail.streamId || detail.id;
 17+    let seq = 0;
 18+
 19+    emitSse({
 20+      id: detail.id,
 21+      stream_id: streamId,
 22+      method: detail.method,
 23+      open: true,
 24+      reqBody: requestBody,
 25+      status: response.status,
 26+      ts: Date.now(),
 27+      url: detail.url
 28+    }, rule);
 29+
 30+    try {
 31+      if (!response.body) {
 32+        emitSse(
 33+          response.status >= 400
 34+            ? {
 35+                error: `upstream_status_${response.status}`,
 36+                id: detail.id,
 37+                method: detail.method,
 38+                reqBody: requestBody,
 39+                status: response.status,
 40+                stream_id: streamId,
 41+                ts: Date.now(),
 42+                url: detail.url
 43+              }
 44+            : {
 45+                id: detail.id,
 46+                stream_id: streamId,
 47+                done: true,
 48+                duration: Date.now() - startedAt,
 49+                method: detail.method,
 50+                reqBody: requestBody,
 51+                status: response.status,
 52+                ts: Date.now(),
 53+                url: detail.url
 54+              },
 55+          rule
 56+        );
 57+        return;
 58+      }
 59+
 60+      const reader = response.body.getReader();
 61+      const decoder = new TextDecoder();
 62+      let buffer = "";
 63+
 64+      while (true) {
 65+        const { done, value } = await reader.read();
 66+        if (done) break;
 67+
 68+        buffer += decoder.decode(value, { stream: true });
 69+        const chunks = shouldSplitChunks ? buffer.split("\n\n") : [buffer];
 70+        buffer = shouldSplitChunks ? (chunks.pop() || "") : "";
 71+
 72+        for (const chunk of chunks) {
 73+          if (!chunk.trim()) continue;
 74+          seq += 1;
 75+          emitSse({
 76+            chunk,
 77+            id: detail.id,
 78+            method: detail.method,
 79+            reqBody: requestBody,
 80+            seq,
 81+            status: response.status,
 82+            stream_id: streamId,
 83+            ts: Date.now(),
 84+            url: detail.url
 85+          }, rule);
 86+        }
 87+      }
 88+
 89+      buffer += decoder.decode();
 90+      if (buffer.trim()) {
 91+        seq += 1;
 92+        emitSse({
 93+          chunk: buffer,
 94+          id: detail.id,
 95+          method: detail.method,
 96+          reqBody: requestBody,
 97+          seq,
 98+          status: response.status,
 99+          stream_id: streamId,
100+          ts: Date.now(),
101+          url: detail.url
102+        }, rule);
103+      }
104+
105+      emitSse(
106+        response.status >= 400
107+          ? {
108+              error: `upstream_status_${response.status}`,
109+              id: detail.id,
110+              method: detail.method,
111+              reqBody: requestBody,
112+              seq,
113+              status: response.status,
114+              stream_id: streamId,
115+              ts: Date.now(),
116+              url: detail.url
117+            }
118+          : {
119+              done: true,
120+              duration: Date.now() - startedAt,
121+              id: detail.id,
122+              method: detail.method,
123+              reqBody: requestBody,
124+              seq,
125+              status: response.status,
126+              stream_id: streamId,
127+              ts: Date.now(),
128+              url: detail.url
129+            },
130+        rule
131+      );
132+    } catch (error) {
133+      emitSse({
134+        error: error.message,
135+        id: detail.id,
136+        method: detail.method,
137+        reqBody: requestBody,
138+        seq,
139+        status: response.status,
140+        stream_id: streamId,
141+        ts: Date.now(),
142+        url: detail.url
143+      }, rule);
144+    }
145+  }
146+
147   window.addEventListener("__baa_proxy_request__", async (event) => {
148     let detail = event.detail || {};
149     if (typeof detail === "string") {
150@@ -297,9 +432,13 @@
151     const id = detail.id;
152     const method = String(detail.method || "GET").toUpperCase();
153     const rawPath = detail.path || detail.url || location.href;
154+    const responseMode = String(detail.response_mode || detail.responseMode || "buffered").toLowerCase();
155 
156     if (!id) return;
157 
158+    const proxyAbortController = new AbortController();
159+    activeProxyControllers.set(id, proxyAbortController);
160+
161     try {
162       const url = new URL(rawPath, location.origin).href;
163       const context = getRequestContext(url);
164@@ -326,15 +465,41 @@
165         method,
166         headers,
167         body,
168-        credentials: "include"
169+        credentials: "include",
170+        signal: proxyAbortController.signal
171       });
172-
173-      const responseBody = await response.text();
174       const resHeaders = readHeaders(response.headers);
175       const contentType = response.headers.get("content-type") || "";
176       const isSse = context ? context.rule.isSse(context.parsed.pathname, contentType) : false;
177       const reqHeaders = readHeaders(headers);
178       const reqBody = typeof body === "string" ? trim(body) : null;
179+
180+      if (responseMode === "sse") {
181+        emitNet({
182+          url,
183+          method,
184+          reqHeaders,
185+          reqBody,
186+          status: response.status,
187+          resHeaders,
188+          resBody: null,
189+          duration: Date.now() - startedAt,
190+          sse: true,
191+          source: "proxy"
192+        }, pageRule);
193+
194+        const replayDetail = {
195+          ...detail,
196+          id,
197+          method,
198+          stream_id: detail.stream_id || detail.streamId || id,
199+          url
200+        };
201+        await streamProxyResponse(replayDetail, response, startedAt, pageRule, reqBody);
202+        return;
203+      }
204+
205+      const responseBody = await response.text();
206       const trimmedResponseBody = trim(responseBody);
207 
208       emitNet({
209@@ -386,6 +551,21 @@
210         error: error.message,
211         source: "proxy"
212       }, pageRule);
213+
214+      if (responseMode === "sse") {
215+        emitSse({
216+          error: error.message,
217+          id,
218+          method,
219+          reqBody: typeof detail.body === "string" ? trim(detail.body) : trimBodyValue(detail.body),
220+          status: null,
221+          stream_id: detail.stream_id || detail.streamId || id,
222+          ts: Date.now(),
223+          url: rawPath
224+        }, pageRule);
225+        return;
226+      }
227+
228       emit("__baa_proxy_response__", {
229         id,
230         platform: pageRule.platform,
231@@ -394,7 +574,30 @@
232         ok: false,
233         error: error.message
234       }, pageRule);
235+    } finally {
236+      activeProxyControllers.delete(id);
237+    }
238+  });
239+
240+  window.addEventListener("__baa_proxy_cancel__", (event) => {
241+    let detail = event.detail || {};
242+
243+    if (typeof detail === "string") {
244+      try {
245+        detail = JSON.parse(detail);
246+      } catch (_) {
247+        detail = {};
248+      }
249     }
250+
251+    const id = detail?.id || detail?.requestId;
252+    if (!id) return;
253+
254+    const controller = activeProxyControllers.get(id);
255+    if (!controller) return;
256+
257+    activeProxyControllers.delete(id);
258+    controller.abort(detail?.reason || "browser_request_cancelled");
259   });
260 
261   window.fetch = async function patchedFetch(input, init) {
M tasks/T-S023.md
+6, -1
 1@@ -22,7 +22,7 @@
 2 
 3 ## 当前状态
 4 
 5-- `TODO`
 6+- `已完成(2026-03-26)`
 7 
 8 ## 建议分支名
 9 
10@@ -158,3 +158,8 @@
11 - 兼容了哪些 Claude 旧路径
12 - 跑了哪些验证
13 - 还有哪些剩余风险
14+
15+## 当前残余风险
16+
17+- 风控状态当前仍是进程内内存态;`conductor` 重启后,限流、退避和熔断计数会重置。
18+- 正式 browser HTTP relay 当前仍只支持 Claude;其它平台目前只有空壳页和元数据链路,还没有接入通用 request / SSE 合同。
M tasks/T-S024.md
+14, -1
 1@@ -25,7 +25,7 @@
 2 
 3 ## 当前状态
 4 
 5-- `TODO`
 6+- `已完成(2026-03-26)`
 7 
 8 ## 建议分支名
 9 
10@@ -140,3 +140,16 @@
11 - 哪些旧文案被删除、降级或标为 legacy
12 - 跑了哪些验证
13 - 还有哪些残余风险
14+
15+## 当前残余风险
16+
17+- 为满足现有 runtime smoke 前置条件,本机仓库根已补空目录:
18+  - `state/`
19+  - `runs/`
20+  - `worktrees/`
21+  - `logs/launchd`
22+  - `logs/codexd`
23+  - `tmp/`
24+- 这些目录不是 git 跟踪改动,而是当前脚本和本机运行环境前提。
25+- 真实 Firefox 手工 smoke 仍未执行,因此“手动关 tab -> `tab_restore` -> WS 重连后状态回报”的浏览器端闭环还没实测。
26+- `shell_runtime` 和 richer 的插件动作结果合同仍未接入 `conductor`,但它们已降为后续增量,不再算当前主线 blocker。
M tasks/TASK_OVERVIEW.md
+18, -12
 1@@ -13,8 +13,8 @@
 2 
 3 ## 状态分类
 4 
 5-- `已完成`:`T-S001` 到 `T-S022`
 6-- `当前 TODO`:`T-S023` 到 `T-S024`
 7+- `已完成`:`T-S001` 到 `T-S024`
 8+- `当前 TODO`:无高优先级主线任务
 9 - `低优先级 TODO`:`4318/status-api` 兼容层删旧与解耦
10 
11 当前新的主需求文档:
12@@ -46,6 +46,10 @@
13 18. [`T-S018.md`](./T-S018.md):把 Firefox 插件收口到空壳标签页并上报账号/指纹/端点
14 19. [`T-S019.md`](./T-S019.md):让 conductor 持久化浏览器登录态并提供查询面
15 20. [`T-S020.md`](./T-S020.md):回写浏览器桥接文档、补持久化 smoke 并同步状态视图
16+21. [`T-S021.md`](./T-S021.md):收口 `conductor` describe 与通用 browser HTTP 合同
17+22. [`T-S022.md`](./T-S022.md):实现 Firefox 空壳页 runtime 与插件管理动作
18+23. [`T-S023.md`](./T-S023.md):打通通用 browser request / cancel / SSE 链路与 `conductor` 风控策略
19+24. [`T-S024.md`](./T-S024.md):回写正式文档、补 browser smoke 并同步主线状态
20 
21 当前主线已经额外收口:
22 
23@@ -58,12 +62,12 @@
24 
25 ## 当前活动任务
26 
27-- 当前主线已切到浏览器桥接第二阶段开发
28-- 当前活动任务:[`T-S023.md`](./T-S023.md)、[`T-S024.md`](./T-S024.md)
29+- 浏览器桥接第二阶段已完成主线收口
30+- 当前没有高优先级活动任务卡;如需继续推进,直接新开后续任务
31 
32-## 当前 TODO
33+## 当前主线收口情况
34 
35-当前浏览器桥接主线已进入正式开发阶段:
36+当前浏览器桥接主线第二阶段已经完成:
37 
38 1. [`T-S017.md`](./T-S017.md):已完成,提供浏览器登录态持久化模型与仓储
39 2. [`T-S018.md`](./T-S018.md):已完成,Firefox 插件已收口到空壳标签页并开始上报账号/指纹/端点
40@@ -71,8 +75,8 @@
41 4. [`T-S020.md`](./T-S020.md):已完成,文档、browser smoke 和任务状态视图已同步到正式模型
42 5. [`T-S021.md`](./T-S021.md):已完成,收口 `conductor` describe 与通用 browser HTTP 合同
43 6. [`T-S022.md`](./T-S022.md):已完成,实现 Firefox 空壳页 runtime 与插件管理动作
44-7. [`T-S023.md`](./T-S023.md):打通通用 browser request/SSE 链路与 `conductor` 风控策略
45-8. [`T-S024.md`](./T-S024.md):回写文档、补 smoke 并同步主线状态
46+7. [`T-S023.md`](./T-S023.md):已完成,通用 browser request / cancel / SSE 链路和首版风控策略已接入主线
47+8. [`T-S024.md`](./T-S024.md):已完成,README / docs / smoke / 状态视图已经同步到正式口径
48 
49 建议并行关系:
50 
51@@ -80,16 +84,18 @@
52 - `T-S019` 已完成集成和验收收口
53 - `T-S020` 已在 `T-S019` 之后完成收尾
54 - `T-S021` 与 `T-S022` 已并行完成
55-- `T-S023` 现在负责服务端集成和通用 request/SSE 主链路
56-- `T-S024` 在 `T-S023` 之后做文档与 smoke 收尾
57+- `T-S023` 已完成服务端集成和通用 request/SSE 主链路收口
58+- `T-S024` 已在 `T-S023` 之后完成文档与 smoke 收尾
59 
60 当前已知主线遗留:
61 
62-- 当前主线开发任务已切到 `T-S023` 到 `T-S024`
63+- 当前高优先级浏览器桥接任务已收口;后续若继续扩展,需单独新开任务卡
64+- 风控状态当前仍是进程内内存态;`conductor` 重启后,限流、退避和熔断计数会重置
65+- 正式 browser HTTP relay 当前仍只支持 Claude;其它平台目前只有空壳页和元数据链路,没有接入通用 request / SSE 合同
66 - runtime smoke 仍依赖仓库根已有 `state/`、`runs/`、`worktrees/`、`logs/launchd/`、`logs/codexd/`、`tmp/` 等本地运行目录;这是现有脚本前提,不是本轮功能回归
67 - 本地工作树里仍存在与本轮并行任务无关的 `plugins/baa-firefox/controller.js` 改动;后续开发继续避免覆盖它
68 - 这轮还没跑真实 Firefox 手工 smoke,因此“手动关 tab -> `tab_restore` -> WS 重连后状态回报”的浏览器端闭环仍未实测
69-- `conductor` 还不会消费新增的 `shell_runtime` 字段,也没有正式的插件管理动作回执合同;插件侧 runtime 和 payload 已准备好,后续由 `T-S023` / `T-S024` 接入
70+- `conductor` 还不会消费新增的 `shell_runtime` 字段,也没有 richer 的插件管理动作结果合同;这两项已降为后续增量,不再算当前主线 blocker
71 
72 ## 低优先级 TODO
73 
M tests/browser/browser-control-e2e-smoke.test.mjs
+201, -0
  1@@ -183,6 +183,35 @@ async function fetchJson(url, init) {
  2   };
  3 }
  4 
  5+async function fetchText(url, init) {
  6+  const response = await fetch(url, init);
  7+  const text = await response.text();
  8+
  9+  return {
 10+    response,
 11+    text
 12+  };
 13+}
 14+
 15+function parseSseFrames(text) {
 16+  return String(text || "")
 17+    .split(/\n\n+/u)
 18+    .map((chunk) => chunk.trim())
 19+    .filter(Boolean)
 20+    .map((chunk) => {
 21+      const lines = chunk.split("\n");
 22+      const eventLine = lines.find((line) => line.startsWith("event:"));
 23+      const dataLines = lines
 24+        .filter((line) => line.startsWith("data:"))
 25+        .map((line) => line.slice(5).trimStart());
 26+
 27+      return {
 28+        data: JSON.parse(dataLines.join("\n")),
 29+        event: eventLine ? eventLine.slice(6).trim() : null
 30+      };
 31+    });
 32+}
 33+
 34 function assertNoSecretLeak(text, secrets) {
 35   for (const secret of secrets) {
 36     assert.doesNotMatch(text, new RegExp(secret.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"), "u"));
 37@@ -303,6 +332,178 @@ test("browser control e2e smoke covers metadata read surface plus Claude relay",
 38     const openMessage = await client.queue.next((message) => message.type === "open_tab");
 39     assert.equal(openMessage.platform, "claude");
 40 
 41+    const pluginStatusResult = await fetchJson(`${baseUrl}/v1/browser/actions`, {
 42+      method: "POST",
 43+      headers: {
 44+        "content-type": "application/json"
 45+      },
 46+      body: JSON.stringify({
 47+        action: "plugin_status",
 48+        client_id: "firefox-browser-control-smoke"
 49+      })
 50+    });
 51+    assert.equal(pluginStatusResult.response.status, 200);
 52+    assert.equal(pluginStatusResult.payload.data.action, "plugin_status");
 53+
 54+    const pluginStatusMessage = await client.queue.next(
 55+      (message) => message.type === "plugin_status"
 56+    );
 57+    assert.equal(pluginStatusMessage.type, "plugin_status");
 58+
 59+    const tabRestoreResult = await fetchJson(`${baseUrl}/v1/browser/actions`, {
 60+      method: "POST",
 61+      headers: {
 62+        "content-type": "application/json"
 63+      },
 64+      body: JSON.stringify({
 65+        action: "tab_restore",
 66+        client_id: "firefox-browser-control-smoke",
 67+        platform: "claude",
 68+        reason: "smoke-test"
 69+      })
 70+    });
 71+    assert.equal(tabRestoreResult.response.status, 200);
 72+    assert.equal(tabRestoreResult.payload.data.action, "tab_restore");
 73+
 74+    const tabRestoreMessage = await client.queue.next(
 75+      (message) => message.type === "tab_restore"
 76+    );
 77+    assert.equal(tabRestoreMessage.platform, "claude");
 78+    assert.equal(tabRestoreMessage.reason, "smoke-test");
 79+
 80+    const browserStreamPromise = fetchText(`${baseUrl}/v1/browser/request`, {
 81+      method: "POST",
 82+      headers: {
 83+        "content-type": "application/json"
 84+      },
 85+      body: JSON.stringify({
 86+        platform: "claude",
 87+        method: "POST",
 88+        path: "/api/organizations/org-smoke-1/chat_conversations/conv-smoke-1/completion",
 89+        requestBody: {
 90+          prompt: "Stream the bridge state."
 91+        },
 92+        requestId: "browser-stream-smoke",
 93+        responseMode: "sse"
 94+      })
 95+    });
 96+
 97+    const browserStreamRequest = await client.queue.next(
 98+      (message) => message.type === "api_request" && message.id === "browser-stream-smoke"
 99+    );
100+    assert.equal(browserStreamRequest.method, "POST");
101+    assert.equal(
102+      browserStreamRequest.path,
103+      "/api/organizations/org-smoke-1/chat_conversations/conv-smoke-1/completion"
104+    );
105+    assert.equal(browserStreamRequest.response_mode, "sse");
106+    assert.equal(browserStreamRequest.stream_id, "browser-stream-smoke");
107+    assert.equal(browserStreamRequest.body.prompt, "Stream the bridge state.");
108+
109+    client.socket.send(
110+      JSON.stringify({
111+        type: "stream_open",
112+        id: "browser-stream-smoke",
113+        stream_id: "browser-stream-smoke",
114+        status: 200,
115+        meta: {
116+          source: "smoke"
117+        }
118+      })
119+    );
120+    client.socket.send(
121+      JSON.stringify({
122+        type: "stream_event",
123+        id: "browser-stream-smoke",
124+        stream_id: "browser-stream-smoke",
125+        seq: 1,
126+        event: "message",
127+        data: {
128+          delta: "Bridge is streaming."
129+        },
130+        raw: 'data: {"delta":"Bridge is streaming."}'
131+      })
132+    );
133+    client.socket.send(
134+      JSON.stringify({
135+        type: "stream_end",
136+        id: "browser-stream-smoke",
137+        stream_id: "browser-stream-smoke",
138+        status: 200
139+      })
140+    );
141+
142+    const browserStreamResult = await browserStreamPromise;
143+    assert.equal(browserStreamResult.response.status, 200);
144+    assert.equal(
145+      browserStreamResult.response.headers.get("content-type"),
146+      "text/event-stream; charset=utf-8"
147+    );
148+    const browserStreamFrames = parseSseFrames(browserStreamResult.text);
149+    assert.deepEqual(
150+      browserStreamFrames.map((frame) => frame.event),
151+      ["stream_open", "stream_event", "stream_end"]
152+    );
153+    assert.equal(browserStreamFrames[0].data.request_id, "browser-stream-smoke");
154+    assert.equal(browserStreamFrames[0].data.response_mode, "sse");
155+    assert.equal(browserStreamFrames[1].data.seq, 1);
156+    assert.equal(browserStreamFrames[1].data.data.delta, "Bridge is streaming.");
157+    assert.equal(browserStreamFrames[2].data.stream_id, "browser-stream-smoke");
158+
159+    const cancelableRequestPromise = fetchJson(`${baseUrl}/v1/browser/request`, {
160+      method: "POST",
161+      headers: {
162+        "content-type": "application/json"
163+      },
164+      body: JSON.stringify({
165+        platform: "claude",
166+        method: "GET",
167+        path: "/api/organizations",
168+        requestId: "browser-cancel-smoke"
169+      })
170+    });
171+
172+    const cancelableRequest = await client.queue.next(
173+      (message) => message.type === "api_request" && message.id === "browser-cancel-smoke"
174+    );
175+    assert.equal(cancelableRequest.method, "GET");
176+    assert.equal(cancelableRequest.path, "/api/organizations");
177+
178+    const cancelResult = await fetchJson(`${baseUrl}/v1/browser/request/cancel`, {
179+      method: "POST",
180+      headers: {
181+        "content-type": "application/json"
182+      },
183+      body: JSON.stringify({
184+        platform: "claude",
185+        requestId: "browser-cancel-smoke",
186+        reason: "smoke-test"
187+      })
188+    });
189+    assert.equal(cancelResult.response.status, 200);
190+    assert.equal(cancelResult.payload.data.status, "cancel_requested");
191+    assert.equal(cancelResult.payload.data.type, "request_cancel");
192+
193+    const cancelMessage = await client.queue.next(
194+      (message) => message.type === "request_cancel" && message.id === "browser-cancel-smoke"
195+    );
196+    assert.equal(cancelMessage.platform, "claude");
197+    assert.equal(cancelMessage.reason, "smoke-test");
198+
199+    client.socket.send(
200+      JSON.stringify({
201+        type: "api_response",
202+        id: "browser-cancel-smoke",
203+        ok: false,
204+        status: 499,
205+        error: "browser_request_cancelled"
206+      })
207+    );
208+
209+    const cancelledRequestResult = await cancelableRequestPromise;
210+    assert.equal(cancelledRequestResult.response.status, 499);
211+    assert.equal(cancelledRequestResult.payload.error, "browser_upstream_error");
212+
213     const sendPromise = fetchJson(`${baseUrl}/v1/browser/claude/send`, {
214       method: "POST",
215       headers: {