baa-conductor

git clone 

commit
6e458f3
parent
6b819bf
author
im_wower
date
2026-03-28 16:06:38 +0800 CST
feat: route browser delivery through proxy requests
10 files changed,  +1231, -114
M apps/conductor-daemon/src/artifacts/types.ts
+26, -0
 1@@ -1,10 +1,25 @@
 2 export type BaaDeliverySessionStage =
 3   | "idle"
 4+  | "proxying"
 5   | "injecting"
 6   | "sending"
 7   | "completed"
 8   | "failed";
 9 
10+export type BaaDeliverySessionMode = "proxy" | "dom_fallback";
11+
12+export interface BaaDeliveryRouteSnapshot {
13+  assistantMessageId: string;
14+  conversationId: string | null;
15+  observedAt: number;
16+  organizationId: string | null;
17+  pageTitle: string | null;
18+  pageUrl: string | null;
19+  platform: string;
20+  shellPage: boolean;
21+  tabId: number | null;
22+}
23+
24 export interface BaaDeliverySessionSnapshot {
25   autoSend: boolean;
26   clientId: string | null;
27@@ -12,6 +27,7 @@ export interface BaaDeliverySessionSnapshot {
28   connectionId: string | null;
29   conversationId: string | null;
30   createdAt: number;
31+  mode: BaaDeliverySessionMode | null;
32   executionCount: number;
33   failedAt: number | null;
34   failedReason: string | null;
35@@ -24,16 +40,26 @@ export interface BaaDeliverySessionSnapshot {
36   messageTruncated: boolean;
37   planId: string;
38   platform: string;
39+  proxyCompletedAt: number | null;
40+  proxyFailedReason: string | null;
41+  proxyRequestId: string | null;
42+  proxyStartedAt: number | null;
43   roundId: string;
44   sendCompletedAt: number | null;
45   sendRequestId: string | null;
46   sendStartedAt: number | null;
47   sourceLineCount: number;
48   stage: BaaDeliverySessionStage;
49+  targetOrganizationId: string | null;
50+  targetPageTitle: string | null;
51+  targetPageUrl: string | null;
52+  targetShellPage: boolean;
53+  targetTabId: number | null;
54   traceId: string;
55 }
56 
57 export interface BaaDeliveryBridgeSnapshot {
58   activeSessionCount: number;
59+  lastRoute: BaaDeliveryRouteSnapshot | null;
60   lastSession: BaaDeliverySessionSnapshot | null;
61 }
M apps/conductor-daemon/src/artifacts/upload-session.ts
+209, -34
  1@@ -6,6 +6,7 @@ import {
  2 } from "../instructions/types.js";
  3 
  4 import type {
  5+  BaaDeliveryRouteSnapshot,
  6   BaaDeliveryBridgeSnapshot,
  7   BaaDeliverySessionSnapshot
  8 } from "./types.js";
  9@@ -49,6 +50,7 @@ export interface BaaBrowserDeliveryInput {
 10   conversationId?: string | null;
 11   platform: string;
 12   processResult: BaaInstructionProcessResult | null;
 13+  route?: BaaDeliveryRouteSnapshot | null;
 14 }
 15 
 16 function sanitizePathSegment(value: string): string {
 17@@ -57,6 +59,20 @@ function sanitizePathSegment(value: string): string {
 18   return collapsed === "" ? "unknown" : collapsed;
 19 }
 20 
 21+function cloneRouteSnapshot(snapshot: BaaDeliveryRouteSnapshot): BaaDeliveryRouteSnapshot {
 22+  return {
 23+    assistantMessageId: snapshot.assistantMessageId,
 24+    conversationId: snapshot.conversationId,
 25+    observedAt: snapshot.observedAt,
 26+    organizationId: snapshot.organizationId,
 27+    pageTitle: snapshot.pageTitle,
 28+    pageUrl: snapshot.pageUrl,
 29+    platform: snapshot.platform,
 30+    shellPage: snapshot.shellPage,
 31+    tabId: snapshot.tabId
 32+  };
 33+}
 34+
 35 function cloneSessionSnapshot(snapshot: BaaDeliverySessionSnapshot): BaaDeliverySessionSnapshot {
 36   return {
 37     autoSend: snapshot.autoSend,
 38@@ -65,6 +81,7 @@ function cloneSessionSnapshot(snapshot: BaaDeliverySessionSnapshot): BaaDelivery
 39     connectionId: snapshot.connectionId,
 40     conversationId: snapshot.conversationId,
 41     createdAt: snapshot.createdAt,
 42+    mode: snapshot.mode,
 43     executionCount: snapshot.executionCount,
 44     failedAt: snapshot.failedAt,
 45     failedReason: snapshot.failedReason,
 46@@ -77,12 +94,21 @@ function cloneSessionSnapshot(snapshot: BaaDeliverySessionSnapshot): BaaDelivery
 47     messageTruncated: snapshot.messageTruncated,
 48     planId: snapshot.planId,
 49     platform: snapshot.platform,
 50+    proxyCompletedAt: snapshot.proxyCompletedAt,
 51+    proxyFailedReason: snapshot.proxyFailedReason,
 52+    proxyRequestId: snapshot.proxyRequestId,
 53+    proxyStartedAt: snapshot.proxyStartedAt,
 54     roundId: snapshot.roundId,
 55     sendCompletedAt: snapshot.sendCompletedAt,
 56     sendRequestId: snapshot.sendRequestId,
 57     sendStartedAt: snapshot.sendStartedAt,
 58     sourceLineCount: snapshot.sourceLineCount,
 59     stage: snapshot.stage,
 60+    targetOrganizationId: snapshot.targetOrganizationId,
 61+    targetPageTitle: snapshot.targetPageTitle,
 62+    targetPageUrl: snapshot.targetPageUrl,
 63+    targetShellPage: snapshot.targetShellPage,
 64+    targetTabId: snapshot.targetTabId,
 65     traceId: snapshot.traceId
 66   };
 67 }
 68@@ -288,8 +314,51 @@ function normalizePositiveInteger(value: unknown, fallback: number): number {
 69     : fallback;
 70 }
 71 
 72+function normalizeRoute(route: BaaDeliveryRouteSnapshot | null | undefined): BaaDeliveryRouteSnapshot | null {
 73+  return route == null ? null : cloneRouteSnapshot(route);
 74+}
 75+
 76+function buildMissingRouteReason(
 77+  input: Pick<BaaBrowserDeliveryInput, "assistantMessageId" | "conversationId" | "platform">
 78+): string {
 79+  return [
 80+    "delivery.route_missing: missing business-page delivery target",
 81+    `platform=${input.platform}`,
 82+    `conversation=${input.conversationId ?? "-"}`,
 83+    `assistant=${input.assistantMessageId}`
 84+  ].join(" ");
 85+}
 86+
 87+function buildInvalidRouteReason(route: BaaDeliveryRouteSnapshot): string {
 88+  if (route.shellPage) {
 89+    return [
 90+      "delivery.shell_page: delivery target resolves to shell page",
 91+      `platform=${route.platform}`,
 92+      `conversation=${route.conversationId ?? "-"}`,
 93+      `tab=${route.tabId ?? "-"}`
 94+    ].join(" ");
 95+  }
 96+
 97+  return [
 98+    "delivery.route_invalid: delivery target is missing a concrete page tab",
 99+    `platform=${route.platform}`,
100+    `conversation=${route.conversationId ?? "-"}`,
101+    `tab=${route.tabId ?? "-"}`
102+  ].join(" ");
103+}
104+
105+function shouldFailClosedWithoutFallback(reason: string): boolean {
106+  return reason.startsWith("delivery.route_")
107+    || reason.startsWith("delivery.shell_page:")
108+    || reason.startsWith("delivery.page_paused:")
109+    || reason.startsWith("delivery.target_mismatch:")
110+    || reason.startsWith("delivery.target_missing:")
111+    || reason.startsWith("delivery.tab_missing:");
112+}
113+
114 export class BaaBrowserDeliveryBridge {
115   private readonly bridge: BrowserBridgeController;
116+  private lastRoute: BaaDeliveryRouteSnapshot | null = null;
117   private lastSession: BaaDeliverySessionSnapshot | null = null;
118   private readonly lineLimit: number;
119   private readonly now: () => number;
120@@ -310,10 +379,42 @@ export class BaaBrowserDeliveryBridge {
121       activeSessionCount: [...this.sessions.values()].filter((entry) =>
122         entry.snapshot.stage !== "completed" && entry.snapshot.stage !== "failed"
123       ).length,
124+      lastRoute: this.lastRoute == null ? null : cloneRouteSnapshot(this.lastRoute),
125       lastSession: this.lastSession == null ? null : cloneSessionSnapshot(this.lastSession)
126     };
127   }
128 
129+  observeRoute(route: BaaDeliveryRouteSnapshot | null | undefined): void {
130+    if (route == null) {
131+      return;
132+    }
133+
134+    this.lastRoute = cloneRouteSnapshot(route);
135+    this.signalChange();
136+  }
137+
138+  private resolveRoute(input: BaaBrowserDeliveryInput): BaaDeliveryRouteSnapshot | null {
139+    const directRoute = normalizeRoute(input.route);
140+
141+    if (directRoute != null) {
142+      return directRoute;
143+    }
144+
145+    if (this.lastRoute == null || this.lastRoute.platform !== input.platform) {
146+      return null;
147+    }
148+
149+    if (this.lastRoute.assistantMessageId === input.assistantMessageId) {
150+      return cloneRouteSnapshot(this.lastRoute);
151+    }
152+
153+    if (input.conversationId != null && this.lastRoute.conversationId === input.conversationId) {
154+      return cloneRouteSnapshot(this.lastRoute);
155+    }
156+
157+    return null;
158+  }
159+
160   async deliver(input: BaaBrowserDeliveryInput): Promise<BaaDeliverySessionSnapshot | null> {
161     this.cleanupExpiredSessions();
162 
163@@ -333,6 +434,7 @@ export class BaaBrowserDeliveryBridge {
164     const roundId = `round_${this.now()}`;
165     const planId = `${traceId}_${roundId}`;
166     const createdAt = this.now();
167+    const route = this.resolveRoute(input);
168     const session: BaaDeliverySessionSnapshot = {
169       autoSend: input.autoSend ?? true,
170       clientId: input.clientId ?? null,
171@@ -340,6 +442,7 @@ export class BaaBrowserDeliveryBridge {
172       connectionId: input.connectionId ?? null,
173       conversationId: input.conversationId ?? null,
174       createdAt,
175+      mode: null,
176       executionCount: rendered.executionCount,
177       failedAt: null,
178       failedReason: null,
179@@ -352,12 +455,21 @@ export class BaaBrowserDeliveryBridge {
180       messageTruncated: rendered.messageTruncated,
181       planId,
182       platform: input.platform,
183+      proxyCompletedAt: null,
184+      proxyFailedReason: null,
185+      proxyRequestId: null,
186+      proxyStartedAt: null,
187       roundId,
188       sendCompletedAt: null,
189       sendRequestId: null,
190       sendStartedAt: null,
191       sourceLineCount: rendered.sourceLineCount,
192-      stage: "injecting",
193+      stage: "proxying",
194+      targetOrganizationId: route?.organizationId ?? null,
195+      targetPageTitle: route?.pageTitle ?? null,
196+      targetPageUrl: route?.pageUrl ?? null,
197+      targetShellPage: route?.shellPage === true,
198+      targetTabId: route?.tabId ?? null,
199       traceId
200     };
201     const record: BaaDeliverySessionRecord = {
202@@ -369,66 +481,129 @@ export class BaaBrowserDeliveryBridge {
203     this.sessions.set(planId, record);
204     this.captureLastSession(record.snapshot);
205 
206+    if (route == null) {
207+      this.failSession(record, buildMissingRouteReason(input));
208+      return cloneSessionSnapshot(record.snapshot);
209+    }
210+
211+    if (route.shellPage || route.tabId == null) {
212+      this.failSession(record, buildInvalidRouteReason(route));
213+      return cloneSessionSnapshot(record.snapshot);
214+    }
215+
216     try {
217-      const injectDispatch = this.bridge.injectMessage({
218+      const proxyDispatch = this.bridge.proxyDelivery({
219+        assistantMessageId: input.assistantMessageId,
220         clientId: input.clientId,
221-        conversationId: input.conversationId,
222+        conversationId: route.conversationId,
223         messageText: rendered.messageText,
224+        organizationId: route.organizationId,
225+        pageTitle: route.pageTitle,
226+        pageUrl: route.pageUrl,
227         planId,
228         platform: input.platform,
229-        pollIntervalMs: DEFAULT_DELIVERY_POLL_INTERVAL_MS,
230-        retryAttempts: DEFAULT_DELIVERY_RETRY_ATTEMPTS,
231-        retryDelayMs: DEFAULT_DELIVERY_RETRY_DELAY_MS,
232+        shellPage: route.shellPage,
233+        tabId: route.tabId,
234         timeoutMs: DEFAULT_DELIVERY_ACTION_TIMEOUT_MS
235       });
236 
237-      record.snapshot.injectRequestId = injectDispatch.requestId;
238-      record.snapshot.injectStartedAt = injectDispatch.dispatchedAt;
239+      record.snapshot.mode = "proxy";
240+      record.snapshot.proxyRequestId = proxyDispatch.requestId;
241+      record.snapshot.proxyStartedAt = proxyDispatch.dispatchedAt;
242       this.captureLastSession(record.snapshot);
243-      const injectResult = await injectDispatch.result;
244+      const proxyResult = await proxyDispatch.result;
245 
246-      if (injectResult.accepted !== true || injectResult.failed === true) {
247-        throw new Error(injectResult.reason ?? "browser inject_message failed");
248+      if (proxyResult.accepted !== true || proxyResult.failed === true) {
249+        throw new Error(proxyResult.reason ?? "browser proxy delivery failed");
250       }
251 
252-      record.snapshot.injectCompletedAt = this.now();
253+      record.snapshot.proxyCompletedAt = this.now();
254+      record.snapshot.completedAt = this.now();
255+      record.snapshot.stage = "completed";
256+      record.expiresAt = record.snapshot.completedAt + DEFAULT_COMPLETED_SESSION_TTL_MS;
257+      this.captureLastSession(record.snapshot);
258+      return cloneSessionSnapshot(record.snapshot);
259+    } catch (error) {
260+      const proxyFailureReason = error instanceof Error ? error.message : String(error);
261+      record.snapshot.proxyFailedReason = proxyFailureReason;
262+      this.captureLastSession(record.snapshot);
263+
264+      if (shouldFailClosedWithoutFallback(proxyFailureReason)) {
265+        this.failSession(record, proxyFailureReason);
266+        return cloneSessionSnapshot(record.snapshot);
267+      }
268 
269-      if (record.snapshot.autoSend) {
270-        const sendDispatch = this.bridge.sendMessage({
271+      try {
272+        const injectDispatch = this.bridge.injectMessage({
273           clientId: input.clientId,
274-          conversationId: input.conversationId,
275+          conversationId: route.conversationId,
276+          messageText: rendered.messageText,
277           planId,
278           platform: input.platform,
279           pollIntervalMs: DEFAULT_DELIVERY_POLL_INTERVAL_MS,
280           retryAttempts: DEFAULT_DELIVERY_RETRY_ATTEMPTS,
281           retryDelayMs: DEFAULT_DELIVERY_RETRY_DELAY_MS,
282+          pageTitle: route.pageTitle,
283+          pageUrl: route.pageUrl,
284+          shellPage: route.shellPage,
285+          tabId: route.tabId,
286           timeoutMs: DEFAULT_DELIVERY_ACTION_TIMEOUT_MS
287         });
288 
289-        record.snapshot.sendRequestId = sendDispatch.requestId;
290-        record.snapshot.sendStartedAt = sendDispatch.dispatchedAt;
291-        record.snapshot.stage = "sending";
292+        record.snapshot.mode = "dom_fallback";
293+        record.snapshot.injectRequestId = injectDispatch.requestId;
294+        record.snapshot.injectStartedAt = injectDispatch.dispatchedAt;
295+        record.snapshot.stage = "injecting";
296         this.captureLastSession(record.snapshot);
297-        const sendResult = await sendDispatch.result;
298+        const injectResult = await injectDispatch.result;
299 
300-        if (sendResult.accepted !== true || sendResult.failed === true) {
301-          throw new Error(sendResult.reason ?? "browser send_message failed");
302+        if (injectResult.accepted !== true || injectResult.failed === true) {
303+          throw new Error(injectResult.reason ?? "browser inject_message failed");
304         }
305 
306-        record.snapshot.sendCompletedAt = this.now();
307-      }
308+        record.snapshot.injectCompletedAt = this.now();
309+
310+        if (record.snapshot.autoSend) {
311+          const sendDispatch = this.bridge.sendMessage({
312+            clientId: input.clientId,
313+            conversationId: route.conversationId,
314+            planId,
315+            platform: input.platform,
316+            pollIntervalMs: DEFAULT_DELIVERY_POLL_INTERVAL_MS,
317+            retryAttempts: DEFAULT_DELIVERY_RETRY_ATTEMPTS,
318+            retryDelayMs: DEFAULT_DELIVERY_RETRY_DELAY_MS,
319+            pageTitle: route.pageTitle,
320+            pageUrl: route.pageUrl,
321+            shellPage: route.shellPage,
322+            tabId: route.tabId,
323+            timeoutMs: DEFAULT_DELIVERY_ACTION_TIMEOUT_MS
324+          });
325+
326+          record.snapshot.sendRequestId = sendDispatch.requestId;
327+          record.snapshot.sendStartedAt = sendDispatch.dispatchedAt;
328+          record.snapshot.stage = "sending";
329+          this.captureLastSession(record.snapshot);
330+          const sendResult = await sendDispatch.result;
331+
332+          if (sendResult.accepted !== true || sendResult.failed === true) {
333+            throw new Error(sendResult.reason ?? "browser send_message failed");
334+          }
335+
336+          record.snapshot.sendCompletedAt = this.now();
337+        }
338 
339-      record.snapshot.completedAt = this.now();
340-      record.snapshot.stage = "completed";
341-      record.expiresAt = record.snapshot.completedAt + DEFAULT_COMPLETED_SESSION_TTL_MS;
342-      this.captureLastSession(record.snapshot);
343-      return cloneSessionSnapshot(record.snapshot);
344-    } catch (error) {
345-      this.failSession(
346-        record,
347-        error instanceof Error ? error.message : String(error)
348-      );
349-      throw error;
350+        record.snapshot.completedAt = this.now();
351+        record.snapshot.stage = "completed";
352+        record.expiresAt = record.snapshot.completedAt + DEFAULT_COMPLETED_SESSION_TTL_MS;
353+        this.captureLastSession(record.snapshot);
354+        return cloneSessionSnapshot(record.snapshot);
355+      } catch (fallbackError) {
356+        this.failSession(
357+          record,
358+          fallbackError instanceof Error ? fallbackError.message : String(fallbackError)
359+        );
360+        return cloneSessionSnapshot(record.snapshot);
361+      }
362     }
363   }
364 
M apps/conductor-daemon/src/browser-types.ts
+27, -0
 1@@ -79,8 +79,13 @@ export interface BrowserBridgeFinalMessageSnapshot {
 2   assistant_message_id: string;
 3   conversation_id: string | null;
 4   observed_at: number;
 5+  organization_id?: string | null;
 6+  page_title?: string | null;
 7+  page_url?: string | null;
 8   platform: string;
 9   raw_text: string;
10+  shell_page?: boolean;
11+  tab_id?: number | null;
12 }
13 
14 export interface BrowserBridgeActionResultTargetSnapshot {
15@@ -263,21 +268,43 @@ export interface BrowserBridgeController {
16     clientId?: string | null;
17     conversationId?: string | null;
18     messageText: string;
19+    pageTitle?: string | null;
20+    pageUrl?: string | null;
21     planId: string;
22     pollIntervalMs?: number | null;
23     platform: string;
24     retryAttempts?: number | null;
25     retryDelayMs?: number | null;
26+    shellPage?: boolean | null;
27+    tabId?: number | null;
28+    timeoutMs?: number | null;
29+  }): BrowserBridgeActionDispatch;
30+  proxyDelivery(input: {
31+    assistantMessageId: string;
32+    clientId?: string | null;
33+    conversationId?: string | null;
34+    messageText: string;
35+    organizationId?: string | null;
36+    pageTitle?: string | null;
37+    pageUrl?: string | null;
38+    planId: string;
39+    platform: string;
40+    shellPage?: boolean | null;
41+    tabId?: number | null;
42     timeoutMs?: number | null;
43   }): BrowserBridgeActionDispatch;
44   sendMessage(input: {
45     clientId?: string | null;
46     conversationId?: string | null;
47+    pageTitle?: string | null;
48+    pageUrl?: string | null;
49     planId: string;
50     pollIntervalMs?: number | null;
51     platform: string;
52     retryAttempts?: number | null;
53     retryDelayMs?: number | null;
54+    shellPage?: boolean | null;
55+    tabId?: number | null;
56     timeoutMs?: number | null;
57   }): BrowserBridgeActionDispatch;
58   cancelApiRequest(input: {
M apps/conductor-daemon/src/firefox-bridge.ts
+62, -0
  1@@ -19,6 +19,7 @@ export type FirefoxBridgeResponseMode = "buffered" | "sse";
  2 export type FirefoxBridgeOutboundCommandType =
  3   | "api_request"
  4   | "browser.inject_message"
  5+  | "browser.proxy_delivery"
  6   | "browser.send_message"
  7   | "controller_reload"
  8   | "open_tab"
  9@@ -103,21 +104,43 @@ export interface FirefoxApiRequestCommandInput extends FirefoxBridgeCommandTarge
 10 export interface FirefoxInjectMessageCommandInput extends FirefoxBridgeCommandTarget {
 11   conversationId?: string | null;
 12   messageText: string;
 13+  pageTitle?: string | null;
 14+  pageUrl?: string | null;
 15   planId: string;
 16   pollIntervalMs?: number | null;
 17   platform: string;
 18   retryAttempts?: number | null;
 19   retryDelayMs?: number | null;
 20+  shellPage?: boolean | null;
 21+  tabId?: number | null;
 22+  timeoutMs?: number | null;
 23+}
 24+
 25+export interface FirefoxProxyDeliveryCommandInput extends FirefoxBridgeCommandTarget {
 26+  assistantMessageId: string;
 27+  conversationId?: string | null;
 28+  messageText: string;
 29+  organizationId?: string | null;
 30+  pageTitle?: string | null;
 31+  pageUrl?: string | null;
 32+  planId: string;
 33+  platform: string;
 34+  shellPage?: boolean | null;
 35+  tabId?: number | null;
 36   timeoutMs?: number | null;
 37 }
 38 
 39 export interface FirefoxSendMessageCommandInput extends FirefoxBridgeCommandTarget {
 40   conversationId?: string | null;
 41+  pageTitle?: string | null;
 42+  pageUrl?: string | null;
 43   planId: string;
 44   pollIntervalMs?: number | null;
 45   platform: string;
 46   retryAttempts?: number | null;
 47   retryDelayMs?: number | null;
 48+  shellPage?: boolean | null;
 49+  tabId?: number | null;
 50   timeoutMs?: number | null;
 51 }
 52 
 53@@ -1443,11 +1466,46 @@ export class FirefoxBridgeService {
 54         action: "inject_message",
 55         conversation_id: normalizeOptionalString(input.conversationId) ?? undefined,
 56         message_text: input.messageText,
 57+        page_title: normalizeOptionalString(input.pageTitle) ?? undefined,
 58+        page_url: normalizeOptionalString(input.pageUrl) ?? undefined,
 59         poll_interval_ms: normalizeOptionalNonNegativeInteger(input.pollIntervalMs),
 60         plan_id: input.planId,
 61         platform: input.platform,
 62         retry_attempts: normalizeOptionalPositiveInteger(input.retryAttempts),
 63         retry_delay_ms: normalizeOptionalNonNegativeInteger(input.retryDelayMs),
 64+        shell_page: typeof input.shellPage === "boolean" ? input.shellPage : undefined,
 65+        target_tab_id: normalizeOptionalPositiveInteger(input.tabId),
 66+        timeout_ms: normalizeOptionalPositiveInteger(input.timeoutMs)
 67+      }),
 68+      compactRecord({
 69+        clientId: normalizeOptionalString(input.clientId) ?? undefined
 70+      }),
 71+      normalizeTimeoutMs(
 72+        input.timeoutMs == null
 73+          ? DEFAULT_BAA_DELIVERY_ACTION_RESULT_TIMEOUT
 74+          : input.timeoutMs + 5_000,
 75+        DEFAULT_BAA_DELIVERY_ACTION_RESULT_TIMEOUT
 76+      )
 77+    );
 78+  }
 79+
 80+  proxyDelivery(
 81+    input: FirefoxProxyDeliveryCommandInput
 82+  ): BrowserBridgeActionDispatch {
 83+    return this.broker.dispatchWithActionResult(
 84+      "browser.proxy_delivery",
 85+      compactRecord({
 86+        action: "proxy_delivery",
 87+        assistant_message_id: normalizeOptionalString(input.assistantMessageId) ?? input.assistantMessageId,
 88+        conversation_id: normalizeOptionalString(input.conversationId) ?? undefined,
 89+        message_text: input.messageText,
 90+        organization_id: normalizeOptionalString(input.organizationId) ?? undefined,
 91+        page_title: normalizeOptionalString(input.pageTitle) ?? undefined,
 92+        page_url: normalizeOptionalString(input.pageUrl) ?? undefined,
 93+        plan_id: input.planId,
 94+        platform: input.platform,
 95+        shell_page: typeof input.shellPage === "boolean" ? input.shellPage : undefined,
 96+        target_tab_id: normalizeOptionalPositiveInteger(input.tabId),
 97         timeout_ms: normalizeOptionalPositiveInteger(input.timeoutMs)
 98       }),
 99       compactRecord({
100@@ -1470,11 +1528,15 @@ export class FirefoxBridgeService {
101       compactRecord({
102         action: "send_message",
103         conversation_id: normalizeOptionalString(input.conversationId) ?? undefined,
104+        page_title: normalizeOptionalString(input.pageTitle) ?? undefined,
105+        page_url: normalizeOptionalString(input.pageUrl) ?? undefined,
106         poll_interval_ms: normalizeOptionalNonNegativeInteger(input.pollIntervalMs),
107         plan_id: input.planId,
108         platform: input.platform,
109         retry_attempts: normalizeOptionalPositiveInteger(input.retryAttempts),
110         retry_delay_ms: normalizeOptionalNonNegativeInteger(input.retryDelayMs),
111+        shell_page: typeof input.shellPage === "boolean" ? input.shellPage : undefined,
112+        target_tab_id: normalizeOptionalPositiveInteger(input.tabId),
113         timeout_ms: normalizeOptionalPositiveInteger(input.timeoutMs)
114       }),
115       compactRecord({
M apps/conductor-daemon/src/firefox-ws.ts
+49, -2
 1@@ -4,6 +4,7 @@ import type { Socket } from "node:net";
 2 import type { ControlPlaneRepository } from "../../../packages/db/dist/index.js";
 3 
 4 import { BaaBrowserDeliveryBridge } from "./artifacts/upload-session.js";
 5+import type { BaaDeliveryRouteSnapshot } from "./artifacts/types.js";
 6 import {
 7   FirefoxBridgeService,
 8   FirefoxCommandBroker,
 9@@ -172,6 +173,38 @@ function readOptionalInteger(
10   return null;
11 }
12 
13+function buildDeliveryRouteSnapshot(
14+  message: Record<string, unknown>,
15+  fallback: {
16+    assistantMessageId: string;
17+    conversationId: string | null;
18+    observedAt: number;
19+    platform: string;
20+  }
21+): BaaDeliveryRouteSnapshot | null {
22+  const tabId = readOptionalInteger(message, ["tab_id", "tabId"]);
23+  const pageUrl = readFirstString(message, ["page_url", "pageUrl"]);
24+  const pageTitle = readFirstString(message, ["page_title", "pageTitle"]);
25+  const shellPage = readOptionalBoolean(message, ["shell_page", "shellPage"]) === true;
26+  const organizationId = readFirstString(message, ["organization_id", "organizationId"]);
27+
28+  if (tabId == null && pageUrl == null && pageTitle == null && organizationId == null && !shellPage) {
29+    return null;
30+  }
31+
32+  return {
33+    assistantMessageId: fallback.assistantMessageId,
34+    conversationId: fallback.conversationId,
35+    observedAt: fallback.observedAt,
36+    organizationId,
37+    pageTitle,
38+    pageUrl,
39+    platform: fallback.platform,
40+    shellPage,
41+    tabId
42+  };
43+}
44+
45 function readStringArray(
46   input: Record<string, unknown>,
47   key: string
48@@ -1235,6 +1268,7 @@ export class ConductorFirefoxWebSocketServer {
49           "state_snapshot",
50           "action_result",
51           "browser.inject_message",
52+          "browser.proxy_delivery",
53           "browser.send_message",
54           "request_credentials",
55           "open_tab",
56@@ -1570,11 +1604,23 @@ export class ConductorFirefoxWebSocketServer {
57       observed_at:
58         readOptionalTimestampMilliseconds(message, ["observed_at", "observedAt"])
59         ?? this.getNowMilliseconds(),
60+      organization_id: readFirstString(message, ["organization_id", "organizationId"]),
61+      page_title: readFirstString(message, ["page_title", "pageTitle"]),
62+      page_url: readFirstString(message, ["page_url", "pageUrl"]),
63       platform,
64-      raw_text: rawText
65+      raw_text: rawText,
66+      shell_page: readOptionalBoolean(message, ["shell_page", "shellPage"]) === true,
67+      tab_id: readOptionalInteger(message, ["tab_id", "tabId"])
68     };
69+    const route = buildDeliveryRouteSnapshot(message, {
70+      assistantMessageId: finalMessage.assistant_message_id,
71+      conversationId: finalMessage.conversation_id,
72+      observedAt: finalMessage.observed_at,
73+      platform: finalMessage.platform
74+    });
75 
76     connection.addFinalMessage(finalMessage);
77+    this.deliveryBridge.observeRoute(route);
78     await this.broadcastStateSnapshot("browser.final_message");
79 
80     if (this.instructionIngest == null) {
81@@ -1602,7 +1648,8 @@ export class ConductorFirefoxWebSocketServer {
82         connectionId: connection.getConnectionId(),
83         conversationId: finalMessage.conversation_id,
84         platform: finalMessage.platform,
85-        processResult: ingestResult.processResult
86+        processResult: ingestResult.processResult,
87+        route
88       });
89     } catch {
90       // delivery session state is already written back into browser snapshots
M apps/conductor-daemon/src/index.test.js
+21, -22
 1@@ -4889,7 +4889,7 @@ test("persistent live ingest survives restart and /v1/browser restores recent hi
 2   }
 3 });
 4 
 5-test("ConductorRuntime exposes text-only browser delivery snapshots", async () => {
 6+test("ConductorRuntime exposes proxy-delivery browser snapshots with routed business-page targets", async () => {
 7   const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-delivery-text-"));
 8   const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-conductor-delivery-text-host-"));
 9   const runtime = new ConductorRuntime(
10@@ -4924,6 +4924,8 @@ test("ConductorRuntime exposes text-only browser delivery snapshots", async () =
11         platform: "chatgpt",
12         conversation_id: "conv-delivery-artifact",
13         assistant_message_id: "msg-delivery-artifact",
14+        page_title: "Delivery Target",
15+        page_url: "https://chatgpt.com/c/conv-delivery-artifact",
16         raw_text: [
17           "```baa",
18           `@conductor::exec::${JSON.stringify({
19@@ -4932,7 +4934,9 @@ test("ConductorRuntime exposes text-only browser delivery snapshots", async () =
20           })}`,
21           "```"
22         ].join("\n"),
23-        observed_at: 1710000030000
24+        observed_at: 1710000030000,
25+        shell_page: false,
26+        tab_id: 71
27       })
28     );
29 
30@@ -4941,30 +4945,21 @@ test("ConductorRuntime exposes text-only browser delivery snapshots", async () =
31       (message) => message.type === "browser.upload_artifacts",
32       700
33     );
34-    const injectMessage = await client.queue.next(
35-      (message) => message.type === "browser.inject_message"
36+    const proxyDelivery = await client.queue.next(
37+      (message) => message.type === "browser.proxy_delivery"
38     );
39-    assert.match(injectMessage.message_text, /\[BAA 执行结果\]/u);
40-    assert.match(injectMessage.message_text, /line-1/u);
41-    assert.match(injectMessage.message_text, /超长截断$/u);
42+    assert.match(proxyDelivery.message_text, /\[BAA 执行结果\]/u);
43+    assert.match(proxyDelivery.message_text, /line-1/u);
44+    assert.match(proxyDelivery.message_text, /超长截断$/u);
45+    assert.equal(proxyDelivery.page_url, "https://chatgpt.com/c/conv-delivery-artifact");
46+    assert.equal(proxyDelivery.target_tab_id, 71);
47 
48     sendPluginActionResult(client.socket, {
49-      action: "inject_message",
50-      commandType: "browser.inject_message",
51+      action: "proxy_delivery",
52+      commandType: "browser.proxy_delivery",
53       platform: "chatgpt",
54-      requestId: injectMessage.requestId,
55-      type: "browser.inject_message"
56-    });
57-
58-    const sendMessage = await client.queue.next(
59-      (message) => message.type === "browser.send_message"
60-    );
61-    sendPluginActionResult(client.socket, {
62-      action: "send_message",
63-      commandType: "browser.send_message",
64-      platform: "chatgpt",
65-      requestId: sendMessage.requestId,
66-      type: "browser.send_message"
67+      requestId: proxyDelivery.requestId,
68+      type: "browser.proxy_delivery"
69     });
70 
71     const browserStatus = await waitForCondition(async () => {
72@@ -4974,7 +4969,11 @@ test("ConductorRuntime exposes text-only browser delivery snapshots", async () =
73       return result;
74     });
75 
76+    assert.equal(browserStatus.payload.data.delivery.last_session.delivery_mode, "proxy");
77     assert.equal(browserStatus.payload.data.delivery.last_session.message_truncated, true);
78+    assert.equal(browserStatus.payload.data.delivery.last_session.target_page_url, "https://chatgpt.com/c/conv-delivery-artifact");
79+    assert.equal(browserStatus.payload.data.delivery.last_session.target_tab_id, 71);
80+    assert.equal(browserStatus.payload.data.delivery.last_route.page_url, "https://chatgpt.com/c/conv-delivery-artifact");
81     assert.ok(
82       browserStatus.payload.data.delivery.last_session.source_line_count
83       > browserStatus.payload.data.delivery.last_session.message_line_count
M apps/conductor-daemon/src/local-api.ts
+27, -0
 1@@ -1706,6 +1706,7 @@ function createEmptyBrowserState(snapshot: ConductorRuntimeApiSnapshot): Browser
 2     clients: [],
 3     delivery: {
 4       activeSessionCount: 0,
 5+      lastRoute: null,
 6       lastSession: null
 7     },
 8     instruction_ingest: createEmptyBrowserInstructionIngestSnapshot(),
 9@@ -1728,6 +1729,7 @@ function normalizeBrowserStateSnapshot(state: BrowserBridgeStateSnapshot): Brows
10     ...state,
11     delivery: state.delivery ?? {
12       activeSessionCount: 0,
13+      lastRoute: null,
14       lastSession: null
15     },
16     instruction_ingest: state.instruction_ingest ?? createEmptyBrowserInstructionIngestSnapshot()
17@@ -2432,11 +2434,26 @@ function serializeBrowserDeliverySnapshot(
18 ): JsonObject {
19   const normalized = snapshot ?? {
20     activeSessionCount: 0,
21+    lastRoute: null,
22     lastSession: null
23   };
24 
25   return compactJsonObject({
26     active_session_count: normalized.activeSessionCount,
27+    last_route:
28+      normalized.lastRoute == null
29+        ? null
30+        : compactJsonObject({
31+            assistant_message_id: normalized.lastRoute.assistantMessageId,
32+            conversation_id: normalized.lastRoute.conversationId ?? undefined,
33+            observed_at: normalized.lastRoute.observedAt,
34+            organization_id: normalized.lastRoute.organizationId ?? undefined,
35+            page_title: normalized.lastRoute.pageTitle ?? undefined,
36+            page_url: normalized.lastRoute.pageUrl ?? undefined,
37+            platform: normalized.lastRoute.platform,
38+            shell_page: normalized.lastRoute.shellPage,
39+            tab_id: normalized.lastRoute.tabId ?? undefined
40+          }),
41     last_session:
42       normalized.lastSession == null
43         ? null
44@@ -2447,6 +2464,7 @@ function serializeBrowserDeliverySnapshot(
45             connection_id: normalized.lastSession.connectionId ?? undefined,
46             conversation_id: normalized.lastSession.conversationId ?? undefined,
47             created_at: normalized.lastSession.createdAt,
48+            delivery_mode: normalized.lastSession.mode ?? undefined,
49             execution_count: normalized.lastSession.executionCount,
50             failed_at: normalized.lastSession.failedAt ?? undefined,
51             failed_reason: normalized.lastSession.failedReason ?? undefined,
52@@ -2459,12 +2477,21 @@ function serializeBrowserDeliverySnapshot(
53             message_truncated: normalized.lastSession.messageTruncated,
54             plan_id: normalized.lastSession.planId,
55             platform: normalized.lastSession.platform,
56+            proxy_completed_at: normalized.lastSession.proxyCompletedAt ?? undefined,
57+            proxy_failed_reason: normalized.lastSession.proxyFailedReason ?? undefined,
58+            proxy_request_id: normalized.lastSession.proxyRequestId ?? undefined,
59+            proxy_started_at: normalized.lastSession.proxyStartedAt ?? undefined,
60             round_id: normalized.lastSession.roundId,
61             send_completed_at: normalized.lastSession.sendCompletedAt ?? undefined,
62             send_request_id: normalized.lastSession.sendRequestId ?? undefined,
63             send_started_at: normalized.lastSession.sendStartedAt ?? undefined,
64             source_line_count: normalized.lastSession.sourceLineCount,
65             stage: normalized.lastSession.stage,
66+            target_organization_id: normalized.lastSession.targetOrganizationId ?? undefined,
67+            target_page_title: normalized.lastSession.targetPageTitle ?? undefined,
68+            target_page_url: normalized.lastSession.targetPageUrl ?? undefined,
69+            target_shell_page: normalized.lastSession.targetShellPage,
70+            target_tab_id: normalized.lastSession.targetTabId ?? undefined,
71             trace_id: normalized.lastSession.traceId
72           })
73   });
M plugins/baa-firefox/controller.js
+480, -11
  1@@ -213,6 +213,7 @@ const state = {
  2   account: createPlatformMap(() => createDefaultAccountState()),
  3   lastCredentialHash: createPlatformMap(() => ""),
  4   lastCredentialSentAt: createPlatformMap(() => 0),
  5+  chatgptSendTemplates: {},
  6   geminiSendTemplate: null,
  7   claudeState: createDefaultClaudeState(),
  8   controllerRuntime: createDefaultControllerRuntimeState(),
  9@@ -557,6 +558,26 @@ function getPageControlState(platform, tabId) {
 10   return key ? clonePageControlState(state.pageControls[key]) : createDefaultPageControlState();
 11 }
 12 
 13+function findPageControlByConversation(platform, conversationId) {
 14+  const normalizedConversationId = trimToNull(conversationId);
 15+
 16+  if (!platform || !normalizedConversationId) {
 17+    return null;
 18+  }
 19+
 20+  return listPageControlStates({ platform }).find((entry) => entry.conversationId === normalizedConversationId) || null;
 21+}
 22+
 23+function findPageControlByUrl(platform, pageUrl) {
 24+  const normalizedPageUrl = trimToNull(pageUrl);
 25+
 26+  if (!platform || !normalizedPageUrl) {
 27+    return null;
 28+  }
 29+
 30+  return listPageControlStates({ platform }).find((entry) => entry.pageUrl === normalizedPageUrl) || null;
 31+}
 32+
 33 function summarizePageControls(platform) {
 34   const entries = listPageControlStates({ platform });
 35 
 36@@ -2972,6 +2993,166 @@ function extractPromptFromProxyBody(body) {
 37   return typeof prompt === "string" && prompt.trim() ? prompt.trim() : null;
 38 }
 39 
 40+function cloneJsonValue(value) {
 41+  if (value == null) {
 42+    return value;
 43+  }
 44+
 45+  try {
 46+    return JSON.parse(JSON.stringify(value));
 47+  } catch (_) {
 48+    return null;
 49+  }
 50+}
 51+
 52+function pruneChatgptSendTemplates(limit = 12) {
 53+  const entries = Object.entries(state.chatgptSendTemplates || {})
 54+    .filter(([, entry]) => isRecord(entry))
 55+    .sort(([, left], [, right]) => (Number(right.updatedAt) || 0) - (Number(left.updatedAt) || 0));
 56+
 57+  for (const [conversationId] of entries.slice(limit)) {
 58+    delete state.chatgptSendTemplates[conversationId];
 59+  }
 60+}
 61+
 62+function rememberChatgptSendTemplate(context, reqBody) {
 63+  const conversationId = trimToNull(context?.conversationId) || extractChatgptConversationIdFromRequestBody(reqBody);
 64+
 65+  if (!conversationId || typeof reqBody !== "string" || !reqBody.trim()) {
 66+    return false;
 67+  }
 68+
 69+  let parsed = null;
 70+  try {
 71+    parsed = JSON.parse(reqBody);
 72+  } catch (_) {
 73+    return false;
 74+  }
 75+
 76+  if (!isRecord(parsed)) {
 77+    return false;
 78+  }
 79+
 80+  const next = {
 81+    conversationId,
 82+    model: trimToNull(parsed.model),
 83+    pageUrl: trimToNull(context?.senderUrl),
 84+    reqBody,
 85+    updatedAt: Date.now()
 86+  };
 87+  const previous = state.chatgptSendTemplates[conversationId];
 88+  const changed = !previous
 89+    || previous.reqBody !== next.reqBody
 90+    || previous.pageUrl !== next.pageUrl
 91+    || previous.model !== next.model;
 92+
 93+  state.chatgptSendTemplates[conversationId] = next;
 94+  pruneChatgptSendTemplates();
 95+
 96+  if (changed) {
 97+    addLog("info", `已捕获 ChatGPT 发送模板 conversation=${conversationId}`, false);
 98+  }
 99+
100+  return true;
101+}
102+
103+function getChatgptSendTemplate(conversationId) {
104+  const normalizedConversationId = trimToNull(conversationId);
105+
106+  if (!normalizedConversationId) {
107+    return null;
108+  }
109+
110+  const template = state.chatgptSendTemplates[normalizedConversationId];
111+  return isRecord(template) ? {
112+    ...template
113+  } : null;
114+}
115+
116+function buildChatgptDeliveryRequest(options = {}) {
117+  const conversationId = trimToNull(options.conversationId);
118+  const sourceAssistantMessageId = trimToNull(options.sourceAssistantMessageId);
119+  const messageText = trimToNull(options.messageText);
120+
121+  if (!conversationId) {
122+    throw new Error("delivery.route_missing: ChatGPT delivery requires conversation_id");
123+  }
124+
125+  if (!messageText) {
126+    throw new Error("delivery.invalid_payload: ChatGPT delivery requires message_text");
127+  }
128+
129+  if (!sourceAssistantMessageId) {
130+    throw new Error("delivery.invalid_payload: ChatGPT delivery requires assistant_message_id");
131+  }
132+
133+  const template = getChatgptSendTemplate(conversationId);
134+  if (!template?.reqBody) {
135+    throw new Error("delivery.template_missing: missing ChatGPT send template; send one real ChatGPT message first");
136+  }
137+
138+  let parsed = null;
139+  try {
140+    parsed = JSON.parse(template.reqBody);
141+  } catch (_) {
142+    throw new Error("delivery.template_invalid: stored ChatGPT send template is not valid JSON");
143+  }
144+
145+  if (!isRecord(parsed)) {
146+    throw new Error("delivery.template_invalid: stored ChatGPT send template is not an object");
147+  }
148+
149+  const messageId = typeof crypto?.randomUUID === "function"
150+    ? crypto.randomUUID()
151+    : `chatgpt-message-${Date.now()}`;
152+  const requestId = typeof crypto?.randomUUID === "function"
153+    ? crypto.randomUUID()
154+    : `chatgpt-request-${Date.now()}`;
155+  const nextBody = cloneJsonValue(parsed);
156+
157+  if (!isRecord(nextBody)) {
158+    throw new Error("delivery.template_invalid: failed to clone ChatGPT send template");
159+  }
160+
161+  const templateMessage = Array.isArray(nextBody.messages) && isRecord(nextBody.messages[0])
162+    ? cloneJsonValue(nextBody.messages[0])
163+    : {};
164+  const nextMessage = isRecord(templateMessage) ? templateMessage : {};
165+
166+  nextMessage.id = messageId;
167+  nextMessage.author = {
168+    role: "user"
169+  };
170+  nextMessage.content = {
171+    content_type: "text",
172+    parts: [messageText]
173+  };
174+
175+  nextBody.action = trimToNull(nextBody.action) || "next";
176+  nextBody.conversation_id = conversationId;
177+  nextBody.messages = [nextMessage];
178+  nextBody.parent_message_id = sourceAssistantMessageId;
179+
180+  if (Object.prototype.hasOwnProperty.call(nextBody, "websocket_request_id")) {
181+    nextBody.websocket_request_id = requestId;
182+  }
183+
184+  if (Object.prototype.hasOwnProperty.call(nextBody, "websocketRequestId")) {
185+    nextBody.websocketRequestId = requestId;
186+  }
187+
188+  return {
189+    body: nextBody,
190+    headers: {
191+      ...buildProxyHeaders("chatgpt", "/backend-api/conversation"),
192+      accept: "text/event-stream",
193+      "content-type": "application/json"
194+    },
195+    method: "POST",
196+    path: "/backend-api/conversation"
197+  };
198+}
199+
200 function sleep(ms) {
201   return new Promise((resolve) => setTimeout(resolve, ms));
202 }
203@@ -4625,6 +4806,36 @@ function connectWs(options = {}) {
204       lastError: null
205     });
206 
207+    if (message.type === "browser.proxy_delivery") {
208+      runProxyDeliveryAction(message).then((result) => {
209+        sendPluginActionResult(result, {
210+          action: "proxy_delivery",
211+          commandType: message.type,
212+          completed: true,
213+          platform: result.platform,
214+          requestId: readPluginActionRequestId(message)
215+        });
216+      }).catch((error) => {
217+        const messageText = error instanceof Error ? error.message : String(error);
218+        addLog("error", `proxy_delivery 失败:${messageText}`, false);
219+        sendPluginActionResult({
220+          action: "proxy_delivery",
221+          platform: trimToNull(message.platform),
222+          results: []
223+        }, {
224+          accepted: true,
225+          action: "proxy_delivery",
226+          commandType: message.type,
227+          completed: true,
228+          failed: true,
229+          platform: trimToNull(message.platform),
230+          reason: messageText,
231+          requestId: readPluginActionRequestId(message)
232+        });
233+      });
234+      return;
235+    }
236+
237     if (message.type === "browser.inject_message" || message.type === "browser.send_message") {
238       const command = message.type === "browser.inject_message" ? "inject_message" : "send_message";
239 
240@@ -5404,7 +5615,7 @@ function isObservedFinalMessageStale(relay, context, pageControl) {
241   return relayConversationId !== currentConversationId;
242 }
243 
244-function relayObservedFinalMessage(platform, relay, source = "page_observed", context = null) {
245+function relayObservedFinalMessage(platform, relay, source = "page_observed", context = null, routeMeta = null) {
246   const observer = state.finalMessageRelayObservers[platform];
247   if (!observer || !relay?.payload) return false;
248 
249@@ -5433,7 +5644,20 @@ function relayObservedFinalMessage(platform, relay, source = "page_observed", co
250     return false;
251   }
252 
253-  if (!wsSend(relay.payload)) {
254+  const payload = {
255+    ...relay.payload,
256+    ...(context
257+      ? {
258+          page_title: trimToNull(context.pageTitle),
259+          page_url: trimToNull(context.senderUrl),
260+          shell_page: context.isShellPage === true,
261+          tab_id: context.tabId
262+        }
263+      : {}),
264+    ...(isRecord(routeMeta) ? routeMeta : {})
265+  };
266+
267+  if (!wsSend(payload)) {
268     addLog("warn", `${platformLabel(platform)} 最终消息未能转发(WS 未连接)`, false);
269     return false;
270   }
271@@ -5463,7 +5687,11 @@ function observeFinalMessageFromPageNetwork(data, sender, context = null) {
272   });
273 
274   if (relay) {
275-    relayObservedFinalMessage(platform, relay, "page_network", context);
276+    relayObservedFinalMessage(platform, relay, "page_network", context, platform === "claude"
277+      ? {
278+          organization_id: parseClaudeApiContext(data.url || "").organizationId || trimToNull(state.claudeState.organizationId)
279+        }
280+      : null);
281   }
282 }
283 
284@@ -5482,7 +5710,11 @@ function observeFinalMessageFromPageSse(data, sender, context = null) {
285   });
286 
287   if (relay) {
288-    relayObservedFinalMessage(platform, relay, "page_sse", context);
289+    relayObservedFinalMessage(platform, relay, "page_sse", context, platform === "claude"
290+      ? {
291+          organization_id: parseClaudeApiContext(data.url || "").organizationId || trimToNull(state.claudeState.organizationId)
292+        }
293+      : null);
294   }
295 }
296 
297@@ -5571,6 +5803,15 @@ function handlePageNetwork(data, sender) {
298     ? mergeKnownHeaders(context.platform, data.reqHeaders || {})
299     : cloneHeaderMap(state.lastHeaders[context.platform]);
300 
301+  if (
302+    context.platform === "chatgpt"
303+    && data.source !== "proxy"
304+    && String(data.method || "GET").toUpperCase() === "POST"
305+    && getRequestPath(data.url, PLATFORMS.chatgpt.rootUrl) === "/backend-api/conversation"
306+  ) {
307+    rememberChatgptSendTemplate(context, data.reqBody);
308+  }
309+
310   if (context.platform === "claude") {
311     applyObservedClaudeResponse(data, context.tabId);
312   }
313@@ -5950,6 +6191,10 @@ function buildDeliveryShellRuntime(platform) {
314   }
315 }
316 
317+function createDeliveryRouteError(code, message) {
318+  return new Error(`delivery.${trimToNull(code) || "route_error"}: ${message}`);
319+}
320+
321 function resolvePausedPageControlForDelivery(platform, conversationId, tabId = null) {
322   const pausedByConversation = findPausedPageControlByConversation(platform, conversationId);
323 
324@@ -5977,7 +6222,230 @@ function createPausedPageDeliveryError(command, pageControl, conversationId = nu
325     parts.push(`conversation=${targetConversationId}`);
326   }
327 
328-  return new Error(parts.join(" "));
329+  return createDeliveryRouteError("page_paused", parts.join(" "));
330+}
331+
332+function readDeliveryTargetTabId(message) {
333+  const candidates = [
334+    message?.target_tab_id,
335+    message?.targetTabId,
336+    message?.tab_id,
337+    message?.tabId
338+  ];
339+
340+  for (const candidate of candidates) {
341+    if (Number.isInteger(candidate) && candidate > 0) {
342+      return candidate;
343+    }
344+  }
345+
346+  return null;
347+}
348+
349+function readDeliveryTargetPageUrl(message) {
350+  return trimToNull(message?.target_page_url || message?.targetPageUrl || message?.page_url || message?.pageUrl);
351+}
352+
353+async function resolveDeliveryTargetPage(message, command) {
354+  const platform = trimToNull(message?.platform);
355+  const conversationId = trimToNull(message?.conversation_id || message?.conversationId);
356+  const pageUrl = readDeliveryTargetPageUrl(message);
357+  const targetTabId = readDeliveryTargetTabId(message);
358+  const explicitShellPage = message?.shell_page === true || message?.shellPage === true;
359+
360+  if (!platform || !["claude", "chatgpt"].includes(platform)) {
361+    throw new Error(`当前 delivery 仅覆盖 claude/chatgpt,收到:${platform || "-"}`);
362+  }
363+
364+  if (explicitShellPage) {
365+    throw createDeliveryRouteError("shell_page", `${command} target resolves to shell page`);
366+  }
367+
368+  let pageControl = null;
369+  let tab = null;
370+
371+  if (targetTabId != null) {
372+    pageControl = getPageControlState(platform, targetTabId);
373+    try {
374+      tab = await browser.tabs.get(targetTabId);
375+    } catch (_) {
376+      throw createDeliveryRouteError(
377+        "tab_missing",
378+        `${command} target tab is unavailable: platform=${platform} tab=${targetTabId}`
379+      );
380+    }
381+
382+    if (!isPlatformUrl(platform, tab?.url || "")) {
383+      throw createDeliveryRouteError(
384+        "target_mismatch",
385+        `${command} target tab is not a ${platformLabel(platform)} page: tab=${targetTabId}`
386+      );
387+    }
388+  } else if (conversationId) {
389+    pageControl = findPageControlByConversation(platform, conversationId);
390+    if (pageControl?.tabId != null) {
391+      tab = await browser.tabs.get(pageControl.tabId).catch(() => null);
392+    }
393+  } else if (pageUrl) {
394+    pageControl = findPageControlByUrl(platform, pageUrl);
395+    if (pageControl?.tabId != null) {
396+      tab = await browser.tabs.get(pageControl.tabId).catch(() => null);
397+    }
398+  }
399+
400+  if (!tab?.id) {
401+    if (conversationId || pageUrl || targetTabId != null) {
402+      throw createDeliveryRouteError(
403+        "route_missing",
404+        `${command} missing business-page target: platform=${platform} conversation=${conversationId || "-"}`
405+      );
406+    }
407+
408+    const shellTab = await resolveDeliveryTab(platform);
409+    return {
410+      conversationId,
411+      organizationId: platform === "claude"
412+        ? (trimToNull(message?.organization_id || message?.organizationId) || trimToNull(state.claudeState.organizationId))
413+        : null,
414+      pageControl: getPageControlState(platform, shellTab.id),
415+      pageTitle: trimToNull(shellTab.title),
416+      pageUrl: trimToNull(shellTab.url),
417+      shellPage: true,
418+      tab: shellTab
419+    };
420+  }
421+
422+  const resolvedPageUrl = trimToNull(tab.url);
423+  const resolvedConversationId = extractConversationIdFromPageUrl(platform, resolvedPageUrl || "") || conversationId;
424+  const resolvedShellPage = isPlatformShellUrl(platform, resolvedPageUrl || "", {
425+    allowFallback: true
426+  }) || pageControl?.shellPage === true;
427+
428+  if (resolvedShellPage) {
429+    throw createDeliveryRouteError(
430+      "shell_page",
431+      `${command} target resolves to shell page: platform=${platform} tab=${tab.id}`
432+    );
433+  }
434+
435+  if (pageUrl && resolvedPageUrl && pageUrl !== resolvedPageUrl) {
436+    throw createDeliveryRouteError(
437+      "target_mismatch",
438+      `${command} page_url mismatch: expected=${pageUrl} actual=${resolvedPageUrl}`
439+    );
440+  }
441+
442+  if (conversationId && resolvedConversationId && conversationId !== resolvedConversationId) {
443+    throw createDeliveryRouteError(
444+      "target_mismatch",
445+      `${command} conversation mismatch: expected=${conversationId} actual=${resolvedConversationId}`
446+    );
447+  }
448+
449+  await injectObserverScriptsIntoTab(tab.id).catch(() => null);
450+  await sleep(150);
451+
452+  return {
453+    conversationId: conversationId || resolvedConversationId || null,
454+    organizationId: platform === "claude"
455+      ? (trimToNull(message?.organization_id || message?.organizationId) || trimToNull(state.claudeState.organizationId))
456+      : null,
457+    pageControl: pageControl || getPageControlState(platform, tab.id),
458+    pageTitle: trimToNull(tab.title),
459+    pageUrl: resolvedPageUrl,
460+    shellPage: false,
461+    tab
462+  };
463+}
464+
465+function buildClaudeDeliveryProxyRequest(message, target) {
466+  const conversationId = trimToNull(target?.conversationId);
467+  const organizationId = trimToNull(target?.organizationId);
468+  const assistantMessageId = trimToNull(message?.assistant_message_id || message?.assistantMessageId);
469+  const messageText = trimToNull(message?.message_text || message?.messageText);
470+
471+  if (!conversationId) {
472+    throw createDeliveryRouteError("route_missing", "proxy_delivery missing Claude conversation_id");
473+  }
474+
475+  if (!organizationId) {
476+    throw new Error("delivery.organization_missing: Claude 缺少 org-id,请先在真实 Claude 页面触发一轮请求");
477+  }
478+
479+  if (!assistantMessageId) {
480+    throw new Error("delivery.invalid_payload: Claude proxy_delivery requires assistant_message_id");
481+  }
482+
483+  if (!messageText) {
484+    throw new Error("delivery.invalid_payload: Claude proxy_delivery requires message_text");
485+  }
486+
487+  const path = `/api/organizations/${organizationId}/chat_conversations/${conversationId}/completion`;
488+  return {
489+    body: {
490+      prompt: messageText,
491+      timezone: "Asia/Shanghai",
492+      attachments: [],
493+      files: [],
494+      parent_message_uuid: assistantMessageId
495+    },
496+    headers: buildClaudeHeaders(path, {
497+      accept: "text/event-stream",
498+      "content-type": "application/json"
499+    }),
500+    method: "POST",
501+    path
502+  };
503+}
504+
505+async function runProxyDeliveryAction(message) {
506+  const platform = trimToNull(message?.platform);
507+  const planId = trimToNull(message?.plan_id || message?.planId);
508+
509+  if (!platform) {
510+    throw new Error("proxy_delivery 缺少 platform");
511+  }
512+
513+  if (!planId) {
514+    throw new Error("proxy_delivery 缺少 plan_id");
515+  }
516+
517+  const target = await resolveDeliveryTargetPage(message, "proxy_delivery");
518+  const pausedPage = target?.pageControl?.paused ? target.pageControl : null;
519+
520+  if (pausedPage) {
521+    addLog("info", createPausedPageDeliveryError("proxy_delivery", pausedPage, target.conversationId).message, false);
522+    throw createPausedPageDeliveryError("proxy_delivery", pausedPage, target.conversationId);
523+  }
524+
525+  const request = platform === "claude"
526+    ? buildClaudeDeliveryProxyRequest(message, target)
527+    : buildChatgptDeliveryRequest({
528+        conversationId: target.conversationId,
529+        messageText: message?.message_text || message?.messageText,
530+        sourceAssistantMessageId: message?.assistant_message_id || message?.assistantMessageId
531+      });
532+
533+  await postProxyRequestToTab(target.tab.id, {
534+    id: `${planId}:proxy_delivery`,
535+    ...request,
536+    platform,
537+    response_mode: "sse",
538+    source: "proxy_delivery"
539+  });
540+
541+  return {
542+    action: "proxy_delivery",
543+    platform,
544+    results: [
545+      {
546+        ok: true,
547+        platform,
548+        shell_runtime: buildDeliveryShellRuntime(platform),
549+        tabId: target.tab.id
550+      }
551+    ]
552+  };
553 }
554 
555 async function runDeliveryAction(message, command) {
556@@ -6000,12 +6468,12 @@ async function runDeliveryAction(message, command) {
557     throw createPausedPageDeliveryError(command, pausedByConversation, conversationId);
558   }
559 
560-  const tab = await resolveDeliveryTab(platform);
561-  const pausedByTab = resolvePausedPageControlForDelivery(platform, conversationId, tab.id);
562+  const target = await resolveDeliveryTargetPage(message, command);
563+  const pausedByTab = resolvePausedPageControlForDelivery(platform, target.conversationId, target.tab.id);
564 
565   if (pausedByTab) {
566-    addLog("info", createPausedPageDeliveryError(command, pausedByTab, conversationId).message, false);
567-    throw createPausedPageDeliveryError(command, pausedByTab, conversationId);
568+    addLog("info", createPausedPageDeliveryError(command, pausedByTab, target.conversationId).message, false);
569+    throw createPausedPageDeliveryError(command, pausedByTab, target.conversationId);
570   }
571 
572   const payload = {
573@@ -6028,7 +6496,7 @@ async function runDeliveryAction(message, command) {
574       ? Number(message?.timeout_ms || message?.timeoutMs)
575       : DELIVERY_COMMAND_TIMEOUT
576   };
577-  const result = await sendDeliveryCommandToTab(tab.id, payload);
578+  const result = await sendDeliveryCommandToTab(target.tab.id, payload);
579 
580   if (result?.ok !== true) {
581     throw new Error(trimToNull(result?.reason) || `${command} failed`);
582@@ -6042,7 +6510,7 @@ async function runDeliveryAction(message, command) {
583         ok: true,
584         platform,
585         shell_runtime: buildDeliveryShellRuntime(platform),
586-        tabId: tab.id
587+        tabId: target.tab.id
588       }
589     ]
590   };
591@@ -6732,6 +7200,7 @@ function exposeControllerTestApi() {
592     reinjectPlatformTabs,
593     restoreFinalMessageRelayCache,
594     runDeliveryAction,
595+    runProxyDeliveryAction,
596     runPageControlAction,
597     runPluginManagementAction,
598     serializeFinalMessageRelayCache,
M plugins/baa-firefox/page-interceptor.js
+31, -11
  1@@ -168,6 +168,12 @@
  2     return text.length > BODY_LIMIT ? text.slice(0, BODY_LIMIT) : text;
  3   }
  4 
  5+  function trimToNull(value) {
  6+    if (typeof value !== "string") return null;
  7+    const normalized = value.trim();
  8+    return normalized ? normalized : null;
  9+  }
 10+
 11   function emit(type, detail, rule = pageRule) {
 12     window.dispatchEvent(new CustomEvent(type, {
 13       detail: {
 14@@ -307,6 +313,7 @@
 15     const contentType = response.headers.get("content-type") || "";
 16     const shouldSplitChunks = contentType.includes("text/event-stream");
 17     const streamId = detail.stream_id || detail.streamId || detail.id;
 18+    const proxySource = trimToNull(detail.source) || "proxy";
 19     let seq = 0;
 20 
 21     emitSse({
 22@@ -315,6 +322,7 @@
 23       method: detail.method,
 24       open: true,
 25       reqBody: requestBody,
 26+      source: proxySource,
 27       status: response.status,
 28       ts: Date.now(),
 29       url: detail.url
 30@@ -329,6 +337,7 @@
 31                 id: detail.id,
 32                 method: detail.method,
 33                 reqBody: requestBody,
 34+                source: proxySource,
 35                 status: response.status,
 36                 stream_id: streamId,
 37                 ts: Date.now(),
 38@@ -341,6 +350,7 @@
 39                 duration: Date.now() - startedAt,
 40                 method: detail.method,
 41                 reqBody: requestBody,
 42+                source: proxySource,
 43                 status: response.status,
 44                 ts: Date.now(),
 45                 url: detail.url
 46@@ -371,6 +381,7 @@
 47             method: detail.method,
 48             reqBody: requestBody,
 49             seq,
 50+            source: proxySource,
 51             status: response.status,
 52             stream_id: streamId,
 53             ts: Date.now(),
 54@@ -388,6 +399,7 @@
 55           method: detail.method,
 56           reqBody: requestBody,
 57           seq,
 58+          source: proxySource,
 59           status: response.status,
 60           stream_id: streamId,
 61           ts: Date.now(),
 62@@ -403,6 +415,7 @@
 63               method: detail.method,
 64               reqBody: requestBody,
 65               seq,
 66+              source: proxySource,
 67               status: response.status,
 68               stream_id: streamId,
 69               ts: Date.now(),
 70@@ -415,6 +428,7 @@
 71               method: detail.method,
 72               reqBody: requestBody,
 73               seq,
 74+              source: proxySource,
 75               status: response.status,
 76               stream_id: streamId,
 77               ts: Date.now(),
 78@@ -428,6 +442,7 @@
 79         id: detail.id,
 80         method: detail.method,
 81         reqBody: requestBody,
 82+        source: proxySource,
 83         seq,
 84         status: response.status,
 85         stream_id: streamId,
 86@@ -452,6 +467,7 @@
 87       const method = String(detail.method || "GET").toUpperCase();
 88       const rawPath = detail.path || detail.url || location.href;
 89       const responseMode = String(detail.response_mode || detail.responseMode || "buffered").toLowerCase();
 90+      const proxySource = trimToNull(detail.source) || "proxy";
 91 
 92       if (!id) return;
 93 
 94@@ -497,21 +513,22 @@
 95           emitNet({
 96             url,
 97             method,
 98-            reqHeaders,
 99-            reqBody,
100-            status: response.status,
101-            resHeaders,
102-            resBody: null,
103-            duration: Date.now() - startedAt,
104-            sse: true,
105-            source: "proxy"
106-          }, pageRule);
107+          reqHeaders,
108+          reqBody,
109+          status: response.status,
110+          resHeaders,
111+          resBody: null,
112+          duration: Date.now() - startedAt,
113+          sse: true,
114+          source: proxySource
115+        }, pageRule);
116 
117           const replayDetail = {
118             ...detail,
119             id,
120             method,
121             stream_id: detail.stream_id || detail.streamId || id,
122+            source: proxySource,
123             url
124           };
125           await streamProxyResponse(replayDetail, response, startedAt, pageRule, reqBody);
126@@ -531,7 +548,7 @@
127           resBody: isSse && pageRule.platform !== "gemini" ? null : trimmedResponseBody,
128           duration: Date.now() - startedAt,
129           sse: isSse,
130-          source: "proxy"
131+          source: proxySource
132         }, pageRule);
133 
134         if (isSse && trimmedResponseBody) {
135@@ -540,6 +557,7 @@
136             method,
137             reqBody,
138             chunk: trimmedResponseBody,
139+            source: proxySource,
140             ts: Date.now()
141           }, pageRule);
142           emitSse({
143@@ -547,6 +565,7 @@
144             method,
145             reqBody,
146             done: true,
147+            source: proxySource,
148             ts: Date.now(),
149             duration: Date.now() - startedAt
150           }, pageRule);
151@@ -568,7 +587,7 @@
152           reqHeaders: readHeaders(detail.headers || {}),
153           reqBody: typeof detail.body === "string" ? trim(detail.body) : trimBodyValue(detail.body),
154           error: error.message,
155-          source: "proxy"
156+          source: proxySource
157         }, pageRule);
158 
159         if (responseMode === "sse") {
160@@ -577,6 +596,7 @@
161             id,
162             method,
163             reqBody: typeof detail.body === "string" ? trim(detail.body) : trimBodyValue(detail.body),
164+            source: proxySource,
165             status: null,
166             stream_id: detail.stream_id || detail.streamId || id,
167             ts: Date.now(),
M tests/browser/browser-control-e2e-smoke.test.mjs
+299, -34
  1@@ -242,6 +242,7 @@ function matchesUrlPatterns(url, patterns = []) {
  2 function createControllerHarness(options = {}) {
  3   const executeScriptCalls = [];
  4   const reloadedTabIds = [];
  5+  const tabMessages = [];
  6   const tabs = new Map();
  7 
  8   for (const tab of options.tabs || []) {
  9@@ -363,6 +364,19 @@ function createControllerHarness(options = {}) {
 10           tabs.delete(tabId);
 11         }
 12       },
 13+      async sendMessage(tabId, payload) {
 14+        const clonedPayload = payload == null ? payload : JSON.parse(JSON.stringify(payload));
 15+        tabMessages.push({
 16+          payload: clonedPayload,
 17+          tabId
 18+        });
 19+
 20+        if (typeof options.onTabMessage === "function") {
 21+          return await options.onTabMessage(tabId, clonedPayload);
 22+        }
 23+
 24+        return { ok: true };
 25+      },
 26       async update(tabId, patch = {}) {
 27         const current = tabs.get(tabId);
 28         if (!current) {
 29@@ -472,6 +486,7 @@ function createControllerHarness(options = {}) {
 30     hooks,
 31     reloadedTabIds,
 32     sentMessages,
 33+    tabMessages,
 34     tabs
 35   };
 36 }
 37@@ -1363,6 +1378,150 @@ test("controller blocks delivery bridge when the target page conversation is pau
 38   );
 39 });
 40 
 41+test("controller proxy delivery targets the observed business page instead of the shell tab", async () => {
 42+  const harness = createControllerHarness({
 43+    tabs: [
 44+      {
 45+        id: 11,
 46+        title: "ChatGPT Shell",
 47+        url: "https://chatgpt.com/#baa-shell"
 48+      },
 49+      {
 50+        id: 51,
 51+        title: "Delivery Target",
 52+        url: "https://chatgpt.com/c/conv-proxy-target"
 53+      }
 54+    ]
 55+  });
 56+  const sender = {
 57+    tab: {
 58+      id: 51,
 59+      title: "Delivery Target",
 60+      url: "https://chatgpt.com/c/conv-proxy-target"
 61+    }
 62+  };
 63+
 64+  harness.hooks.handlePageBridgeReady({
 65+    source: "smoke_test"
 66+  }, sender);
 67+  harness.hooks.handlePageNetwork({
 68+    method: "POST",
 69+    platform: "chatgpt",
 70+    reqBody: JSON.stringify({
 71+      action: "next",
 72+      conversation_id: "conv-proxy-target",
 73+      messages: [
 74+        {
 75+          author: {
 76+            role: "user"
 77+          },
 78+          content: {
 79+            content_type: "text",
 80+            parts: ["original prompt"]
 81+          },
 82+          id: "msg-user-1"
 83+        }
 84+      ],
 85+      model: "gpt-5.4",
 86+      parent_message_id: "msg-parent-0"
 87+    }),
 88+    url: "https://chatgpt.com/backend-api/conversation"
 89+  }, sender);
 90+  harness.hooks.state.trackedTabs.chatgpt = 51;
 91+  harness.hooks.state.lastHeaders.chatgpt = {
 92+    authorization: "Bearer smoke-token",
 93+    "openai-sentinel-chat-requirements-token": "sentinel-1"
 94+  };
 95+  harness.hooks.state.lastCredentialTabId.chatgpt = 51;
 96+  harness.hooks.state.credentialCapturedAt.chatgpt = Date.now();
 97+  harness.hooks.state.lastCredentialAt.chatgpt = Date.now();
 98+  harness.hooks.state.lastCredentialUrl.chatgpt = "https://chatgpt.com/backend-api/conversation";
 99+
100+  const result = await harness.hooks.runProxyDeliveryAction({
101+    assistant_message_id: "msg-assistant-source",
102+    conversation_id: "conv-proxy-target",
103+    message_text: "[BAA 执行结果]\nproxy delivery",
104+    plan_id: "plan-proxy-target",
105+    platform: "chatgpt",
106+    shell_page: false,
107+    tab_id: 51
108+  });
109+
110+  assert.equal(result.action, "proxy_delivery");
111+  assert.equal(result.results[0].tabId, 51);
112+  assert.equal(harness.tabMessages.length, 1);
113+  assert.equal(harness.tabMessages[0].tabId, 51);
114+  assert.equal(harness.tabMessages[0].payload.type, "baa_page_proxy_request");
115+  assert.equal(harness.tabMessages[0].payload.data.source, "proxy_delivery");
116+  assert.equal(harness.tabMessages[0].payload.data.path, "/backend-api/conversation");
117+  assert.equal(harness.tabMessages[0].payload.data.response_mode, "sse");
118+  assert.equal(harness.tabMessages[0].payload.data.body.conversation_id, "conv-proxy-target");
119+  assert.equal(harness.tabMessages[0].payload.data.body.parent_message_id, "msg-assistant-source");
120+  assert.equal(
121+    harness.tabMessages[0].payload.data.body.messages[0].content.parts[0],
122+    "[BAA 执行结果]\nproxy delivery"
123+  );
124+});
125+
126+test("controller proxy delivery fails closed when the target page route is missing", async () => {
127+  const harness = createControllerHarness({
128+    tabs: [
129+      {
130+        id: 11,
131+        title: "ChatGPT Shell",
132+        url: "https://chatgpt.com/#baa-shell"
133+      }
134+    ]
135+  });
136+
137+  await assert.rejects(
138+    () => harness.hooks.runProxyDeliveryAction({
139+      assistant_message_id: "msg-assistant-source",
140+      conversation_id: "conv-missing-target",
141+      message_text: "proxy delivery should fail closed",
142+      plan_id: "plan-proxy-missing",
143+      platform: "chatgpt",
144+      shell_page: false
145+    }),
146+    /delivery\.route_missing/u
147+  );
148+
149+  assert.equal(harness.tabMessages.length, 0);
150+});
151+
152+test("controller relays final_message for proxy_delivery SSE traffic", () => {
153+  const harness = createControllerHarness({
154+    finalMessageHelpers
155+  });
156+  const sender = {
157+    tab: {
158+      id: 51,
159+      title: "Delivery Target",
160+      url: "https://chatgpt.com/c/conv-proxy-relay"
161+    }
162+  };
163+
164+  harness.hooks.handlePageBridgeReady({
165+    source: "smoke_test"
166+  }, sender);
167+  harness.hooks.handlePageSse({
168+    chunk: 'data: {"conversation_id":"conv-proxy-relay","message":{"id":"msg-proxy-relay","author":{"role":"assistant"},"status":"finished_successfully","end_turn":true,"content":{"content_type":"text","parts":["proxy relay answer"]}}}',
169+    done: true,
170+    method: "POST",
171+    source: "proxy_delivery",
172+    url: "https://chatgpt.com/backend-api/conversation"
173+  }, sender);
174+
175+  const relay = harness.sentMessages.find((message) =>
176+    message.type === "browser.final_message" && message.assistant_message_id === "msg-proxy-relay"
177+  );
178+
179+  assert.ok(relay);
180+  assert.equal(relay.page_url, "https://chatgpt.com/c/conv-proxy-relay");
181+  assert.equal(relay.shell_page, false);
182+  assert.equal(relay.tab_id, 51);
183+});
184+
185 test("browser control e2e smoke covers metadata read surface plus Claude and ChatGPT relay", async () => {
186   const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-control-e2e-smoke-"));
187   const runtime = new ConductorRuntime(
188@@ -2151,7 +2310,7 @@ test("browser control e2e smoke covers metadata read surface plus Claude and Cha
189   }
190 });
191 
192-test("browser delivery bridge injects text-only results and truncates overlong output", async () => {
193+test("browser delivery bridge uses proxy delivery on the routed business page and records target context", async () => {
194   const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-delivery-smoke-"));
195   const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-browser-delivery-host-"));
196   const runtime = new ConductorRuntime(
197@@ -2187,6 +2346,8 @@ test("browser delivery bridge injects text-only results and truncates overlong o
198         platform: "chatgpt",
199         conversation_id: "conv-delivery-smoke",
200         assistant_message_id: "msg-delivery-smoke",
201+        page_title: "Delivery Target",
202+        page_url: "https://chatgpt.com/c/conv-delivery-smoke",
203         raw_text: [
204           "```baa",
205           `@conductor::exec::${JSON.stringify({
206@@ -2195,7 +2356,9 @@ test("browser delivery bridge injects text-only results and truncates overlong o
207           })}`,
208           "```"
209         ].join("\n"),
210-        observed_at: 1710000010000
211+        observed_at: 1710000010000,
212+        shell_page: false,
213+        tab_id: 51
214       })
215     );
216 
217@@ -2205,38 +2368,31 @@ test("browser delivery bridge injects text-only results and truncates overlong o
218       700
219     );
220 
221-    const injectMessage = await client.queue.next(
222-      (message) => message.type === "browser.inject_message"
223+    const proxyDelivery = await client.queue.next(
224+      (message) => message.type === "browser.proxy_delivery"
225     );
226-    assert.equal(injectMessage.platform, "chatgpt");
227-    assert.match(injectMessage.message_text, /\[BAA 执行结果\]/u);
228-    assert.match(injectMessage.message_text, /line-1/u);
229-    assert.doesNotMatch(injectMessage.message_text, /line-260/u);
230-    assert.match(injectMessage.message_text, /超长截断$/u);
231+    assert.equal(proxyDelivery.platform, "chatgpt");
232+    assert.equal(proxyDelivery.conversation_id, "conv-delivery-smoke");
233+    assert.equal(proxyDelivery.page_url, "https://chatgpt.com/c/conv-delivery-smoke");
234+    assert.equal(proxyDelivery.shell_page, false);
235+    assert.equal(proxyDelivery.target_tab_id, 51);
236+    assert.match(proxyDelivery.message_text, /\[BAA 执行结果\]/u);
237+    assert.match(proxyDelivery.message_text, /line-1/u);
238+    assert.doesNotMatch(proxyDelivery.message_text, /line-260/u);
239+    assert.match(proxyDelivery.message_text, /超长截断$/u);
240 
241     await expectQueueTimeout(
242       client.queue,
243-      (message) => message.type === "browser.send_message"
244+      (message) => message.type === "browser.inject_message" || message.type === "browser.send_message",
245+      700
246     );
247 
248     sendPluginActionResult(client.socket, {
249-      action: "inject_message",
250-      commandType: "browser.inject_message",
251+      action: "proxy_delivery",
252+      commandType: "browser.proxy_delivery",
253       platform: "chatgpt",
254-      requestId: injectMessage.requestId,
255-      type: "browser.inject_message"
256-    });
257-
258-    const sendMessage = await client.queue.next(
259-      (message) => message.type === "browser.send_message"
260-    );
261-    assert.equal(sendMessage.platform, "chatgpt");
262-    sendPluginActionResult(client.socket, {
263-      action: "send_message",
264-      commandType: "browser.send_message",
265-      platform: "chatgpt",
266-      requestId: sendMessage.requestId,
267-      type: "browser.send_message"
268+      requestId: proxyDelivery.requestId,
269+      type: "browser.proxy_delivery"
270     });
271 
272     const browserStatus = await waitForCondition(async () => {
273@@ -2247,7 +2403,12 @@ test("browser delivery bridge injects text-only results and truncates overlong o
274     });
275 
276     assert.equal(browserStatus.payload.data.delivery.last_session.platform, "chatgpt");
277+    assert.equal(browserStatus.payload.data.delivery.last_session.delivery_mode, "proxy");
278     assert.equal(browserStatus.payload.data.delivery.last_session.message_truncated, true);
279+    assert.equal(browserStatus.payload.data.delivery.last_session.target_page_url, "https://chatgpt.com/c/conv-delivery-smoke");
280+    assert.equal(browserStatus.payload.data.delivery.last_session.target_tab_id, 51);
281+    assert.equal(browserStatus.payload.data.delivery.last_route.page_url, "https://chatgpt.com/c/conv-delivery-smoke");
282+    assert.equal(browserStatus.payload.data.delivery.last_route.tab_id, 51);
283     assert.ok(
284       browserStatus.payload.data.delivery.last_session.source_line_count
285       > browserStatus.payload.data.delivery.last_session.message_line_count
286@@ -2267,7 +2428,7 @@ test("browser delivery bridge injects text-only results and truncates overlong o
287   }
288 });
289 
290-test("browser delivery bridge fails closed when inject_message reports failure", async () => {
291+test("browser delivery bridge falls back to DOM delivery on the routed page when proxy delivery is rejected", async () => {
292   const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-delivery-fail-"));
293   const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-browser-delivery-fail-host-"));
294   const runtime = new ConductorRuntime(
295@@ -2302,12 +2463,16 @@ test("browser delivery bridge fails closed when inject_message reports failure",
296         platform: "chatgpt",
297         conversation_id: "conv-delivery-fail",
298         assistant_message_id: "msg-delivery-fail",
299+        page_title: "Fallback Target",
300+        page_url: "https://chatgpt.com/c/conv-delivery-fail",
301         raw_text: [
302           "```baa",
303           `@conductor::exec::{"command":"printf 'artifact-fail\\n'","cwd":${JSON.stringify(hostOpsDir)}}`,
304           "```"
305         ].join("\n"),
306-        observed_at: 1710000020000
307+        observed_at: 1710000020000,
308+        shell_page: false,
309+        tab_id: 61
310       })
311     );
312 
313@@ -2317,22 +2482,123 @@ test("browser delivery bridge fails closed when inject_message reports failure",
314       700
315     );
316 
317+    const proxyDelivery = await client.queue.next(
318+      (message) => message.type === "browser.proxy_delivery"
319+    );
320+    sendPluginActionResult(client.socket, {
321+      action: "proxy_delivery",
322+      commandType: "browser.proxy_delivery",
323+      failed: true,
324+      platform: "chatgpt",
325+      reason: "delivery.template_missing: missing ChatGPT send template; send one real ChatGPT message first",
326+      requestId: proxyDelivery.requestId,
327+      type: "browser.proxy_delivery"
328+    });
329+
330     const injectMessage = await client.queue.next(
331       (message) => message.type === "browser.inject_message"
332     );
333+    assert.equal(injectMessage.target_tab_id, 61);
334+    assert.equal(injectMessage.page_url, "https://chatgpt.com/c/conv-delivery-fail");
335     sendPluginActionResult(client.socket, {
336       action: "inject_message",
337       commandType: "browser.inject_message",
338-      failed: true,
339       platform: "chatgpt",
340-      reason: "inject_failed",
341       requestId: injectMessage.requestId,
342       type: "browser.inject_message"
343     });
344 
345+    const sendMessage = await client.queue.next(
346+      (message) => message.type === "browser.send_message"
347+    );
348+    assert.equal(sendMessage.target_tab_id, 61);
349+    assert.equal(sendMessage.page_url, "https://chatgpt.com/c/conv-delivery-fail");
350+    sendPluginActionResult(client.socket, {
351+      action: "send_message",
352+      commandType: "browser.send_message",
353+      platform: "chatgpt",
354+      requestId: sendMessage.requestId,
355+      type: "browser.send_message"
356+    });
357+
358+    const browserStatus = await waitForCondition(async () => {
359+      const result = await fetchJson(`${baseUrl}/v1/browser`);
360+      assert.equal(result.response.status, 200);
361+      assert.equal(result.payload.data.delivery.last_session.stage, "completed");
362+      return result;
363+    });
364+
365+    assert.equal(browserStatus.payload.data.delivery.last_session.delivery_mode, "dom_fallback");
366+    assert.match(browserStatus.payload.data.delivery.last_session.proxy_failed_reason, /template_missing/u);
367+    assert.ok(browserStatus.payload.data.delivery.last_session.inject_started_at);
368+    assert.ok(browserStatus.payload.data.delivery.last_session.send_started_at);
369+    assert.equal(browserStatus.payload.data.delivery.last_session.target_tab_id, 61);
370+    assert.equal(browserStatus.payload.data.delivery.last_session.target_page_url, "https://chatgpt.com/c/conv-delivery-fail");
371+  } finally {
372+    client?.queue.stop();
373+    client?.socket.close(1000, "done");
374+    await runtime.stop();
375+    rmSync(stateDir, {
376+      force: true,
377+      recursive: true
378+    });
379+    rmSync(hostOpsDir, {
380+      force: true,
381+      recursive: true
382+    });
383+  }
384+});
385+
386+test("browser delivery bridge fails closed when the business-page target route is missing", async () => {
387+  const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-delivery-missing-route-"));
388+  const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-browser-delivery-missing-route-host-"));
389+  const runtime = new ConductorRuntime(
390+    {
391+      nodeId: "mini-main",
392+      host: "mini",
393+      role: "primary",
394+      controlApiBase: "https://conductor.example.test",
395+      localApiBase: "http://127.0.0.1:0",
396+      sharedToken: "replace-me",
397+      paths: {
398+        runsDir: "/tmp/runs",
399+        stateDir
400+      }
401+    },
402+    {
403+      autoStartLoops: false,
404+      now: () => 100
405+    }
406+  );
407+
408+  let client = null;
409+
410+  try {
411+    const snapshot = await runtime.start();
412+    const baseUrl = snapshot.controlApi.localApiBase;
413+    client = await connectFirefoxBridgeClient(snapshot.controlApi.firefoxWsUrl, "firefox-delivery-missing-route");
414+
415+    client.socket.send(
416+      JSON.stringify({
417+        type: "browser.final_message",
418+        platform: "chatgpt",
419+        conversation_id: "conv-delivery-missing-route",
420+        assistant_message_id: "msg-delivery-missing-route",
421+        raw_text: [
422+          "```baa",
423+          `@conductor::exec::{"command":"printf 'missing-route\\n'","cwd":${JSON.stringify(hostOpsDir)}}`,
424+          "```"
425+        ].join("\n"),
426+        observed_at: 1710000025000
427+      })
428+    );
429+
430     await expectQueueTimeout(
431       client.queue,
432-      (message) => message.type === "browser.send_message",
433+      (message) =>
434+        message.type === "browser.proxy_delivery"
435+        || message.type === "browser.inject_message"
436+        || message.type === "browser.send_message",
437       700
438     );
439 
440@@ -2343,9 +2609,8 @@ test("browser delivery bridge fails closed when inject_message reports failure",
441       return result;
442     });
443 
444-    assert.match(browserStatus.payload.data.delivery.last_session.failed_reason, /inject_failed/u);
445-    assert.ok(browserStatus.payload.data.delivery.last_session.inject_started_at);
446-    assert.equal(browserStatus.payload.data.delivery.last_session.send_started_at, undefined);
447+    assert.match(browserStatus.payload.data.delivery.last_session.failed_reason, /delivery\.route_missing/u);
448+    assert.equal(browserStatus.payload.data.delivery.last_route, null);
449   } finally {
450     client?.queue.stop();
451     client?.socket.close(1000, "done");