baa-conductor

git clone 

commit
275517e
parent
5d4febb
author
codex@macbookpro
date
2026-03-26 17:56:18 +0800 CST
feat: advance browser bridge control integration
11 files changed,  +1869, -208
M apps/conductor-daemon/src/index.test.js
+100, -4
  1@@ -1380,9 +1380,29 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
  2     assert.equal(describePayload.data.codex.enabled, true);
  3     assert.equal(describePayload.data.codex.target_base_url, codexd.baseUrl);
  4     assert.equal(describePayload.data.browser.route_prefix, "/v1/browser");
  5+    assert.equal(describePayload.data.browser.action_contract.route.path, "/v1/browser/actions");
  6+    assert.equal(describePayload.data.browser.request_contract.route.path, "/v1/browser/request");
  7+    assert.equal(
  8+      describePayload.data.browser.cancel_contract.route.path,
  9+      "/v1/browser/request/cancel"
 10+    );
 11     assert.equal(describePayload.data.host_operations.enabled, true);
 12     assert.equal(describePayload.data.host_operations.auth.header, "Authorization: Bearer <BAA_SHARED_TOKEN>");
 13     assert.equal(describePayload.data.host_operations.auth.configured, true);
 14+    assert.deepEqual(
 15+      describePayload.data.browser.routes.map((route) => route.path),
 16+      [
 17+        "/v1/browser",
 18+        "/v1/browser/actions",
 19+        "/v1/browser/request",
 20+        "/v1/browser/request/cancel"
 21+      ]
 22+    );
 23+    const legacyClaudeOpen = describePayload.data.browser.legacy_routes.find(
 24+      (route) => route.path === "/v1/browser/claude/open"
 25+    );
 26+    assert.equal(legacyClaudeOpen.lifecycle, "legacy");
 27+    assert.equal(legacyClaudeOpen.legacy_replacement_path, "/v1/browser/actions");
 28     assert.doesNotMatch(JSON.stringify(describePayload.data.codex.routes), /\/v1\/codex\/runs/u);
 29     assert.doesNotMatch(JSON.stringify(describePayload.data.capabilities.read_endpoints), /\/v1\/runs/u);
 30 
 31@@ -1398,11 +1418,16 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
 32     assert.equal(businessDescribePayload.data.surface, "business");
 33     assert.match(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/tasks/u);
 34     assert.match(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/codex/u);
 35+    assert.match(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/browser\/request/u);
 36+    assert.match(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/browser\/request\/cancel/u);
 37     assert.match(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/browser\/claude\/current/u);
 38+    assert.doesNotMatch(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/browser\/actions/u);
 39+    assert.doesNotMatch(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/browser\/claude\/open/u);
 40     assert.doesNotMatch(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/system\/pause/u);
 41     assert.doesNotMatch(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/exec/u);
 42     assert.doesNotMatch(JSON.stringify(businessDescribePayload.data.endpoints), /\/v1\/runs/u);
 43     assert.equal(businessDescribePayload.data.codex.backend, "independent_codexd");
 44+    assert.equal(businessDescribePayload.data.browser.request_contract.route.path, "/v1/browser/request");
 45 
 46     const controlDescribeResponse = await handleConductorHttpRequest(
 47       {
 48@@ -1414,10 +1439,14 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
 49     assert.equal(controlDescribeResponse.status, 200);
 50     const controlDescribePayload = parseJsonBody(controlDescribeResponse);
 51     assert.equal(controlDescribePayload.data.surface, "control");
 52+    assert.match(JSON.stringify(controlDescribePayload.data.endpoints), /\/v1\/browser/u);
 53+    assert.match(JSON.stringify(controlDescribePayload.data.endpoints), /\/v1\/browser\/actions/u);
 54+    assert.match(JSON.stringify(controlDescribePayload.data.endpoints), /\/v1\/browser\/claude\/open/u);
 55     assert.match(JSON.stringify(controlDescribePayload.data.endpoints), /\/v1\/system\/pause/u);
 56     assert.match(JSON.stringify(controlDescribePayload.data.endpoints), /\/v1\/exec/u);
 57     assert.doesNotMatch(JSON.stringify(controlDescribePayload.data.endpoints), /\/v1\/tasks/u);
 58     assert.equal(controlDescribePayload.data.codex.target_base_url, codexd.baseUrl);
 59+    assert.equal(controlDescribePayload.data.browser.action_contract.route.path, "/v1/browser/actions");
 60     assert.equal(controlDescribePayload.data.host_operations.auth.header, "Authorization: Bearer <BAA_SHARED_TOKEN>");
 61 
 62     const healthResponse = await handleConductorHttpRequest(
 63@@ -1443,6 +1472,8 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
 64     assert.equal(capabilitiesPayload.data.browser.route_prefix, "/v1/browser");
 65     assert.match(JSON.stringify(capabilitiesPayload.data.read_endpoints), /\/v1\/codex/u);
 66     assert.match(JSON.stringify(capabilitiesPayload.data.read_endpoints), /\/v1\/browser/u);
 67+    assert.match(JSON.stringify(capabilitiesPayload.data.write_endpoints), /\/v1\/browser\/actions/u);
 68+    assert.match(JSON.stringify(capabilitiesPayload.data.write_endpoints), /\/v1\/browser\/request/u);
 69     assert.doesNotMatch(JSON.stringify(capabilitiesPayload.data.read_endpoints), /\/v1\/runs/u);
 70 
 71     const browserStatusResponse = await handleConductorHttpRequest(
 72@@ -1460,6 +1491,57 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
 73     assert.equal(browserStatusPayload.data.records[0].live.credentials.account, "ops@example.com");
 74     assert.equal(browserStatusPayload.data.records[0].status, "fresh");
 75 
 76+    const browserActionsResponse = await handleConductorHttpRequest(
 77+      {
 78+        body: JSON.stringify({
 79+          action: "tab_open",
 80+          client_id: "firefox-claude",
 81+          platform: "claude"
 82+        }),
 83+        method: "POST",
 84+        path: "/v1/browser/actions"
 85+      },
 86+      localApiContext
 87+    );
 88+    assert.equal(browserActionsResponse.status, 200);
 89+    assert.equal(parseJsonBody(browserActionsResponse).data.action, "tab_open");
 90+
 91+    const browserRequestResponse = await handleConductorHttpRequest(
 92+      {
 93+        body: JSON.stringify({
 94+          platform: "claude",
 95+          prompt: "hello generic browser request"
 96+        }),
 97+        method: "POST",
 98+        path: "/v1/browser/request"
 99+      },
100+      localApiContext
101+    );
102+    assert.equal(browserRequestResponse.status, 200);
103+    const browserRequestPayload = parseJsonBody(browserRequestResponse);
104+    assert.equal(browserRequestPayload.data.organization.organization_id, "org-1");
105+    assert.equal(browserRequestPayload.data.conversation.conversation_id, "conv-1");
106+    assert.equal(browserRequestPayload.data.request_mode, "claude_prompt");
107+    assert.equal(
108+      browserRequestPayload.data.proxy.path,
109+      "/api/organizations/org-1/chat_conversations/conv-1/completion"
110+    );
111+
112+    const browserRequestCancelResponse = await handleConductorHttpRequest(
113+      {
114+        body: JSON.stringify({
115+          platform: "claude",
116+          request_id: "browser-123"
117+        }),
118+        method: "POST",
119+        path: "/v1/browser/request/cancel"
120+      },
121+      localApiContext
122+    );
123+    assert.equal(browserRequestCancelResponse.status, 501);
124+    const browserRequestCancelPayload = parseJsonBody(browserRequestCancelResponse);
125+    assert.equal(browserRequestCancelPayload.error, "browser_request_cancel_not_supported");
126+
127     const browserOpenResponse = await handleConductorHttpRequest(
128       {
129         body: JSON.stringify({
130@@ -1852,6 +1934,10 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
131   assert.deepEqual(
132     browser.calls.map((entry) => entry.kind === "apiRequest" ? `${entry.kind}:${entry.method}:${entry.path}` : `${entry.kind}:${entry.platform || "-"}`),
133     [
134+      "openTab:claude",
135+      "apiRequest:GET:/api/organizations",
136+      "apiRequest:GET:/api/organizations/org-1/chat_conversations",
137+      "apiRequest:POST:/api/organizations/org-1/chat_conversations/conv-1/completion",
138       "openTab:claude",
139       "apiRequest:GET:/api/organizations",
140       "apiRequest:GET:/api/organizations/org-1/chat_conversations",
141@@ -2697,25 +2783,30 @@ test("ConductorRuntime exposes /v1/browser Claude HTTP routes over the local Fir
142     assert.equal(browserStatusPayload.data.records[0].status, "fresh");
143     assert.equal(browserStatusPayload.data.records[0].persisted.credential_fingerprint, "fp-claude-http");
144 
145-    const openResponse = await fetch(`${baseUrl}/v1/browser/claude/open`, {
146+    const openResponse = await fetch(`${baseUrl}/v1/browser/actions`, {
147       method: "POST",
148       headers: {
149         "content-type": "application/json"
150       },
151       body: JSON.stringify({
152-        client_id: "firefox-claude-http"
153+        action: "tab_open",
154+        client_id: "firefox-claude-http",
155+        platform: "claude"
156       })
157     });
158     assert.equal(openResponse.status, 200);
159+    const openPayload = await openResponse.json();
160+    assert.equal(openPayload.data.action, "tab_open");
161     const openMessage = await client.queue.next((message) => message.type === "open_tab");
162     assert.equal(openMessage.platform, "claude");
163 
164-    const sendPromise = fetch(`${baseUrl}/v1/browser/claude/send`, {
165+    const sendPromise = fetch(`${baseUrl}/v1/browser/request`, {
166       method: "POST",
167       headers: {
168         "content-type": "application/json"
169       },
170       body: JSON.stringify({
171+        platform: "claude",
172         prompt: "hello from conductor http"
173       })
174     });
175@@ -2786,6 +2877,7 @@ test("ConductorRuntime exposes /v1/browser Claude HTTP routes over the local Fir
176     const sendPayload = await sendResponse.json();
177     assert.equal(sendPayload.data.organization.organization_id, "org-http-1");
178     assert.equal(sendPayload.data.conversation.conversation_id, "conv-http-1");
179+    assert.equal(sendPayload.data.request_mode, "claude_prompt");
180     assert.equal(sendPayload.data.response.accepted, true);
181 
182     const currentPromise = fetch(`${baseUrl}/v1/browser/claude/current`);
183@@ -2876,16 +2968,20 @@ test("ConductorRuntime exposes /v1/browser Claude HTTP routes over the local Fir
184     assert.equal(currentPayload.data.messages[0].role, "user");
185     assert.equal(currentPayload.data.messages[1].role, "assistant");
186 
187-    const reloadResponse = await fetch(`${baseUrl}/v1/browser/claude/reload`, {
188+    const reloadResponse = await fetch(`${baseUrl}/v1/browser/actions`, {
189       method: "POST",
190       headers: {
191         "content-type": "application/json"
192       },
193       body: JSON.stringify({
194+        action: "tab_reload",
195+        platform: "claude",
196         reason: "http_integration_test"
197       })
198     });
199     assert.equal(reloadResponse.status, 200);
200+    const reloadPayload = await reloadResponse.json();
201+    assert.equal(reloadPayload.data.action, "tab_reload");
202     const reloadMessage = await client.queue.next((message) => message.type === "reload");
203     assert.equal(reloadMessage.reason, "http_integration_test");
204   } finally {
M apps/conductor-daemon/src/local-api.ts
+763, -129
   1@@ -76,10 +76,35 @@ const BROWSER_CLAUDE_ORGANIZATIONS_PATH = "/api/organizations";
   2 const BROWSER_CLAUDE_CONVERSATIONS_PATH = "/api/organizations/{id}/chat_conversations";
   3 const BROWSER_CLAUDE_CONVERSATION_PATH = "/api/organizations/{id}/chat_conversations/{id}";
   4 const BROWSER_CLAUDE_COMPLETION_PATH = "/api/organizations/{id}/chat_conversations/{id}/completion";
   5+const SUPPORTED_BROWSER_ACTIONS = [
   6+  "request_credentials",
   7+  "tab_focus",
   8+  "tab_open",
   9+  "tab_reload"
  10+] as const;
  11+const RESERVED_BROWSER_ACTIONS = [
  12+  "controller_reload",
  13+  "plugin_status",
  14+  "tab_restore",
  15+  "ws_reconnect"
  16+] as const;
  17+const SUPPORTED_BROWSER_REQUEST_RESPONSE_MODES = ["buffered"] as const;
  18+const RESERVED_BROWSER_REQUEST_RESPONSE_MODES = ["sse"] as const;
  19 
  20 type LocalApiRouteMethod = "GET" | "POST";
  21 type LocalApiRouteKind = "probe" | "read" | "write";
  22 type LocalApiDescribeSurface = "business" | "control";
  23+type LocalApiRouteLifecycle = "legacy" | "stable";
  24+type BrowserActionName =
  25+  | "controller_reload"
  26+  | "plugin_status"
  27+  | "request_credentials"
  28+  | "tab_focus"
  29+  | "tab_open"
  30+  | "tab_reload"
  31+  | "tab_restore"
  32+  | "ws_reconnect";
  33+type BrowserRequestResponseMode = "buffered" | "sse";
  34 type BrowserRecordView = "active_and_persisted" | "active_only" | "persisted_only";
  35 type SharedTokenAuthFailureReason =
  36   | "empty_bearer_token"
  37@@ -91,6 +116,8 @@ interface LocalApiRouteDefinition {
  38   id: string;
  39   exposeInDescribe?: boolean;
  40   kind: LocalApiRouteKind;
  41+  legacyReplacementPath?: string;
  42+  lifecycle?: LocalApiRouteLifecycle;
  43   method: LocalApiRouteMethod;
  44   pathPattern: string;
  45   summary: string;
  46@@ -322,35 +349,63 @@ const LOCAL_API_ROUTES: LocalApiRouteDefinition[] = [
  47     kind: "read",
  48     method: "GET",
  49     pathPattern: "/v1/browser",
  50-    summary: "读取本地浏览器 bridge 摘要与 Claude 就绪状态"
  51+    summary: "读取本地浏览器 bridge、插件在线状态与登录态摘要"
  52+  },
  53+  {
  54+    id: "browser.actions",
  55+    kind: "write",
  56+    method: "POST",
  57+    pathPattern: "/v1/browser/actions",
  58+    summary: "派发通用 browser/plugin 管理动作"
  59+  },
  60+  {
  61+    id: "browser.request",
  62+    kind: "write",
  63+    method: "POST",
  64+    pathPattern: "/v1/browser/request",
  65+    summary: "发起通用 browser HTTP 代发请求"
  66+  },
  67+  {
  68+    id: "browser.request.cancel",
  69+    kind: "write",
  70+    method: "POST",
  71+    pathPattern: "/v1/browser/request/cancel",
  72+    summary: "取消通用 browser 请求或流"
  73   },
  74   {
  75     id: "browser.claude.open",
  76     kind: "write",
  77+    legacyReplacementPath: "/v1/browser/actions",
  78+    lifecycle: "legacy",
  79     method: "POST",
  80     pathPattern: "/v1/browser/claude/open",
  81-    summary: "打开或聚焦 Claude 标签页"
  82+    summary: "legacy 包装:打开或聚焦 Claude 标签页"
  83   },
  84   {
  85     id: "browser.claude.send",
  86     kind: "write",
  87+    legacyReplacementPath: "/v1/browser/request",
  88+    lifecycle: "legacy",
  89     method: "POST",
  90     pathPattern: "/v1/browser/claude/send",
  91-    summary: "通过本地 Firefox bridge 发起一轮 Claude 对话"
  92+    summary: "legacy 包装:通过本地 Firefox bridge 发起一轮 Claude 对话"
  93   },
  94   {
  95     id: "browser.claude.current",
  96     kind: "read",
  97+    lifecycle: "legacy",
  98     method: "GET",
  99     pathPattern: "/v1/browser/claude/current",
 100-    summary: "读取当前 Claude 对话内容与页面代理状态"
 101+    summary: "legacy 辅助读:读取当前 Claude 对话内容与页面代理状态"
 102   },
 103   {
 104     id: "browser.claude.reload",
 105     kind: "write",
 106+    legacyReplacementPath: "/v1/browser/actions",
 107+    lifecycle: "legacy",
 108     method: "POST",
 109     pathPattern: "/v1/browser/claude/reload",
 110-    summary: "请求当前 Claude 浏览器 bridge 页面重载"
 111+    summary: "legacy 包装:请求当前 Claude 浏览器 bridge 页面重载"
 112   },
 113   {
 114     id: "system.state",
 115@@ -580,6 +635,18 @@ function readOptionalStringField(body: JsonObject, fieldName: string): string |
 116   return normalized === "" ? undefined : normalized;
 117 }
 118 
 119+function readOptionalStringBodyField(body: JsonObject, ...fieldNames: string[]): string | undefined {
 120+  for (const fieldName of fieldNames) {
 121+    const value = readOptionalStringField(body, fieldName);
 122+
 123+    if (value !== undefined) {
 124+      return value;
 125+    }
 126+  }
 127+
 128+  return undefined;
 129+}
 130+
 131 function readBodyField(body: JsonObject, ...fieldNames: string[]): JsonValue | undefined {
 132   for (const fieldName of fieldNames) {
 133     if (Object.prototype.hasOwnProperty.call(body, fieldName)) {
 134@@ -1064,6 +1131,32 @@ interface ClaudeConversationSummary {
 135   updated_at: number | null;
 136 }
 137 
 138+interface BrowserActionDispatchResult {
 139+  action: BrowserActionName;
 140+  client_id: string;
 141+  connection_id: string;
 142+  dispatched_at: number;
 143+  platform: string | null;
 144+  reason?: string | null;
 145+  status: "dispatched";
 146+  type: string;
 147+}
 148+
 149+interface BrowserRequestExecutionResult {
 150+  client_id: string;
 151+  conversation: ClaudeConversationSummary | null;
 152+  organization: ClaudeOrganizationSummary | null;
 153+  platform: string;
 154+  request_body: JsonValue | null;
 155+  request_id: string;
 156+  request_method: string;
 157+  request_mode: "api_request" | "claude_prompt";
 158+  request_path: string;
 159+  response: JsonValue | string | null;
 160+  response_mode: BrowserRequestResponseMode;
 161+  status: number | null;
 162+}
 163+
 164 function asUnknownRecord(value: unknown): Record<string, unknown> | null {
 165   if (value === null || typeof value !== "object" || Array.isArray(value)) {
 166     return null;
 167@@ -1284,6 +1377,71 @@ function readOptionalTimeoutMs(body: JsonObject, url: URL): number | undefined {
 168   return Math.round(numeric);
 169 }
 170 
 171+function readBrowserActionName(body: JsonObject): BrowserActionName {
 172+  const action = readOptionalStringBodyField(body, "action", "type");
 173+
 174+  if (action == null) {
 175+    throw new LocalApiHttpError(
 176+      400,
 177+      "invalid_request",
 178+      'Field "action" is required for POST /v1/browser/actions.',
 179+      {
 180+        field: "action",
 181+        supported_actions: [...SUPPORTED_BROWSER_ACTIONS, ...RESERVED_BROWSER_ACTIONS]
 182+      }
 183+    );
 184+  }
 185+
 186+  const supportedAction = [...SUPPORTED_BROWSER_ACTIONS, ...RESERVED_BROWSER_ACTIONS].find(
 187+    (entry) => entry === action
 188+  );
 189+
 190+  if (supportedAction == null) {
 191+    throw new LocalApiHttpError(
 192+      400,
 193+      "invalid_request",
 194+      `Unsupported browser action "${action}".`,
 195+      {
 196+        action,
 197+        field: "action",
 198+        supported_actions: [...SUPPORTED_BROWSER_ACTIONS, ...RESERVED_BROWSER_ACTIONS]
 199+      }
 200+    );
 201+  }
 202+
 203+  return supportedAction;
 204+}
 205+
 206+function readBrowserRequestResponseMode(body: JsonObject): BrowserRequestResponseMode {
 207+  const responseMode = readOptionalStringBodyField(body, "responseMode", "response_mode");
 208+
 209+  if (responseMode == null) {
 210+    return "buffered";
 211+  }
 212+
 213+  const supportedMode = [
 214+    ...SUPPORTED_BROWSER_REQUEST_RESPONSE_MODES,
 215+    ...RESERVED_BROWSER_REQUEST_RESPONSE_MODES
 216+  ].find((entry) => entry === responseMode);
 217+
 218+  if (supportedMode == null) {
 219+    throw new LocalApiHttpError(
 220+      400,
 221+      "invalid_request",
 222+      `Unsupported browser response mode "${responseMode}".`,
 223+      {
 224+        field: "responseMode",
 225+        supported_response_modes: [
 226+          ...SUPPORTED_BROWSER_REQUEST_RESPONSE_MODES,
 227+          ...RESERVED_BROWSER_REQUEST_RESPONSE_MODES
 228+        ]
 229+      }
 230+    );
 231+  }
 232+
 233+  return supportedMode;
 234+}
 235+
 236 function createEmptyBrowserState(snapshot: ConductorRuntimeApiSnapshot): BrowserBridgeStateSnapshot {
 237   return {
 238     active_client_id: null,
 239@@ -1991,6 +2149,7 @@ async function requestBrowserProxy(
 240     body?: JsonValue;
 241     clientId?: string | null;
 242     headers?: Record<string, string>;
 243+    id?: string | null;
 244     method: string;
 245     path: string;
 246     platform: string;
 247@@ -2005,6 +2164,7 @@ async function requestBrowserProxy(
 248       body: input.body,
 249       clientId: input.clientId,
 250       headers: input.headers,
 251+      id: input.id,
 252       method: input.method,
 253       path: input.path,
 254       platform: input.platform,
 255@@ -2552,26 +2712,142 @@ function buildFirefoxWebSocketData(snapshot: ConductorRuntimeApiSnapshot): JsonO
 256   };
 257 }
 258 
 259-function buildBrowserHttpData(snapshot: ConductorRuntimeApiSnapshot): JsonObject {
 260+function buildBrowserActionContract(origin: string): JsonObject {
 261+  return {
 262+    route: describeRoute(requireRouteDefinition("browser.actions")),
 263+    request_body: {
 264+      action:
 265+        "必填字符串。当前正式支持 request_credentials、tab_open、tab_focus、tab_reload;plugin_status、ws_reconnect、controller_reload、tab_restore 已保留进合同但当前返回 501。",
 266+      platform: "tab_open、tab_focus、request_credentials 建议带非空平台字符串;当前正式平台仍是 claude。",
 267+      clientId: "可选字符串;指定目标 Firefox bridge client。",
 268+      reason: "可选字符串;tab_reload 和 request_credentials 会原样透传给浏览器侧。"
 269+    },
 270+    supported_actions: [...SUPPORTED_BROWSER_ACTIONS],
 271+    reserved_actions: [...RESERVED_BROWSER_ACTIONS],
 272+    examples: [
 273+      {
 274+        title: "Open or focus the Claude shell page",
 275+        curl: buildCurlExample(origin, requireRouteDefinition("browser.actions"), {
 276+          action: "tab_open",
 277+          platform: "claude"
 278+        })
 279+      },
 280+      {
 281+        title: "Ask the browser plugin to refresh credentials",
 282+        curl: buildCurlExample(origin, requireRouteDefinition("browser.actions"), {
 283+          action: "request_credentials",
 284+          platform: "claude",
 285+          reason: "describe_refresh"
 286+        })
 287+      }
 288+    ],
 289+    error_semantics: [
 290+      "503 browser_bridge_unavailable: 当前没有可用 Firefox bridge client。",
 291+      "409 browser_client_not_found: 指定的 clientId 当前未连接。",
 292+      "501 browser_action_not_supported: 合同已预留,但当前 bridge 还没有实现该管理动作。"
 293+    ]
 294+  };
 295+}
 296+
 297+function buildBrowserRequestContract(origin: string): JsonObject {
 298+  return {
 299+    route: describeRoute(requireRouteDefinition("browser.request")),
 300+    request_body: {
 301+      platform: "必填字符串;当前正式支持 claude。",
 302+      clientId: "可选字符串;指定目标 Firefox bridge client。",
 303+      requestId: "可选字符串;用于 trace 和未来 cancel 对齐。缺省时由 conductor 生成。",
 304+      method: "可选字符串;默认 GET。若携带 requestBody 或 prompt 且未显式指定,则默认 POST。",
 305+      path: "raw proxy 模式下必填;直接转发给浏览器本地 HTTP 代理路径。",
 306+      headers: "可选 string map;附加到浏览器本地代发请求。",
 307+      requestBody: "可选任意 JSON;作为代发请求体原样传入。",
 308+      prompt: '可选 Claude 兼容字段;当 platform=claude 且省略 path 时,会自动补全 completion 路径。',
 309+      organizationId: "可选 Claude 字段;覆盖自动选择的 organization。",
 310+      conversationId: "可选 Claude 字段;覆盖自动选择的 conversation。",
 311+      responseMode:
 312+        '可选字符串 buffered 或 sse;当前正式只实现 buffered,sse 会返回 501。',
 313+      timeoutMs: `可选整数 > 0;默认 ${DEFAULT_BROWSER_PROXY_TIMEOUT_MS}。`
 314+    },
 315+    supported_response_modes: [...SUPPORTED_BROWSER_REQUEST_RESPONSE_MODES],
 316+    reserved_response_modes: [...RESERVED_BROWSER_REQUEST_RESPONSE_MODES],
 317+    examples: [
 318+      {
 319+        title: "Send a Claude prompt through the generic browser request route",
 320+        curl: buildCurlExample(origin, requireRouteDefinition("browser.request"), {
 321+          platform: "claude",
 322+          prompt: "Summarize the current bridge state."
 323+        })
 324+      },
 325+      {
 326+        title: "Issue a raw browser proxy read against a captured Claude endpoint",
 327+        curl: buildCurlExample(origin, requireRouteDefinition("browser.request"), {
 328+          platform: "claude",
 329+          method: "GET",
 330+          path: "/api/organizations"
 331+        })
 332+      }
 333+    ],
 334+    error_semantics: [
 335+      "400 invalid_request: 缺字段或组合不合法;例如既没有 path 也没有 Claude prompt。",
 336+      "409 claude_credentials_unavailable: Claude prompt 模式还没有捕获到可用凭证。",
 337+      "503 browser_bridge_unavailable: 当前没有活跃 Firefox bridge client。",
 338+      "4xx/5xx browser_upstream_error: 浏览器本地代理已返回上游 HTTP 错误。",
 339+      "501 browser_streaming_not_supported: responseMode=sse 已入合同,但当前 HTTP 面还没有实现。"
 340+    ]
 341+  };
 342+}
 343+
 344+function buildBrowserRequestCancelContract(): JsonObject {
 345+  return {
 346+    route: describeRoute(requireRouteDefinition("browser.request.cancel")),
 347+    request_body: {
 348+      requestId: "必填字符串;对应 /v1/browser/request 的 requestId 或响应里返回的 proxy.request_id。",
 349+      platform: "必填字符串;与原始请求平台保持一致。",
 350+      clientId: "可选字符串;用于未来精确定位执行侧。",
 351+      reason: "可选字符串;取消原因。"
 352+    },
 353+    current_state: "reserved",
 354+    implementation_status: "当前返回 501,等待 browser bridge 增加 request_cancel plumbing。",
 355+    error_semantics: [
 356+      "400 invalid_request: requestId 或 platform 缺失。",
 357+      "501 browser_request_cancel_not_supported: 合同已固定,但本轮不实现真实取消链路。"
 358+    ]
 359+  };
 360+}
 361+
 362+function buildBrowserLegacyRouteData(): JsonObject[] {
 363+  return [
 364+    describeRoute(requireRouteDefinition("browser.claude.open")),
 365+    describeRoute(requireRouteDefinition("browser.claude.send")),
 366+    describeRoute(requireRouteDefinition("browser.claude.current")),
 367+    describeRoute(requireRouteDefinition("browser.claude.reload"))
 368+  ];
 369+}
 370+
 371+function buildBrowserHttpData(snapshot: ConductorRuntimeApiSnapshot, origin: string): JsonObject {
 372   return {
 373     enabled: snapshot.controlApi.firefoxWsUrl != null,
 374     platform: BROWSER_CLAUDE_PLATFORM,
 375     route_prefix: "/v1/browser",
 376     routes: [
 377       describeRoute(requireRouteDefinition("browser.status")),
 378-      describeRoute(requireRouteDefinition("browser.claude.open")),
 379-      describeRoute(requireRouteDefinition("browser.claude.send")),
 380-      describeRoute(requireRouteDefinition("browser.claude.current")),
 381-      describeRoute(requireRouteDefinition("browser.claude.reload"))
 382+      describeRoute(requireRouteDefinition("browser.actions")),
 383+      describeRoute(requireRouteDefinition("browser.request")),
 384+      describeRoute(requireRouteDefinition("browser.request.cancel"))
 385     ],
 386+    action_contract: buildBrowserActionContract(origin),
 387+    request_contract: buildBrowserRequestContract(origin),
 388+    cancel_contract: buildBrowserRequestCancelContract(),
 389+    legacy_routes: buildBrowserLegacyRouteData(),
 390     transport: {
 391       http: snapshot.controlApi.localApiBase ?? null,
 392       websocket: snapshot.controlApi.firefoxWsUrl ?? null
 393     },
 394     notes: [
 395-      "All Claude actions go through conductor HTTP first; conductor then forwards them over the local /ws/firefox bridge.",
 396-      "Claude send/current use the Firefox extension's page-internal HTTP proxy instead of DOM automation.",
 397-      "This surface currently supports Claude only and expects a local Firefox bridge client."
 398+      "Business-facing browser work now lands on POST /v1/browser/request; browser/plugin management lands on POST /v1/browser/actions.",
 399+      "GET /v1/browser remains the shared read model for login-state metadata and plugin connectivity.",
 400+      "The generic browser HTTP surface currently supports Claude only and expects a local Firefox bridge client.",
 401+      "POST /v1/browser/request with responseMode=sse and POST /v1/browser/request/cancel are already reserved in the contract but intentionally return 501 in this task.",
 402+      "The /v1/browser/claude/* routes remain available as legacy wrappers during the migration window."
 403     ]
 404   };
 405 }
 406@@ -2779,12 +3055,14 @@ function buildHostOperationsData(origin: string, snapshot: ConductorRuntimeApiSn
 407 }
 408 
 409 function describeRoute(route: LocalApiRouteDefinition): JsonObject {
 410-  return {
 411+  return compactJsonObject({
 412     id: route.id,
 413     method: route.method,
 414     path: route.pathPattern,
 415     kind: route.kind === "probe" ? "read" : route.kind,
 416     implementation: "implemented",
 417+    legacy_replacement_path: route.legacyReplacementPath,
 418+    lifecycle: route.lifecycle ?? "stable",
 419     summary: route.summary,
 420     access: isHostOperationsRoute(route) ? "bearer_shared_token" : "local_network",
 421     ...(isHostOperationsRoute(route)
 422@@ -2795,7 +3073,7 @@ function describeRoute(route: LocalApiRouteDefinition): JsonObject {
 423           }
 424         }
 425       : {})
 426-  };
 427+  });
 428 }
 429 
 430 function routeBelongsToSurface(
 431@@ -2815,10 +3093,10 @@ function routeBelongsToSurface(
 432       "status.view.json",
 433       "status.view.ui",
 434       "browser.status",
 435-      "browser.claude.open",
 436+      "browser.request",
 437+      "browser.request.cancel",
 438       "browser.claude.send",
 439       "browser.claude.current",
 440-      "browser.claude.reload",
 441       "codex.status",
 442       "codex.sessions.list",
 443       "codex.sessions.read",
 444@@ -2836,6 +3114,10 @@ function routeBelongsToSurface(
 445     "service.health",
 446     "service.version",
 447     "system.capabilities",
 448+    "browser.status",
 449+    "browser.actions",
 450+    "browser.claude.open",
 451+    "browser.claude.reload",
 452     "system.state",
 453     "system.pause",
 454     "system.resume",
 455@@ -2869,11 +3151,13 @@ function buildCapabilitiesData(
 456       "GET /describe",
 457       "GET /v1/capabilities",
 458       "GET /v1/status for the narrower read-only status view",
 459-      "GET /v1/browser if browser mediation is needed",
 460+      "GET /v1/browser if browser mediation or plugin state is needed",
 461       "GET /v1/system/state",
 462-      "GET /v1/browser/claude/current or /v1/tasks or /v1/codex",
 463+      "POST /v1/browser/request for browser-mediated business requests",
 464+      "POST /v1/browser/actions for browser/plugin management actions",
 465+      "GET /v1/browser/claude/current or /v1/tasks or /v1/codex when a legacy Claude helper read is needed",
 466       "Use /v1/codex/* for interactive Codex session and turn work",
 467-      "Use /v1/browser/claude/* for Claude page open/send/current over the local Firefox bridge",
 468+      "Use /v1/browser/claude/* only as legacy compatibility wrappers",
 469       "GET /describe/control if local shell/file access is needed",
 470       "Use POST system routes or host operations only when a write/exec is intended"
 471     ],
 472@@ -2887,7 +3171,7 @@ function buildCapabilitiesData(
 473       scheduler_enabled: snapshot.daemon.schedulerEnabled,
 474       started: snapshot.runtime.started
 475     },
 476-    browser: buildBrowserHttpData(snapshot),
 477+    browser: buildBrowserHttpData(snapshot, origin),
 478     transports: {
 479       http: {
 480         auth: buildHttpAuthData(snapshot),
 481@@ -2940,7 +3224,7 @@ async function handleDescribeRead(context: LocalApiRequestContext, version: stri
 482     auth: buildHttpAuthData(snapshot),
 483     system,
 484     websocket: buildFirefoxWebSocketData(snapshot),
 485-    browser: buildBrowserHttpData(snapshot),
 486+    browser: buildBrowserHttpData(snapshot, origin),
 487     codex: buildCodexProxyData(snapshot),
 488     describe_endpoints: {
 489       business: {
 490@@ -3045,7 +3329,8 @@ async function handleDescribeRead(context: LocalApiRequestContext, version: stri
 491     notes: [
 492       "AI callers should prefer /describe/business for business queries and /describe/control for control actions.",
 493       "GET /v1/status and GET /v1/status/ui expose the narrow read-only compatibility status view; /v1/system/state remains the fuller control-oriented truth surface.",
 494-      "The formal /v1/browser/* surface currently supports Claude only and forwards browser work through the local Firefox bridge.",
 495+      "The formal /v1/browser/* surface is now split into generic GET /v1/browser, POST /v1/browser/request, and POST /v1/browser/actions contracts.",
 496+      "The /v1/browser/claude/* routes remain available as legacy compatibility wrappers during migration.",
 497       "All /v1/codex routes proxy the independent codexd daemon; this process does not host Codex sessions itself.",
 498       "POST /v1/exec and POST /v1/files/* require Authorization: Bearer <BAA_SHARED_TOKEN>; missing or wrong tokens return 401 JSON.",
 499       "These routes read and mutate the mini node's local truth source directly.",
 500@@ -3083,12 +3368,13 @@ async function handleScopedDescribeRead(
 501       recommended_flow: [
 502         "GET /describe/business",
 503         "Optionally GET /v1/capabilities",
 504-        "Use business routes such as /v1/controllers, /v1/tasks and /v1/codex",
 505+        "GET /v1/browser if browser login-state metadata is needed",
 506+        "Use business routes such as /v1/browser/request, /v1/controllers, /v1/tasks and /v1/codex",
 507         "Use /describe/control if a local shell or file operation is intended"
 508       ],
 509       system,
 510       websocket: buildFirefoxWebSocketData(snapshot),
 511-      browser: buildBrowserHttpData(snapshot),
 512+      browser: buildBrowserHttpData(snapshot, origin),
 513       codex: buildCodexProxyData(snapshot),
 514       endpoints: routes.map(describeRoute),
 515       examples: [
 516@@ -3099,7 +3385,16 @@ async function handleScopedDescribeRead(
 517           curl: buildCurlExample(origin, requireRouteDefinition("browser.status"))
 518         },
 519         {
 520-          title: "Read current Claude conversation state",
 521+          title: "Send a Claude prompt through the generic browser request route",
 522+          method: "POST",
 523+          path: "/v1/browser/request",
 524+          curl: buildCurlExample(origin, requireRouteDefinition("browser.request"), {
 525+            platform: "claude",
 526+            prompt: "Summarize the current bridge state."
 527+          })
 528+        },
 529+        {
 530+          title: "Read current Claude conversation state through the legacy helper route",
 531           method: "GET",
 532           path: "/v1/browser/claude/current",
 533           curl: buildCurlExample(origin, requireRouteDefinition("browser.claude.current"))
 534@@ -3135,10 +3430,11 @@ async function handleScopedDescribeRead(
 535       notes: [
 536         "This surface is intended to be enough for business-query discovery without reading external docs.",
 537         "Use GET /v1/status for the narrow read-only compatibility snapshot and GET /v1/status/ui for the matching HTML panel.",
 538-        "The formal /v1/browser/* surface currently supports Claude only and rides on the local Firefox bridge.",
 539+        "Business-facing browser work now lands on POST /v1/browser/request; POST /v1/browser/request/cancel is already reserved in the contract but currently returns 501.",
 540+        "GET /v1/browser/claude/current and POST /v1/browser/claude/send remain available as legacy Claude helpers during migration.",
 541         "All /v1/codex routes proxy the independent codexd daemon instead of an in-process bridge.",
 542         "If you pivot to /describe/control for /v1/exec or /v1/files/*, those host-ops routes require Authorization: Bearer <BAA_SHARED_TOKEN>.",
 543-        "Control actions and host-level exec/file operations are intentionally excluded; use /describe/control."
 544+        "Browser/plugin management actions such as tab open/reload live under /describe/control via POST /v1/browser/actions."
 545       ]
 546     });
 547   }
 548@@ -3159,17 +3455,35 @@ async function handleScopedDescribeRead(
 549     recommended_flow: [
 550       "GET /describe/control",
 551       "Optionally GET /v1/capabilities",
 552+      "GET /v1/browser if plugin status or bridge connectivity is relevant",
 553       "GET /v1/system/state",
 554+      "Review POST /v1/browser/actions if a browser/plugin management action is intended",
 555       "Read host_operations if a local shell/file action is intended",
 556       "Only then decide whether to call pause, resume, drain or a host operation"
 557     ],
 558     system,
 559     websocket: buildFirefoxWebSocketData(snapshot),
 560     auth: buildHttpAuthData(snapshot),
 561+    browser: buildBrowserHttpData(snapshot, origin),
 562     codex: buildCodexProxyData(snapshot),
 563     host_operations: buildHostOperationsData(origin, snapshot),
 564     endpoints: routes.map(describeRoute),
 565     examples: [
 566+      {
 567+        title: "Inspect browser plugin and bridge status",
 568+        method: "GET",
 569+        path: "/v1/browser",
 570+        curl: buildCurlExample(origin, requireRouteDefinition("browser.status"))
 571+      },
 572+      {
 573+        title: "Open or focus the Claude shell page through the generic action route",
 574+        method: "POST",
 575+        path: "/v1/browser/actions",
 576+        curl: buildCurlExample(origin, requireRouteDefinition("browser.actions"), {
 577+          action: "tab_open",
 578+          platform: "claude"
 579+        })
 580+      },
 581       {
 582         title: "Read current system state first",
 583         method: "GET",
 584@@ -3227,6 +3541,7 @@ async function handleScopedDescribeRead(
 585       "This surface is intended to be enough for control discovery without reading external docs.",
 586       "The interactive Codex surface is proxied to independent codexd; inspect /v1/codex or /describe/business for those routes.",
 587       "Business queries such as tasks and runs are intentionally excluded; use /describe/business.",
 588+      "Browser/plugin management actions live on POST /v1/browser/actions; the legacy Claude open/reload routes remain available as compatibility wrappers.",
 589       "POST /v1/exec and POST /v1/files/* require Authorization: Bearer <BAA_SHARED_TOKEN>; missing or wrong tokens return 401 JSON.",
 590       "Host operations return the structured host-ops union inside the outer conductor HTTP envelope."
 591     ]
 592@@ -3269,7 +3584,8 @@ async function handleCapabilitiesRead(
 593     system: await buildSystemStateData(repository),
 594     notes: [
 595       "Read routes are safe for discovery and inspection.",
 596-      "The /v1/browser/* surface currently supports Claude only and uses the local Firefox bridge plus page-internal HTTP proxy.",
 597+      "The browser HTTP contract is now split into GET /v1/browser, POST /v1/browser/request, and POST /v1/browser/actions.",
 598+      "The generic browser surface currently supports Claude only; /v1/browser/claude/* remains available as legacy compatibility wrappers.",
 599       "All /v1/codex routes proxy the independent codexd daemon over local HTTP.",
 600       "POST /v1/system/* writes the local automation mode immediately.",
 601       "POST /v1/exec and POST /v1/files/* require Authorization: Bearer <BAA_SHARED_TOKEN> and return 401 JSON on missing or wrong tokens.",
 602@@ -3317,110 +3633,426 @@ async function handleBrowserStatusRead(context: LocalApiRequestContext): Promise
 603   return buildSuccessEnvelope(context.requestId, 200, await buildBrowserStatusData(context));
 604 }
 605 
 606-async function handleBrowserClaudeOpen(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
 607-  const body = readBodyObject(context.request, true);
 608-  const clientId = readOptionalStringField(body, "clientId") ?? readOptionalStringField(body, "client_id");
 609+function serializeClaudeOrganizationSummary(summary: ClaudeOrganizationSummary | null): JsonObject | null {
 610+  return summary == null
 611+    ? null
 612+    : {
 613+        organization_id: summary.id,
 614+        name: summary.name
 615+      };
 616+}
 617 
 618+function serializeClaudeConversationSummary(summary: ClaudeConversationSummary | null): JsonObject | null {
 619+  return summary == null
 620+    ? null
 621+    : {
 622+        conversation_id: summary.id,
 623+        created_at: summary.created_at,
 624+        title: summary.title,
 625+        updated_at: summary.updated_at
 626+      };
 627+}
 628+
 629+function dispatchBrowserAction(
 630+  context: LocalApiRequestContext,
 631+  input: {
 632+    action: BrowserActionName;
 633+    clientId?: string | null;
 634+    platform?: string | null;
 635+    reason?: string | null;
 636+  }
 637+): BrowserActionDispatchResult {
 638   try {
 639-    const receipt = requireBrowserBridge(context).openTab({
 640-      clientId,
 641-      platform: BROWSER_CLAUDE_PLATFORM
 642-    });
 643+    switch (input.action) {
 644+      case "tab_open":
 645+      case "tab_focus": {
 646+        const receipt = requireBrowserBridge(context).openTab({
 647+          clientId: input.clientId,
 648+          platform: input.platform
 649+        });
 650+
 651+        return {
 652+          action: input.action,
 653+          client_id: receipt.clientId,
 654+          connection_id: receipt.connectionId,
 655+          dispatched_at: receipt.dispatchedAt,
 656+          platform: input.platform ?? null,
 657+          status: "dispatched",
 658+          type: receipt.type
 659+        };
 660+      }
 661+      case "tab_reload": {
 662+        const receipt = requireBrowserBridge(context).reload({
 663+          clientId: input.clientId,
 664+          reason: input.reason
 665+        });
 666+
 667+        return {
 668+          action: input.action,
 669+          client_id: receipt.clientId,
 670+          connection_id: receipt.connectionId,
 671+          dispatched_at: receipt.dispatchedAt,
 672+          platform: input.platform ?? null,
 673+          reason: input.reason ?? null,
 674+          status: "dispatched",
 675+          type: receipt.type
 676+        };
 677+      }
 678+      case "request_credentials": {
 679+        const receipt = requireBrowserBridge(context).requestCredentials({
 680+          clientId: input.clientId,
 681+          platform: input.platform,
 682+          reason: input.reason
 683+        });
 684+
 685+        return {
 686+          action: input.action,
 687+          client_id: receipt.clientId,
 688+          connection_id: receipt.connectionId,
 689+          dispatched_at: receipt.dispatchedAt,
 690+          platform: input.platform ?? null,
 691+          reason: input.reason ?? null,
 692+          status: "dispatched",
 693+          type: receipt.type
 694+        };
 695+      }
 696+      case "plugin_status":
 697+      case "ws_reconnect":
 698+      case "controller_reload":
 699+      case "tab_restore":
 700+        throw new LocalApiHttpError(
 701+          501,
 702+          "browser_action_not_supported",
 703+          `Browser action "${input.action}" is reserved in the HTTP contract but not implemented yet.`,
 704+          compactJsonObject({
 705+            action: input.action,
 706+            browser_status_route: input.action === "plugin_status" ? "/v1/browser" : undefined,
 707+            route: "/v1/browser/actions",
 708+            supported_actions: [...SUPPORTED_BROWSER_ACTIONS]
 709+          })
 710+        );
 711+    }
 712+  } catch (error) {
 713+    if (error instanceof LocalApiHttpError) {
 714+      throw error;
 715+    }
 716 
 717-    return buildSuccessEnvelope(context.requestId, 200, {
 718-      client_id: receipt.clientId,
 719-      connection_id: receipt.connectionId,
 720-      dispatched_at: receipt.dispatchedAt,
 721-      open_url: BROWSER_CLAUDE_ROOT_URL,
 722-      platform: BROWSER_CLAUDE_PLATFORM,
 723-      type: receipt.type
 724+    throw createBrowserBridgeHttpError(`browser action ${input.action}`, error);
 725+  }
 726+}
 727+
 728+async function executeBrowserRequest(
 729+  context: LocalApiRequestContext,
 730+  input: {
 731+    clientId?: string | null;
 732+    conversationId?: string | null;
 733+    headers?: Record<string, string>;
 734+    method?: string | null;
 735+    organizationId?: string | null;
 736+    path?: string | null;
 737+    platform: string;
 738+    prompt?: string | null;
 739+    requestBody?: JsonValue;
 740+    requestId?: string | null;
 741+    responseMode?: BrowserRequestResponseMode;
 742+    timeoutMs?: number;
 743+  }
 744+): Promise<BrowserRequestExecutionResult> {
 745+  const responseMode = input.responseMode ?? "buffered";
 746+
 747+  if (responseMode === "sse") {
 748+    throw new LocalApiHttpError(
 749+      501,
 750+      "browser_streaming_not_supported",
 751+      "Browser SSE relay is reserved in the HTTP contract but not implemented on this surface yet.",
 752+      {
 753+        platform: input.platform,
 754+        route: "/v1/browser/request"
 755+      }
 756+    );
 757+  }
 758+
 759+  const explicitPath = normalizeOptionalString(input.path);
 760+  const prompt = normalizeOptionalString(input.prompt);
 761+  const requestBody =
 762+    input.requestBody !== undefined ? input.requestBody : prompt == null ? undefined : { prompt };
 763+  const requestMethod =
 764+    normalizeOptionalString(input.method)?.toUpperCase()
 765+    ?? (
 766+      requestBody !== undefined || explicitPath == null
 767+        ? "POST"
 768+        : "GET"
 769+    );
 770+
 771+  if (explicitPath == null) {
 772+    if (input.platform !== BROWSER_CLAUDE_PLATFORM || prompt == null) {
 773+      throw new LocalApiHttpError(
 774+        400,
 775+        "invalid_request",
 776+        'Field "path" is required unless platform="claude" and a non-empty "prompt" is provided.',
 777+        {
 778+          field: "path",
 779+          platform: input.platform
 780+        }
 781+      );
 782+    }
 783+
 784+    const selection = ensureClaudeBridgeReady(
 785+      selectClaudeBrowserClient(loadBrowserState(context), input.clientId),
 786+      input.clientId
 787+    );
 788+    const organization = await resolveClaudeOrganization(
 789+      context,
 790+      selection,
 791+      input.organizationId,
 792+      input.timeoutMs
 793+    );
 794+    const conversation = await resolveClaudeConversation(
 795+      context,
 796+      selection,
 797+      organization.id,
 798+      {
 799+        conversationId: input.conversationId,
 800+        createIfMissing: true,
 801+        timeoutMs: input.timeoutMs
 802+      }
 803+    );
 804+    const requestPath = buildClaudeRequestPath(
 805+      BROWSER_CLAUDE_COMPLETION_PATH,
 806+      organization.id,
 807+      conversation.id
 808+    );
 809+    const result = await requestBrowserProxy(context, {
 810+      action: "browser request",
 811+      body: requestBody ?? { prompt: "" },
 812+      clientId: selection.client.client_id,
 813+      headers: input.headers,
 814+      id: input.requestId,
 815+      method: requestMethod,
 816+      path: requestPath,
 817+      platform: input.platform,
 818+      timeoutMs: input.timeoutMs
 819     });
 820-  } catch (error) {
 821-    throw createBrowserBridgeHttpError("claude open", error);
 822+
 823+    return {
 824+      client_id: result.apiResponse.clientId,
 825+      conversation,
 826+      organization,
 827+      platform: input.platform,
 828+      request_body: requestBody ?? null,
 829+      request_id: result.apiResponse.id,
 830+      request_method: requestMethod,
 831+      request_mode: "claude_prompt",
 832+      request_path: requestPath,
 833+      response: result.body,
 834+      response_mode: responseMode,
 835+      status: result.apiResponse.status
 836+    };
 837   }
 838+
 839+  const result = await requestBrowserProxy(context, {
 840+    action: "browser request",
 841+    body: requestBody,
 842+    clientId: input.clientId,
 843+    headers: input.headers,
 844+    id: input.requestId,
 845+    method: requestMethod,
 846+    path: explicitPath,
 847+    platform: input.platform,
 848+    timeoutMs: input.timeoutMs
 849+  });
 850+
 851+  return {
 852+    client_id: result.apiResponse.clientId,
 853+    conversation: null,
 854+    organization: null,
 855+    platform: input.platform,
 856+    request_body: requestBody ?? null,
 857+    request_id: result.apiResponse.id,
 858+    request_method: requestMethod,
 859+    request_mode: "api_request",
 860+    request_path: explicitPath,
 861+    response: result.body,
 862+    response_mode: responseMode,
 863+    status: result.apiResponse.status
 864+  };
 865 }
 866 
 867-async function handleBrowserClaudeSend(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
 868+async function handleBrowserActions(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
 869   const body = readBodyObject(context.request, true);
 870-  const clientId = readOptionalStringField(body, "clientId") ?? readOptionalStringField(body, "client_id");
 871-  const organizationId =
 872-    readOptionalStringField(body, "organizationId")
 873-    ?? readOptionalStringField(body, "organization_id");
 874-  const conversationId =
 875-    readOptionalStringField(body, "conversationId")
 876-    ?? readOptionalStringField(body, "conversation_id");
 877-  const prompt =
 878-    readOptionalStringField(body, "prompt")
 879-    ?? readOptionalStringField(body, "message")
 880-    ?? readOptionalStringField(body, "input");
 881-  const explicitPath = readOptionalStringField(body, "path");
 882-  const method = readOptionalStringField(body, "method") ?? "POST";
 883-  const headers =
 884-    readOptionalStringMap(body, "headers")
 885-    ?? readOptionalStringMap(body, "request_headers")
 886-    ?? undefined;
 887-  const requestBody =
 888-    readOptionalObjectField(body, "requestBody")
 889-    ?? readOptionalObjectField(body, "request_body")
 890-    ?? (prompt != null ? { prompt } : undefined);
 891-  const timeoutMs = readOptionalTimeoutMs(body, context.url);
 892+  const action = readBrowserActionName(body);
 893+  const clientId = readOptionalStringBodyField(body, "clientId", "client_id");
 894+  const platform = readOptionalStringBodyField(body, "platform");
 895+  const reason = readOptionalStringBodyField(body, "reason");
 896 
 897-  if (explicitPath == null && prompt == null) {
 898+  if (
 899+    (action === "request_credentials" || action === "tab_focus" || action === "tab_open")
 900+    && platform == null
 901+  ) {
 902     throw new LocalApiHttpError(
 903       400,
 904       "invalid_request",
 905-      'Field "prompt" is required unless an explicit Claude proxy "path" is provided.'
 906+      `Field "platform" is required for browser action "${action}".`,
 907+      {
 908+        action,
 909+        field: "platform"
 910+      }
 911     );
 912   }
 913 
 914-  const selection = ensureClaudeBridgeReady(
 915-    selectClaudeBrowserClient(loadBrowserState(context), clientId),
 916-    clientId
 917+  return buildSuccessEnvelope(
 918+    context.requestId,
 919+    200,
 920+    dispatchBrowserAction(context, {
 921+      action,
 922+      clientId,
 923+      platform,
 924+      reason
 925+    }) as unknown as JsonValue
 926   );
 927-  const organization = explicitPath == null
 928-    ? await resolveClaudeOrganization(context, selection, organizationId, timeoutMs)
 929-    : null;
 930-  const conversation = explicitPath == null
 931-    ? await resolveClaudeConversation(context, selection, organization!.id, {
 932-        conversationId,
 933-        createIfMissing: true,
 934-        timeoutMs
 935-      })
 936-    : null;
 937-  const requestPath =
 938-    explicitPath
 939-    ?? buildClaudeRequestPath(BROWSER_CLAUDE_COMPLETION_PATH, organization!.id, conversation!.id);
 940-  const result = await requestBrowserProxy(context, {
 941-    action: "claude send",
 942-    body: requestBody ?? { prompt: "" },
 943-    clientId: selection.client.client_id,
 944-    headers,
 945-    method,
 946-    path: requestPath,
 947-    platform: BROWSER_CLAUDE_PLATFORM,
 948-    timeoutMs
 949+}
 950+
 951+async function handleBrowserRequest(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
 952+  const body = readBodyObject(context.request, true);
 953+  const platform = readOptionalStringBodyField(body, "platform");
 954+
 955+  if (platform == null) {
 956+    throw new LocalApiHttpError(
 957+      400,
 958+      "invalid_request",
 959+      'Field "platform" is required for POST /v1/browser/request.',
 960+      {
 961+        field: "platform"
 962+      }
 963+    );
 964+  }
 965+
 966+  const execution = await executeBrowserRequest(context, {
 967+    clientId: readOptionalStringBodyField(body, "clientId", "client_id"),
 968+    conversationId: readOptionalStringBodyField(body, "conversationId", "conversation_id"),
 969+    headers:
 970+      readOptionalStringMap(body, "headers")
 971+      ?? readOptionalStringMap(body, "request_headers")
 972+      ?? undefined,
 973+    method: readOptionalStringBodyField(body, "method"),
 974+    organizationId: readOptionalStringBodyField(body, "organizationId", "organization_id"),
 975+    path: readOptionalStringBodyField(body, "path"),
 976+    platform,
 977+    prompt: readOptionalStringBodyField(body, "prompt", "message", "input"),
 978+    requestBody: readBodyField(body, "requestBody", "request_body", "body", "payload"),
 979+    requestId: readOptionalStringBodyField(body, "requestId", "request_id", "id"),
 980+    responseMode: readBrowserRequestResponseMode(body),
 981+    timeoutMs: readOptionalTimeoutMs(body, context.url)
 982   });
 983 
 984   return buildSuccessEnvelope(context.requestId, 200, {
 985-    client_id: result.apiResponse.clientId,
 986-    conversation: conversation == null ? null : {
 987-      conversation_id: conversation.id,
 988-      created_at: conversation.created_at,
 989-      title: conversation.title,
 990-      updated_at: conversation.updated_at
 991-    },
 992-    organization: organization == null ? null : {
 993-      organization_id: organization.id,
 994-      name: organization.name
 995+    client_id: execution.client_id,
 996+    conversation: serializeClaudeConversationSummary(execution.conversation),
 997+    organization: serializeClaudeOrganizationSummary(execution.organization),
 998+    platform: execution.platform,
 999+    proxy: {
1000+      method: execution.request_method,
1001+      path: execution.request_path,
1002+      request_body: execution.request_body,
1003+      request_id: execution.request_id,
1004+      response_mode: execution.response_mode,
1005+      status: execution.status
1006     },
1007+    request_mode: execution.request_mode,
1008+    response: execution.response
1009+  });
1010+}
1011+
1012+async function handleBrowserRequestCancel(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
1013+  const body = readBodyObject(context.request, true);
1014+  const requestId = readOptionalStringBodyField(body, "requestId", "request_id", "id");
1015+  const platform = readOptionalStringBodyField(body, "platform");
1016+
1017+  if (requestId == null) {
1018+    throw new LocalApiHttpError(
1019+      400,
1020+      "invalid_request",
1021+      'Field "requestId" is required for POST /v1/browser/request/cancel.',
1022+      {
1023+        field: "requestId"
1024+      }
1025+    );
1026+  }
1027+
1028+  if (platform == null) {
1029+    throw new LocalApiHttpError(
1030+      400,
1031+      "invalid_request",
1032+      'Field "platform" is required for POST /v1/browser/request/cancel.',
1033+      {
1034+        field: "platform"
1035+      }
1036+    );
1037+  }
1038+
1039+  throw new LocalApiHttpError(
1040+    501,
1041+    "browser_request_cancel_not_supported",
1042+    "Browser request cancel is reserved in the HTTP contract but not implemented yet.",
1043+    compactJsonObject({
1044+      client_id: readOptionalStringBodyField(body, "clientId", "client_id"),
1045+      platform,
1046+      reason: readOptionalStringBodyField(body, "reason") ?? undefined,
1047+      request_id: requestId,
1048+      route: "/v1/browser/request/cancel"
1049+    })
1050+  );
1051+}
1052+
1053+async function handleBrowserClaudeOpen(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
1054+  const body = readBodyObject(context.request, true);
1055+  const dispatch = dispatchBrowserAction(context, {
1056+    action: "tab_open",
1057+    clientId: readOptionalStringBodyField(body, "clientId", "client_id"),
1058+    platform: BROWSER_CLAUDE_PLATFORM
1059+  });
1060+
1061+  return buildSuccessEnvelope(context.requestId, 200, {
1062+    client_id: dispatch.client_id,
1063+    connection_id: dispatch.connection_id,
1064+    dispatched_at: dispatch.dispatched_at,
1065+    open_url: BROWSER_CLAUDE_ROOT_URL,
1066+    platform: BROWSER_CLAUDE_PLATFORM,
1067+    type: dispatch.type
1068+  });
1069+}
1070+
1071+async function handleBrowserClaudeSend(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
1072+  const body = readBodyObject(context.request, true);
1073+  const execution = await executeBrowserRequest(context, {
1074+    clientId: readOptionalStringBodyField(body, "clientId", "client_id"),
1075+    conversationId: readOptionalStringBodyField(body, "conversationId", "conversation_id"),
1076+    headers:
1077+      readOptionalStringMap(body, "headers")
1078+      ?? readOptionalStringMap(body, "request_headers")
1079+      ?? undefined,
1080+    method: readOptionalStringBodyField(body, "method") ?? "POST",
1081+    organizationId: readOptionalStringBodyField(body, "organizationId", "organization_id"),
1082+    path: readOptionalStringBodyField(body, "path"),
1083+    platform: BROWSER_CLAUDE_PLATFORM,
1084+    prompt: readOptionalStringBodyField(body, "prompt", "message", "input"),
1085+    requestBody: readBodyField(body, "requestBody", "request_body", "body", "payload"),
1086+    requestId: readOptionalStringBodyField(body, "requestId", "request_id", "id"),
1087+    timeoutMs: readOptionalTimeoutMs(body, context.url)
1088+  });
1089+
1090+  return buildSuccessEnvelope(context.requestId, 200, {
1091+    client_id: execution.client_id,
1092+    conversation: serializeClaudeConversationSummary(execution.conversation),
1093+    organization: serializeClaudeOrganizationSummary(execution.organization),
1094     platform: BROWSER_CLAUDE_PLATFORM,
1095     proxy: {
1096-      path: requestPath,
1097-      request_body: requestBody ?? null,
1098-      request_id: result.apiResponse.id,
1099-      status: result.apiResponse.status
1100+      path: execution.request_path,
1101+      request_body: execution.request_body,
1102+      request_id: execution.request_id,
1103+      status: execution.status
1104     },
1105-    response: result.body
1106+    response: execution.response
1107   });
1108 }
1109 
1110@@ -3465,26 +4097,22 @@ async function handleBrowserClaudeCurrent(context: LocalApiRequestContext): Prom
1111 
1112 async function handleBrowserClaudeReload(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
1113   const body = readBodyObject(context.request, true);
1114-  const clientId = readOptionalStringField(body, "clientId") ?? readOptionalStringField(body, "client_id");
1115-  const reason = readOptionalStringField(body, "reason") ?? "browser_http_reload";
1116-
1117-  try {
1118-    const receipt = requireBrowserBridge(context).reload({
1119-      clientId,
1120-      reason
1121-    });
1122+  const reason = readOptionalStringBodyField(body, "reason") ?? "browser_http_reload";
1123+  const dispatch = dispatchBrowserAction(context, {
1124+    action: "tab_reload",
1125+    clientId: readOptionalStringBodyField(body, "clientId", "client_id"),
1126+    platform: BROWSER_CLAUDE_PLATFORM,
1127+    reason
1128+  });
1129 
1130-    return buildSuccessEnvelope(context.requestId, 200, {
1131-      client_id: receipt.clientId,
1132-      connection_id: receipt.connectionId,
1133-      dispatched_at: receipt.dispatchedAt,
1134-      platform: BROWSER_CLAUDE_PLATFORM,
1135-      reason,
1136-      type: receipt.type
1137-    });
1138-  } catch (error) {
1139-    throw createBrowserBridgeHttpError("claude reload", error);
1140-  }
1141+  return buildSuccessEnvelope(context.requestId, 200, {
1142+    client_id: dispatch.client_id,
1143+    connection_id: dispatch.connection_id,
1144+    dispatched_at: dispatch.dispatched_at,
1145+    platform: BROWSER_CLAUDE_PLATFORM,
1146+    reason,
1147+    type: dispatch.type
1148+  });
1149 }
1150 
1151 function buildHostOperationsAuthError(
1152@@ -3864,6 +4492,12 @@ async function dispatchBusinessRoute(
1153       return handleCapabilitiesRead(context, version);
1154     case "browser.status":
1155       return handleBrowserStatusRead(context);
1156+    case "browser.actions":
1157+      return handleBrowserActions(context);
1158+    case "browser.request":
1159+      return handleBrowserRequest(context);
1160+    case "browser.request.cancel":
1161+      return handleBrowserRequestCancel(context);
1162     case "browser.claude.open":
1163       return handleBrowserClaudeOpen(context);
1164     case "browser.claude.send":
M docs/api/README.md
+42, -24
  1@@ -60,8 +60,8 @@
  2 
  3 ### AI / CLI 最短分流
  4 
  5-- 做业务查询、Claude 浏览器动作、Codex 会话:先读 [`business-interfaces.md`](./business-interfaces.md),再调 `GET /describe/business`
  6-- 做系统控制、本机 shell / 文件操作:先读 [`control-interfaces.md`](./control-interfaces.md),再调 `GET /describe/control`
  7+- 做业务查询、browser request、Codex 会话:先读 [`business-interfaces.md`](./business-interfaces.md),再调 `GET /describe/business`
  8+- 做系统控制、browser/plugin 管理、本机 shell / 文件操作:先读 [`control-interfaces.md`](./control-interfaces.md),再调 `GET /describe/control`
  9 - 不确定当前服务暴露了哪些能力:补调 `GET /v1/capabilities`
 10 - 准备直接连 `codexd`:先调 `GET ${BAA_CODEXD_LOCAL_API_BASE}/describe`
 11 
 12@@ -126,20 +126,27 @@
 13 
 14 当前正式浏览器桥接分成两层:
 15 
 16-- `GET /v1/browser`:浏览器登录态元数据与持久化状态读面
 17-- `POST` / `GET /v1/browser/claude/*`:Claude 专用的浏览器本地代发面
 18+- `GET /v1/browser`:浏览器登录态元数据、持久化状态和插件在线状态读面
 19+- `POST /v1/browser/request`:通用 browser 代发入口
 20+- `POST /v1/browser/actions`:通用 browser/plugin 管理动作入口
 21+- `POST /v1/browser/request/cancel`:请求或流取消入口;合同已固定,但当前返回 `501`
 22+- `POST` / `GET /v1/browser/claude/*`:Claude 专用 legacy 兼容包装层
 23 
 24 | 方法 | 路径 | 说明 |
 25 | --- | --- | --- |
 26-| `GET` | `/v1/browser` | 读取活跃 Firefox bridge、持久化登录态记录和 `fresh` / `stale` / `lost` 状态 |
 27-| `POST` | `/v1/browser/claude/open` | 打开或聚焦 Claude shell 标签页 |
 28-| `POST` | `/v1/browser/claude/send` | 发起一轮 Claude prompt;由 daemon 转发到本地 `/ws/firefox`,再由插件走页面内 HTTP 代理 |
 29-| `GET` | `/v1/browser/claude/current` | 读取当前 Claude 代理回读结果;这是 Claude relay 辅助读接口,不是持久化主模型 |
 30-| `POST` | `/v1/browser/claude/reload` | 请求当前 Claude bridge 页面重载 |
 31+| `GET` | `/v1/browser` | 读取活跃 Firefox bridge、插件在线状态、持久化登录态记录和 `fresh` / `stale` / `lost` 状态 |
 32+| `POST` | `/v1/browser/actions` | 派发通用 browser/plugin 管理动作;当前正式支持 `request_credentials`、`tab_open`、`tab_focus`、`tab_reload` |
 33+| `POST` | `/v1/browser/request` | 发起通用 browser HTTP 代发请求;当前正式支持 Claude 的 buffered 请求 |
 34+| `POST` | `/v1/browser/request/cancel` | 取消请求或流;合同已固定,但当前返回 `501`,等待 bridge 侧补齐 cancel plumbing |
 35+| `POST` | `/v1/browser/claude/open` | legacy 包装:等价映射到 `POST /v1/browser/actions` |
 36+| `POST` | `/v1/browser/claude/send` | legacy 包装:等价映射到 `POST /v1/browser/request` |
 37+| `GET` | `/v1/browser/claude/current` | legacy 辅助读:读取当前 Claude 代理回读结果;不是未来通用主模型 |
 38+| `POST` | `/v1/browser/claude/reload` | legacy 包装:等价映射到 `POST /v1/browser/actions` |
 39 
 40 Browser 面约定:
 41 
 42-- Claude 浏览器动作属于业务面,AI / CLI 应先读 `GET /describe/business`
 43+- 浏览器功能类能力归 `business`,AI / CLI 先读 `GET /describe/business`
 44+- browser/plugin 管理动作归 `control`,在调用 `POST /v1/browser/actions` 前先读 `GET /describe/control`
 45 - 当前正式模型是“单平台单空壳页 + 登录态元数据持久化 + 浏览器本地代发”;`GET /v1/browser/claude/current` 只是 Claude relay 辅助读接口
 46 - `GET /v1/browser` 是当前正式浏览器桥接 truth read;支持 `platform`、`account`、`browser`、`client_id`、`host`、`status` 过滤
 47 - `records[]` 会合并“活跃 WS 连接视图”和“持久化记录视图”,`view` 可能是 `active_and_persisted`、`active_only` 或 `persisted_only`
 48@@ -147,10 +154,12 @@ Browser 面约定:
 49 - 原始 `cookie`、`token`、header 值不会入库,也不会出现在 `/v1/browser` 读接口里
 50 - 连接断开或流量老化后,持久化记录仍可读,但状态会从 `fresh` 变成 `stale` / `lost`
 51 - 当前浏览器本地代发面只支持 `claude`;ChatGPT / Gemini 目前只有壳页和元数据上报,不在正式 HTTP relay 合同里
 52-- `open` 通过本地 WS 下发 `open_tab`
 53-- `send` / `current` 优先通过本地 WS 下发 `api_request`,由插件在浏览器本地代发,不是 DOM 自动化
 54+- `POST /v1/browser/request` 要求 `platform`;若 `platform=claude` 且省略 `path`,可用 `prompt` 走 Claude completion 兼容模式
 55+- `POST /v1/browser/request` 当前只正式支持 `responseMode=buffered`;`responseMode=sse` 已纳入合同,但当前返回 `501`
 56+- `POST /v1/browser/actions` 当前正式支持 `request_credentials`、`tab_open`、`tab_focus`、`tab_reload`
 57+- `plugin_status`、`ws_reconnect`、`controller_reload`、`tab_restore` 已在合同中保留,但当前通过 `POST /v1/browser/actions` 调用会返回 `501`
 58 - `/ws/firefox` 只在本地 listener 上可用,不是公网产品接口
 59-- `send` / `current` 只有在 `mini` 上已有活跃 Firefox bridge client,且 Claude 页面已捕获有效凭证和 endpoint 时才可用
 60+- `request` 的 Claude prompt 模式和 `current` 辅助读只有在 `mini` 上已有活跃 Firefox bridge client,且 Claude 页面已捕获有效凭证和 endpoint 时才可用
 61 - 如果当前没有活跃 Firefox client,会返回清晰的 `503` JSON 错误
 62 - 如果已连接 client 还没拿到 Claude 凭证,会返回 `409` JSON 错误并提示先在浏览器里完成一轮真实请求
 63 
 64@@ -181,21 +190,30 @@ curl "${LOCAL_API_BASE}/v1/browser?platform=claude&account=smoke%40example.com"
 65 
 66 ```bash
 67 LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
 68-curl -X POST "${LOCAL_API_BASE}/v1/browser/claude/open" \
 69+curl -X POST "${LOCAL_API_BASE}/v1/browser/actions" \
 70   -H 'Content-Type: application/json' \
 71-  -d '{}'
 72+  -d '{"action":"tab_open","platform":"claude"}'
 73 ```
 74 
 75-发送一轮 Claude prompt:
 76+通过通用 request 合同发送一轮 Claude prompt:
 77 
 78 ```bash
 79 LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
 80-curl -X POST "${LOCAL_API_BASE}/v1/browser/claude/send" \
 81+curl -X POST "${LOCAL_API_BASE}/v1/browser/request" \
 82   -H 'Content-Type: application/json' \
 83-  -d '{"prompt":"Summarize the current bridge state."}'
 84+  -d '{"platform":"claude","prompt":"Summarize the current bridge state."}'
 85 ```
 86 
 87-读取当前 Claude 代理回读:
 88+取消合同占位:
 89+
 90+```bash
 91+LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
 92+curl -X POST "${LOCAL_API_BASE}/v1/browser/request/cancel" \
 93+  -H 'Content-Type: application/json' \
 94+  -d '{"platform":"claude","requestId":"browser-request-demo"}'
 95+```
 96+
 97+读取当前 Claude 代理回读(legacy helper):
 98 
 99 ```bash
100 LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
101@@ -295,7 +313,7 @@ host-ops 约定:
102 - 浏览器发来的 `credentials` / `api_endpoints` 会被转换成 `account`、凭证指纹、端点元数据和 `fresh/stale/lost` 持久化记录
103 - `headers` 只保留名称与数量;原始 `cookie` / `token` / header 值既不会入库,也不会在 snapshot 或 `/v1/browser` 中回显
104 - `GET /v1/browser` 会合并当前活跃连接和持久化记录;即使 client 断开或 daemon 重启,最近一次记录仍可读取
105-- `/v1/browser/claude/send` 和 `/v1/browser/claude/current` 会复用这条 WS bridge 的 `api_request` / `api_response` 做 Claude 页面内 HTTP 代理
106+- `/v1/browser/request` 和 legacy 的 `/v1/browser/claude/current` 会复用这条 WS bridge 的 `api_request` / `api_response` 做 Claude 页面内 HTTP 代理
107 
108 详细消息模型和 smoke 示例见:
109 
110@@ -369,16 +387,16 @@ curl "${LOCAL_API_BASE}/v1/browser?platform=claude&status=fresh"
111 
112 ```bash
113 LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
114-curl -X POST "${LOCAL_API_BASE}/v1/browser/claude/open" \
115+curl -X POST "${LOCAL_API_BASE}/v1/browser/actions" \
116   -H 'Content-Type: application/json' \
117-  -d '{}'
118+  -d '{"action":"tab_open","platform":"claude"}'
119 ```
120 
121 ```bash
122 LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
123-curl -X POST "${LOCAL_API_BASE}/v1/browser/claude/send" \
124+curl -X POST "${LOCAL_API_BASE}/v1/browser/request" \
125   -H 'Content-Type: application/json' \
126-  -d '{"prompt":"Summarize the current bridge state."}'
127+  -d '{"platform":"claude","prompt":"Summarize the current bridge state."}'
128 ```
129 
130 ```bash
M docs/api/business-interfaces.md
+15, -11
 1@@ -53,20 +53,20 @@
 2 
 3 | 方法 | 路径 | 作用 |
 4 | --- | --- | --- |
 5-| `GET` | `/v1/browser` | 查看浏览器登录态元数据、持久化记录和 `fresh` / `stale` / `lost` 状态 |
 6-| `GET` | `/v1/browser/claude/current` | 查看当前 Claude 代理回读结果;这是 Claude relay 辅助读接口,不是浏览器桥接主模型 |
 7+| `GET` | `/v1/browser` | 查看浏览器登录态元数据、持久化记录、插件在线状态和 `fresh` / `stale` / `lost` 状态 |
 8+| `GET` | `/v1/browser/claude/current` | 查看当前 Claude 代理回读结果;这是 legacy Claude 辅助读接口,不是浏览器桥接主模型 |
 9 | `GET` | `/v1/controllers?limit=20` | 查看当前 controller 摘要 |
10 | `GET` | `/v1/tasks?status=queued&limit=20` | 查看任务列表,可按 `status` 过滤 |
11 | `GET` | `/v1/tasks/:task_id` | 查看单个任务详情 |
12 | `GET` | `/v1/tasks/:task_id/logs?limit=200` | 查看单个任务日志,可按 `run_id` 过滤 |
13 
14-### 浏览器 Claude 代发接口
15+### 通用浏览器请求接口
16 
17 | 方法 | 路径 | 作用 |
18 | --- | --- | --- |
19-| `POST` | `/v1/browser/claude/open` | 打开或聚焦 Claude shell 标签页 |
20-| `POST` | `/v1/browser/claude/send` | 通过 `conductor -> /ws/firefox -> 插件页面内 HTTP 代理` 发起一轮 Claude prompt |
21-| `POST` | `/v1/browser/claude/reload` | 请求当前 Claude bridge 页面重载 |
22+| `POST` | `/v1/browser/request` | 通过 `conductor -> /ws/firefox -> 插件页面内 HTTP 代理` 发起通用 browser request;当前正式支持 Claude buffered 请求 |
23+| `POST` | `/v1/browser/request/cancel` | 取消 request 或流;合同已保留,但当前返回 `501` |
24+| `POST` | `/v1/browser/claude/send` | legacy 包装:等价映射到 `POST /v1/browser/request` |
25 
26 说明:
27 
28@@ -74,9 +74,12 @@
29 - 这个读面只返回 `account`、凭证指纹、端点元数据和时间戳状态;不会暴露原始 `cookie`、`token` 或 header 值
30 - `records[].view` 会区分活跃连接与仅持久化记录,`status` 会暴露 `fresh`、`stale`、`lost`
31 - 当前浏览器代发面只支持 `claude`
32+- `POST /v1/browser/request` 要求 `platform`;若 `platform=claude` 且省略 `path`,可用 `prompt` 走 Claude completion 兼容模式
33+- `POST /v1/browser/request` 当前只正式支持 `responseMode=buffered`;`responseMode=sse` 已入合同,但当前返回 `501`
34 - `send` / `current` 不是 DOM 自动化,而是通过插件已有的页面内 HTTP 代理完成
35 - 如果没有活跃 Firefox bridge client,会返回 `503`
36 - 如果 client 还没有 Claude 凭证快照,会返回 `409`
37+- 打开、聚焦、重载标签页等 browser/plugin 管理动作已经移到 [`control-interfaces.md`](./control-interfaces.md) 和 `GET /describe/control`
38 
39 ### Codex 会话查询与写入
40 
41@@ -141,16 +144,16 @@ curl "${BASE_URL}/v1/browser?platform=claude&status=fresh"
42 
43 ```bash
44 BASE_URL="http://100.71.210.78:4317"
45-curl -X POST "${BASE_URL}/v1/browser/claude/open" \
46+curl -X POST "${BASE_URL}/v1/browser/request" \
47   -H 'Content-Type: application/json' \
48-  -d '{}'
49+  -d '{"platform":"claude","prompt":"Summarize the current repository status."}'
50 ```
51 
52 ```bash
53 BASE_URL="http://100.71.210.78:4317"
54-curl -X POST "${BASE_URL}/v1/browser/claude/send" \
55+curl -X POST "${BASE_URL}/v1/browser/request/cancel" \
56   -H 'Content-Type: application/json' \
57-  -d '{"prompt":"Summarize the current repository status."}'
58+  -d '{"platform":"claude","requestId":"browser-request-demo"}'
59 ```
60 
61 ```bash
62@@ -203,8 +206,9 @@ curl "${BASE_URL}/v1/tasks/${TASK_ID}/logs?limit=50"
63 ## 当前边界
64 
65 - 业务类接口当前以“只读查询”为主
66-- 浏览器业务面当前以“登录态元数据读面 + Claude 浏览器本地代发”收口,不把页面对话 UI 当成正式能力
67+- 浏览器业务面当前以“登录态元数据读面 + 通用 browser request 合同”收口,不把页面对话 UI 当成正式能力
68 - 浏览器 relay 当前只正式支持 Claude,且依赖本地 Firefox bridge 已连接
69+- `/v1/browser/request/cancel` 和 `responseMode=sse` 已保留进合同,但当前不在本轮实现范围内
70 - `/v1/codex/*` 是少数已经正式开放的业务写接口,但后端固定代理到独立 `codexd`
71 - 控制动作例如 `pause` / `resume` / `drain` 不在本文件讨论范围内
72 - 本机能力接口 `/v1/exec`、`/v1/files/read`、`/v1/files/write` 也不在本文件讨论范围内
M docs/api/control-interfaces.md
+45, -5
 1@@ -28,9 +28,10 @@
 2 1. 先读本文件
 3 2. 调 `GET /describe/control`
 4 3. 如有需要,再调 `GET /v1/capabilities`
 5-4. 调 `GET /v1/system/state`
 6-5. 如果要做本机 shell / 文件操作,再看 `host_operations`
 7-6. 确认当前模式和动作目标后,再执行 `pause` / `resume` / `drain` 或 host-ops
 8+4. 如果涉及 browser/plugin 管理,先调 `GET /v1/browser`
 9+5. 调 `GET /v1/system/state`
10+6. 如果要做本机 shell / 文件操作,再看 `host_operations`
11+7. 确认当前模式和动作目标后,再执行 `POST /v1/browser/actions`、`pause` / `resume` / `drain` 或 host-ops
12 
13 如果目标是 Codex 会话而不是控制动作:
14 
15@@ -68,8 +69,31 @@
16 
17 | 方法 | 路径 | 作用 |
18 | --- | --- | --- |
19+| `GET` | `/v1/browser` | 返回 Firefox bridge 在线状态、插件摘要和浏览器登录态持久化记录 |
20 | `GET` | `/v1/system/state` | 返回当前 automation mode、leader、queue、active runs |
21 
22+### Browser / Plugin 管理
23+
24+| 方法 | 路径 | 作用 |
25+| --- | --- | --- |
26+| `POST` | `/v1/browser/actions` | 派发通用 browser/plugin 管理动作;当前正式支持 `request_credentials`、`tab_open`、`tab_focus`、`tab_reload` |
27+| `POST` | `/v1/browser/claude/open` | legacy 包装:等价映射到 `POST /v1/browser/actions` |
28+| `POST` | `/v1/browser/claude/reload` | legacy 包装:等价映射到 `POST /v1/browser/actions` |
29+
30+browser/plugin 管理约定:
31+
32+- `GET /v1/browser` 是 plugin 状态和登录态元数据的共享读面;不是单独的第三层 describe
33+- `POST /v1/browser/actions` 当前正式支持:
34+  - `request_credentials`
35+  - `tab_open`
36+  - `tab_focus`
37+  - `tab_reload`
38+- `plugin_status`、`ws_reconnect`、`controller_reload`、`tab_restore` 已保留进合同,但当前会返回 `501`
39+- 当前正式平台仍是 `claude`
40+- 如果没有活跃 Firefox bridge client,会返回 `503`
41+- 如果指定了不存在的 `clientId`,会返回 `409`
42+- browser 业务请求不在本节;请改读 [`business-interfaces.md`](./business-interfaces.md) 和 `POST /v1/browser/request`
43+
44 ### 控制动作
45 
46 | 方法 | 路径 | 作用 |
47@@ -132,8 +156,22 @@ BASE_URL="http://100.71.210.78:4317"
48 curl "${BASE_URL}/v1/system/state"
49 ```
50 
51+### 需要时先看 browser/plugin 状态
52+
53+```bash
54+BASE_URL="http://100.71.210.78:4317"
55+curl "${BASE_URL}/v1/browser?platform=claude"
56+```
57+
58 ### 最后再执行控制动作
59 
60+```bash
61+BASE_URL="http://100.71.210.78:4317"
62+curl -X POST "${BASE_URL}/v1/browser/actions" \
63+  -H 'Content-Type: application/json' \
64+  -d '{"action":"tab_open","platform":"claude"}'
65+```
66+
67 ```bash
68 BASE_URL="http://100.71.210.78:4317"
69 curl -X POST "${BASE_URL}/v1/system/pause" \
70@@ -178,14 +216,16 @@ curl -X POST "${BASE_URL}/v1/files/write" \
71 ## 给 CLI / 网页版 AI 的推荐提示
72 
73 ```text
74-先阅读控制类接口文档,再请求 /describe/control、/v1/capabilities、/v1/system/state。
75-只有在确认当前状态后,才执行 pause / resume / drain;如果要做本机 shell / 文件操作,也先看 host_operations,并带上 Authorization: Bearer <BAA_SHARED_TOKEN>。
76+先阅读控制类接口文档,再请求 /describe/control、/v1/capabilities,并在需要时查看 /v1/browser 和 /v1/system/state。
77+只有在确认当前状态后,才执行 /v1/browser/actions、pause / resume / drain;如果要做本机 shell / 文件操作,也先看 host_operations,并带上 Authorization: Bearer <BAA_SHARED_TOKEN>。
78 ```
79 
80 ## 当前边界
81 
82 - 当前控制面是单节点 `mini`
83 - 控制动作默认作用于当前唯一活动节点
84+- browser/plugin 管理动作已经纳入 control,但当前正式只实现 `request_credentials`、`tab_open`、`tab_focus`、`tab_reload`
85+- `plugin_status`、`ws_reconnect`、`controller_reload`、`tab_restore` 已保留在合同里,但当前不在本轮实现范围内
86 - Codex 会话能力不在本文件主讨论范围;它通过 `/v1/codex/*` 代理到独立 `codexd`
87 - 业务查询不在本文件讨论范围内
88 - 业务类接口见 [`business-interfaces.md`](./business-interfaces.md)
M plans/STATUS_SUMMARY.md
+12, -7
 1@@ -6,20 +6,21 @@
 2 
 3 ## 当前代码基线
 4 
 5-- 主线基线:`main@4796db4`
 6+- 主线基线:`main@5d4febb`
 7 - 任务文档已统一收口到 `tasks/`
 8 - 当前活动任务见 `tasks/TASK_OVERVIEW.md`
 9-- `T-S001` 到 `T-S020` 已经完成
10+- `T-S001` 到 `T-S022` 已经完成
11 
12 ## 当前状态分类
13 
14-- `已完成`:`T-S001` 到 `T-S020`
15-- `当前 TODO`:`无`
16+- `已完成`:`T-S001` 到 `T-S022`
17+- `当前 TODO`:`T-S023`、`T-S024`
18 - `低优先级 TODO`:`4318/status-api` 兼容层删旧与解耦
19 
20 当前新的主需求文档:
21 
22 - [`./BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md`](./BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md)
23+- [`./FIREFOX_BRIDGE_CONTROL_REQUIREMENTS.md`](./FIREFOX_BRIDGE_CONTROL_REQUIREMENTS.md)
24 
25 ## 当前状态
26 
27@@ -56,10 +57,10 @@
28 
29 ## 当前主 TODO
30 
31-当前主 TODO 已清空;当前主线只剩低优先级 backlog:
32+当前主线已切到浏览器桥接第二阶段开发:
33 
34-1. 清理仍依赖 `4318` / `status-api` wrapper 的旧脚本、书签和运维说明
35-2. 把 `conductor-daemon` 对 `status-api` 构建产物的复用提成共享模块,再评估是否删除 `apps/status-api`
36+1. `T-S023`:打通通用 browser request/SSE 链路与 `conductor` 风控策略
37+2. `T-S024`:回写文档、补 smoke 并同步主线状态
38 
39 ## 低优先级 TODO
40 
41@@ -91,6 +92,8 @@
42 - `T-S018`:Firefox 插件已收口到空壳标签页,并开始上报 `account`、凭证指纹和端点元数据
43 - `T-S019`:`conductor-daemon` 已接入浏览器登录态持久化、读接口合并视图和 `fresh` / `stale` / `lost` 状态收口
44 - `T-S020`:浏览器桥接文档、browser smoke 和状态视图已经回写到“登录态元数据持久化 + 空壳标签页 + 浏览器本地代发”正式模型
45+- `T-S021`:`conductor` 的浏览器能力发现已继续收口到 `business` / `control` 两层 describe,并定义了通用 browser HTTP 合同与 legacy Claude 包装位次
46+- `T-S022`:Firefox 插件侧已完成空壳页 runtime、`desired/actual` 状态模型和插件管理类 payload 准备
47 - 根级 `pnpm smoke` 已进主线,覆盖 runtime public-api compatibility、legacy absence、codexd e2e 和 browser-control e2e smoke
48 
49 ## 4318 依赖盘点与结论
50@@ -120,3 +123,5 @@
51 - runtime smoke 当前仍假定仓库根已经存在 `state/`、`runs/`、`worktrees/`、`logs/launchd/`、`logs/codexd/`、`tmp/` 等本地运行目录;这是现有脚本前提,不是本轮浏览器桥接功能改动本身
52 - `pnpm verify:mini` 只收口 on-node 静态检查和运行态探针,不替代会话级 smoke
53 - `status-api` 的终局已经先收口到“保留为 opt-in 兼容层”;真正删除它之前,还要先清 `4318` 调用方并拆掉当前构建时复用
54+- 这轮还没跑真实 Firefox 手工 smoke,因此“手动关 tab -> tab_restore -> WS 重连后状态回报”的浏览器端闭环仍未实测
55+- `conductor` 还不会消费新增的 `shell_runtime` 字段,也没有正式的插件管理动作回执合同;插件侧 runtime 和 payload 已准备好,后续由 `T-S023` / `T-S024` 接入
M plugins/baa-firefox/README.md
+53, -0
 1@@ -36,6 +36,50 @@ Firefox 插件的正式能力已经收口到三件事:
 2 - 如果壳页被导航到其他业务 URL,插件会把它从正式能力里摘掉
 3 - 需要代理请求时,插件会优先复用或重新收口到对应平台的壳页
 4 
 5+## Runtime 模型
 6+
 7+插件现在把壳页状态拆成两层:
 8+
 9+- `desired`:这个平台是否应该保有一个正式 shell tab
10+- `actual`:浏览器里当前真实存在的 shell tab 状态
11+
12+默认行为:
13+
14+- `tab_open` / `tab_focus` / `tab_reload` 会把对应平台写进 `desired`
15+- `tab_restore` 只会恢复 `desired=true` 但 `actual` 缺失的平台
16+- tab 生命周期事件和 `30s` 周期巡检都会刷新 `actual`
17+- 管理页的“空壳页”和“平台状态”面板会直接显示 `desired / actual / drift`
18+
19+## 插件管理动作
20+
21+控制器页内部统一支持这些动作:
22+
23+- `plugin_status`
24+- `ws_reconnect`
25+- `controller_reload`
26+- `tab_open`
27+- `tab_focus`
28+- `tab_reload`
29+- `tab_restore`
30+
31+除了 legacy `open_tab` / `reload` WS 消息外,也可以通过扩展内消息调用:
32+
33+```js
34+browser.runtime.sendMessage({
35+  type: "plugin_runtime_action",
36+  action: "tab_restore",
37+  platform: "claude"
38+});
39+```
40+
41+读取当前 runtime:
42+
43+```js
44+browser.runtime.sendMessage({
45+  type: "get_plugin_runtime_status"
46+});
47+```
48+
49 ## 上报的元数据
50 
51 插件会通过本地 WS 发送:
52@@ -46,6 +90,7 @@ Firefox 插件的正式能力已经收口到三件事:
53 - `freshness`
54 - `captured_at`
55 - `last_seen_at`
56+- `shell_runtime`
57 - `endpoints`
58 - `endpoint_metadata`
59 
60@@ -57,6 +102,14 @@ Firefox 插件的正式能力已经收口到三件事:
61 
62 为兼容现有 bridge 汇总,`credentials` 消息仍会带一个 `headers` 对象,但值全部是脱敏占位符,只用于保留 header 名称和数量,不包含原始值。
63 
64+`shell_runtime` 里会带每个平台的:
65+
66+- `desired`
67+- `actual`
68+- `drift`
69+
70+当前 `conductor` 还不会消费这部分字段,但插件已经按稳定 payload 发出,后续服务端可直接接入。
71+
72 ## 明确不上报的敏感值
73 
74 以下内容仍只允许停留在浏览器本地,不通过 bridge 回传给 `conductor`:
M plugins/baa-firefox/controller.js
+801, -16
   1@@ -12,6 +12,8 @@ const CONTROLLER_STORAGE_KEYS = {
   2   controlState: "baaFirefox.controlState",
   3   statusSchemaVersion: "baaFirefox.statusSchemaVersion",
   4   trackedTabs: "baaFirefox.trackedTabs",
   5+  desiredTabs: "baaFirefox.desiredTabs",
   6+  controllerRuntime: "baaFirefox.controllerRuntime",
   7   endpointsByPlatform: "baaFirefox.endpointsByPlatform",
   8   lastHeadersByPlatform: "baaFirefox.lastHeadersByPlatform",
   9   credentialCapturedAtByPlatform: "baaFirefox.credentialCapturedAtByPlatform",
  10@@ -39,6 +41,7 @@ const CONTROL_RETRY_DELAYS = [1_000, 3_000, 5_000];
  11 const CONTROL_RETRY_SLOW_INTERVAL = 30_000;
  12 const CONTROL_RETRY_LOG_INTERVAL = 60_000;
  13 const TRACKED_TAB_REFRESH_DELAY = 150;
  14+const SHELL_RUNTIME_HEALTHCHECK_INTERVAL = 30_000;
  15 const CONTROL_STATUS_BODY_LIMIT = 12_000;
  16 const WS_RECONNECT_DELAY = 3_000;
  17 const PROXY_REQUEST_TIMEOUT = 180_000;
  18@@ -177,9 +180,13 @@ const state = {
  19   lastControlFailureLogAt: 0,
  20   lastControlFailureKey: "",
  21   trackedTabRefreshTimer: null,
  22+  shellRuntimeTimer: null,
  23+  shellRuntimeLastHealthCheckAt: 0,
  24   trackedTabRefreshRunning: false,
  25   trackedTabRefreshQueued: false,
  26   trackedTabs: createPlatformMap(() => null),
  27+  desiredTabs: createPlatformMap((platform) => createDefaultDesiredTabState(platform)),
  28+  actualTabs: createPlatformMap(() => createDefaultActualTabState()),
  29   endpoints: createPlatformMap(() => ({})),
  30   lastHeaders: createPlatformMap(() => ({})),
  31   credentialCapturedAt: createPlatformMap(() => 0),
  32@@ -192,6 +199,7 @@ const state = {
  33   lastCredentialSentAt: createPlatformMap(() => 0),
  34   geminiSendTemplate: null,
  35   claudeState: createDefaultClaudeState(),
  36+  controllerRuntime: createDefaultControllerRuntimeState(),
  37   logs: []
  38 };
  39 
  40@@ -306,6 +314,122 @@ function cloneWsState(value) {
  41   };
  42 }
  43 
  44+function createDefaultDesiredTabState(platform, overrides = {}) {
  45+  return {
  46+    exists: false,
  47+    shellUrl: getPlatformShellUrl(platform),
  48+    source: "bootstrap",
  49+    reason: null,
  50+    updatedAt: 0,
  51+    lastAction: null,
  52+    lastActionAt: 0,
  53+    ...overrides
  54+  };
  55+}
  56+
  57+function cloneDesiredTabState(platform, value) {
  58+  if (typeof value === "boolean") {
  59+    return createDefaultDesiredTabState(platform, {
  60+      exists: value
  61+    });
  62+  }
  63+
  64+  if (!isRecord(value)) {
  65+    return createDefaultDesiredTabState(platform);
  66+  }
  67+
  68+  return createDefaultDesiredTabState(platform, {
  69+    exists: value.exists === true || value.shouldExist === true,
  70+    shellUrl: trimToNull(value.shellUrl) || getPlatformShellUrl(platform),
  71+    source: trimToNull(value.source) || "storage",
  72+    reason: trimToNull(value.reason),
  73+    updatedAt: Number(value.updatedAt) || 0,
  74+    lastAction: trimToNull(value.lastAction),
  75+    lastActionAt: Number(value.lastActionAt) || 0
  76+  });
  77+}
  78+
  79+function createDefaultActualTabState(overrides = {}) {
  80+  return {
  81+    exists: false,
  82+    tabId: null,
  83+    url: null,
  84+    title: null,
  85+    windowId: null,
  86+    active: false,
  87+    status: null,
  88+    discarded: false,
  89+    hidden: false,
  90+    healthy: false,
  91+    issue: "missing",
  92+    lastSeenAt: 0,
  93+    lastReadyAt: 0,
  94+    updatedAt: 0,
  95+    candidateTabId: null,
  96+    candidateUrl: null,
  97+    candidateTitle: null,
  98+    candidateStatus: null,
  99+    ...overrides
 100+  };
 101+}
 102+
 103+function cloneActualTabState(value) {
 104+  if (!isRecord(value)) {
 105+    return createDefaultActualTabState();
 106+  }
 107+
 108+  const hasIssue = Object.prototype.hasOwnProperty.call(value, "issue");
 109+  return createDefaultActualTabState({
 110+    exists: value.exists === true,
 111+    tabId: Number.isInteger(value.tabId) ? value.tabId : null,
 112+    url: trimToNull(value.url),
 113+    title: trimToNull(value.title),
 114+    windowId: Number.isInteger(value.windowId) ? value.windowId : null,
 115+    active: value.active === true,
 116+    status: trimToNull(value.status),
 117+    discarded: value.discarded === true,
 118+    hidden: value.hidden === true,
 119+    healthy: value.healthy === true,
 120+    issue: hasIssue ? trimToNull(value.issue) : "missing",
 121+    lastSeenAt: Number(value.lastSeenAt) || 0,
 122+    lastReadyAt: Number(value.lastReadyAt) || 0,
 123+    updatedAt: Number(value.updatedAt) || 0,
 124+    candidateTabId: Number.isInteger(value.candidateTabId) ? value.candidateTabId : null,
 125+    candidateUrl: trimToNull(value.candidateUrl),
 126+    candidateTitle: trimToNull(value.candidateTitle),
 127+    candidateStatus: trimToNull(value.candidateStatus)
 128+  });
 129+}
 130+
 131+function createDefaultControllerRuntimeState(overrides = {}) {
 132+  return {
 133+    tabId: null,
 134+    ready: false,
 135+    status: "booting",
 136+    lastReadyAt: 0,
 137+    lastReloadAt: 0,
 138+    lastAction: null,
 139+    lastActionAt: 0,
 140+    ...overrides
 141+  };
 142+}
 143+
 144+function cloneControllerRuntimeState(value) {
 145+  if (!isRecord(value)) {
 146+    return createDefaultControllerRuntimeState();
 147+  }
 148+
 149+  return createDefaultControllerRuntimeState({
 150+    tabId: Number.isInteger(value.tabId) ? value.tabId : null,
 151+    ready: value.ready === true,
 152+    status: trimToNull(value.status) || "booting",
 153+    lastReadyAt: Number(value.lastReadyAt) || 0,
 154+    lastReloadAt: Number(value.lastReloadAt) || 0,
 155+    lastAction: trimToNull(value.lastAction),
 156+    lastActionAt: Number(value.lastActionAt) || 0
 157+  });
 158+}
 159+
 160 function normalizeClaudeMessageRole(value) {
 161   if (value === "human" || value === "user") return "user";
 162   if (value === "assistant") return "assistant";
 163@@ -719,6 +843,29 @@ function loadTrackedTabs(raw, legacyClaudeTabId) {
 164   return next;
 165 }
 166 
 167+function loadDesiredTabs(raw, fallbackTrackedTabs = null) {
 168+  const next = createPlatformMap((platform) => createDefaultDesiredTabState(platform));
 169+
 170+  if (hasPlatformShape(raw)) {
 171+    for (const platform of PLATFORM_ORDER) {
 172+      next[platform] = cloneDesiredTabState(platform, raw[platform]);
 173+    }
 174+    return next;
 175+  }
 176+
 177+  for (const platform of PLATFORM_ORDER) {
 178+    const shouldExist = Number.isInteger(fallbackTrackedTabs?.[platform]);
 179+    next[platform] = createDefaultDesiredTabState(platform, {
 180+      exists: shouldExist,
 181+      source: shouldExist ? "migration" : "bootstrap",
 182+      reason: shouldExist ? "tracked_tab_recovered" : null,
 183+      updatedAt: 0
 184+    });
 185+  }
 186+
 187+  return next;
 188+}
 189+
 190 function loadObjectMap(raw, legacyValue = null) {
 191   const next = createPlatformMap(() => ({}));
 192   if (hasPlatformShape(raw)) {
 193@@ -861,6 +1008,10 @@ function loadControlState(raw) {
 194   return cloneControlState(raw);
 195 }
 196 
 197+function loadControllerRuntimeState(raw) {
 198+  return cloneControllerRuntimeState(raw);
 199+}
 200+
 201 function getNestedValue(source, path) {
 202   const segments = String(path || "").split(".");
 203   let current = source;
 204@@ -1248,6 +1399,256 @@ function findTrackedPlatformByTabId(tabId) {
 205   return null;
 206 }
 207 
 208+function setDesiredTabState(platform, exists, options = {}) {
 209+  const now = Number(options.updatedAt) || Date.now();
 210+  const previous = cloneDesiredTabState(platform, state.desiredTabs[platform]);
 211+  const nextExists = exists === true;
 212+  const nextSource = trimToNull(options.source) || previous.source || "runtime";
 213+  const nextReason = trimToNull(options.reason) || previous.reason;
 214+  const nextAction = trimToNull(options.action);
 215+  const shouldStamp = previous.exists !== nextExists
 216+    || previous.source !== nextSource
 217+    || previous.reason !== nextReason
 218+    || nextAction != null
 219+    || options.touch === true;
 220+  const next = createDefaultDesiredTabState(platform, {
 221+    ...previous,
 222+    exists: nextExists,
 223+    shellUrl: getPlatformShellUrl(platform),
 224+    source: nextSource,
 225+    reason: nextReason,
 226+    updatedAt: shouldStamp ? now : previous.updatedAt,
 227+    lastAction: nextAction || previous.lastAction,
 228+    lastActionAt: nextAction ? now : previous.lastActionAt
 229+  });
 230+
 231+  const changed = JSON.stringify(previous) !== JSON.stringify(next);
 232+  if (!changed) return false;
 233+
 234+  state.desiredTabs[platform] = next;
 235+
 236+  if (options.persist) {
 237+    persistState().catch(() => {});
 238+  }
 239+  if (options.render) {
 240+    render();
 241+  }
 242+
 243+  return true;
 244+}
 245+
 246+function setControllerRuntimeState(patch = {}, options = {}) {
 247+  state.controllerRuntime = cloneControllerRuntimeState({
 248+    ...state.controllerRuntime,
 249+    ...patch
 250+  });
 251+
 252+  if (options.persist) {
 253+    persistState().catch(() => {});
 254+  }
 255+  if (options.render) {
 256+    render();
 257+  }
 258+
 259+  return state.controllerRuntime;
 260+}
 261+
 262+function buildActualTabSnapshot(platform, shellTab = null, candidateTab = null, previousSnapshot = null) {
 263+  const now = Date.now();
 264+  const previous = cloneActualTabState(previousSnapshot || state.actualTabs[platform]);
 265+
 266+  if (shellTab && Number.isInteger(shellTab.id)) {
 267+    const isReady = String(shellTab.status || "").toLowerCase() === "complete";
 268+    const url = trimToNull(shellTab.url);
 269+    const nextBase = createDefaultActualTabState({
 270+      exists: true,
 271+      tabId: shellTab.id,
 272+      url,
 273+      title: trimToNull(shellTab.title),
 274+      windowId: Number.isInteger(shellTab.windowId) ? shellTab.windowId : null,
 275+      active: shellTab.active === true,
 276+      status: trimToNull(shellTab.status),
 277+      discarded: shellTab.discarded === true,
 278+      hidden: shellTab.hidden === true,
 279+      healthy: isPlatformShellUrl(platform, url || "") && !shellTab.discarded,
 280+      issue: isReady ? null : "loading",
 281+      candidateTabId: null,
 282+      candidateUrl: null,
 283+      candidateTitle: null,
 284+      candidateStatus: null
 285+    });
 286+    const isSame = JSON.stringify({
 287+      ...nextBase,
 288+      lastSeenAt: 0,
 289+      lastReadyAt: 0,
 290+      updatedAt: 0
 291+    }) === JSON.stringify({
 292+      ...previous,
 293+      lastSeenAt: 0,
 294+      lastReadyAt: 0,
 295+      updatedAt: 0
 296+    });
 297+
 298+    return createDefaultActualTabState({
 299+      ...nextBase,
 300+      lastSeenAt: isSame ? (previous.lastSeenAt || now) : now,
 301+      lastReadyAt: isReady
 302+        ? (isSame ? (previous.lastReadyAt || now) : now)
 303+        : previous.lastReadyAt,
 304+      updatedAt: isSame ? previous.updatedAt : now
 305+    });
 306+  }
 307+
 308+  const nextBase = createDefaultActualTabState({
 309+    exists: false,
 310+    issue: candidateTab ? "non_shell" : "missing",
 311+    candidateTabId: Number.isInteger(candidateTab?.id) ? candidateTab.id : null,
 312+    candidateUrl: trimToNull(candidateTab?.url),
 313+    candidateTitle: trimToNull(candidateTab?.title),
 314+    candidateStatus: trimToNull(candidateTab?.status)
 315+  });
 316+  const isSame = JSON.stringify({
 317+    ...nextBase,
 318+    lastSeenAt: 0,
 319+    lastReadyAt: 0,
 320+    updatedAt: 0
 321+  }) === JSON.stringify({
 322+    ...previous,
 323+    lastSeenAt: 0,
 324+    lastReadyAt: 0,
 325+    updatedAt: 0
 326+  });
 327+
 328+  return createDefaultActualTabState({
 329+    ...nextBase,
 330+    lastSeenAt: previous.lastSeenAt,
 331+    lastReadyAt: previous.lastReadyAt,
 332+    updatedAt: isSame ? previous.updatedAt : now
 333+  });
 334+}
 335+
 336+function buildPlatformRuntimeDrift(platform, desiredState = null, actualState = null) {
 337+  const desired = cloneDesiredTabState(platform, desiredState || state.desiredTabs[platform]);
 338+  const actual = cloneActualTabState(actualState || state.actualTabs[platform]);
 339+  const needsRestore = desired.exists && !actual.exists;
 340+  const unexpectedActual = !desired.exists && actual.exists;
 341+  const loading = actual.exists && actual.issue === "loading";
 342+  let reason = "aligned";
 343+
 344+  if (needsRestore) {
 345+    reason = actual.issue === "non_shell" ? "shell_missing" : "missing_actual";
 346+  } else if (unexpectedActual) {
 347+    reason = "unexpected_actual";
 348+  } else if (loading) {
 349+    reason = "loading";
 350+  }
 351+
 352+  return {
 353+    aligned: !needsRestore && !unexpectedActual && !loading,
 354+    needsRestore,
 355+    unexpectedActual,
 356+    reason
 357+  };
 358+}
 359+
 360+function buildPlatformRuntimeSnapshot(platform) {
 361+  const desired = cloneDesiredTabState(platform, state.desiredTabs[platform]);
 362+  const actual = cloneActualTabState(state.actualTabs[platform]);
 363+  const drift = buildPlatformRuntimeDrift(platform, desired, actual);
 364+
 365+  return {
 366+    platform,
 367+    desired: {
 368+      exists: desired.exists,
 369+      shell_url: desired.shellUrl,
 370+      source: desired.source,
 371+      reason: desired.reason,
 372+      updated_at: desired.updatedAt || null,
 373+      last_action: desired.lastAction,
 374+      last_action_at: desired.lastActionAt || null
 375+    },
 376+    actual: {
 377+      exists: actual.exists,
 378+      tab_id: actual.tabId,
 379+      url: actual.url,
 380+      title: actual.title,
 381+      window_id: actual.windowId,
 382+      active: actual.active,
 383+      status: actual.status,
 384+      discarded: actual.discarded,
 385+      hidden: actual.hidden,
 386+      healthy: actual.healthy,
 387+      issue: actual.issue,
 388+      last_seen_at: actual.lastSeenAt || null,
 389+      last_ready_at: actual.lastReadyAt || null,
 390+      candidate_tab_id: actual.candidateTabId,
 391+      candidate_url: actual.candidateUrl
 392+    },
 393+    drift: {
 394+      aligned: drift.aligned,
 395+      needs_restore: drift.needsRestore,
 396+      unexpected_actual: drift.unexpectedActual,
 397+      reason: drift.reason
 398+    }
 399+  };
 400+}
 401+
 402+function getDesiredCount() {
 403+  return PLATFORM_ORDER.filter((platform) => cloneDesiredTabState(platform, state.desiredTabs[platform]).exists).length;
 404+}
 405+
 406+function getRuntimeDriftCount() {
 407+  return PLATFORM_ORDER.filter((platform) => !buildPlatformRuntimeDrift(platform).aligned).length;
 408+}
 409+
 410+function buildPluginStatusPayload(options = {}) {
 411+  const includeVolatile = options.includeVolatile !== false;
 412+  const platforms = {};
 413+
 414+  for (const platform of PLATFORM_ORDER) {
 415+    platforms[platform] = buildPlatformRuntimeSnapshot(platform);
 416+  }
 417+
 418+  const controller = cloneControllerRuntimeState(state.controllerRuntime);
 419+  const ws = cloneWsState(state.wsState);
 420+  const payload = {
 421+    schema_version: 1,
 422+    client_id: state.clientId,
 423+    summary: {
 424+      desired_count: getDesiredCount(),
 425+      actual_count: getTrackedCount(),
 426+      drift_count: getRuntimeDriftCount()
 427+    },
 428+    controller: {
 429+      tab_id: controller.tabId,
 430+      ready: controller.ready,
 431+      status: controller.status,
 432+      last_ready_at: controller.lastReadyAt || null,
 433+      last_reload_at: controller.lastReloadAt || null,
 434+      last_action: controller.lastAction,
 435+      last_action_at: controller.lastActionAt || null,
 436+      last_health_check_at: includeVolatile ? (state.shellRuntimeLastHealthCheckAt || null) : null
 437+    },
 438+    ws: {
 439+      connected: state.wsConnected,
 440+      connection: ws.connection,
 441+      protocol: ws.protocol,
 442+      version: ws.version,
 443+      retry_count: ws.retryCount,
 444+      last_open_at: ws.lastOpenAt || null,
 445+      last_message_at: ws.lastMessageAt || null,
 446+      last_error: ws.lastError
 447+    },
 448+    platforms
 449+  };
 450+
 451+  if (includeVolatile) {
 452+    payload.generated_at = Date.now();
 453+  }
 454+
 455+  return payload;
 456+}
 457+
 458 function addLog(level, text, sendRemote = true) {
 459   const line = `[${new Date().toLocaleTimeString("zh-CN", { hour12: false })}] [${level}] ${text}`;
 460   state.logs.push(line);
 461@@ -1870,6 +2271,7 @@ function buildCredentialTransportSnapshot(platform) {
 462   const credential = getCredentialState(platform);
 463   const account = cloneAccountState(state.account[platform]);
 464   const fingerprint = trimToNull(state.credentialFingerprint[platform]) || null;
 465+  const runtime = buildPlatformRuntimeSnapshot(platform);
 466 
 467   return {
 468     platform,
 469@@ -1885,7 +2287,8 @@ function buildCredentialTransportSnapshot(platform) {
 470     timestamp: credential.capturedAt || credential.lastSeenAt || account.lastSeenAt || account.capturedAt || 0,
 471     endpoint_count: getEndpointCount(platform),
 472     headers: redactCredentialHeaders(credential.headers || {}),
 473-    header_names: Object.keys(credential.headers || {}).sort()
 474+    header_names: Object.keys(credential.headers || {}).sort(),
 475+    shell_runtime: runtime
 476   };
 477 }
 478 
 479@@ -2050,6 +2453,8 @@ async function persistState() {
 480     [CONTROLLER_STORAGE_KEYS.controlState]: state.controlState,
 481     [CONTROLLER_STORAGE_KEYS.statusSchemaVersion]: STATUS_SCHEMA_VERSION,
 482     [CONTROLLER_STORAGE_KEYS.trackedTabs]: state.trackedTabs,
 483+    [CONTROLLER_STORAGE_KEYS.desiredTabs]: state.desiredTabs,
 484+    [CONTROLLER_STORAGE_KEYS.controllerRuntime]: state.controllerRuntime,
 485     [CONTROLLER_STORAGE_KEYS.endpointsByPlatform]: state.endpoints,
 486     [CONTROLLER_STORAGE_KEYS.lastHeadersByPlatform]: state.lastHeaders,
 487     [CONTROLLER_STORAGE_KEYS.credentialCapturedAtByPlatform]: state.credentialCapturedAt,
 488@@ -2069,7 +2474,7 @@ async function persistState() {
 489 }
 490 
 491 function getTrackedCount() {
 492-  return PLATFORM_ORDER.filter((platform) => Number.isInteger(state.trackedTabs[platform])).length;
 493+  return PLATFORM_ORDER.filter((platform) => cloneActualTabState(state.actualTabs[platform]).exists).length;
 494 }
 495 
 496 function getAccountCount() {
 497@@ -2092,10 +2497,19 @@ function getTotalEndpointCount() {
 498 }
 499 
 500 function formatTrackedMeta() {
 501+  const desiredCount = getDesiredCount();
 502+  const actualCount = getTrackedCount();
 503+  const driftCount = getRuntimeDriftCount();
 504   const labels = PLATFORM_ORDER
 505-    .filter((platform) => Number.isInteger(state.trackedTabs[platform]))
 506-    .map((platform) => `${platformLabel(platform)}#${state.trackedTabs[platform]}`);
 507-  return labels.length > 0 ? labels.join(" · ") : "当前没有已建立的空壳页";
 508+    .map((platform) => {
 509+      const runtime = buildPlatformRuntimeSnapshot(platform);
 510+      if (!runtime.desired.exists && !runtime.actual.exists) return null;
 511+      return `${platformLabel(platform)}(desired=${runtime.desired.exists ? "on" : "off"}, actual=${runtime.actual.exists ? `#${runtime.actual.tab_id}` : runtime.actual.issue})`;
 512+    })
 513+    .filter(Boolean);
 514+
 515+  const summary = `desired=${desiredCount} · actual=${actualCount} · drift=${driftCount}`;
 516+  return labels.length > 0 ? `${summary} · ${labels.join(" · ")}` : `${summary} · 当前没有已建立的空壳页`;
 517 }
 518 
 519 function formatAccountMeta() {
 520@@ -2134,14 +2548,18 @@ function formatEndpointMeta() {
 521 function renderPlatformStatus() {
 522   const lines = [];
 523   for (const platform of PLATFORM_ORDER) {
 524-    const tabId = Number.isInteger(state.trackedTabs[platform]) ? state.trackedTabs[platform] : "-";
 525+    const runtime = buildPlatformRuntimeSnapshot(platform);
 526     const credential = getCredentialState(platform);
 527     const account = trimToNull(state.account[platform]?.value) || "-";
 528     const fingerprint = trimToNull(state.credentialFingerprint[platform]);
 529     const credentialLabel = `${getCredentialFreshness(credential.reason)} / ${describeCredentialReason(credential.reason)}`;
 530     const endpointCount = getEndpointCount(platform);
 531+    const actualLabel = runtime.actual.exists
 532+      ? `#${runtime.actual.tab_id}${runtime.actual.issue === "loading" ? "/loading" : ""}`
 533+      : (runtime.actual.candidate_tab_id ? `missing(candidate#${runtime.actual.candidate_tab_id})` : runtime.actual.issue);
 534+    const driftLabel = runtime.drift.reason;
 535     lines.push(
 536-      `${platformLabel(platform).padEnd(8)} 空壳页=${String(tabId).padEnd(4)} 账号=${account.padEnd(18)} 登录态=${credentialLabel.padEnd(18)} 指纹=${(fingerprint || "-").slice(0, 12).padEnd(12)} 端点=${endpointCount}`
 537+      `${platformLabel(platform).padEnd(8)} desired=${String(runtime.desired.exists).padEnd(5)} actual=${actualLabel.padEnd(24)} drift=${driftLabel.padEnd(16)} 账号=${account.padEnd(18)} 登录态=${credentialLabel.padEnd(18)} 指纹=${(fingerprint || "-").slice(0, 12).padEnd(12)} 端点=${endpointCount}`
 538     );
 539   }
 540   return lines.join("\n");
 541@@ -2599,8 +3017,21 @@ function sendHello() {
 542       formal_mode: "shell_tab_metadata_proxy",
 543       shell_tabs: true,
 544       page_conversation_runtime: false,
 545-      credential_metadata: true
 546-    }
 547+      credential_metadata: true,
 548+      desired_actual_runtime: true,
 549+      plugin_actions: [
 550+        "plugin_status",
 551+        "ws_reconnect",
 552+        "controller_reload",
 553+        "tab_open",
 554+        "tab_focus",
 555+        "tab_reload",
 556+        "tab_restore"
 557+      ]
 558+    },
 559+    plugin_status: buildPluginStatusPayload({
 560+      includeVolatile: false
 561+    })
 562   });
 563 }
 564 
 565@@ -2609,6 +3040,260 @@ function getTargetPlatforms(platform) {
 566   return PLATFORM_ORDER;
 567 }
 568 
 569+function normalizePluginManagementAction(value) {
 570+  switch (String(value || "").trim().toLowerCase()) {
 571+    case "plugin_status":
 572+    case "status":
 573+      return "plugin_status";
 574+    case "ws_reconnect":
 575+    case "reconnect_ws":
 576+    case "reconnect":
 577+      return "ws_reconnect";
 578+    case "controller_reload":
 579+    case "reload_controller":
 580+      return "controller_reload";
 581+    case "tab_open":
 582+    case "open_tab":
 583+      return "tab_open";
 584+    case "tab_focus":
 585+    case "focus_tab":
 586+      return "tab_focus";
 587+    case "tab_reload":
 588+    case "reload_tab":
 589+      return "tab_reload";
 590+    case "tab_restore":
 591+    case "restore_tab":
 592+      return "tab_restore";
 593+    default:
 594+      return null;
 595+  }
 596+}
 597+
 598+function extractPluginManagementMessage(message) {
 599+  const messageType = String(message?.type || "").trim().toLowerCase();
 600+  const platform = trimToNull(message?.platform);
 601+
 602+  if (messageType === "open_tab") {
 603+    return {
 604+      action: platform ? "tab_focus" : "tab_open",
 605+      platform,
 606+      source: "ws_open_tab"
 607+    };
 608+  }
 609+
 610+  if (messageType === "reload") {
 611+    return {
 612+      action: "controller_reload",
 613+      platform: null,
 614+      source: "ws_reload"
 615+    };
 616+  }
 617+
 618+  const directAction = normalizePluginManagementAction(messageType);
 619+  if (directAction) {
 620+    return {
 621+      action: directAction,
 622+      platform,
 623+      source: "ws_direct"
 624+    };
 625+  }
 626+
 627+  const action = normalizePluginManagementAction(message?.action);
 628+  if (action && ["plugin_action", "browser_action", "tab_action", "action_request"].includes(messageType)) {
 629+    return {
 630+      action,
 631+      platform,
 632+      source: messageType
 633+    };
 634+  }
 635+
 636+  return null;
 637+}
 638+
 639+function resolvePluginActionPlatforms(action, platform = null) {
 640+  if (platform && !PLATFORMS[platform]) {
 641+    throw new Error(`未知平台:${platform}`);
 642+  }
 643+
 644+  if (action === "tab_focus" && !platform) {
 645+    throw new Error("tab_focus 需要明确 platform");
 646+  }
 647+
 648+  return getTargetPlatforms(platform);
 649+}
 650+
 651+function restartShellRuntimeHealthTimer(delay = SHELL_RUNTIME_HEALTHCHECK_INTERVAL) {
 652+  clearTimeout(state.shellRuntimeTimer);
 653+  state.shellRuntimeTimer = setTimeout(() => {
 654+    runShellRuntimeHealthCheck("poll").catch((error) => {
 655+      addLog("error", `空壳页 runtime 巡检失败:${error.message}`);
 656+    });
 657+  }, Math.max(0, Number(delay) || 0));
 658+}
 659+
 660+async function runShellRuntimeHealthCheck(reason = "poll") {
 661+  try {
 662+    await refreshTrackedTabsFromBrowser(reason);
 663+  } finally {
 664+    restartShellRuntimeHealthTimer(SHELL_RUNTIME_HEALTHCHECK_INTERVAL);
 665+  }
 666+}
 667+
 668+async function runPluginManagementAction(action, options = {}) {
 669+  const methodName = normalizePluginManagementAction(action);
 670+  if (!methodName) {
 671+    throw new Error(`未知插件动作:${action || "-"}`);
 672+  }
 673+
 674+  const source = trimToNull(options.source) || "runtime";
 675+  const reason = trimToNull(options.reason) || "plugin_action";
 676+  const actionAt = Date.now();
 677+  const results = [];
 678+
 679+  if (methodName === "plugin_status" || methodName.startsWith("tab_")) {
 680+    await refreshTrackedTabsFromBrowser(`${methodName}_pre`);
 681+  }
 682+
 683+  switch (methodName) {
 684+    case "plugin_status":
 685+      break;
 686+    case "ws_reconnect":
 687+      addLog("info", "正在重连本地 WS", false);
 688+      closeWsConnection();
 689+      connectWs({ silentWhenDisabled: true });
 690+      break;
 691+    case "controller_reload":
 692+      setControllerRuntimeState({
 693+        ready: false,
 694+        status: "reloading",
 695+        lastReloadAt: actionAt,
 696+        lastAction: methodName,
 697+        lastActionAt: actionAt
 698+      }, {
 699+        persist: true,
 700+        render: true
 701+      });
 702+      sendCredentialSnapshot(null, true);
 703+      addLog("warn", "插件管理页即将重载", false);
 704+      setTimeout(() => {
 705+        window.location.reload();
 706+      }, 80);
 707+      return {
 708+        action: methodName,
 709+        platform: null,
 710+        results,
 711+        scheduled: true,
 712+        snapshot: buildPluginStatusPayload()
 713+      };
 714+    case "tab_open":
 715+    case "tab_focus":
 716+    case "tab_reload":
 717+    case "tab_restore": {
 718+      const targets = resolvePluginActionPlatforms(methodName, trimToNull(options.platform));
 719+      for (const target of targets) {
 720+        if (methodName !== "tab_restore") {
 721+          setDesiredTabState(target, true, {
 722+            source,
 723+            reason,
 724+            action: methodName
 725+          });
 726+        }
 727+
 728+        if (methodName === "tab_restore") {
 729+          const desired = cloneDesiredTabState(target, state.desiredTabs[target]);
 730+          const actual = cloneActualTabState(state.actualTabs[target]);
 731+          if (!desired.exists) {
 732+            results.push({
 733+              platform: target,
 734+              ok: true,
 735+              restored: false,
 736+              skipped: "desired_missing"
 737+            });
 738+            continue;
 739+          }
 740+
 741+          if (actual.exists) {
 742+            results.push({
 743+              platform: target,
 744+              ok: true,
 745+              restored: false,
 746+              tabId: actual.tabId,
 747+              skipped: "actual_present"
 748+            });
 749+            continue;
 750+          }
 751+
 752+          const restoredTab = await ensurePlatformTab(target, {
 753+            focus: false,
 754+            reloadIfExisting: false,
 755+            recordDesired: false,
 756+            source,
 757+            reason,
 758+            action: methodName
 759+          });
 760+          results.push({
 761+            platform: target,
 762+            ok: true,
 763+            restored: true,
 764+            tabId: restoredTab?.id ?? null
 765+          });
 766+          continue;
 767+        }
 768+
 769+        const previousActual = cloneActualTabState(state.actualTabs[target]);
 770+        const tab = await ensurePlatformTab(target, {
 771+          focus: methodName === "tab_focus",
 772+          reloadIfExisting: methodName === "tab_reload",
 773+          recordDesired: false,
 774+          source,
 775+          reason,
 776+          action: methodName
 777+        });
 778+        results.push({
 779+          platform: target,
 780+          ok: true,
 781+          tabId: tab?.id ?? null,
 782+          restored: methodName === "tab_open" ? !previousActual.exists : undefined
 783+        });
 784+      }
 785+      break;
 786+    }
 787+    default:
 788+      break;
 789+  }
 790+
 791+  if (methodName !== "plugin_status") {
 792+    setControllerRuntimeState({
 793+      ready: true,
 794+      status: "ready",
 795+      lastAction: methodName,
 796+      lastActionAt: actionAt
 797+    });
 798+  }
 799+
 800+  if (methodName !== "plugin_status") {
 801+    await refreshTrackedTabsFromBrowser(`${methodName}_post`);
 802+  }
 803+  if (methodName !== "plugin_status") {
 804+    await persistState();
 805+    render();
 806+    sendCredentialSnapshot(null, true);
 807+  } else if (source.startsWith("ws")) {
 808+    sendCredentialSnapshot(null, true);
 809+  }
 810+
 811+  if (methodName !== "plugin_status") {
 812+    addLog("info", `插件动作 ${methodName} 已执行`, false);
 813+  }
 814+
 815+  return {
 816+    action: methodName,
 817+    platform: trimToNull(options.platform),
 818+    results,
 819+    snapshot: buildPluginStatusPayload()
 820+  };
 821+}
 822+
 823 function getProxyHeaderPath(apiPath) {
 824   try {
 825     return new URL(apiPath, "https://example.invalid").pathname || "/";
 826@@ -2802,6 +3487,7 @@ function sendEndpointSnapshot(platform = null) {
 827       credential_fingerprint: fingerprint,
 828       updated_at: Math.max(...endpointEntries.map((entry) => entry.lastObservedAt || 0), 0) || null,
 829       endpoints: endpointEntries.map((entry) => entry.key),
 830+      shell_runtime: buildPlatformRuntimeSnapshot(target),
 831       endpoint_metadata: endpointEntries.map((entry) => ({
 832         method: entry.method,
 833         path: entry.path,
 834@@ -2820,6 +3506,7 @@ function sendCredentialSnapshot(platform = null, force = false) {
 835       && !payload.credential_fingerprint
 836       && payload.header_names.length === 0
 837       && !Number.isInteger(state.trackedTabs[target])
 838+      && !cloneDesiredTabState(target, state.desiredTabs[target]).exists
 839     ) {
 840       continue;
 841     }
 842@@ -2987,6 +3674,18 @@ function connectWs(options = {}) {
 843       lastError: null
 844     });
 845 
 846+    const pluginAction = extractPluginManagementMessage(message);
 847+    if (pluginAction) {
 848+      runPluginManagementAction(pluginAction.action, {
 849+        platform: pluginAction.platform,
 850+        source: pluginAction.source,
 851+        reason: trimToNull(message.reason) || "ws_plugin_action"
 852+      }).catch((error) => {
 853+        addLog("error", `插件动作 ${pluginAction.action} 失败:${error.message}`, false);
 854+      });
 855+      return;
 856+    }
 857+
 858     switch (message.type) {
 859       case "hello_ack":
 860         handleWsHelloAck(message);
 861@@ -3133,6 +3832,12 @@ async function findPlatformShellTab(platform, preferredTabId = null) {
 862 
 863 async function setTrackedTab(platform, tab) {
 864   state.trackedTabs[platform] = tab ? tab.id : null;
 865+  state.actualTabs[platform] = buildActualTabSnapshot(
 866+    platform,
 867+    tab || null,
 868+    null,
 869+    state.actualTabs[platform]
 870+  );
 871   pruneInvalidCredentialState();
 872   if (platform === "claude") {
 873     updateClaudeState({
 874@@ -3151,7 +3856,20 @@ async function setTrackedTab(platform, tab) {
 875 }
 876 
 877 async function ensurePlatformTab(platform, options = {}) {
 878-  const { focus = false, reloadIfExisting = false } = options;
 879+  const {
 880+    focus = false,
 881+    reloadIfExisting = false,
 882+    recordDesired = true
 883+  } = options;
 884+
 885+  if (recordDesired) {
 886+    setDesiredTabState(platform, true, {
 887+      source: options.source || "runtime",
 888+      reason: options.reason || "shell_requested",
 889+      action: options.action
 890+    });
 891+  }
 892+
 893   let tab = await resolveTrackedTab(platform);
 894   if (!tab) {
 895     tab = await findPlatformShellTab(platform);
 896@@ -3215,29 +3933,56 @@ async function refreshTrackedTabsFromBrowser(reason = "sync") {
 897   try {
 898     do {
 899       state.trackedTabRefreshQueued = false;
 900+      state.shellRuntimeLastHealthCheckAt = Date.now();
 901 
 902       const next = createPlatformMap(() => null);
 903+      const nextActual = createPlatformMap(() => createDefaultActualTabState());
 904       for (const platform of PLATFORM_ORDER) {
 905-        let tab = await resolveTrackedTab(platform, { requireShell: true });
 906-        if (!tab) {
 907-          tab = await findPlatformShellTab(platform);
 908+        const trackedCandidate = await resolveTrackedTab(platform);
 909+        let shellTab = trackedCandidate && isPlatformShellUrl(platform, trackedCandidate.url || "")
 910+          ? trackedCandidate
 911+          : await resolveTrackedTab(platform, { requireShell: true });
 912+
 913+        if (!shellTab) {
 914+          shellTab = await findPlatformShellTab(platform);
 915+        }
 916+
 917+        let candidateTab = null;
 918+        if (!shellTab) {
 919+          candidateTab = trackedCandidate || await findPlatformTab(platform);
 920         }
 921-        next[platform] = tab ? tab.id : null;
 922+
 923+        next[platform] = shellTab ? shellTab.id : null;
 924+        nextActual[platform] = buildActualTabSnapshot(
 925+          platform,
 926+          shellTab || null,
 927+          candidateTab,
 928+          state.actualTabs[platform]
 929+        );
 930       }
 931 
 932       let changed = false;
 933+      let actualChanged = false;
 934       for (const platform of PLATFORM_ORDER) {
 935         if (state.trackedTabs[platform] !== next[platform]) {
 936           state.trackedTabs[platform] = next[platform];
 937           changed = true;
 938         }
 939+
 940+        if (JSON.stringify(state.actualTabs[platform]) !== JSON.stringify(nextActual[platform])) {
 941+          state.actualTabs[platform] = nextActual[platform];
 942+          actualChanged = true;
 943+        }
 944       }
 945 
 946       const credentialChanged = pruneInvalidCredentialState();
 947-      if (changed || credentialChanged) {
 948+      if (changed || actualChanged || credentialChanged) {
 949         await persistState();
 950       }
 951-      if (changed || credentialChanged || reason === "poll") {
 952+      if (changed || actualChanged || credentialChanged) {
 953+        sendCredentialSnapshot(null, true);
 954+      }
 955+      if (changed || actualChanged || credentialChanged || reason === "poll") {
 956         render();
 957       }
 958     } while (state.trackedTabRefreshQueued);
 959@@ -3984,6 +4729,33 @@ function registerRuntimeListeners() {
 960           ok: true,
 961           snapshot: cloneControlState(state.controlState)
 962         });
 963+      case "plugin_runtime_action":
 964+      case "plugin_action":
 965+        return runPluginManagementAction(message.action, {
 966+          platform: message.platform,
 967+          source: message.source || "runtime_message",
 968+          reason: message.reason || "runtime_plugin_action"
 969+        }).then((result) => ({
 970+          ok: true,
 971+          ...result
 972+        })).catch((error) => ({
 973+          ok: false,
 974+          error: error.message,
 975+          snapshot: buildPluginStatusPayload()
 976+        }));
 977+      case "plugin_status":
 978+      case "get_plugin_status":
 979+      case "get_plugin_runtime_status":
 980+        return runPluginManagementAction("plugin_status", {
 981+          source: message.source || "runtime_status"
 982+        }).then((result) => ({
 983+          ok: true,
 984+          ...result
 985+        })).catch((error) => ({
 986+          ok: false,
 987+          error: error.message,
 988+          snapshot: buildPluginStatusPayload()
 989+        }));
 990       case "claude_send":
 991       case "claude_read_conversation":
 992       case "claude_read_state":
 993@@ -4083,6 +4855,10 @@ async function init() {
 994     saved[CONTROLLER_STORAGE_KEYS.trackedTabs],
 995     saved[LEGACY_STORAGE_KEYS.claudeTabId]
 996   );
 997+  state.desiredTabs = loadDesiredTabs(
 998+    saved[CONTROLLER_STORAGE_KEYS.desiredTabs],
 999+    state.trackedTabs
1000+  );
1001   state.endpoints = loadEndpointEntries(
1002     saved[CONTROLLER_STORAGE_KEYS.endpointsByPlatform],
1003     saved[LEGACY_STORAGE_KEYS.endpoints]
1004@@ -4112,6 +4888,7 @@ async function init() {
1005   );
1006   state.geminiSendTemplate = saved[CONTROLLER_STORAGE_KEYS.geminiSendTemplate] || null;
1007   state.claudeState = loadClaudeState(saved[CONTROLLER_STORAGE_KEYS.claudeState]);
1008+  state.controllerRuntime = loadControllerRuntimeState(saved[CONTROLLER_STORAGE_KEYS.controllerRuntime]);
1009   if (needsStatusReset) {
1010     state.lastHeaders = createPlatformMap(() => ({}));
1011     state.credentialCapturedAt = createPlatformMap(() => 0);
1012@@ -4134,6 +4911,12 @@ async function init() {
1013   registerWebRequestListeners();
1014 
1015   const current = await browser.tabs.getCurrent();
1016+  setControllerRuntimeState({
1017+    tabId: current?.id ?? null,
1018+    ready: true,
1019+    status: "ready",
1020+    lastReadyAt: Date.now()
1021+  });
1022   await browser.runtime.sendMessage({ type: "controller_ready", tabId: current.id });
1023   await refreshTrackedTabsFromBrowser("startup");
1024   await refreshClaudeTabState(false);
1025@@ -4145,6 +4928,7 @@ async function init() {
1026   }
1027 
1028   connectWs({ silentWhenDisabled: true });
1029+  restartShellRuntimeHealthTimer(SHELL_RUNTIME_HEALTHCHECK_INTERVAL);
1030   await prepareStartupControlState();
1031   refreshControlPlaneState({ source: "startup", silent: true }).catch(() => {});
1032 }
1033@@ -4152,6 +4936,7 @@ async function init() {
1034 window.addEventListener("beforeunload", () => {
1035   clearTimeout(state.controlRefreshTimer);
1036   clearTimeout(state.trackedTabRefreshTimer);
1037+  clearTimeout(state.shellRuntimeTimer);
1038   closeWsConnection();
1039 });
1040 
M tasks/T-S021.md
+13, -1
 1@@ -23,7 +23,7 @@
 2 
 3 ## 当前状态
 4 
 5-- `TODO`
 6+- `已完成`
 7 
 8 ## 建议分支名
 9 
10@@ -154,3 +154,15 @@
11 - 现有 Claude 路径如何兼容
12 - 跑了哪些验证
13 - 还有哪些剩余风险
14+
15+## 最新接力说明
16+
17+- 本任务已完成,`conductor` 的浏览器能力发现继续收口到 `business` / `control` 两层 describe。
18+- 当前剩余集成工作已经转入:
19+  - `/Users/george/code/baa-conductor/tasks/T-S023.md`
20+  - `/Users/george/code/baa-conductor/tasks/T-S024.md`
21+
22+## 当前残余风险
23+
24+- `conductor` 目前还不会消费新增的 `shell_runtime` 字段。
25+- 正式的插件管理动作回执合同还未完全收口;插件侧 runtime 和 payload 已准备好,后续由服务端接入。
M tasks/T-S022.md
+13, -1
 1@@ -23,7 +23,7 @@
 2 
 3 ## 当前状态
 4 
 5-- `TODO`
 6+- `已完成`
 7 
 8 ## 建议分支名
 9 
10@@ -146,3 +146,15 @@
11 - `desired/actual` 如何同步
12 - 跑了哪些验证
13 - 还有哪些剩余风险
14+
15+## 最新接力说明
16+
17+- 本任务已完成,Firefox 插件侧已经把空壳页 runtime、`desired/actual` 和管理类 payload 准备好。
18+- 当前剩余集成工作已经转入:
19+  - `/Users/george/code/baa-conductor/tasks/T-S023.md`
20+  - `/Users/george/code/baa-conductor/tasks/T-S024.md`
21+
22+## 当前残余风险
23+
24+- 这轮没有跑真实 Firefox 手工 smoke。
25+- 还没实测“手动关 tab -> `tab_restore` -> WS 重连后状态回报”的浏览器端闭环。
M tasks/TASK_OVERVIEW.md
+12, -10
 1@@ -9,12 +9,12 @@
 2 - `control-api.makefile.so`、Cloudflare Worker、D1 只剩迁移期 legacy 兼容残留和依赖盘点用途
 3 - `baa-hand` / `baa-shell` 只保留为接口语义参考,不再作为主系统维护
 4 - 当前任务卡都放在本目录
 5-- 当前任务基线:`main@0f218b9`
 6+- 当前任务基线:`main@5d4febb`
 7 
 8 ## 状态分类
 9 
10-- `已完成`:`T-S001` 到 `T-S020`
11-- `当前 TODO`:`T-S021` 到 `T-S024`
12+- `已完成`:`T-S001` 到 `T-S022`
13+- `当前 TODO`:`T-S023` 到 `T-S024`
14 - `低优先级 TODO`:`4318/status-api` 兼容层删旧与解耦
15 
16 当前新的主需求文档:
17@@ -59,7 +59,7 @@
18 ## 当前活动任务
19 
20 - 当前主线已切到浏览器桥接第二阶段开发
21-- 当前首批任务:[`T-S021.md`](./T-S021.md)、[`T-S022.md`](./T-S022.md)、[`T-S023.md`](./T-S023.md)、[`T-S024.md`](./T-S024.md)
22+- 当前活动任务:[`T-S023.md`](./T-S023.md)、[`T-S024.md`](./T-S024.md)
23 
24 ## 当前 TODO
25 
26@@ -69,8 +69,8 @@
27 2. [`T-S018.md`](./T-S018.md):已完成,Firefox 插件已收口到空壳标签页并开始上报账号/指纹/端点
28 3. [`T-S019.md`](./T-S019.md):已完成,`conductor` 已接上仓储、读接口和状态老化逻辑
29 4. [`T-S020.md`](./T-S020.md):已完成,文档、browser smoke 和任务状态视图已同步到正式模型
30-5. [`T-S021.md`](./T-S021.md):收口 `conductor` describe 与通用 browser HTTP 合同
31-6. [`T-S022.md`](./T-S022.md):实现 Firefox 空壳页 runtime 与插件管理动作
32+5. [`T-S021.md`](./T-S021.md):已完成,收口 `conductor` describe 与通用 browser HTTP 合同
33+6. [`T-S022.md`](./T-S022.md):已完成,实现 Firefox 空壳页 runtime 与插件管理动作
34 7. [`T-S023.md`](./T-S023.md):打通通用 browser request/SSE 链路与 `conductor` 风控策略
35 8. [`T-S024.md`](./T-S024.md):回写文档、补 smoke 并同步主线状态
36 
37@@ -79,15 +79,17 @@
38 - `T-S017` 与 `T-S018` 已并行完成
39 - `T-S019` 已完成集成和验收收口
40 - `T-S020` 已在 `T-S019` 之后完成收尾
41-- `T-S021` 与 `T-S022` 可并行
42-- `T-S023` 在 `T-S021`、`T-S022` 之后做集成
43-- `T-S024` 在 `T-S023` 之后做收尾
44+- `T-S021` 与 `T-S022` 已并行完成
45+- `T-S023` 现在负责服务端集成和通用 request/SSE 主链路
46+- `T-S024` 在 `T-S023` 之后做文档与 smoke 收尾
47 
48 当前已知主线遗留:
49 
50-- 当前主线开发任务已切到 `T-S021` 到 `T-S024`
51+- 当前主线开发任务已切到 `T-S023` 到 `T-S024`
52 - runtime smoke 仍依赖仓库根已有 `state/`、`runs/`、`worktrees/`、`logs/launchd/`、`logs/codexd/`、`tmp/` 等本地运行目录;这是现有脚本前提,不是本轮功能回归
53 - 本地工作树里仍存在与本轮并行任务无关的 `plugins/baa-firefox/controller.js` 改动;后续开发继续避免覆盖它
54+- 这轮还没跑真实 Firefox 手工 smoke,因此“手动关 tab -> `tab_restore` -> WS 重连后状态回报”的浏览器端闭环仍未实测
55+- `conductor` 还不会消费新增的 `shell_runtime` 字段,也没有正式的插件管理动作回执合同;插件侧 runtime 和 payload 已准备好,后续由 `T-S023` / `T-S024` 接入
56 
57 ## 低优先级 TODO
58