baa-conductor

git clone 

commit
aade8e2
parent
37bf1d0
author
codex@macbookpro
date
2026-04-01 12:10:24 +0800 CST
feat: unify overlay automation control
12 files changed,  +937, -73
M apps/conductor-daemon/src/browser-types.ts
+34, -0
 1@@ -1,5 +1,10 @@
 2 import type { BaaLiveInstructionIngestSnapshot } from "./instructions/ingest.js";
 3 import type { BaaDeliveryBridgeSnapshot } from "./artifacts/types.js";
 4+import type {
 5+  ConversationAutomationExecutionState,
 6+  ConversationAutomationStatus,
 7+  ConversationPauseReason
 8+} from "../../../packages/artifact-db/dist/index.js";
 9 
10 export type BrowserBridgeLoginStatus = "fresh" | "stale" | "lost";
11 
12@@ -146,9 +151,38 @@ export interface BrowserBridgeClientSnapshot {
13   shell_runtime: BrowserBridgeShellRuntimeSnapshot[];
14 }
15 
16+export interface BrowserBridgeAutomationLinkSnapshot {
17+  client_id: string | null;
18+  link_id: string;
19+  local_conversation_id: string;
20+  page_title: string | null;
21+  page_url: string | null;
22+  remote_conversation_id: string | null;
23+  route_path: string | null;
24+  route_pattern: string | null;
25+  target_id: string | null;
26+  target_kind: string | null;
27+  updated_at: number;
28+}
29+
30+export interface BrowserBridgeConversationAutomationSnapshot {
31+  active_link: BrowserBridgeAutomationLinkSnapshot | null;
32+  automation_status: ConversationAutomationStatus;
33+  execution_state: ConversationAutomationExecutionState;
34+  last_error: string | null;
35+  last_non_paused_automation_status: ConversationAutomationStatus;
36+  local_conversation_id: string;
37+  pause_reason: ConversationPauseReason | null;
38+  paused_at: number | null;
39+  platform: string;
40+  remote_conversation_id: string | null;
41+  updated_at: number;
42+}
43+
44 export interface BrowserBridgeStateSnapshot {
45   active_client_id: string | null;
46   active_connection_id: string | null;
47+  automation_conversations: BrowserBridgeConversationAutomationSnapshot[];
48   client_count: number;
49   clients: BrowserBridgeClientSnapshot[];
50   delivery: BaaDeliveryBridgeSnapshot;
M apps/conductor-daemon/src/firefox-ws.ts
+97, -0
  1@@ -18,6 +18,7 @@ import type {
  2   BrowserBridgeActionResultSnapshot,
  3   BrowserBridgeActionResultSummarySnapshot,
  4   BrowserBridgeActionResultTargetSnapshot,
  5+  BrowserBridgeConversationAutomationSnapshot,
  6   BrowserBridgeClientSnapshot,
  7   BrowserBridgeEndpointMetadataSnapshot,
  8   BrowserBridgeFinalMessageSnapshot,
  9@@ -1032,6 +1033,7 @@ export class ConductorFirefoxWebSocketServer {
 10   private readonly snapshotLoader: () => ConductorRuntimeSnapshot;
 11   private readonly connections = new Set<FirefoxWebSocketConnection>();
 12   private readonly connectionsByClientId = new Map<string, FirefoxWebSocketConnection>();
 13+  private automationConversationsSnapshot: BrowserBridgeConversationAutomationSnapshot[] = [];
 14   private broadcastQueue: Promise<void> = Promise.resolve();
 15   private lastSnapshotSignature: string | null = null;
 16   private lastTimestampMs = 0;
 17@@ -2066,6 +2068,7 @@ export class ConductorFirefoxWebSocketServer {
 18   }
 19 
 20   private async buildStateSnapshot(): Promise<Record<string, unknown>> {
 21+    await this.refreshConversationAutomationSnapshot();
 22     const runtime = this.snapshotLoader();
 23 
 24     return {
 25@@ -2099,6 +2102,15 @@ export class ConductorFirefoxWebSocketServer {
 26     return {
 27       active_client_id: activeClient?.clientId ?? null,
 28       active_connection_id: activeClient?.connectionId ?? null,
 29+      automation_conversations: this.automationConversationsSnapshot.map((entry) => ({
 30+        ...entry,
 31+        active_link:
 32+          entry.active_link == null
 33+            ? null
 34+            : {
 35+                ...entry.active_link
 36+              }
 37+      })),
 38       client_count: clients.length,
 39       clients,
 40       delivery: this.deliveryBridge.getSnapshot(),
 41@@ -2113,6 +2125,91 @@ export class ConductorFirefoxWebSocketServer {
 42     };
 43   }
 44 
 45+  private async refreshConversationAutomationSnapshot(): Promise<void> {
 46+    this.automationConversationsSnapshot = await this.loadConversationAutomationSnapshot();
 47+  }
 48+
 49+  private async loadConversationAutomationSnapshot(): Promise<BrowserBridgeConversationAutomationSnapshot[]> {
 50+    if (this.artifactStore == null) {
 51+      return [];
 52+    }
 53+
 54+    const activeLinks = await this.artifactStore.listConversationLinks({
 55+      isActive: true,
 56+      limit: 200
 57+    });
 58+
 59+    if (activeLinks.length === 0) {
 60+      return [];
 61+    }
 62+
 63+    const conversations = await Promise.all(
 64+      [...new Set(activeLinks.map((entry) => entry.localConversationId))]
 65+        .map(async (localConversationId) => [
 66+          localConversationId,
 67+          await this.artifactStore!.getLocalConversation(localConversationId)
 68+        ] as const)
 69+    );
 70+    const conversationsById = new Map(
 71+      conversations.filter(([, entry]) => entry != null) as Array<
 72+        readonly [string, NonNullable<Awaited<ReturnType<ArtifactStore["getLocalConversation"]>>>]
 73+      >
 74+    );
 75+
 76+    const snapshots: BrowserBridgeConversationAutomationSnapshot[] = [];
 77+
 78+    for (const link of activeLinks) {
 79+      const conversation = conversationsById.get(link.localConversationId);
 80+
 81+      if (conversation == null) {
 82+        continue;
 83+      }
 84+
 85+      snapshots.push({
 86+        active_link: {
 87+          client_id: link.clientId ?? null,
 88+          link_id: link.linkId,
 89+          local_conversation_id: link.localConversationId,
 90+          page_title: link.pageTitle ?? null,
 91+          page_url: link.pageUrl ?? null,
 92+          remote_conversation_id: link.remoteConversationId ?? null,
 93+          route_path: link.routePath ?? null,
 94+          route_pattern: link.routePattern ?? null,
 95+          target_id: link.targetId ?? null,
 96+          target_kind: link.targetKind ?? null,
 97+          updated_at: link.updatedAt
 98+        },
 99+        automation_status: conversation.automationStatus,
100+        execution_state: conversation.executionState,
101+        last_error: conversation.lastError ?? null,
102+        last_non_paused_automation_status: conversation.lastNonPausedAutomationStatus,
103+        local_conversation_id: conversation.localConversationId,
104+        pause_reason: conversation.pauseReason ?? null,
105+        paused_at: conversation.pausedAt ?? null,
106+        platform: conversation.platform,
107+        remote_conversation_id: link.remoteConversationId ?? null,
108+        updated_at: conversation.updatedAt
109+      });
110+    }
111+
112+    return snapshots.sort((left, right) => {
113+        const leftKey = [
114+          left.platform,
115+          left.remote_conversation_id ?? "",
116+          left.local_conversation_id,
117+          left.active_link?.link_id ?? ""
118+        ].join("\u0000");
119+        const rightKey = [
120+          right.platform,
121+          right.remote_conversation_id ?? "",
122+          right.local_conversation_id,
123+          right.active_link?.link_id ?? ""
124+        ].join("\u0000");
125+
126+        return leftKey.localeCompare(rightKey);
127+      });
128+  }
129+
130   private async sendStateSnapshotTo(
131     connection: FirefoxWebSocketConnection,
132     reason: string
M apps/conductor-daemon/src/index.test.js
+115, -0
  1@@ -8431,6 +8431,121 @@ test("ConductorRuntime persists renewal conversation links from browser.final_me
  2   }
  3 });
  4 
  5+test("ConductorRuntime broadcasts conversation automation snapshots after renewal API mutations", async () => {
  6+  const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-overlay-automation-sync-"));
  7+  const runtime = new ConductorRuntime(
  8+    {
  9+      nodeId: "mini-main",
 10+      host: "mini",
 11+      role: "primary",
 12+      controlApiBase: "https://control.example.test",
 13+      localApiBase: "http://127.0.0.1:0",
 14+      sharedToken: "replace-me",
 15+      paths: {
 16+        runsDir: "/tmp/runs",
 17+        stateDir
 18+      }
 19+    },
 20+    {
 21+      autoStartLoops: false,
 22+      now: () => 250
 23+    }
 24+  );
 25+
 26+  let client = null;
 27+
 28+  try {
 29+    const snapshot = await runtime.start();
 30+    const baseUrl = snapshot.controlApi.localApiBase;
 31+    client = await connectFirefoxBridgeClient(snapshot.controlApi.firefoxWsUrl, "firefox-overlay-automation");
 32+
 33+    client.socket.send(
 34+      JSON.stringify({
 35+        type: "browser.final_message",
 36+        platform: "chatgpt",
 37+        conversation_id: "conv-overlay-automation",
 38+        assistant_message_id: "msg-overlay-automation-1",
 39+        raw_text: "hello overlay automation",
 40+        observed_at: 1_710_000_060_000,
 41+        page_title: "ChatGPT Overlay Automation",
 42+        page_url: "https://chatgpt.com/c/conv-overlay-automation",
 43+        tab_id: 99
 44+      })
 45+    );
 46+
 47+    const localConversationId = await waitForCondition(async () => {
 48+      const linksResponse = await fetch(
 49+        `${baseUrl}/v1/renewal/links?platform=chatgpt&remote_conversation_id=conv-overlay-automation`
 50+      );
 51+      assert.equal(linksResponse.status, 200);
 52+      const payload = await linksResponse.json();
 53+      assert.equal(payload.data.count, 1);
 54+      return payload.data.links[0].local_conversation_id;
 55+    }, 5_000, 50);
 56+
 57+    const pauseResponse = await fetch(
 58+      `${baseUrl}/v1/renewal/conversations/${localConversationId}/paused`,
 59+      {
 60+        method: "POST",
 61+        headers: {
 62+          "content-type": "application/json"
 63+        },
 64+        body: JSON.stringify({
 65+          pause_reason: "user_pause"
 66+        })
 67+      }
 68+    );
 69+    assert.equal(pauseResponse.status, 200);
 70+
 71+    const pausedSnapshot = await client.queue.next(
 72+      (message) =>
 73+        message.type === "state_snapshot"
 74+        && Array.isArray(message.snapshot?.browser?.automation_conversations)
 75+        && message.snapshot.browser.automation_conversations.some((entry) =>
 76+          entry.platform === "chatgpt"
 77+          && entry.remote_conversation_id === "conv-overlay-automation"
 78+          && entry.automation_status === "paused"
 79+          && entry.pause_reason === "user_pause"
 80+        ),
 81+      8_000
 82+    );
 83+    assert.equal(
 84+      pausedSnapshot.snapshot.browser.automation_conversations.find((entry) =>
 85+        entry.remote_conversation_id === "conv-overlay-automation"
 86+      )?.local_conversation_id,
 87+      localConversationId
 88+    );
 89+
 90+    const autoResponse = await fetch(
 91+      `${baseUrl}/v1/renewal/conversations/${localConversationId}/auto`,
 92+      {
 93+        method: "POST"
 94+      }
 95+    );
 96+    assert.equal(autoResponse.status, 200);
 97+
 98+    await client.queue.next(
 99+      (message) =>
100+        message.type === "state_snapshot"
101+        && Array.isArray(message.snapshot?.browser?.automation_conversations)
102+        && message.snapshot.browser.automation_conversations.some((entry) =>
103+          entry.platform === "chatgpt"
104+          && entry.remote_conversation_id === "conv-overlay-automation"
105+          && entry.automation_status === "auto"
106+        ),
107+      8_000
108+    );
109+  } finally {
110+    client?.queue.stop();
111+    client?.socket.close(1000, "done");
112+    await runtime.stop();
113+    rmSync(stateDir, {
114+      force: true,
115+      recursive: true
116+    });
117+  }
118+});
119+
120 test("ConductorRuntime gives control instructions priority over ordinary baa instructions and persists pause_reason", async () => {
121   const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-control-priority-"));
122   const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-conductor-control-priority-host-"));
M apps/conductor-daemon/src/local-api.ts
+41, -0
 1@@ -2128,6 +2128,7 @@ function createEmptyBrowserState(snapshot: ConductorRuntimeApiSnapshot): Browser
 2   return {
 3     active_client_id: null,
 4     active_connection_id: null,
 5+    automation_conversations: [],
 6     client_count: 0,
 7     clients: [],
 8     delivery: {
 9@@ -2153,6 +2154,9 @@ function createEmptyBrowserInstructionIngestSnapshot(): BrowserBridgeStateSnapsh
10 function normalizeBrowserStateSnapshot(state: BrowserBridgeStateSnapshot): BrowserBridgeStateSnapshot {
11   return {
12     ...state,
13+    automation_conversations: Array.isArray(state.automation_conversations)
14+      ? state.automation_conversations
15+      : [],
16     delivery: state.delivery ?? {
17       activeSessionCount: 0,
18       lastRoute: null,
19@@ -3120,6 +3124,39 @@ function serializeBrowserClientSnapshot(snapshot: BrowserBridgeClientSnapshot):
20   };
21 }
22 
23+function serializeBrowserAutomationConversationSnapshot(
24+  snapshot: BrowserBridgeStateSnapshot["automation_conversations"][number]
25+): JsonObject {
26+  return compactJsonObject({
27+    active_link:
28+      snapshot.active_link == null
29+        ? null
30+        : compactJsonObject({
31+            client_id: snapshot.active_link.client_id ?? undefined,
32+            link_id: snapshot.active_link.link_id,
33+            local_conversation_id: snapshot.active_link.local_conversation_id,
34+            page_title: snapshot.active_link.page_title ?? undefined,
35+            page_url: snapshot.active_link.page_url ?? undefined,
36+            remote_conversation_id: snapshot.active_link.remote_conversation_id ?? undefined,
37+            route_path: snapshot.active_link.route_path ?? undefined,
38+            route_pattern: snapshot.active_link.route_pattern ?? undefined,
39+            target_id: snapshot.active_link.target_id ?? undefined,
40+            target_kind: snapshot.active_link.target_kind ?? undefined,
41+            updated_at: snapshot.active_link.updated_at
42+          }),
43+    automation_status: snapshot.automation_status,
44+    execution_state: snapshot.execution_state,
45+    last_error: snapshot.last_error ?? undefined,
46+    last_non_paused_automation_status: snapshot.last_non_paused_automation_status,
47+    local_conversation_id: snapshot.local_conversation_id,
48+    pause_reason: snapshot.pause_reason ?? undefined,
49+    paused_at: snapshot.paused_at ?? undefined,
50+    platform: snapshot.platform,
51+    remote_conversation_id: snapshot.remote_conversation_id ?? undefined,
52+    updated_at: snapshot.updated_at
53+  });
54+}
55+
56 function extractArrayObjects(value: JsonValue | string | null, fieldNames: readonly string[]): JsonObject[] {
57   if (Array.isArray(value)) {
58     return value.filter((entry): entry is JsonObject => isJsonObject(entry));
59@@ -3711,6 +3748,9 @@ async function buildBrowserStatusData(context: LocalApiRequestContext): Promise<
60   }
61 
62   return {
63+    automation_conversations: browserState.automation_conversations.map(
64+      serializeBrowserAutomationConversationSnapshot
65+    ),
66     bridge: {
67       active_client_id: browserState.active_client_id,
68       active_connection_id: browserState.active_connection_id,
69@@ -3793,6 +3833,7 @@ async function buildBrowserStatusData(context: LocalApiRequestContext): Promise<
70       serializeBrowserMergedRecord(record, browserState.active_client_id)
71     ),
72     summary: {
73+      automation_conversation_count: browserState.automation_conversations.length,
74       active_records: records.filter((record) => record.activeConnection != null).length,
75       matched_records: records.length,
76       persisted_only_records: records.filter((record) => record.view === "persisted_only").length,
M docs/api/business-interfaces.md
+2, -1
 1@@ -53,7 +53,7 @@
 2 
 3 | 方法 | 路径 | 作用 |
 4 | --- | --- | --- |
 5-| `GET` | `/v1/browser` | 查看浏览器登录态元数据、持久化记录、插件在线状态和 `fresh` / `stale` / `lost` 状态 |
 6+| `GET` | `/v1/browser` | 查看浏览器登录态元数据、持久化记录、插件在线状态,以及供 Firefox 浮层同步的 `automation_conversations` 快照 |
 7 | `GET` | `/v1/browser/claude/current` | 查看当前 Claude 代理回读结果;这是 legacy Claude 辅助读接口,不是浏览器桥接主模型 |
 8 | `GET` | `/v1/renewal/conversations?platform=chatgpt&status=paused` | 查看续命对话自动化状态,可按 `platform` / `status` 过滤 |
 9 | `GET` | `/v1/renewal/conversations/:local_conversation_id` | 查看单个本地续命对话、当前自动化状态和 active link |
10@@ -77,6 +77,7 @@
11 
12 - `GET /v1/browser` 是当前正式浏览器桥接读面;支持按 `platform`、`account`、`client_id`、`host`、`status` 过滤
13 - 这个读面只返回 `account`、凭证指纹、端点元数据和时间戳状态;不会暴露原始 `cookie`、`token` 或 header 值
14+- `GET /v1/browser` 现在会额外返回 `automation_conversations`,按 `platform + remote_conversation_id` 暴露当前对话的 `automation_status`、`pause_reason` 和 active link,供 Firefox 浮层和调试读面同步统一自动化状态
15 - `records[].view` 会区分活跃连接与仅持久化记录,`status` 会暴露 `fresh`、`stale`、`lost`
16 - 当前浏览器代发面正式支持 `claude` 和 `chatgpt`;Gemini 继续留在下一波
17 - `POST /v1/browser/request` 要求 `platform`;若 `platform=claude` 且省略 `path`,可用 `prompt` 走 Claude completion 兼容模式;`platform=chatgpt` 当前必须显式带 `path`
M docs/api/control-interfaces.md
+3, -1
 1@@ -69,7 +69,7 @@
 2 
 3 | 方法 | 路径 | 作用 |
 4 | --- | --- | --- |
 5-| `GET` | `/v1/browser` | 返回 Firefox bridge 在线状态、插件摘要、最新 `shell_runtime` / `last_action_result` 和浏览器登录态持久化记录 |
 6+| `GET` | `/v1/browser` | 返回 Firefox bridge 在线状态、插件摘要、最新 `shell_runtime` / `last_action_result`、浏览器登录态持久化记录,以及浮层同步所需的 `automation_conversations` |
 7 | `GET` | `/v1/system/state` | 返回当前 automation mode、leader、queue、active runs |
 8 
 9 ### Browser / Plugin 管理
10@@ -83,6 +83,7 @@
11 browser/plugin 管理约定:
12 
13 - `GET /v1/browser` 是 plugin 状态和登录态元数据的共享读面;不是单独的第三层 describe
14+- `GET /v1/browser` 现在也会暴露 `automation_conversations`,把当前 active link 对应的 `automation_status`、`pause_reason` 和 `remote_conversation_id` 提供给 Firefox 浮层同步
15 - `POST /v1/browser/actions` 当前正式支持:
16   - `request_credentials`
17   - `tab_open`
18@@ -127,6 +128,7 @@ browser/plugin 管理约定:
19   - `GET /v1/renewal/jobs?local_conversation_id=...`
20 - `paused` 不会删除任务,只会阻止 dispatcher 继续推进待执行 job
21 - `manual` 和 `auto` / `paused` 共用同一份 `local_conversations` 后端状态,不存在插件侧单独影子开关
22+- renewal REST 写接口修改状态后,Firefox bridge 的 `state_snapshot.browser.automation_conversations` 会在下一轮 WS 推送中同步更新,供浮层实时刷新
23 - `GET /v1/renewal/conversations/:local_conversation_id` 现在会额外暴露:
24   - `pause_reason`
25   - `last_error`
M docs/api/firefox-local-ws.md
+15, -0
 1@@ -125,6 +125,20 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
 2       }
 3     },
 4     "browser": {
 5+      "automation_conversations": [
 6+        {
 7+          "platform": "chatgpt",
 8+          "remote_conversation_id": "conv_overlay_demo",
 9+          "local_conversation_id": "lc_overlay_demo",
10+          "automation_status": "paused",
11+          "last_non_paused_automation_status": "auto",
12+          "pause_reason": "repeated_message",
13+          "active_link": {
14+            "page_url": "https://chatgpt.com/c/conv_overlay_demo",
15+            "page_title": "ChatGPT Overlay Demo"
16+          }
17+        }
18+      ],
19       "client_count": 1,
20       "clients": [
21         {
22@@ -145,6 +159,7 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
23 - `snapshot.system` 直接复用 `GET /v1/system/state` 的合同
24 - `snapshot.browser.clients[].credentials` 只回传 `account`、`credential_fingerprint`、`freshness`、`header_count` 和时间戳
25 - `snapshot.browser.clients[].final_messages` 只保留当前活跃 bridge client 最近观测到的最终消息,不写入当前持久化表
26+- `snapshot.browser.automation_conversations` 会按 active link 暴露当前页面/对话的 `automation_status`、`pause_reason`、`local_conversation_id` 和 `remote_conversation_id`,供 Firefox 浮层同步统一自动化状态
27 - `snapshot.browser.instruction_ingest` 暴露 live ingest / execute 的持久化读面:
28   - `last_ingest`
29   - `last_execute`
M plugins/baa-firefox/content-script.js
+122, -37
  1@@ -119,18 +119,25 @@ function resolveCompactControlAction(snapshot, pendingAction) {
  2   if (mode === "paused" || mode === "draining") {
  3     return {
  4       action: "resume",
  5-      label: "恢复",
  6-      title: "恢复 Conductor"
  7+      label: "恢复系统",
  8+      title: "恢复系统自动化"
  9     };
 10   }
 11 
 12   return {
 13     action: "pause",
 14-    label: "暂停",
 15-    title: "暂停 Conductor"
 16+    label: "暂停系统",
 17+    title: "暂停系统自动化"
 18   };
 19 }
 20 
 21+function normalizePageAutomationStatus(value) {
 22+  const normalized = String(value || "").trim().toLowerCase();
 23+  return normalized === "auto" || normalized === "manual" || normalized === "paused"
 24+    ? normalized
 25+    : null;
 26+}
 27+
 28 function normalizePageControlSnapshot(value) {
 29   if (!value || typeof value !== "object" || Array.isArray(value)) {
 30     return null;
 31@@ -141,9 +148,13 @@ function normalizePageControlSnapshot(value) {
 32     platform: trimToNull(value.platform),
 33     tabId: Number.isInteger(value.tabId) ? value.tabId : null,
 34     conversationId: trimToNull(value.conversationId),
 35+    localConversationId: trimToNull(value.localConversationId),
 36     pageUrl: trimToNull(value.pageUrl),
 37     pageTitle: trimToNull(value.pageTitle),
 38+    automationStatus: normalizePageAutomationStatus(value.automationStatus),
 39+    lastNonPausedAutomationStatus: normalizePageAutomationStatus(value.lastNonPausedAutomationStatus),
 40     paused: value.paused === true,
 41+    pauseReason: trimToNull(value.pauseReason),
 42     shellPage: value.shellPage === true,
 43     updatedAt: Number(value.updatedAt) || 0
 44   };
 45@@ -162,17 +173,52 @@ function formatPageStatusLabel(snapshot) {
 46     return "未识别";
 47   }
 48 
 49-  return snapshot.paused ? "本页已暂停" : "本页运行中";
 50+  switch (normalizePageAutomationStatus(snapshot.automationStatus)) {
 51+    case "auto":
 52+      return "自动";
 53+    case "manual":
 54+      return "手动";
 55+    case "paused":
 56+      return "已暂停";
 57+    default:
 58+      return snapshot.paused ? "本页已暂停" : "本页运行中";
 59+  }
 60+}
 61+
 62+function formatPauseReasonLabel(reason) {
 63+  switch (trimToNull(reason)) {
 64+    case "ai_pause":
 65+      return "AI 主动暂停";
 66+    case "error_loop":
 67+      return "错误循环";
 68+    case "execution_failure":
 69+      return "执行失败";
 70+    case "page_paused_by_user":
 71+    case "user_pause":
 72+      return "用户暂停";
 73+    case "repeated_message":
 74+      return "重复消息";
 75+    case "repeated_renewal":
 76+      return "重复续命";
 77+    case "rescue_wait":
 78+      return "等待救援";
 79+    case "system_pause":
 80+      return "系统暂停";
 81+    default:
 82+      return trimToNull(reason);
 83+  }
 84 }
 85 
 86 function formatPendingActionLabel(action) {
 87   switch (String(action || "").trim().toLowerCase()) {
 88     case "pause":
 89-      return "全局暂停";
 90+      return "系统暂停";
 91     case "resume":
 92-      return "全局恢复";
 93+      return "系统恢复";
 94     case "drain":
 95-      return "全局排空";
 96+      return "系统排空";
 97+    case "page_manual":
 98+      return "设为手动";
 99     case "page_pause":
100       return "暂停本页";
101     case "page_resume":
102@@ -218,9 +264,9 @@ function createPageControlOverlayRuntime() {
103   let dismissButton = null;
104   let hidden = false;
105   let buttonMap = {};
106+  let pageButtonMap = {};
107   let badgeButton = null;
108   let compactActionButton = null;
109-  let pageActionButton = null;
110 
111   const overlayStyle = `
112     :host {
113@@ -232,7 +278,7 @@ function createPageControlOverlayRuntime() {
114       right: 14px;
115       bottom: 14px;
116       z-index: 2147483647;
117-      width: 220px;
118+      width: 252px;
119       box-sizing: border-box;
120       font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
121       color: #f7f3e8;
122@@ -295,7 +341,7 @@ function createPageControlOverlayRuntime() {
123 
124     .baa-page-actions {
125       display: grid;
126-      grid-template-columns: minmax(0, 1fr);
127+      grid-template-columns: repeat(3, minmax(0, 1fr));
128       gap: 6px;
129       margin-bottom: 6px;
130     }
131@@ -353,7 +399,11 @@ function createPageControlOverlayRuntime() {
132       color: #9fd9ff;
133     }
134 
135-    .baa-btn[data-role="page"][data-paused="true"] {
136+    .baa-btn[data-role="page"][data-state="manual"] {
137+      color: #f2cb78;
138+    }
139+
140+    .baa-btn[data-role="page"][data-state="paused"] {
141       color: #b9f0c7;
142     }
143 
144@@ -455,25 +505,50 @@ function createPageControlOverlayRuntime() {
145     const modeLabel = formatModeLabel(snapshot.mode);
146     const connectionLabel = formatConnectionLabel(snapshot.controlConnection);
147     const pageLabel = formatPageStatusLabel(pageSnapshot);
148+    const pauseReasonLabel = pageSnapshot?.paused && pageSnapshot?.pauseReason
149+      ? `\n暂停原因: ${formatPauseReasonLabel(pageSnapshot.pauseReason) || pageSnapshot.pauseReason}`
150+      : "";
151     const conversationLabel = pageSnapshot?.conversationId ? `\n对话: ${pageSnapshot.conversationId}` : "";
152     const actionLabel = formatPendingActionLabel(pendingAction)
153       ? `\n动作: ${formatPendingActionLabel(pendingAction)}`
154       : "";
155 
156-    statusText.textContent = `本页: ${pageLabel}${conversationLabel}\nConductor: ${connectionLabel}\n系统: ${modeLabel}${actionLabel}`;
157+    statusText.textContent = `系统: ${modeLabel}\n连接: ${connectionLabel}\n当前对话: ${pageLabel}${pauseReasonLabel}${conversationLabel}${actionLabel}`;
158   }
159 
160   function updateButtons() {
161     const activePending = trimToNull(pendingAction);
162     const normalizedMode = normalizeMode(controlSnapshot?.mode);
163+    const automationStatus = normalizePageAutomationStatus(pageControlSnapshot?.automationStatus);
164     const pagePaused = pageControlSnapshot?.paused === true;
165+    const hasConversationAutomation = trimToNull(pageControlSnapshot?.localConversationId) != null || automationStatus != null;
166 
167-    if (pageActionButton) {
168-      pageActionButton.disabled = activePending != null || !pageControlSnapshot;
169-      pageActionButton.dataset.paused = pagePaused ? "true" : "false";
170-      pageActionButton.textContent = pageControlSnapshot
171-        ? (pagePaused ? "恢复本页" : "暂停本页")
172-        : "本页未识别";
173+    for (const [action, button] of Object.entries(pageButtonMap)) {
174+      if (!button) {
175+        continue;
176+      }
177+
178+      let disabled = activePending != null || !pageControlSnapshot;
179+
180+      if (!disabled) {
181+        switch (action) {
182+          case "pause":
183+            disabled = pagePaused;
184+            break;
185+          case "resume":
186+            disabled = !pagePaused;
187+            break;
188+          case "manual":
189+            disabled = !hasConversationAutomation || automationStatus === "manual";
190+            break;
191+          default:
192+            disabled = true;
193+            break;
194+        }
195+      }
196+
197+      button.disabled = disabled;
198+      button.dataset.state = automationStatus || (pagePaused ? "paused" : "auto");
199     }
200 
201     for (const [action, button] of Object.entries(buttonMap)) {
202@@ -493,7 +568,7 @@ function createPageControlOverlayRuntime() {
203 
204     if (badgeButton) {
205       badgeButton.dataset.state = visualState;
206-      badgeButton.title = `打开 BAA 控制面板(${connectionLabel} / ${modeLabel})`;
207+      badgeButton.title = `打开自动化控制面板(${connectionLabel} / ${modeLabel})`;
208     }
209 
210     if (compactActionButton) {
211@@ -516,7 +591,7 @@ function createPageControlOverlayRuntime() {
212 
213     if (dismissButton) {
214       dismissButton.textContent = hidden ? "+" : "×";
215-      dismissButton.title = hidden ? "显示 BAA 控制" : "隐藏 BAA 控制";
216+      dismissButton.title = hidden ? "显示自动化控制" : "隐藏自动化控制";
217     }
218   }
219 
220@@ -587,7 +662,10 @@ function createPageControlOverlayRuntime() {
221   }
222 
223   async function runPageControlAction(action) {
224-    pendingAction = action === "pause" ? "page_pause" : "page_resume";
225+    pendingAction =
226+      action === "manual"
227+        ? "page_manual"
228+        : (action === "pause" ? "page_pause" : "page_resume");
229     setError("");
230     render();
231 
232@@ -694,7 +772,7 @@ function createPageControlOverlayRuntime() {
233 
234     const title = document.createElement("div");
235     title.className = "baa-title";
236-    title.textContent = "BAA Control";
237+    title.textContent = "自动化控制";
238     head.appendChild(title);
239 
240     dismissButton = document.createElement("button");
241@@ -712,16 +790,23 @@ function createPageControlOverlayRuntime() {
242     const pageActions = document.createElement("div");
243     pageActions.className = "baa-page-actions";
244 
245-    pageActionButton = document.createElement("button");
246-    pageActionButton.type = "button";
247-    pageActionButton.className = "baa-btn";
248-    pageActionButton.dataset.role = "page";
249-    pageActionButton.dataset.paused = "false";
250-    pageActionButton.textContent = "暂停本页";
251-    pageActionButton.addEventListener("click", () => {
252-      runPageControlAction(pageControlSnapshot?.paused === true ? "resume" : "pause").catch(() => {});
253-    });
254-    pageActions.appendChild(pageActionButton);
255+    for (const entry of [
256+      { action: "pause", label: "暂停本页" },
257+      { action: "resume", label: "恢复本页" },
258+      { action: "manual", label: "设为手动" }
259+    ]) {
260+      const button = document.createElement("button");
261+      button.type = "button";
262+      button.className = "baa-btn";
263+      button.dataset.role = "page";
264+      button.dataset.state = "auto";
265+      button.textContent = entry.label;
266+      button.addEventListener("click", () => {
267+        runPageControlAction(entry.action).catch(() => {});
268+      });
269+      pageButtonMap[entry.action] = button;
270+      pageActions.appendChild(button);
271+    }
272     panel.appendChild(pageActions);
273 
274     errorText = document.createElement("div");
275@@ -732,9 +817,9 @@ function createPageControlOverlayRuntime() {
276     actions.className = "baa-actions";
277 
278     for (const entry of [
279-      { action: "pause", label: "暂停" },
280-      { action: "resume", label: "恢复" },
281-      { action: "drain", label: "排空" }
282+      { action: "pause", label: "系统暂停" },
283+      { action: "resume", label: "系统恢复" },
284+      { action: "drain", label: "系统排空" }
285     ]) {
286       const button = document.createElement("button");
287       button.type = "button";
288@@ -783,8 +868,8 @@ function createPageControlOverlayRuntime() {
289       dismissButton = null;
290       badgeButton = null;
291       compactActionButton = null;
292-      pageActionButton = null;
293       buttonMap = {};
294+      pageButtonMap = {};
295     }
296   };
297 }
M plugins/baa-firefox/controller.js
+349, -22
  1@@ -332,6 +332,23 @@ function cloneControlState(value) {
  2   };
  3 }
  4 
  5+function normalizeConversationAutomationStatus(value) {
  6+  const normalized = String(value || "").trim().toLowerCase();
  7+  return normalized === "auto" || normalized === "manual" || normalized === "paused"
  8+    ? normalized
  9+    : null;
 10+}
 11+
 12+function derivePageControlPaused(automationStatus, fallbackPaused = false) {
 13+  const normalizedAutomationStatus = normalizeConversationAutomationStatus(automationStatus);
 14+
 15+  if (normalizedAutomationStatus == null) {
 16+    return fallbackPaused === true;
 17+  }
 18+
 19+  return normalizedAutomationStatus !== "auto";
 20+}
 21+
 22 function createDefaultWsState(overrides = {}) {
 23   return {
 24     connection: "disconnected",
 25@@ -491,9 +508,12 @@ function createDefaultPageControlState(overrides = {}) {
 26     platform: null,
 27     tabId: null,
 28     conversationId: null,
 29+    localConversationId: null,
 30     pageUrl: null,
 31     pageTitle: null,
 32     shellPage: false,
 33+    automationStatus: null,
 34+    lastNonPausedAutomationStatus: null,
 35     paused: false,
 36     pauseSource: null,
 37     pauseReason: null,
 38@@ -515,10 +535,16 @@ function clonePageControlState(value) {
 39     platform: normalizedPlatform,
 40     tabId: Number.isInteger(value.tabId) ? value.tabId : null,
 41     conversationId: trimToNull(value.conversationId),
 42+    localConversationId: trimToNull(value.localConversationId),
 43     pageUrl: trimToNull(value.pageUrl),
 44     pageTitle: trimToNull(value.pageTitle),
 45     shellPage: value.shellPage === true,
 46-    paused: value.paused === true,
 47+    automationStatus: normalizeConversationAutomationStatus(value.automationStatus),
 48+    lastNonPausedAutomationStatus: normalizeConversationAutomationStatus(value.lastNonPausedAutomationStatus),
 49+    paused: derivePageControlPaused(
 50+      normalizeConversationAutomationStatus(value.automationStatus),
 51+      value.paused === true
 52+    ),
 53     pauseSource: trimToNull(value.pauseSource),
 54     pauseReason: trimToNull(value.pauseReason),
 55     updatedAt: Number(value.updatedAt) || 0
 56@@ -628,13 +654,16 @@ function serializePageControlState(entry) {
 57     platform: normalized.platform,
 58     tabId: normalized.tabId,
 59     conversationId: normalized.conversationId,
 60+    localConversationId: normalized.localConversationId,
 61     pageUrl: normalized.pageUrl,
 62     pageTitle: normalized.pageTitle,
 63     shellPage: normalized.shellPage,
 64+    automationStatus: normalized.automationStatus,
 65+    lastNonPausedAutomationStatus: normalized.lastNonPausedAutomationStatus,
 66     paused: normalized.paused,
 67     pauseSource: normalized.pauseSource,
 68     pauseReason: normalized.pauseReason,
 69-    status: normalized.paused ? "paused" : "running",
 70+    status: normalized.automationStatus || (normalized.paused ? "paused" : "running"),
 71     updatedAt: normalized.updatedAt || 0
 72   };
 73 }
 74@@ -656,11 +685,21 @@ function updatePageControlState(input = {}, options = {}) {
 75     tabId
 76   });
 77   let changed = !state.pageControls[key];
 78+  let conversationChanged = false;
 79 
 80   if (Object.prototype.hasOwnProperty.call(input, "conversationId")) {
 81     const conversationId = trimToNull(input.conversationId);
 82     if (next.conversationId !== conversationId) {
 83       next.conversationId = conversationId;
 84+      conversationChanged = true;
 85+      changed = true;
 86+    }
 87+  }
 88+
 89+  if (Object.prototype.hasOwnProperty.call(input, "localConversationId")) {
 90+    const localConversationId = trimToNull(input.localConversationId);
 91+    if (next.localConversationId !== localConversationId) {
 92+      next.localConversationId = localConversationId;
 93       changed = true;
 94     }
 95   }
 96@@ -689,6 +728,38 @@ function updatePageControlState(input = {}, options = {}) {
 97     }
 98   }
 99 
100+  if (Object.prototype.hasOwnProperty.call(input, "automationStatus")) {
101+    const automationStatus = normalizeConversationAutomationStatus(input.automationStatus);
102+    if (next.automationStatus !== automationStatus) {
103+      next.automationStatus = automationStatus;
104+      changed = true;
105+    }
106+  }
107+
108+  if (Object.prototype.hasOwnProperty.call(input, "lastNonPausedAutomationStatus")) {
109+    const lastNonPausedAutomationStatus = normalizeConversationAutomationStatus(input.lastNonPausedAutomationStatus);
110+    if (next.lastNonPausedAutomationStatus !== lastNonPausedAutomationStatus) {
111+      next.lastNonPausedAutomationStatus = lastNonPausedAutomationStatus;
112+      changed = true;
113+    }
114+  }
115+
116+  if (
117+    conversationChanged
118+    && !Object.prototype.hasOwnProperty.call(input, "automationStatus")
119+    && !Object.prototype.hasOwnProperty.call(input, "lastNonPausedAutomationStatus")
120+    && !Object.prototype.hasOwnProperty.call(input, "localConversationId")
121+    && previous.localConversationId != null
122+  ) {
123+    next.localConversationId = null;
124+    next.automationStatus = null;
125+    next.lastNonPausedAutomationStatus = null;
126+    next.paused = false;
127+    next.pauseSource = null;
128+    next.pauseReason = null;
129+    changed = true;
130+  }
131+
132   if (Object.prototype.hasOwnProperty.call(input, "paused")) {
133     const paused = input.paused === true;
134     const pauseSource = paused
135@@ -714,6 +785,32 @@ function updatePageControlState(input = {}, options = {}) {
136     }
137   }
138 
139+  if (Object.prototype.hasOwnProperty.call(input, "automationStatus")) {
140+    const paused = derivePageControlPaused(next.automationStatus, next.paused);
141+    const pauseSource = paused
142+      ? (trimToNull(input.pauseSource) || trimToNull(options.source) || next.pauseSource)
143+      : null;
144+    const pauseReason =
145+      next.automationStatus === "paused"
146+        ? (trimToNull(input.pauseReason) || trimToNull(options.reason) || next.pauseReason || "user_pause")
147+        : null;
148+
149+    if (next.paused !== paused) {
150+      next.paused = paused;
151+      changed = true;
152+    }
153+
154+    if (next.pauseSource !== pauseSource) {
155+      next.pauseSource = pauseSource;
156+      changed = true;
157+    }
158+
159+    if (next.pauseReason !== pauseReason) {
160+      next.pauseReason = pauseReason;
161+      changed = true;
162+    }
163+  }
164+
165   if (!changed) {
166     return serializePageControlState(previous);
167   }
168@@ -2198,9 +2295,12 @@ function buildPluginStatusPayload(options = {}) {
169       platform: entry.platform,
170       tab_id: entry.tabId,
171       conversation_id: entry.conversationId,
172+      local_conversation_id: entry.localConversationId,
173       page_url: entry.pageUrl,
174       page_title: entry.pageTitle,
175       shell_page: entry.shellPage,
176+      automation_status: entry.automationStatus,
177+      last_non_paused_automation_status: entry.lastNonPausedAutomationStatus,
178       paused: entry.paused,
179       pause_source: entry.pauseSource,
180       pause_reason: entry.pauseReason,
181@@ -3924,7 +4024,74 @@ async function refreshControlPlaneState(options = {}) {
182 
183 function normalizePageControlAction(action) {
184   const methodName = String(action || "").trim().toLowerCase();
185-  return methodName === "pause" || methodName === "resume" ? methodName : null;
186+  return methodName === "manual" || methodName === "pause" || methodName === "resume"
187+    ? methodName
188+    : null;
189+}
190+
191+function extractRenewalConversationData(payload) {
192+  if (!isRecord(payload)) {
193+    return null;
194+  }
195+
196+  return isRecord(payload.data) ? payload.data : payload;
197+}
198+
199+function updatePageControlFromRenewalDetail(pageControl, detail, options = {}) {
200+  if (!pageControl?.platform || !Number.isInteger(pageControl.tabId) || !isRecord(detail)) {
201+    return pageControl || null;
202+  }
203+
204+  const activeLink = isRecord(detail.active_link) ? detail.active_link : null;
205+  const nextConversationId = trimToNull(activeLink?.remote_conversation_id) || pageControl.conversationId;
206+
207+  return updatePageControlState({
208+    platform: pageControl.platform,
209+    tabId: pageControl.tabId,
210+    conversationId: nextConversationId,
211+    localConversationId: trimToNull(detail.local_conversation_id) || pageControl.localConversationId,
212+    pageUrl: trimToNull(activeLink?.page_url) || pageControl.pageUrl,
213+    pageTitle: trimToNull(activeLink?.page_title) || pageControl.pageTitle,
214+    automationStatus: detail.automation_status,
215+    lastNonPausedAutomationStatus: detail.last_non_paused_automation_status,
216+    pauseSource: options.source || "automation_sync",
217+    pauseReason: detail.automation_status === "paused" ? detail.pause_reason : null,
218+    updatedAt: Number(detail.updated_at) || Date.now()
219+  }, {
220+    persist: options.persist !== false,
221+    render: options.render !== false,
222+    reason: detail.automation_status === "paused"
223+      ? (trimToNull(detail.pause_reason) || "user_pause")
224+      : null,
225+    source: options.source || "automation_sync"
226+  });
227+}
228+
229+async function requestConversationAutomationControl(context, pageControl, action, options = {}) {
230+  const conversationId = trimToNull(pageControl?.conversationId) || trimToNull(context?.conversationId);
231+
232+  if (!context?.platform || !conversationId) {
233+    return null;
234+  }
235+
236+  const response = await requestControlPlane("/v1/internal/automation/conversations/control", {
237+    method: "POST",
238+    body: JSON.stringify({
239+      action,
240+      scope: "current",
241+      ...(action === "manual" || action === "resume"
242+        ? {
243+            action: "mode",
244+            mode: action === "manual" ? "manual" : "auto"
245+          }
246+        : {}),
247+      platform: context.platform,
248+      reason: action === "pause" ? (options.reason || "user_pause") : undefined,
249+      source_conversation_id: conversationId
250+    })
251+  });
252+
253+  return extractRenewalConversationData(response.payload);
254 }
255 
256 async function runPageControlAction(action, sender, options = {}) {
257@@ -3940,21 +4107,57 @@ async function runPageControlAction(action, sender, options = {}) {
258     throw new Error("当前页面不是受支持的 AI 页面");
259   }
260 
261-  const page = syncPageControlFromContext(context, {
262-    paused: methodName === "pause",
263-    pauseSource: options.source || "runtime",
264-    pauseReason: methodName === "pause"
265-      ? (options.reason || "page_paused_by_user")
266-      : (options.reason || "page_resumed_by_user")
267-  }, {
268-    persist: true,
269-    render: true,
270-    reason: options.reason,
271-    source: options.source
272-  });
273+  const currentPage = buildPageControlSnapshotForSender(sender, detectPlatformFromUrl(sender?.tab?.url || ""));
274+  let page = currentPage;
275+
276+  if (currentPage?.conversationId) {
277+    try {
278+      const detail = await requestConversationAutomationControl(context, currentPage, methodName, {
279+        reason: methodName === "pause" ? "user_pause" : null
280+      });
281+
282+      if (detail != null) {
283+        page = updatePageControlFromRenewalDetail(currentPage, detail, {
284+          persist: true,
285+          render: true,
286+          source: options.source || "runtime"
287+        });
288+      }
289+    } catch (error) {
290+      const statusCode = Number.isFinite(Number(error?.statusCode)) ? Number(error.statusCode) : null;
291+
292+      if (methodName === "manual" || statusCode !== 404) {
293+        throw error;
294+      }
295+    }
296+  }
297+
298+  if (page == null || (methodName !== "manual" && page.automationStatus == null && page.localConversationId == null)) {
299+    if (methodName === "manual") {
300+      throw new Error("当前对话还没有 conductor 记录,暂不能设为手动");
301+    }
302+
303+    page = syncPageControlFromContext(context, {
304+      localConversationId: null,
305+      automationStatus: null,
306+      lastNonPausedAutomationStatus: null,
307+      paused: methodName === "pause",
308+      pauseSource: options.source || "runtime",
309+      pauseReason: methodName === "pause"
310+        ? (options.reason || "page_paused_by_user")
311+        : null
312+    }, {
313+      persist: true,
314+      render: true,
315+      reason: options.reason,
316+      source: options.source
317+    });
318+  }
319+
320+  const pageStateLabel = page?.automationStatus || (page?.paused ? "paused" : "auto");
321   addLog(
322     "info",
323-    `${platformLabel(context.platform)} 页面 ${page?.paused ? "已暂停" : "已恢复"},tab=${context.tabId}${page?.conversationId ? ` conversation=${page.conversationId}` : ""}`,
324+    `${platformLabel(context.platform)} 页面控制已更新:state=${pageStateLabel},tab=${context.tabId}${page?.conversationId ? ` conversation=${page.conversationId}` : ""}`,
325     false
326   );
327 
328@@ -4806,25 +5009,105 @@ function handleWsHelloAck(message) {
329   });
330 }
331 
332+function findMatchingAutomationSnapshot(pageControl, snapshots) {
333+  if (!pageControl?.platform || !Array.isArray(snapshots) || snapshots.length === 0) {
334+    return null;
335+  }
336+
337+  const conversationId = trimToNull(pageControl.conversationId);
338+  const pageUrl = trimToNull(pageControl.pageUrl);
339+
340+  return snapshots.find((entry) =>
341+    entry?.platform === pageControl.platform
342+    && (
343+      (conversationId && entry.remoteConversationId === conversationId)
344+      || (pageUrl && entry.pageUrl === pageUrl)
345+    )
346+  ) || null;
347+}
348+
349+function normalizeAutomationConversationSnapshot(value) {
350+  if (!isRecord(value)) {
351+    return null;
352+  }
353+
354+  const activeLink = isRecord(value.active_link) ? value.active_link : null;
355+  return {
356+    automationStatus: normalizeConversationAutomationStatus(value.automation_status),
357+    lastNonPausedAutomationStatus: normalizeConversationAutomationStatus(value.last_non_paused_automation_status),
358+    localConversationId: trimToNull(value.local_conversation_id),
359+    pageTitle: trimToNull(activeLink?.page_title),
360+    pageUrl: trimToNull(activeLink?.page_url),
361+    pauseReason: trimToNull(value.pause_reason),
362+    platform: trimToNull(value.platform),
363+    remoteConversationId: trimToNull(value.remote_conversation_id) || trimToNull(activeLink?.remote_conversation_id),
364+    updatedAt: Number(value.updated_at) || 0
365+  };
366+}
367+
368+function applyWsAutomationConversationSnapshot(browserSnapshot) {
369+  const rawEntries = Array.isArray(browserSnapshot?.automation_conversations)
370+    ? browserSnapshot.automation_conversations
371+    : [];
372+  const snapshots = rawEntries
373+    .map(normalizeAutomationConversationSnapshot)
374+    .filter((entry) => entry?.platform && entry.automationStatus);
375+  let changed = false;
376+
377+  for (const pageControl of listPageControlStates()) {
378+    const match = findMatchingAutomationSnapshot(pageControl, snapshots);
379+
380+    if (!match) {
381+      continue;
382+    }
383+
384+    const previous = serializePageControlState(pageControl);
385+    const next = updatePageControlState({
386+      platform: pageControl.platform,
387+      tabId: pageControl.tabId,
388+      conversationId: match.remoteConversationId || pageControl.conversationId,
389+      localConversationId: match.localConversationId,
390+      pageUrl: match.pageUrl || pageControl.pageUrl,
391+      pageTitle: match.pageTitle || pageControl.pageTitle,
392+      automationStatus: match.automationStatus,
393+      lastNonPausedAutomationStatus: match.lastNonPausedAutomationStatus,
394+      pauseReason: match.automationStatus === "paused" ? match.pauseReason : null,
395+      pauseSource: "ws_snapshot",
396+      updatedAt: match.updatedAt || Date.now()
397+    }, {
398+      persist: false,
399+      render: false,
400+      reason: match.automationStatus === "paused" ? match.pauseReason : null,
401+      source: "ws_snapshot"
402+    });
403+
404+    if (JSON.stringify(previous) !== JSON.stringify(next)) {
405+      changed = true;
406+    }
407+  }
408+
409+  return changed;
410+}
411+
412 function handleWsStateSnapshot(message) {
413-  const previous = cloneWsState(state.wsState);
414+  const previousWsState = cloneWsState(state.wsState);
415+  const previousControlState = cloneControlState(state.controlState);
416   const snapshot = isRecord(message.snapshot) ? message.snapshot : {};
417   const server = isRecord(snapshot.server) ? snapshot.server : {};
418   const browserSnapshot = isRecord(snapshot.browser) ? snapshot.browser : {};
419   const clientCount = normalizeCount(getFirstDefinedValue(browserSnapshot, ["client_count", "clients"]));
420-
421-  setWsState({
422-    ...previous,
423+  const nextWsState = createDefaultWsState({
424+    ...previousWsState,
425     connection: "connected",
426     wsUrl: typeof server.ws_url === "string" && server.ws_url.trim() ? server.ws_url.trim() : state.wsUrl,
427     localApiBase: typeof server.local_api_base === "string" && server.local_api_base.trim()
428       ? server.local_api_base.trim()
429-      : previous.localApiBase,
430+      : previousWsState.localApiBase,
431     serverIdentity: typeof server.identity === "string" && server.identity.trim() ? server.identity.trim() : null,
432     serverHost: typeof server.host === "string" && server.host.trim() ? server.host.trim() : null,
433     serverRole: typeof server.role === "string" && server.role.trim() ? server.role.trim() : null,
434     leaseState: typeof server.lease_state === "string" && server.lease_state.trim() ? server.lease_state.trim() : null,
435-    clientCount: clientCount ?? previous.clientCount,
436+    clientCount: clientCount ?? previousWsState.clientCount,
437     retryCount: 0,
438     nextRetryAt: 0,
439     lastMessageAt: Date.now(),
440@@ -4833,6 +5116,37 @@ function handleWsStateSnapshot(message) {
441     lastError: null,
442     raw: truncateControlRaw(snapshot)
443   });
444+  let renderChanged = JSON.stringify(previousWsState) !== JSON.stringify(nextWsState);
445+  let persistChanged = false;
446+
447+  state.wsState = nextWsState;
448+
449+  if (isRecord(snapshot.system)) {
450+    const nextControlState = createControlSuccessState(snapshot.system, {
451+      ok: true,
452+      statusCode: 200,
453+      source: "ws_snapshot"
454+    }, previousControlState);
455+
456+    if (JSON.stringify(previousControlState) !== JSON.stringify(nextControlState)) {
457+      state.controlState = nextControlState;
458+      renderChanged = true;
459+      persistChanged = true;
460+    }
461+  }
462+
463+  if (applyWsAutomationConversationSnapshot(browserSnapshot)) {
464+    renderChanged = true;
465+    persistChanged = true;
466+  }
467+
468+  if (persistChanged) {
469+    persistState().catch(() => {});
470+  }
471+
472+  if (renderChanged) {
473+    render();
474+  }
475 }
476 
477 function closeWsConnection() {
478@@ -5830,6 +6144,10 @@ function syncPageControlFromContext(context, overrides = {}, options = {}) {
479       : context.isShellPage
480   };
481 
482+  if (Object.prototype.hasOwnProperty.call(overrides, "localConversationId")) {
483+    input.localConversationId = overrides.localConversationId;
484+  }
485+
486   if (Object.prototype.hasOwnProperty.call(overrides, "paused")) {
487     input.paused = overrides.paused;
488   }
489@@ -5842,6 +6160,14 @@ function syncPageControlFromContext(context, overrides = {}, options = {}) {
490     input.pauseReason = overrides.pauseReason;
491   }
492 
493+  if (Object.prototype.hasOwnProperty.call(overrides, "automationStatus")) {
494+    input.automationStatus = overrides.automationStatus;
495+  }
496+
497+  if (Object.prototype.hasOwnProperty.call(overrides, "lastNonPausedAutomationStatus")) {
498+    input.lastNonPausedAutomationStatus = overrides.lastNonPausedAutomationStatus;
499+  }
500+
501   if (Object.prototype.hasOwnProperty.call(overrides, "updatedAt")) {
502     input.updatedAt = overrides.updatedAt;
503   }
504@@ -7583,6 +7909,7 @@ function exposeControllerTestApi() {
505     handlePageDiagnosticLog,
506     handlePageNetwork,
507     handlePageSse,
508+    handleWsStateSnapshot,
509     persistFinalMessageRelayCache,
510     reinjectAllOpenPlatformTabs,
511     reinjectPlatformTabs,
M plugins/baa-firefox/controller.test.cjs
+130, -1
  1@@ -39,7 +39,7 @@ class FakeWebSocket {
  2   }
  3 }
  4 
  5-function createControllerHarness() {
  6+function createControllerHarness(options = {}) {
  7   FakeWebSocket.instances = [];
  8 
  9   const windowListeners = new Map();
 10@@ -133,6 +133,9 @@ function createControllerHarness() {
 11       }
 12     },
 13     fetch() {
 14+      if (typeof options.fetchImpl === "function") {
 15+        return options.fetchImpl(...arguments);
 16+      }
 17       return Promise.resolve(new Response("{}", { status: 200 }));
 18     },
 19     location: window.location,
 20@@ -229,3 +232,129 @@ test("controller bounds buffered plugin diagnostic logs and flushes the newest e
 21   assert.equal(sent[49]?.text, "buffered-log-60");
 22   assert.equal(api.state.pendingPluginDiagnosticLogs.length, 0);
 23 });
 24+
 25+test("controller routes page control actions to conversation automation control and updates page state", async () => {
 26+  const requests = [];
 27+  const { api } = createControllerHarness({
 28+    fetchImpl(url, options = {}) {
 29+      requests.push({
 30+        body: options.body ? JSON.parse(options.body) : null,
 31+        method: options.method || "GET",
 32+        url
 33+      });
 34+      return Promise.resolve(new Response(JSON.stringify({
 35+        ok: true,
 36+        data: {
 37+          local_conversation_id: "lc-chatgpt-1",
 38+          platform: "chatgpt",
 39+          automation_status: "manual",
 40+          last_non_paused_automation_status: "manual",
 41+          updated_at: 1_710_000_100_000,
 42+          active_link: {
 43+            remote_conversation_id: "conv-automation-control",
 44+            page_url: "https://chatgpt.com/c/conv-automation-control",
 45+            page_title: "ChatGPT Automation"
 46+          }
 47+        }
 48+      }), {
 49+        status: 200,
 50+        headers: {
 51+          "content-type": "application/json"
 52+        }
 53+      }));
 54+    }
 55+  });
 56+
 57+  api.state.controlBaseUrl = "http://127.0.0.1:4317";
 58+
 59+  const sender = {
 60+    tab: {
 61+      id: 42,
 62+      title: "ChatGPT Automation",
 63+      url: "https://chatgpt.com/c/conv-automation-control"
 64+    }
 65+  };
 66+
 67+  const result = await api.runPageControlAction("manual", sender, {
 68+    source: "page_overlay"
 69+  });
 70+
 71+  assert.equal(requests.length, 1);
 72+  assert.equal(requests[0].method, "POST");
 73+  assert.equal(requests[0].url, "http://127.0.0.1:4317/v1/internal/automation/conversations/control");
 74+  assert.deepEqual(requests[0].body, {
 75+    action: "mode",
 76+    scope: "current",
 77+    mode: "manual",
 78+    platform: "chatgpt",
 79+    source_conversation_id: "conv-automation-control"
 80+  });
 81+  assert.equal(result.page.automationStatus, "manual");
 82+  assert.equal(result.page.localConversationId, "lc-chatgpt-1");
 83+  assert.equal(result.page.paused, true);
 84+});
 85+
 86+test("controller applies websocket automation snapshots to control and page state", () => {
 87+  const { api } = createControllerHarness();
 88+  const sender = {
 89+    tab: {
 90+      id: 7,
 91+      title: "ChatGPT Overlay",
 92+      url: "https://chatgpt.com/c/conv-ws-sync"
 93+    }
 94+  };
 95+  const context = api.getSenderContext(sender, "chatgpt");
 96+
 97+  api.syncPageControlFromContext(context, {}, {
 98+    persist: false,
 99+    render: false
100+  });
101+
102+  api.handleWsStateSnapshot({
103+    reason: "poll",
104+    snapshot: {
105+      server: {
106+        identity: "mini-main@mini(primary)",
107+        local_api_base: "http://127.0.0.1:4317",
108+        ws_url: "ws://127.0.0.1:4317/ws/firefox"
109+      },
110+      system: {
111+        mode: "paused",
112+        updated_at: 1_710_000_000_000,
113+        leader: {
114+          controller_id: "mini-main"
115+        },
116+        queue: {
117+          active_runs: 0,
118+          queued_tasks: 0
119+        }
120+      },
121+      browser: {
122+        client_count: 1,
123+        automation_conversations: [
124+          {
125+            platform: "chatgpt",
126+            remote_conversation_id: "conv-ws-sync",
127+            local_conversation_id: "lc-ws-sync",
128+            automation_status: "paused",
129+            last_non_paused_automation_status: "auto",
130+            pause_reason: "repeated_message",
131+            updated_at: 1_710_000_000_500,
132+            active_link: {
133+              page_url: "https://chatgpt.com/c/conv-ws-sync",
134+              page_title: "ChatGPT Overlay"
135+            }
136+          }
137+        ]
138+      }
139+    }
140+  });
141+
142+  assert.equal(api.state.controlState.mode, "paused");
143+  assert.equal(api.state.controlState.controlConnection, "connected");
144+  const pageState = api.state.pageControls["chatgpt:7"];
145+  assert.equal(pageState.localConversationId, "lc-ws-sync");
146+  assert.equal(pageState.automationStatus, "paused");
147+  assert.equal(pageState.pauseReason, "repeated_message");
148+  assert.equal(pageState.paused, true);
149+});
M tasks/T-S061.md
+25, -6
 1@@ -2,7 +2,7 @@
 2 
 3 ## 状态
 4 
 5-- 当前状态:`待开始`
 6+- 当前状态:`已完成`
 7 - 规模预估:`M`
 8 - 依赖任务:`T-S060`
 9 - 建议执行者:`Claude / Codex`(需要同时处理插件浮层、WS 同步和状态映射)
10@@ -151,22 +151,41 @@
11 
12 ### 开始执行
13 
14-- 执行者:
15-- 开始时间:
16+- 执行者:`Codex`
17+- 开始时间:`2026-04-01 11:31:00 CST`
18 - 状态变更:`待开始` → `进行中`
19 
20 ### 完成摘要
21 
22-- 完成时间:
23+- 完成时间:`2026-04-01 12:08:52 CST`
24 - 状态变更:`进行中` → `已完成`
25 - 修改了哪些文件:
26+  - `plugins/baa-firefox/content-script.js`
27+  - `plugins/baa-firefox/controller.js`
28+  - `plugins/baa-firefox/controller.test.cjs`
29+  - `apps/conductor-daemon/src/browser-types.ts`
30+  - `apps/conductor-daemon/src/firefox-ws.ts`
31+  - `apps/conductor-daemon/src/local-api.ts`
32+  - `apps/conductor-daemon/src/index.test.js`
33+  - `docs/api/business-interfaces.md`
34+  - `docs/api/control-interfaces.md`
35+  - `docs/api/firefox-local-ws.md`
36+  - `tasks/T-S061.md`
37+  - `tasks/TASK_OVERVIEW.md`
38 - 核心实现思路:
39+  - 把 Firefox 浮层从单一 `page paused` 布尔值升级成系统级 + 当前对话级分层展示,新增 `manual / auto / paused`、`local_conversation_id` 和 `pause_reason` 的本地状态映射,并把页面按钮拆成 `暂停本页` / `恢复本页` / `设为手动`
40+  - controller 在当前对话已被 conductor 识别时,统一通过 `/v1/internal/automation/conversations/control` 驱动 renewal 自动化状态;如果对话还没有 local conversation,则继续只落本地 page_control,避免误把“恢复本页”升级成系统恢复
41+  - conductor 的 Firefox WS `state_snapshot` 新增 `automation_conversations`,按 active link 暴露 `remote_conversation_id`、`automation_status`、`pause_reason` 和 `local_conversation_id`,让 REST 改状态、自动熔断和插件浮层都走同一份快照同步
42 - 跑了哪些测试:
43+  - `cd /Users/george/code/baa-conductor-unified-overlay-automation-control && pnpm install`
44+  - `node --test /Users/george/code/baa-conductor-unified-overlay-automation-control/plugins/baa-firefox/controller.test.cjs`
45+  - `cd /Users/george/code/baa-conductor-unified-overlay-automation-control && pnpm -C apps/conductor-daemon test`
46 
47 ### 执行过程中遇到的问题
48 
49-- 暂无
50+- 新 worktree 初始没有 `node_modules`,`pnpm exec tsc` 会直接报 `tsc not found`;先在 worktree 根目录补了一次 `pnpm install`
51+- Firefox WS 新增 `automation_conversations` 后,TypeScript 对 `map(...).filter(Boolean)` 的推断不够收敛;改成显式构造数组后恢复通过
52 
53 ### 剩余风险
54 
55-- 暂无
56+- 浏览器侧 real-time 刷新目前依赖 Firefox WS 的 2 秒轮询快照;对外部 REST 改状态已经能自动同步到浮层,但如果后续要做“立即 push”级别的时效性,可能还需要单独事件通知通道
M tasks/TASK_OVERVIEW.md
+4, -5
 1@@ -44,6 +44,7 @@
 2   - conductor 执行链路已补统一超时保护
 3   - renewal dispatcher 已支持 inter-job jitter 和 retry jitter
 4   - 自动化仲裁基础已经落地:同条 final-message 现在按 `control > instruction > renewal` 顺序仲裁,同对话具备执行锁、`pause_reason` 和自动熔断基础能力
 5+  - Firefox 浮层统一自动化控制已经落地:页面入口会同步显示系统状态、当前对话 `automation_status` 和 `pause_reason`,并通过 WS 与 renewal/page_control 保持一致
 6 
 7 ## 当前已确认的不一致
 8 
 9@@ -74,13 +75,13 @@
10 | [`T-S058`](./T-S058.md) | 消息同步任务生成续命任务 | M | T-S055, T-S056, T-S057 | Claude / Codex | 已完成 |
11 | [`T-S059`](./T-S059.md) | 续命执行任务与运维接口 | M | T-S055, T-S056, T-S057, T-S058 | Codex | 已完成 |
12 | [`T-S060`](./T-S060.md) | 自动化仲裁与自动熔断基础 | L | T-S056, T-S058, T-S059 | Codex | 已完成 |
13+| [`T-S061`](./T-S061.md) | 浮层统一自动化控制 | M | T-S060 | Codex | 已完成 |
14 
15 ### 当前下一波任务
16 
17 | 项目 | 标题 | 类型 | 状态 | 说明 |
18 |---|---|---|---|---|
19 | [`T-S063`](./T-S063.md) | normalize / parse 错误隔离 | task | 待开始 | 收口指令链路健壮性,避免单个坏 block 中断整批 |
20-| [`T-S061`](./T-S061.md) | 浮层统一自动化控制 | task | 待开始 | 统一页面级 BAA / renewal 控制入口并显示 pause_reason |
21 | [`T-S062`](./T-S062.md) | 系统级暂停接入自动化主链 | task | 待开始 | 把系统级暂停接入 BAA 与 timed-jobs 主链 |
22 | [`T-S064`](./T-S064.md) | timed-jobs 异步日志写入 | task | 待开始 | 减少同步日志 IO 对事件循环的阻塞 |
23 | [`T-S065`](./T-S065.md) | policy 配置化 | task | 待开始 | 为自动化控制指令和后续扩面提供策略入口 |
24@@ -137,7 +138,6 @@
25 
26 ### P2(次级优化)
27 
28-- [`T-S061`](./T-S061.md)
29 - [`T-S062`](./T-S062.md)
30 - [`T-S064`](./T-S064.md)
31 - [`T-S065`](./T-S065.md)
32@@ -177,11 +177,10 @@
33 
34 ## 当前主线判断
35 
36-Phase 1(浏览器主链)、Artifact 静态服务,以及 timed-jobs + 续命主线都已完成收口。`T-S060` 的自动化仲裁基础也已落地。当前主线已经没有 open bug blocker,下一步是:
37+Phase 1(浏览器主链)、Artifact 静态服务,以及 timed-jobs + 续命主线都已完成收口。`T-S060` 的自动化仲裁基础和 `T-S061` 的浮层统一自动化控制都已落地。当前主线已经没有 open bug blocker,下一步是:
38 
39 - 先做 `T-S063`
40-- 然后做 `T-S061`
41-- 再做 `T-S062`
42+- 然后做 `T-S062`
43 - 之后收口 `T-S064`、`T-S065`
44 - `OPT-004`、`OPT-009` 继续保留为 open opt
45