- 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
+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 {
+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":
+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
+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` 也不在本文件讨论范围内
+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)
+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` 接入
+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`:
+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
+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 已准备好,后续由服务端接入。
+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 重连后状态回报”的浏览器端闭环。
+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