- commit
- 782e4c7
- parent
- b2f6ea7
- author
- codex@macbookpro
- date
- 2026-03-26 20:41:27 +0800 CST
feat: close browser control runtime loop
14 files changed,
+1601,
-158
1@@ -30,16 +30,103 @@ export interface BrowserBridgeRequestHookSnapshot {
2 updated_at: number;
3 }
4
5+export interface BrowserBridgeShellRuntimeDesiredSnapshot {
6+ exists: boolean;
7+ last_action: string | null;
8+ last_action_at: number | null;
9+ reason: string | null;
10+ shell_url: string | null;
11+ source: string | null;
12+ updated_at: number | null;
13+}
14+
15+export interface BrowserBridgeShellRuntimeActualSnapshot {
16+ active: boolean | null;
17+ candidate_tab_id: number | null;
18+ candidate_url: string | null;
19+ discarded: boolean | null;
20+ exists: boolean;
21+ healthy: boolean | null;
22+ hidden: boolean | null;
23+ issue: string | null;
24+ last_ready_at: number | null;
25+ last_seen_at: number | null;
26+ status: string | null;
27+ tab_id: number | null;
28+ title: string | null;
29+ url: string | null;
30+ window_id: number | null;
31+}
32+
33+export interface BrowserBridgeShellRuntimeDriftSnapshot {
34+ aligned: boolean;
35+ needs_restore: boolean;
36+ reason: string | null;
37+ unexpected_actual: boolean;
38+}
39+
40+export interface BrowserBridgeShellRuntimeSnapshot {
41+ actual: BrowserBridgeShellRuntimeActualSnapshot;
42+ desired: BrowserBridgeShellRuntimeDesiredSnapshot;
43+ drift: BrowserBridgeShellRuntimeDriftSnapshot;
44+ platform: string;
45+}
46+
47+export interface BrowserBridgeActionResultTargetSnapshot {
48+ client_id: string | null;
49+ connection_id: string | null;
50+ platform: string | null;
51+ requested_client_id: string | null;
52+ requested_platform: string | null;
53+}
54+
55+export interface BrowserBridgeActionResultItemSnapshot {
56+ ok: boolean;
57+ platform: string | null;
58+ restored: boolean | null;
59+ shell_runtime: BrowserBridgeShellRuntimeSnapshot | null;
60+ skipped: string | null;
61+ tab_id: number | null;
62+}
63+
64+export interface BrowserBridgeActionResultSummarySnapshot {
65+ actual_count: number;
66+ desired_count: number;
67+ drift_count: number;
68+ failed_count: number;
69+ ok_count: number;
70+ platform_count: number;
71+ restored_count: number;
72+ skipped_reasons: string[];
73+}
74+
75+export interface BrowserBridgeActionResultSnapshot {
76+ accepted: boolean;
77+ action: string;
78+ completed: boolean;
79+ failed: boolean;
80+ reason: string | null;
81+ received_at: number;
82+ request_id: string;
83+ result: BrowserBridgeActionResultSummarySnapshot;
84+ results: BrowserBridgeActionResultItemSnapshot[];
85+ shell_runtime: BrowserBridgeShellRuntimeSnapshot[];
86+ target: BrowserBridgeActionResultTargetSnapshot;
87+ type: string;
88+}
89+
90 export interface BrowserBridgeClientSnapshot {
91 client_id: string;
92 connected_at: number;
93 connection_id: string;
94 credentials: BrowserBridgeCredentialSnapshot[];
95+ last_action_result: BrowserBridgeActionResultSnapshot | null;
96 last_message_at: number;
97 node_category: string | null;
98 node_platform: string | null;
99 node_type: string | null;
100 request_hooks: BrowserBridgeRequestHookSnapshot[];
101+ shell_runtime: BrowserBridgeShellRuntimeSnapshot[];
102 }
103
104 export interface BrowserBridgeStateSnapshot {
105@@ -58,6 +145,11 @@ export interface BrowserBridgeDispatchReceipt {
106 type: string;
107 }
108
109+export interface BrowserBridgeActionDispatch extends BrowserBridgeDispatchReceipt {
110+ requestId: string;
111+ result: Promise<BrowserBridgeActionResultSnapshot>;
112+}
113+
114 export interface BrowserBridgeRequestCancelReceipt extends BrowserBridgeDispatchReceipt {
115 reason?: string | null;
116 requestId: string;
117@@ -165,15 +257,16 @@ export interface BrowserBridgeController {
118 clientId?: string | null;
119 platform?: string | null;
120 reason?: string | null;
121- }): BrowserBridgeDispatchReceipt;
122+ }): BrowserBridgeActionDispatch;
123 openTab(input?: {
124 clientId?: string | null;
125 platform?: string | null;
126- }): BrowserBridgeDispatchReceipt;
127+ }): BrowserBridgeActionDispatch;
128 reload(input?: {
129 clientId?: string | null;
130+ platform?: string | null;
131 reason?: string | null;
132- }): BrowserBridgeDispatchReceipt;
133+ }): BrowserBridgeActionDispatch;
134 streamRequest(input: {
135 body?: unknown;
136 clientId?: string | null;
137@@ -193,5 +286,5 @@ export interface BrowserBridgeController {
138 clientId?: string | null;
139 platform?: string | null;
140 reason?: string | null;
141- }): BrowserBridgeDispatchReceipt;
142+ }): BrowserBridgeActionDispatch;
143 }
+180,
-8
1@@ -1,6 +1,12 @@
2 import { randomUUID } from "node:crypto";
3
4+import type {
5+ BrowserBridgeActionDispatch,
6+ BrowserBridgeActionResultSnapshot
7+} from "./browser-types.js";
8+
9 const DEFAULT_FIREFOX_API_REQUEST_TIMEOUT_MS = 15_000;
10+const DEFAULT_FIREFOX_ACTION_RESULT_TIMEOUT_MS = 10_000;
11 const DEFAULT_FIREFOX_STREAM_IDLE_TIMEOUT_MS = 30_000;
12 const DEFAULT_FIREFOX_STREAM_MAX_BUFFERED_BYTES = 512 * 1024;
13 const DEFAULT_FIREFOX_STREAM_MAX_BUFFERED_EVENTS = 256;
14@@ -22,6 +28,7 @@ export type FirefoxBridgeOutboundCommandType =
15 | "ws_reconnect";
16
17 export type FirefoxBridgeErrorCode =
18+ | "action_timeout"
19 | "client_disconnected"
20 | "client_not_found"
21 | "client_replaced"
22@@ -61,6 +68,7 @@ export interface FirefoxRequestCredentialsCommandInput extends FirefoxBridgeComm
23 }
24
25 export interface FirefoxReloadCommandInput extends FirefoxBridgeCommandTarget {
26+ platform?: string | null;
27 reason?: string | null;
28 }
29
30@@ -228,6 +236,15 @@ interface FirefoxPendingApiRequest {
31 timer: TimeoutHandle;
32 }
33
34+interface FirefoxPendingActionRequest {
35+ clientId: string;
36+ connectionId: string;
37+ reject: (error: FirefoxBridgeError) => void;
38+ requestId: string;
39+ resolve: (response: BrowserBridgeActionResultSnapshot) => void;
40+ timer: TimeoutHandle;
41+}
42+
43 interface FirefoxCommandBrokerOptions {
44 clearTimeoutImpl?: (handle: TimeoutHandle) => void;
45 now?: () => number;
46@@ -726,6 +743,7 @@ class FirefoxBridgeApiStreamSession implements FirefoxBridgeApiStream {
47 export class FirefoxCommandBroker {
48 private readonly clearTimeoutImpl: (handle: TimeoutHandle) => void;
49 private readonly now: () => number;
50+ private readonly pendingActionRequests = new Map<string, FirefoxPendingActionRequest>();
51 private readonly pendingApiRequests = new Map<string, FirefoxPendingApiRequest>();
52 private readonly pendingStreamRequests = new Map<string, FirefoxBridgeApiStreamSession>();
53 private readonly resolveActiveClient: () => FirefoxBridgeRegisteredClient | null;
54@@ -771,6 +789,81 @@ export class FirefoxCommandBroker {
55 };
56 }
57
58+ dispatchWithActionResult(
59+ type: Exclude<FirefoxBridgeOutboundCommandType, "api_request" | "request_cancel">,
60+ payload: Record<string, unknown>,
61+ target: FirefoxBridgeCommandTarget = {},
62+ timeoutMs: number = DEFAULT_FIREFOX_ACTION_RESULT_TIMEOUT_MS
63+ ): BrowserBridgeActionDispatch {
64+ const client = this.selectClient(target);
65+ const dispatchedAt = this.now();
66+ const requestId = randomUUID();
67+ const envelope = compactRecord({
68+ ...payload,
69+ requestId,
70+ type
71+ });
72+
73+ let resolveResult!: (response: BrowserBridgeActionResultSnapshot) => void;
74+ let rejectResult!: (error: FirefoxBridgeError) => void;
75+ const result = new Promise<BrowserBridgeActionResultSnapshot>((resolve, reject) => {
76+ resolveResult = resolve;
77+ rejectResult = reject;
78+ });
79+
80+ // Low-level callers may only care about the dispatch side-effect.
81+ void result.catch(() => {});
82+
83+ const timer = this.setTimeoutImpl(() => {
84+ this.pendingActionRequests.delete(requestId);
85+ rejectResult(
86+ new FirefoxBridgeError(
87+ "action_timeout",
88+ `Firefox client "${client.clientId}" did not report action_result "${requestId}" within ${timeoutMs}ms.`,
89+ {
90+ clientId: client.clientId,
91+ connectionId: client.connectionId,
92+ requestId
93+ }
94+ )
95+ );
96+ }, normalizeTimeoutMs(timeoutMs, DEFAULT_FIREFOX_ACTION_RESULT_TIMEOUT_MS));
97+
98+ this.pendingActionRequests.set(requestId, {
99+ clientId: client.clientId,
100+ connectionId: client.connectionId,
101+ reject: rejectResult,
102+ requestId,
103+ resolve: resolveResult,
104+ timer
105+ });
106+
107+ if (!client.sendJson(envelope)) {
108+ const pending = this.clearPendingActionRequest(requestId);
109+ const error = new FirefoxBridgeError(
110+ "send_failed",
111+ `Failed to send ${type} to Firefox client "${client.clientId}".`,
112+ {
113+ clientId: client.clientId,
114+ connectionId: client.connectionId,
115+ requestId
116+ }
117+ );
118+
119+ pending?.reject(error);
120+ throw error;
121+ }
122+
123+ return {
124+ clientId: client.clientId,
125+ connectionId: client.connectionId,
126+ dispatchedAt,
127+ requestId,
128+ result,
129+ type
130+ };
131+ }
132+
133 sendApiRequest(
134 payload: Record<string, unknown>,
135 options: FirefoxBridgeCommandTarget & {
136@@ -1011,6 +1104,18 @@ export class FirefoxCommandBroker {
137 return true;
138 }
139
140+ handleActionResult(connectionId: string, payload: BrowserBridgeActionResultSnapshot): boolean {
141+ const pending = this.pendingActionRequests.get(payload.request_id);
142+
143+ if (pending == null || pending.connectionId !== connectionId) {
144+ return false;
145+ }
146+
147+ this.clearPendingActionRequest(payload.request_id);
148+ pending.resolve(payload);
149+ return true;
150+ }
151+
152 handleStreamOpen(connectionId: string, payload: FirefoxStreamOpenPayload): boolean {
153 const pending = this.pendingStreamRequests.get(payload.id);
154
155@@ -1052,6 +1157,9 @@ export class FirefoxCommandBroker {
156 }
157
158 handleConnectionClosed(event: FirefoxBridgeConnectionClosedEvent): void {
159+ const actionRequestIds = [...this.pendingActionRequests.values()]
160+ .filter((entry) => entry.connectionId === event.connectionId)
161+ .map((entry) => entry.requestId);
162 const requestIds = [...this.pendingApiRequests.values()]
163 .filter((entry) => entry.connectionId === event.connectionId)
164 .map((entry) => entry.requestId);
165@@ -1065,6 +1173,28 @@ export class FirefoxCommandBroker {
166 const reasonSuffix =
167 normalizeOptionalString(event.reason) == null ? "" : ` (${normalizeOptionalString(event.reason)})`;
168
169+ for (const requestId of actionRequestIds) {
170+ const pending = this.clearPendingActionRequest(requestId);
171+
172+ if (pending == null) {
173+ continue;
174+ }
175+
176+ pending.reject(
177+ new FirefoxBridgeError(
178+ errorCode,
179+ errorCode === "client_replaced"
180+ ? `Firefox client "${clientLabel}" was replaced before action "${requestId}" completed${reasonSuffix}.`
181+ : `Firefox client "${clientLabel}" disconnected before action "${requestId}" completed${reasonSuffix}.`,
182+ {
183+ clientId: pending.clientId,
184+ connectionId: pending.connectionId,
185+ requestId
186+ }
187+ )
188+ );
189+ }
190+
191 for (const requestId of requestIds) {
192 const pending = this.clearPendingRequest(requestId);
193
194@@ -1104,9 +1234,30 @@ export class FirefoxCommandBroker {
195 }
196
197 stop(): void {
198+ const actionRequestIds = [...this.pendingActionRequests.keys()];
199 const requestIds = [...this.pendingApiRequests.keys()];
200 const streamIds = [...this.pendingStreamRequests.keys()];
201
202+ for (const requestId of actionRequestIds) {
203+ const pending = this.clearPendingActionRequest(requestId);
204+
205+ if (pending == null) {
206+ continue;
207+ }
208+
209+ pending.reject(
210+ new FirefoxBridgeError(
211+ "service_stopped",
212+ `Firefox bridge stopped before action "${requestId}" completed.`,
213+ {
214+ clientId: pending.clientId,
215+ connectionId: pending.connectionId,
216+ requestId
217+ }
218+ )
219+ );
220+ }
221+
222 for (const requestId of requestIds) {
223 const pending = this.clearPendingRequest(requestId);
224
225@@ -1153,6 +1304,18 @@ export class FirefoxCommandBroker {
226 return pending;
227 }
228
229+ private clearPendingActionRequest(requestId: string): FirefoxPendingActionRequest | null {
230+ const pending = this.pendingActionRequests.get(requestId);
231+
232+ if (pending == null) {
233+ return null;
234+ }
235+
236+ this.pendingActionRequests.delete(requestId);
237+ this.clearTimeoutImpl(pending.timer);
238+ return pending;
239+ }
240+
241 private selectClient(target: FirefoxBridgeCommandTarget): FirefoxBridgeRegisteredClient {
242 const normalizedClientId = normalizeOptionalString(target.clientId);
243 const client =
244@@ -1186,10 +1349,11 @@ export class FirefoxBridgeService {
245
246 dispatchPluginAction(
247 input: FirefoxPluginActionCommandInput
248- ): FirefoxBridgeDispatchReceipt {
249- return this.broker.dispatch(
250+ ): BrowserBridgeActionDispatch {
251+ return this.broker.dispatchWithActionResult(
252 input.action,
253 compactRecord({
254+ action: input.action,
255 platform: normalizeOptionalString(input.platform) ?? undefined,
256 reason: normalizeOptionalString(input.reason) ?? undefined
257 }),
258@@ -1197,12 +1361,13 @@ export class FirefoxBridgeService {
259 );
260 }
261
262- openTab(input: FirefoxOpenTabCommandInput = {}): FirefoxBridgeDispatchReceipt {
263+ openTab(input: FirefoxOpenTabCommandInput = {}): BrowserBridgeActionDispatch {
264 const platform = normalizeOptionalString(input.platform);
265
266- return this.broker.dispatch(
267+ return this.broker.dispatchWithActionResult(
268 "open_tab",
269 compactRecord({
270+ action: "tab_open",
271 platform: platform ?? undefined
272 }),
273 input
274@@ -1211,10 +1376,11 @@ export class FirefoxBridgeService {
275
276 requestCredentials(
277 input: FirefoxRequestCredentialsCommandInput = {}
278- ): FirefoxBridgeDispatchReceipt {
279- return this.broker.dispatch(
280+ ): BrowserBridgeActionDispatch {
281+ return this.broker.dispatchWithActionResult(
282 "request_credentials",
283 compactRecord({
284+ action: "request_credentials",
285 platform: normalizeOptionalString(input.platform) ?? undefined,
286 reason: normalizeOptionalString(input.reason) ?? undefined
287 }),
288@@ -1222,10 +1388,12 @@ export class FirefoxBridgeService {
289 );
290 }
291
292- reload(input: FirefoxReloadCommandInput = {}): FirefoxBridgeDispatchReceipt {
293- return this.broker.dispatch(
294+ reload(input: FirefoxReloadCommandInput = {}): BrowserBridgeActionDispatch {
295+ return this.broker.dispatchWithActionResult(
296 "reload",
297 compactRecord({
298+ action: normalizeOptionalString(input.platform) == null ? "controller_reload" : "tab_reload",
299+ platform: normalizeOptionalString(input.platform) ?? undefined,
300 reason: normalizeOptionalString(input.reason) ?? undefined
301 }),
302 input
303@@ -1360,6 +1528,10 @@ export class FirefoxBridgeService {
304 });
305 }
306
307+ handleActionResult(connectionId: string, payload: BrowserBridgeActionResultSnapshot): boolean {
308+ return this.broker.handleActionResult(connectionId, payload);
309+ }
310+
311 handleConnectionClosed(event: FirefoxBridgeConnectionClosedEvent): void {
312 this.broker.handleConnectionClosed(event);
313 }
+334,
-2
1@@ -9,9 +9,14 @@ import {
2 type FirefoxBridgeRegisteredClient
3 } from "./firefox-bridge.js";
4 import type {
5+ BrowserBridgeActionResultItemSnapshot,
6+ BrowserBridgeActionResultSnapshot,
7+ BrowserBridgeActionResultSummarySnapshot,
8+ BrowserBridgeActionResultTargetSnapshot,
9 BrowserBridgeClientSnapshot,
10 BrowserBridgeEndpointMetadataSnapshot,
11 BrowserBridgeLoginStatus,
12+ BrowserBridgeShellRuntimeSnapshot,
13 BrowserBridgeStateSnapshot
14 } from "./browser-types.js";
15 import { buildSystemStateData, setAutomationMode } from "./local-api.js";
16@@ -74,11 +79,13 @@ interface FirefoxBrowserSession {
17 connectedAt: number;
18 credentials: Map<string, FirefoxBrowserCredentialSummary>;
19 id: string;
20+ lastActionResult: BrowserBridgeActionResultSnapshot | null;
21 lastMessageAt: number;
22 nodeCategory: string | null;
23 nodePlatform: string | null;
24 nodeType: string | null;
25 requestHooks: Map<string, FirefoxBrowserHookSummary>;
26+ shellRuntime: Map<string, BrowserBridgeShellRuntimeSnapshot>;
27 }
28
29 function normalizeBrowserLoginStatus(value: unknown): BrowserBridgeLoginStatus | null {
30@@ -130,6 +137,36 @@ function readFirstString(
31 return null;
32 }
33
34+function readOptionalBoolean(
35+ input: Record<string, unknown>,
36+ keys: readonly string[]
37+): boolean | null {
38+ for (const key of keys) {
39+ const value = input[key];
40+
41+ if (typeof value === "boolean") {
42+ return value;
43+ }
44+ }
45+
46+ return null;
47+}
48+
49+function readOptionalInteger(
50+ input: Record<string, unknown>,
51+ keys: readonly string[]
52+): number | null {
53+ for (const key of keys) {
54+ const value = input[key];
55+
56+ if (typeof value === "number" && Number.isFinite(value)) {
57+ return Math.round(value);
58+ }
59+ }
60+
61+ return null;
62+}
63+
64 function readStringArray(
65 input: Record<string, unknown>,
66 key: string
67@@ -246,6 +283,201 @@ function countHeaderNames(input: Record<string, unknown>): number {
68 return readStringArray(input, "header_names").length;
69 }
70
71+function normalizeShellRuntimeSnapshot(
72+ value: unknown,
73+ fallbackPlatform: string | null = null
74+): BrowserBridgeShellRuntimeSnapshot | null {
75+ const record = asRecord(value);
76+
77+ if (record == null) {
78+ return null;
79+ }
80+
81+ const platform = readFirstString(record, ["platform"]) ?? fallbackPlatform;
82+
83+ if (platform == null) {
84+ return null;
85+ }
86+
87+ const desired = asRecord(record.desired) ?? {};
88+ const actual = asRecord(record.actual) ?? {};
89+ const drift = asRecord(record.drift) ?? {};
90+
91+ return {
92+ actual: {
93+ active: readOptionalBoolean(actual, ["active"]),
94+ candidate_tab_id: readOptionalInteger(actual, ["candidate_tab_id", "candidateTabId"]),
95+ candidate_url: readFirstString(actual, ["candidate_url", "candidateUrl"]),
96+ discarded: readOptionalBoolean(actual, ["discarded"]),
97+ exists: readOptionalBoolean(actual, ["exists"]) === true,
98+ healthy: readOptionalBoolean(actual, ["healthy"]),
99+ hidden: readOptionalBoolean(actual, ["hidden"]),
100+ issue: readFirstString(actual, ["issue"]),
101+ last_ready_at: readOptionalTimestampMilliseconds(actual, ["last_ready_at", "lastReadyAt"]),
102+ last_seen_at: readOptionalTimestampMilliseconds(actual, ["last_seen_at", "lastSeenAt"]),
103+ status: readFirstString(actual, ["status"]),
104+ tab_id: readOptionalInteger(actual, ["tab_id", "tabId"]),
105+ title: readFirstString(actual, ["title"]),
106+ url: readFirstString(actual, ["url"]),
107+ window_id: readOptionalInteger(actual, ["window_id", "windowId"])
108+ },
109+ desired: {
110+ exists: readOptionalBoolean(desired, ["exists"]) === true,
111+ last_action: readFirstString(desired, ["last_action", "lastAction"]),
112+ last_action_at: readOptionalTimestampMilliseconds(desired, ["last_action_at", "lastActionAt"]),
113+ reason: readFirstString(desired, ["reason"]),
114+ shell_url: readFirstString(desired, ["shell_url", "shellUrl"]),
115+ source: readFirstString(desired, ["source"]),
116+ updated_at: readOptionalTimestampMilliseconds(desired, ["updated_at", "updatedAt"])
117+ },
118+ drift: {
119+ aligned: readOptionalBoolean(drift, ["aligned"]) !== false,
120+ needs_restore: readOptionalBoolean(drift, ["needs_restore", "needsRestore"]) === true,
121+ reason: readFirstString(drift, ["reason"]),
122+ unexpected_actual: readOptionalBoolean(drift, ["unexpected_actual", "unexpectedActual"]) === true
123+ },
124+ platform
125+ };
126+}
127+
128+function readShellRuntimeArray(
129+ value: unknown,
130+ fallbackPlatform: string | null = null
131+): BrowserBridgeShellRuntimeSnapshot[] {
132+ const runtimes = new Map<string, BrowserBridgeShellRuntimeSnapshot>();
133+
134+ const append = (
135+ entry: unknown,
136+ nextFallbackPlatform: string | null = fallbackPlatform
137+ ): void => {
138+ const runtime = normalizeShellRuntimeSnapshot(entry, nextFallbackPlatform);
139+
140+ if (runtime != null) {
141+ runtimes.set(runtime.platform, runtime);
142+ }
143+ };
144+
145+ if (Array.isArray(value)) {
146+ for (const entry of value) {
147+ append(entry, null);
148+ }
149+ } else {
150+ append(value, fallbackPlatform);
151+ }
152+
153+ return [...runtimes.values()].sort((left, right) => left.platform.localeCompare(right.platform));
154+}
155+
156+function mergeShellRuntimeSnapshots(
157+ ...groups: readonly BrowserBridgeShellRuntimeSnapshot[][]
158+): BrowserBridgeShellRuntimeSnapshot[] {
159+ const runtimes = new Map<string, BrowserBridgeShellRuntimeSnapshot>();
160+
161+ for (const group of groups) {
162+ for (const runtime of group) {
163+ runtimes.set(runtime.platform, runtime);
164+ }
165+ }
166+
167+ return [...runtimes.values()].sort((left, right) => left.platform.localeCompare(right.platform));
168+}
169+
170+function normalizeActionResultItem(
171+ value: unknown,
172+ fallbackPlatform: string | null = null
173+): BrowserBridgeActionResultItemSnapshot | null {
174+ const record = asRecord(value);
175+
176+ if (record == null) {
177+ return null;
178+ }
179+
180+ const platform = readFirstString(record, ["platform"]) ?? fallbackPlatform;
181+ const runtimes = readShellRuntimeArray(record.shell_runtime ?? record.shellRuntime, platform);
182+
183+ return {
184+ ok: readOptionalBoolean(record, ["ok"]) !== false,
185+ platform: platform ?? runtimes[0]?.platform ?? null,
186+ restored: readOptionalBoolean(record, ["restored"]),
187+ shell_runtime: runtimes[0] ?? null,
188+ skipped: readFirstString(record, ["skipped"]),
189+ tab_id: readOptionalInteger(record, ["tab_id", "tabId"])
190+ };
191+}
192+
193+function buildActionResultSummary(
194+ record: Record<string, unknown> | null,
195+ results: BrowserBridgeActionResultItemSnapshot[],
196+ shellRuntime: BrowserBridgeShellRuntimeSnapshot[]
197+): BrowserBridgeActionResultSummarySnapshot {
198+ const skippedReasons = new Set<string>();
199+
200+ for (const result of results) {
201+ if (result.skipped != null) {
202+ skippedReasons.add(result.skipped);
203+ }
204+ }
205+
206+ if (record != null) {
207+ const providedSkippedReasons = [
208+ ...readStringArray(record, "skipped_reasons"),
209+ ...readStringArray(record, "skippedReasons")
210+ ];
211+
212+ for (const skippedReason of providedSkippedReasons) {
213+ skippedReasons.add(skippedReason);
214+ }
215+ }
216+
217+ return {
218+ actual_count:
219+ readOptionalInteger(record ?? {}, ["actual_count", "actualCount"])
220+ ?? shellRuntime.filter((runtime) => runtime.actual.exists).length,
221+ desired_count:
222+ readOptionalInteger(record ?? {}, ["desired_count", "desiredCount"])
223+ ?? shellRuntime.filter((runtime) => runtime.desired.exists).length,
224+ drift_count:
225+ readOptionalInteger(record ?? {}, ["drift_count", "driftCount"])
226+ ?? shellRuntime.filter((runtime) => runtime.drift.aligned === false).length,
227+ failed_count:
228+ readOptionalInteger(record ?? {}, ["failed_count", "failedCount"])
229+ ?? results.filter((result) => result.ok === false).length,
230+ ok_count:
231+ readOptionalInteger(record ?? {}, ["ok_count", "okCount"])
232+ ?? results.filter((result) => result.ok).length,
233+ platform_count:
234+ readOptionalInteger(record ?? {}, ["platform_count", "platformCount"])
235+ ?? new Set([
236+ ...results.map((result) => result.platform).filter((value): value is string => value != null),
237+ ...shellRuntime.map((runtime) => runtime.platform)
238+ ]).size,
239+ restored_count:
240+ readOptionalInteger(record ?? {}, ["restored_count", "restoredCount"])
241+ ?? results.filter((result) => result.restored === true).length,
242+ skipped_reasons: [...skippedReasons].sort((left, right) => left.localeCompare(right))
243+ };
244+}
245+
246+function normalizeActionResultTarget(
247+ connection: FirefoxWebSocketConnection,
248+ value: unknown,
249+ fallbackPlatform: string | null = null
250+): BrowserBridgeActionResultTargetSnapshot {
251+ const record = asRecord(value) ?? {};
252+
253+ return {
254+ client_id: connection.getClientId(),
255+ connection_id: connection.getConnectionId(),
256+ platform: readFirstString(record, ["platform"]) ?? fallbackPlatform,
257+ requested_client_id:
258+ readFirstString(record, ["requested_client_id", "requestedClientId"])
259+ ?? connection.getClientId(),
260+ requested_platform:
261+ readFirstString(record, ["requested_platform", "requestedPlatform"])
262+ ?? fallbackPlatform
263+ };
264+}
265+
266 function getLatestEndpointTimestamp(
267 updatedAt: number | null,
268 endpointMetadata: FirefoxBrowserEndpointMetadataSummary[],
269@@ -368,11 +600,13 @@ class FirefoxWebSocketConnection {
270 connectedAt: now,
271 credentials: new Map(),
272 id: randomUUID(),
273+ lastActionResult: null,
274 lastMessageAt: now,
275 nodeCategory: null,
276 nodePlatform: null,
277 nodeType: null,
278- requestHooks: new Map()
279+ requestHooks: new Map(),
280+ shellRuntime: new Map()
281 };
282
283 this.socket.setNoDelay(true);
284@@ -439,6 +673,18 @@ class FirefoxWebSocketConnection {
285 return this.session.requestHooks.get(platform) ?? null;
286 }
287
288+ updateShellRuntime(summary: BrowserBridgeShellRuntimeSnapshot): void {
289+ this.session.shellRuntime.set(summary.platform, summary);
290+ }
291+
292+ getShellRuntime(platform: string): BrowserBridgeShellRuntimeSnapshot | null {
293+ return this.session.shellRuntime.get(platform) ?? null;
294+ }
295+
296+ setLastActionResult(result: BrowserBridgeActionResultSnapshot): void {
297+ this.session.lastActionResult = result;
298+ }
299+
300 touch(): void {
301 this.session.lastMessageAt = this.server.getNextTimestampMilliseconds();
302 }
303@@ -474,17 +720,27 @@ class FirefoxWebSocketConnection {
304 last_verified_at: summary.lastVerifiedAt,
305 updated_at: summary.updatedAt
306 }));
307+ const shellRuntime = [...this.session.shellRuntime.values()]
308+ .sort((left, right) => left.platform.localeCompare(right.platform))
309+ .map((entry) => ({
310+ actual: { ...entry.actual },
311+ desired: { ...entry.desired },
312+ drift: { ...entry.drift },
313+ platform: entry.platform
314+ }));
315
316 return {
317 client_id: this.session.clientId ?? `anonymous-${this.session.id.slice(0, 8)}`,
318 connected_at: this.session.connectedAt,
319 connection_id: this.session.id,
320 credentials,
321+ last_action_result: this.session.lastActionResult,
322 last_message_at: this.session.lastMessageAt,
323 node_category: this.session.nodeCategory,
324 node_platform: this.session.nodePlatform,
325 node_type: this.session.nodeType,
326- request_hooks: requestHooks
327+ request_hooks: requestHooks,
328+ shell_runtime: shellRuntime
329 };
330 }
331
332@@ -841,6 +1097,9 @@ export class ConductorFirefoxWebSocketServer {
333 case "action_request":
334 await this.handleActionRequest(connection, message);
335 return;
336+ case "action_result":
337+ await this.handlePluginActionResult(connection, message);
338+ return;
339 case "credentials":
340 await this.handleCredentials(connection, message);
341 return;
342@@ -908,6 +1167,7 @@ export class ConductorFirefoxWebSocketServer {
343 "hello",
344 "state_request",
345 "action_request",
346+ "action_result",
347 "credentials",
348 "api_endpoints",
349 "client_log",
350@@ -1005,6 +1265,68 @@ export class ConductorFirefoxWebSocketServer {
351 }
352 }
353
354+ private async handlePluginActionResult(
355+ connection: FirefoxWebSocketConnection,
356+ message: Record<string, unknown>
357+ ): Promise<void> {
358+ const action = normalizeNonEmptyString(message.action);
359+ const requestId = readFirstString(message, ["requestId", "request_id", "id"]);
360+
361+ if (action == null || requestId == null) {
362+ this.sendError(
363+ connection,
364+ "invalid_message",
365+ "action_result requires non-empty action and requestId fields."
366+ );
367+ return;
368+ }
369+
370+ const fallbackPlatform = readFirstString(message, ["platform"]);
371+ const resultRecord = asRecord(message.result);
372+ const results = Array.isArray(message.results)
373+ ? message.results
374+ .map((entry) => normalizeActionResultItem(entry, fallbackPlatform))
375+ .filter((entry): entry is BrowserBridgeActionResultItemSnapshot => entry != null)
376+ : [];
377+ const shellRuntime = mergeShellRuntimeSnapshots(
378+ readShellRuntimeArray(message.shell_runtime ?? message.shellRuntime, fallbackPlatform),
379+ readShellRuntimeArray(resultRecord?.shell_runtime ?? resultRecord?.shellRuntime, fallbackPlatform),
380+ results
381+ .map((entry) => entry.shell_runtime)
382+ .filter((entry): entry is BrowserBridgeShellRuntimeSnapshot => entry != null)
383+ );
384+ const normalizedResult: BrowserBridgeActionResultSnapshot = {
385+ accepted: readOptionalBoolean(message, ["accepted"]) !== false,
386+ action,
387+ completed: readOptionalBoolean(message, ["completed"]) !== false,
388+ failed:
389+ readOptionalBoolean(message, ["failed"])
390+ ?? results.some((entry) => entry.ok === false),
391+ reason: readFirstString(message, ["reason", "message", "error"]),
392+ received_at: this.getNextTimestampMilliseconds(),
393+ request_id: requestId,
394+ result: buildActionResultSummary(resultRecord, results, shellRuntime),
395+ results: [...results].sort((left, right) =>
396+ String(left.platform ?? "").localeCompare(String(right.platform ?? ""))
397+ ),
398+ shell_runtime: shellRuntime,
399+ target: normalizeActionResultTarget(connection, message.target, fallbackPlatform),
400+ type:
401+ readFirstString(message, ["command_type", "commandType", "type_name", "typeName"])
402+ ?? action
403+ };
404+
405+ for (const runtime of normalizedResult.shell_runtime) {
406+ connection.updateShellRuntime(runtime);
407+ }
408+
409+ connection.setLastActionResult(normalizedResult);
410+ this.bridgeService.handleActionResult(connection.getConnectionId(), normalizedResult);
411+ await this.broadcastStateSnapshot("action_result", {
412+ force: true
413+ });
414+ }
415+
416 private async handleCredentials(
417 connection: FirefoxWebSocketConnection,
418 message: Record<string, unknown>
419@@ -1034,9 +1356,14 @@ export class ConductorFirefoxWebSocketServer {
420 headerCount: countHeaderNames(message),
421 lastSeenAt
422 };
423+ const shellRuntime = readShellRuntimeArray(message.shell_runtime, platform)[0] ?? null;
424
425 connection.updateCredential(platform, credentialSummary);
426
427+ if (shellRuntime != null) {
428+ connection.updateShellRuntime(shellRuntime);
429+ }
430+
431 const requestHook = connection.getRequestHook(platform);
432
433 if (requestHook != null) {
434@@ -1121,8 +1448,13 @@ export class ConductorFirefoxWebSocketServer {
435 nowMs
436 )
437 };
438+ const shellRuntime = readShellRuntimeArray(message.shell_runtime, platform)[0] ?? null;
439
440 connection.updateRequestHook(platform, requestHookSummary);
441+
442+ if (shellRuntime != null) {
443+ connection.updateShellRuntime(shellRuntime);
444+ }
445 await this.persistEndpointSnapshot(connection, platform, requestHookSummary);
446 await this.broadcastStateSnapshot("api_endpoints");
447 }
+272,
-22
1@@ -498,8 +498,182 @@ function parseSseFrames(text) {
2 });
3 }
4
5+function buildShellRuntime(platform, overrides = {}) {
6+ return {
7+ platform,
8+ desired: {
9+ exists: true,
10+ shell_url: "https://claude.ai/",
11+ source: "integration",
12+ reason: "test",
13+ updated_at: 1710000002000,
14+ last_action: "tab_open",
15+ last_action_at: 1710000002100
16+ },
17+ actual: {
18+ exists: true,
19+ tab_id: 321,
20+ url: "https://claude.ai/chats/http",
21+ title: "Claude HTTP",
22+ window_id: 91,
23+ active: true,
24+ status: "complete",
25+ discarded: false,
26+ hidden: false,
27+ healthy: true,
28+ issue: null,
29+ last_seen_at: 1710000002200,
30+ last_ready_at: 1710000002300,
31+ candidate_tab_id: null,
32+ candidate_url: null
33+ },
34+ drift: {
35+ aligned: true,
36+ needs_restore: false,
37+ unexpected_actual: false,
38+ reason: "aligned"
39+ },
40+ ...overrides
41+ };
42+}
43+
44+function sendPluginActionResult(socket, input) {
45+ const shellRuntime = input.shell_runtime ?? (input.platform ? [buildShellRuntime(input.platform)] : []);
46+ const results =
47+ input.results
48+ ?? shellRuntime.map((runtime) => ({
49+ ok: true,
50+ platform: runtime.platform,
51+ restored: input.restored ?? false,
52+ shell_runtime: runtime,
53+ skipped: input.skipped ?? null,
54+ tab_id: runtime.actual.tab_id
55+ }));
56+
57+ socket.send(
58+ JSON.stringify({
59+ type: "action_result",
60+ requestId: input.requestId,
61+ action: input.action,
62+ command_type: input.commandType ?? input.type ?? input.action,
63+ accepted: input.accepted ?? true,
64+ completed: input.completed ?? true,
65+ failed: input.failed ?? false,
66+ reason: input.reason ?? null,
67+ target: {
68+ platform: input.platform ?? null,
69+ requested_platform: input.platform ?? null
70+ },
71+ result: {
72+ actual_count: shellRuntime.filter((runtime) => runtime.actual.exists).length,
73+ desired_count: shellRuntime.filter((runtime) => runtime.desired.exists).length,
74+ drift_count: shellRuntime.filter((runtime) => runtime.drift.aligned === false).length,
75+ failed_count: results.filter((entry) => entry.ok === false).length,
76+ ok_count: results.filter((entry) => entry.ok).length,
77+ platform_count: shellRuntime.length,
78+ restored_count: results.filter((entry) => entry.restored === true).length,
79+ skipped_reasons: results.map((entry) => entry.skipped).filter(Boolean)
80+ },
81+ results,
82+ shell_runtime: shellRuntime
83+ })
84+ );
85+}
86+
87 function createBrowserBridgeStub() {
88 const calls = [];
89+ const buildShellRuntime = (platform = "claude", overrides = {}) => ({
90+ platform,
91+ desired: {
92+ exists: true,
93+ shell_url: "https://claude.ai/",
94+ source: "stub",
95+ reason: "test",
96+ updated_at: 1710000001200,
97+ last_action: "tab_open",
98+ last_action_at: 1710000001300
99+ },
100+ actual: {
101+ exists: true,
102+ tab_id: 88,
103+ url: "https://claude.ai/chats/stub",
104+ title: "Claude",
105+ window_id: 12,
106+ active: true,
107+ status: "complete",
108+ discarded: false,
109+ hidden: false,
110+ healthy: true,
111+ issue: null,
112+ last_seen_at: 1710000001400,
113+ last_ready_at: 1710000001500,
114+ candidate_tab_id: null,
115+ candidate_url: null
116+ },
117+ drift: {
118+ aligned: true,
119+ needs_restore: false,
120+ unexpected_actual: false,
121+ reason: "aligned"
122+ },
123+ ...overrides
124+ });
125+ const buildActionDispatch = ({
126+ action,
127+ clientId,
128+ connectionId = "conn-firefox-claude",
129+ dispatchedAt,
130+ platform = null,
131+ reason = null,
132+ type
133+ }) => {
134+ const shellRuntime = platform ? [buildShellRuntime(platform)] : [buildShellRuntime("claude")];
135+ const requestId = `${type}-stub-${calls.length + 1}`;
136+
137+ return {
138+ clientId,
139+ connectionId,
140+ dispatchedAt,
141+ requestId,
142+ result: Promise.resolve({
143+ accepted: true,
144+ action,
145+ completed: true,
146+ failed: false,
147+ reason,
148+ received_at: dispatchedAt + 25,
149+ request_id: requestId,
150+ result: {
151+ actual_count: shellRuntime.filter((entry) => entry.actual.exists).length,
152+ desired_count: shellRuntime.filter((entry) => entry.desired.exists).length,
153+ drift_count: shellRuntime.filter((entry) => entry.drift.aligned === false).length,
154+ failed_count: 0,
155+ ok_count: shellRuntime.length,
156+ platform_count: shellRuntime.length,
157+ restored_count: 0,
158+ skipped_reasons: []
159+ },
160+ results: shellRuntime.map((entry) => ({
161+ ok: true,
162+ platform: entry.platform,
163+ restored: false,
164+ shell_runtime: entry,
165+ skipped: null,
166+ tab_id: entry.actual.tab_id
167+ })),
168+ shell_runtime: shellRuntime,
169+ target: {
170+ client_id: clientId,
171+ connection_id: connectionId,
172+ platform,
173+ requested_client_id: clientId,
174+ requested_platform: platform
175+ },
176+ type
177+ }),
178+ type
179+ };
180+ };
181 const browserState = {
182 active_client_id: "firefox-claude",
183 active_connection_id: "conn-firefox-claude",
184@@ -522,6 +696,44 @@ function createBrowserBridgeStub() {
185 last_seen_at: 1710000001100
186 }
187 ],
188+ last_action_result: {
189+ accepted: true,
190+ action: "plugin_status",
191+ completed: true,
192+ failed: false,
193+ reason: null,
194+ received_at: 1710000003600,
195+ request_id: "plugin_status-stub-1",
196+ result: {
197+ actual_count: 1,
198+ desired_count: 1,
199+ drift_count: 0,
200+ failed_count: 0,
201+ ok_count: 1,
202+ platform_count: 1,
203+ restored_count: 0,
204+ skipped_reasons: []
205+ },
206+ results: [
207+ {
208+ ok: true,
209+ platform: "claude",
210+ restored: false,
211+ shell_runtime: buildShellRuntime("claude"),
212+ skipped: null,
213+ tab_id: 88
214+ }
215+ ],
216+ shell_runtime: [buildShellRuntime("claude")],
217+ target: {
218+ client_id: "firefox-claude",
219+ connection_id: "conn-firefox-claude",
220+ platform: "claude",
221+ requested_client_id: "firefox-claude",
222+ requested_platform: "claude"
223+ },
224+ type: "plugin_status"
225+ },
226 last_message_at: 1710000002000,
227 node_category: "proxy",
228 node_platform: "firefox",
229@@ -548,7 +760,8 @@ function createBrowserBridgeStub() {
230 last_verified_at: 1710000003500,
231 updated_at: 1710000003000
232 }
233- ]
234+ ],
235+ shell_runtime: [buildShellRuntime("claude")]
236 }
237 ],
238 ws_path: "/ws/firefox",
239@@ -686,12 +899,14 @@ function createBrowserBridgeStub() {
240 kind: "dispatchPluginAction"
241 });
242
243- return {
244+ return buildActionDispatch({
245+ action: input.action || "plugin_status",
246 clientId: input.clientId || "firefox-claude",
247- connectionId: "conn-firefox-claude",
248 dispatchedAt: 1710000004750,
249+ platform: input.platform || null,
250+ reason: input.reason || null,
251 type: input.action || "plugin_status"
252- };
253+ });
254 },
255 openTab(input = {}) {
256 calls.push({
257@@ -699,12 +914,13 @@ function createBrowserBridgeStub() {
258 kind: "openTab"
259 });
260
261- return {
262+ return buildActionDispatch({
263+ action: "tab_open",
264 clientId: input.clientId || "firefox-claude",
265- connectionId: "conn-firefox-claude",
266 dispatchedAt: 1710000005000,
267+ platform: input.platform || null,
268 type: "open_tab"
269- };
270+ });
271 },
272 reload(input = {}) {
273 calls.push({
274@@ -712,12 +928,14 @@ function createBrowserBridgeStub() {
275 kind: "reload"
276 });
277
278- return {
279+ return buildActionDispatch({
280+ action: input.platform ? "tab_reload" : "controller_reload",
281 clientId: input.clientId || "firefox-claude",
282- connectionId: "conn-firefox-claude",
283 dispatchedAt: 1710000006000,
284+ platform: input.platform || null,
285+ reason: input.reason || null,
286 type: "reload"
287- };
288+ });
289 },
290 requestCredentials(input = {}) {
291 calls.push({
292@@ -725,12 +943,14 @@ function createBrowserBridgeStub() {
293 kind: "requestCredentials"
294 });
295
296- return {
297+ return buildActionDispatch({
298+ action: "request_credentials",
299 clientId: input.clientId || "firefox-claude",
300- connectionId: "conn-firefox-claude",
301 dispatchedAt: 1710000007000,
302+ platform: input.platform || null,
303+ reason: input.reason || null,
304 type: "request_credentials"
305- };
306+ });
307 },
308 streamRequest(input) {
309 calls.push({
310@@ -1587,6 +1807,7 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
311 assert.doesNotMatch(JSON.stringify(controlDescribePayload.data.endpoints), /\/v1\/tasks/u);
312 assert.equal(controlDescribePayload.data.codex.target_base_url, codexd.baseUrl);
313 assert.equal(controlDescribePayload.data.browser.action_contract.route.path, "/v1/browser/actions");
314+ assert.equal(controlDescribePayload.data.browser.action_contract.response_body.accepted, "布尔值;插件是否接受了该动作。");
315 assert.equal(controlDescribePayload.data.host_operations.auth.header, "Authorization: Bearer <BAA_SHARED_TOKEN>");
316
317 const healthResponse = await handleConductorHttpRequest(
318@@ -1627,8 +1848,12 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
319 const browserStatusPayload = parseJsonBody(browserStatusResponse);
320 assert.equal(browserStatusPayload.data.bridge.client_count, 1);
321 assert.equal(browserStatusPayload.data.current_client.client_id, "firefox-claude");
322+ assert.equal(browserStatusPayload.data.current_client.shell_runtime[0].platform, "claude");
323+ assert.equal(browserStatusPayload.data.current_client.last_action_result.action, "plugin_status");
324 assert.equal(browserStatusPayload.data.claude.ready, true);
325+ assert.equal(browserStatusPayload.data.claude.shell_runtime.platform, "claude");
326 assert.equal(browserStatusPayload.data.records[0].live.credentials.account, "ops@example.com");
327+ assert.equal(browserStatusPayload.data.records[0].live.shell_runtime.platform, "claude");
328 assert.equal(browserStatusPayload.data.records[0].status, "fresh");
329
330 const browserActionsResponse = await handleConductorHttpRequest(
331@@ -1644,7 +1869,11 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
332 localApiContext
333 );
334 assert.equal(browserActionsResponse.status, 200);
335- assert.equal(parseJsonBody(browserActionsResponse).data.action, "tab_open");
336+ const browserActionPayload = parseJsonBody(browserActionsResponse);
337+ assert.equal(browserActionPayload.data.action, "tab_open");
338+ assert.equal(browserActionPayload.data.accepted, true);
339+ assert.equal(browserActionPayload.data.completed, true);
340+ assert.equal(browserActionPayload.data.shell_runtime[0].platform, "claude");
341
342 const browserPluginActionResponse = await handleConductorHttpRequest(
343 {
344@@ -1658,7 +1887,9 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
345 localApiContext
346 );
347 assert.equal(browserPluginActionResponse.status, 200);
348- assert.equal(parseJsonBody(browserPluginActionResponse).data.action, "plugin_status");
349+ const browserPluginActionPayload = parseJsonBody(browserPluginActionResponse);
350+ assert.equal(browserPluginActionPayload.data.action, "plugin_status");
351+ assert.equal(browserPluginActionPayload.data.result.platform_count, 1);
352
353 const browserRequestResponse = await handleConductorHttpRequest(
354 {
355@@ -2131,7 +2362,7 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
356 "apiRequest:GET:/api/organizations",
357 "apiRequest:GET:/api/organizations/org-1/chat_conversations",
358 "apiRequest:GET:/api/organizations/org-1/chat_conversations/conv-1",
359- "reload:-"
360+ "reload:claude"
361 ]
362 );
363 });
364@@ -2927,6 +3158,7 @@ test("ConductorRuntime exposes /v1/browser Claude HTTP routes over the local Fir
365 cookie: "session=1",
366 "x-csrf-token": "token-1"
367 },
368+ shell_runtime: buildShellRuntime("claude"),
369 timestamp: 1710000001000
370 })
371 );
372@@ -2953,7 +3185,8 @@ test("ConductorRuntime exposes /v1/browser Claude HTTP routes over the local Fir
373 first_seen_at: 1710000001200,
374 last_seen_at: 1710000002000
375 }
376- ]
377+ ],
378+ shell_runtime: buildShellRuntime("claude")
379 })
380 );
381 await client.queue.next(
382@@ -2965,11 +3198,14 @@ test("ConductorRuntime exposes /v1/browser Claude HTTP routes over the local Fir
383 const browserStatusPayload = await browserStatusResponse.json();
384 assert.equal(browserStatusPayload.data.bridge.client_count, 1);
385 assert.equal(browserStatusPayload.data.claude.ready, true);
386+ assert.equal(browserStatusPayload.data.claude.shell_runtime.platform, "claude");
387 assert.equal(browserStatusPayload.data.current_client.client_id, "firefox-claude-http");
388+ assert.equal(browserStatusPayload.data.current_client.shell_runtime[0].platform, "claude");
389 assert.equal(browserStatusPayload.data.records[0].status, "fresh");
390+ assert.equal(browserStatusPayload.data.records[0].live.shell_runtime.platform, "claude");
391 assert.equal(browserStatusPayload.data.records[0].persisted.credential_fingerprint, "fp-claude-http");
392
393- const openResponse = await fetch(`${baseUrl}/v1/browser/actions`, {
394+ const openPromise = fetch(`${baseUrl}/v1/browser/actions`, {
395 method: "POST",
396 headers: {
397 "content-type": "application/json"
398@@ -2980,11 +3216,18 @@ test("ConductorRuntime exposes /v1/browser Claude HTTP routes over the local Fir
399 platform: "claude"
400 })
401 });
402+ const openMessage = await client.queue.next((message) => message.type === "open_tab");
403+ assert.equal(openMessage.platform, "claude");
404+ sendPluginActionResult(client.socket, {
405+ action: "tab_open",
406+ platform: "claude",
407+ requestId: openMessage.requestId
408+ });
409+ const openResponse = await openPromise;
410 assert.equal(openResponse.status, 200);
411 const openPayload = await openResponse.json();
412 assert.equal(openPayload.data.action, "tab_open");
413- const openMessage = await client.queue.next((message) => message.type === "open_tab");
414- assert.equal(openMessage.platform, "claude");
415+ assert.equal(openPayload.data.accepted, true);
416
417 const sendPromise = fetch(`${baseUrl}/v1/browser/request`, {
418 method: "POST",
419@@ -3154,7 +3397,7 @@ test("ConductorRuntime exposes /v1/browser Claude HTTP routes over the local Fir
420 assert.equal(currentPayload.data.messages[0].role, "user");
421 assert.equal(currentPayload.data.messages[1].role, "assistant");
422
423- const reloadResponse = await fetch(`${baseUrl}/v1/browser/actions`, {
424+ const reloadPromise = fetch(`${baseUrl}/v1/browser/actions`, {
425 method: "POST",
426 headers: {
427 "content-type": "application/json"
428@@ -3165,10 +3408,17 @@ test("ConductorRuntime exposes /v1/browser Claude HTTP routes over the local Fir
429 reason: "http_integration_test"
430 })
431 });
432+ const reloadMessage = await client.queue.next((message) => message.type === "reload");
433+ sendPluginActionResult(client.socket, {
434+ action: "tab_reload",
435+ platform: "claude",
436+ requestId: reloadMessage.requestId,
437+ commandType: "reload"
438+ });
439+ const reloadResponse = await reloadPromise;
440 assert.equal(reloadResponse.status, 200);
441 const reloadPayload = await reloadResponse.json();
442 assert.equal(reloadPayload.data.action, "tab_reload");
443- const reloadMessage = await client.queue.next((message) => message.type === "reload");
444 assert.equal(reloadMessage.reason, "http_integration_test");
445 } finally {
446 client?.queue.stop();
+238,
-72
1@@ -41,12 +41,15 @@ import {
2 type ConductorHttpResponse
3 } from "./http-types.js";
4 import type {
5+ BrowserBridgeActionResultItemSnapshot,
6+ BrowserBridgeActionResultSnapshot,
7 BrowserBridgeApiStream,
8 BrowserBridgeApiResponse,
9 BrowserBridgeClientSnapshot,
10 BrowserBridgeController,
11 BrowserBridgeCredentialSnapshot,
12 BrowserBridgeRequestHookSnapshot,
13+ BrowserBridgeShellRuntimeSnapshot,
14 BrowserBridgeStreamEvent,
15 BrowserBridgeStateSnapshot
16 } from "./browser-types.js";
17@@ -163,6 +166,7 @@ interface BrowserMergedRecord {
18 persistedLoginState: BrowserLoginStateRecord | null;
19 platform: string;
20 requestHook: BrowserBridgeRequestHookSnapshot | null;
21+ shellRuntime: BrowserBridgeShellRuntimeSnapshot | null;
22 view: BrowserRecordView;
23 }
24
25@@ -1125,12 +1129,14 @@ interface ClaudeBrowserSelection {
26 client: BrowserBridgeClientSnapshot | null;
27 credential: BrowserBridgeCredentialSnapshot | null;
28 requestHook: BrowserBridgeRequestHookSnapshot | null;
29+ shellRuntime: BrowserBridgeShellRuntimeSnapshot | null;
30 }
31
32 interface ReadyClaudeBrowserSelection {
33 client: BrowserBridgeClientSnapshot;
34 credential: BrowserBridgeCredentialSnapshot;
35 requestHook: BrowserBridgeRequestHookSnapshot | null;
36+ shellRuntime: BrowserBridgeShellRuntimeSnapshot | null;
37 }
38
39 interface ClaudeOrganizationSummary {
40@@ -1148,13 +1154,20 @@ interface ClaudeConversationSummary {
41 }
42
43 interface BrowserActionDispatchResult {
44+ accepted: boolean;
45 action: BrowserActionName;
46 client_id: string;
47+ completed: boolean;
48 connection_id: string;
49 dispatched_at: number;
50+ failed: boolean;
51 platform: string | null;
52- reason?: string | null;
53- status: "dispatched";
54+ reason: string | null;
55+ request_id: string;
56+ result: JsonObject;
57+ results: JsonObject[];
58+ shell_runtime: JsonObject[];
59+ target: JsonObject;
60 type: string;
61 }
62
63@@ -1558,7 +1571,8 @@ function selectClaudeBrowserClient(
64 return {
65 client,
66 credential: client?.credentials.find((entry) => entry.platform === BROWSER_CLAUDE_PLATFORM) ?? null,
67- requestHook: client?.request_hooks.find((entry) => entry.platform === BROWSER_CLAUDE_PLATFORM) ?? null
68+ requestHook: client?.request_hooks.find((entry) => entry.platform === BROWSER_CLAUDE_PLATFORM) ?? null,
69+ shellRuntime: client?.shell_runtime.find((entry) => entry.platform === BROWSER_CLAUDE_PLATFORM) ?? null
70 };
71 }
72
73@@ -1583,6 +1597,13 @@ function findMatchingRequestHook(
74 return client.request_hooks.find((entry) => entry.platform === platform) ?? null;
75 }
76
77+function findMatchingShellRuntime(
78+ client: BrowserBridgeClientSnapshot,
79+ platform: string
80+): BrowserBridgeShellRuntimeSnapshot | null {
81+ return client.shell_runtime.find((entry) => entry.platform === platform) ?? null;
82+}
83+
84 function upsertBrowserMergedRecord(
85 records: Map<string, BrowserMergedRecord>,
86 record: BrowserMergedRecord
87@@ -1606,6 +1627,7 @@ function upsertBrowserMergedRecord(
88 persistedLoginState: record.persistedLoginState ?? current.persistedLoginState,
89 platform: record.platform,
90 requestHook: record.requestHook ?? current.requestHook,
91+ shellRuntime: record.shellRuntime ?? current.shellRuntime,
92 view:
93 (record.activeConnection ?? current.activeConnection) != null
94 && (record.persistedLoginState ?? current.persistedLoginState) != null
95@@ -1635,6 +1657,7 @@ function buildActiveBrowserRecords(
96 persistedLoginState: null,
97 platform: credential.platform,
98 requestHook: findMatchingRequestHook(client, credential.platform, credential.account),
99+ shellRuntime: findMatchingShellRuntime(client, credential.platform),
100 view: "active_only"
101 });
102 }
103@@ -1655,6 +1678,7 @@ function buildActiveBrowserRecords(
104 persistedLoginState: null,
105 platform: requestHook.platform,
106 requestHook,
107+ shellRuntime: findMatchingShellRuntime(client, requestHook.platform),
108 view: "active_only"
109 });
110 }
111@@ -1760,6 +1784,7 @@ async function listBrowserMergedRecords(
112 persistedLoginState: entry,
113 platform: entry.platform,
114 requestHook: records.get(buildBrowserRecordKey(entry.platform, entry.clientId, entry.account))?.requestHook ?? null,
115+ shellRuntime: records.get(buildBrowserRecordKey(entry.platform, entry.clientId, entry.account))?.shellRuntime ?? null,
116 view: "persisted_only"
117 });
118 }
119@@ -1776,6 +1801,7 @@ async function listBrowserMergedRecords(
120 persistedLoginState: records.get(buildBrowserRecordKey(entry.platform, entry.clientId, entry.account))?.persistedLoginState ?? null,
121 platform: entry.platform,
122 requestHook: records.get(buildBrowserRecordKey(entry.platform, entry.clientId, entry.account))?.requestHook ?? null,
123+ shellRuntime: records.get(buildBrowserRecordKey(entry.platform, entry.clientId, entry.account))?.shellRuntime ?? null,
124 view: "persisted_only"
125 });
126 }
127@@ -1819,7 +1845,11 @@ function serializeBrowserMergedRecord(
128 request_hooks:
129 record.requestHook == null
130 ? undefined
131- : serializeBrowserRequestHookSnapshot(record.requestHook)
132+ : serializeBrowserRequestHookSnapshot(record.requestHook),
133+ shell_runtime:
134+ record.shellRuntime == null
135+ ? undefined
136+ : serializeBrowserShellRuntimeSnapshot(record.shellRuntime)
137 }),
138 persisted:
139 record.persistedLoginState == null && record.endpointMetadata == null
140@@ -1891,6 +1921,13 @@ function createBrowserBridgeHttpError(action: string, error: unknown): LocalApiH
141 `Timed out while waiting for the browser bridge to complete ${action}.`,
142 details
143 );
144+ case "action_timeout":
145+ return new LocalApiHttpError(
146+ 504,
147+ "browser_action_timeout",
148+ `Timed out while waiting for the browser bridge to complete ${action}.`,
149+ details
150+ );
151 case "client_disconnected":
152 case "client_replaced":
153 case "send_failed":
154@@ -2031,7 +2068,8 @@ function ensureClaudeBridgeReady(
155 return {
156 client: selection.client,
157 credential: selection.credential,
158- requestHook: selection.requestHook
159+ requestHook: selection.requestHook,
160+ shellRuntime: selection.shellRuntime
161 };
162 }
163
164@@ -2069,17 +2107,108 @@ function serializeBrowserRequestHookSnapshot(snapshot: BrowserBridgeRequestHookS
165 });
166 }
167
168+function serializeBrowserShellRuntimeSnapshot(snapshot: BrowserBridgeShellRuntimeSnapshot): JsonObject {
169+ return compactJsonObject({
170+ actual: compactJsonObject({
171+ active: snapshot.actual.active ?? undefined,
172+ candidate_tab_id: snapshot.actual.candidate_tab_id ?? undefined,
173+ candidate_url: snapshot.actual.candidate_url ?? undefined,
174+ discarded: snapshot.actual.discarded ?? undefined,
175+ exists: snapshot.actual.exists,
176+ healthy: snapshot.actual.healthy ?? undefined,
177+ hidden: snapshot.actual.hidden ?? undefined,
178+ issue: snapshot.actual.issue ?? undefined,
179+ last_ready_at: snapshot.actual.last_ready_at ?? undefined,
180+ last_seen_at: snapshot.actual.last_seen_at ?? undefined,
181+ status: snapshot.actual.status ?? undefined,
182+ tab_id: snapshot.actual.tab_id ?? undefined,
183+ title: snapshot.actual.title ?? undefined,
184+ url: snapshot.actual.url ?? undefined,
185+ window_id: snapshot.actual.window_id ?? undefined
186+ }),
187+ desired: compactJsonObject({
188+ exists: snapshot.desired.exists,
189+ last_action: snapshot.desired.last_action ?? undefined,
190+ last_action_at: snapshot.desired.last_action_at ?? undefined,
191+ reason: snapshot.desired.reason ?? undefined,
192+ shell_url: snapshot.desired.shell_url ?? undefined,
193+ source: snapshot.desired.source ?? undefined,
194+ updated_at: snapshot.desired.updated_at ?? undefined
195+ }),
196+ drift: compactJsonObject({
197+ aligned: snapshot.drift.aligned,
198+ needs_restore: snapshot.drift.needs_restore,
199+ reason: snapshot.drift.reason ?? undefined,
200+ unexpected_actual: snapshot.drift.unexpected_actual
201+ }),
202+ platform: snapshot.platform
203+ });
204+}
205+
206+function serializeBrowserActionResultItemSnapshot(
207+ snapshot: BrowserBridgeActionResultItemSnapshot
208+): JsonObject {
209+ return compactJsonObject({
210+ ok: snapshot.ok,
211+ platform: snapshot.platform ?? undefined,
212+ restored: snapshot.restored ?? undefined,
213+ shell_runtime:
214+ snapshot.shell_runtime == null
215+ ? undefined
216+ : serializeBrowserShellRuntimeSnapshot(snapshot.shell_runtime),
217+ skipped: snapshot.skipped ?? undefined,
218+ tab_id: snapshot.tab_id ?? undefined
219+ });
220+}
221+
222+function serializeBrowserActionResultSnapshot(snapshot: BrowserBridgeActionResultSnapshot): JsonObject {
223+ return compactJsonObject({
224+ accepted: snapshot.accepted,
225+ action: snapshot.action,
226+ completed: snapshot.completed,
227+ failed: snapshot.failed,
228+ reason: snapshot.reason ?? undefined,
229+ received_at: snapshot.received_at,
230+ request_id: snapshot.request_id,
231+ result: compactJsonObject({
232+ actual_count: snapshot.result.actual_count,
233+ desired_count: snapshot.result.desired_count,
234+ drift_count: snapshot.result.drift_count,
235+ failed_count: snapshot.result.failed_count,
236+ ok_count: snapshot.result.ok_count,
237+ platform_count: snapshot.result.platform_count,
238+ restored_count: snapshot.result.restored_count,
239+ skipped_reasons: snapshot.result.skipped_reasons
240+ }),
241+ results: snapshot.results.map(serializeBrowserActionResultItemSnapshot),
242+ shell_runtime: snapshot.shell_runtime.map(serializeBrowserShellRuntimeSnapshot),
243+ target: compactJsonObject({
244+ client_id: snapshot.target.client_id ?? undefined,
245+ connection_id: snapshot.target.connection_id ?? undefined,
246+ platform: snapshot.target.platform ?? undefined,
247+ requested_client_id: snapshot.target.requested_client_id ?? undefined,
248+ requested_platform: snapshot.target.requested_platform ?? undefined
249+ }),
250+ type: snapshot.type
251+ });
252+}
253+
254 function serializeBrowserClientSnapshot(snapshot: BrowserBridgeClientSnapshot): JsonObject {
255 return {
256 client_id: snapshot.client_id,
257 connected_at: snapshot.connected_at,
258 connection_id: snapshot.connection_id,
259 credentials: snapshot.credentials.map(serializeBrowserCredentialSnapshot),
260+ last_action_result:
261+ snapshot.last_action_result == null
262+ ? null
263+ : serializeBrowserActionResultSnapshot(snapshot.last_action_result),
264 last_message_at: snapshot.last_message_at,
265 node_category: snapshot.node_category,
266 node_platform: snapshot.node_platform,
267 node_type: snapshot.node_type,
268- request_hooks: snapshot.request_hooks.map(serializeBrowserRequestHookSnapshot)
269+ request_hooks: snapshot.request_hooks.map(serializeBrowserRequestHookSnapshot),
270+ shell_runtime: snapshot.shell_runtime.map(serializeBrowserShellRuntimeSnapshot)
271 };
272 }
273
274@@ -2606,7 +2735,11 @@ async function readClaudeConversationCurrentData(
275 request_hooks:
276 selection.requestHook == null
277 ? undefined
278- : serializeBrowserRequestHookSnapshot(selection.requestHook)
279+ : serializeBrowserRequestHookSnapshot(selection.requestHook),
280+ shell_runtime:
281+ selection.shellRuntime == null
282+ ? undefined
283+ : serializeBrowserShellRuntimeSnapshot(selection.shellRuntime)
284 })
285 };
286 }
287@@ -2621,12 +2754,19 @@ async function buildBrowserStatusData(context: LocalApiRequestContext): Promise<
288 ?? [...browserState.clients].sort((left, right) => right.last_message_at - left.last_message_at)[0]
289 ?? null;
290 const claudeSelection = selectClaudeBrowserClient(browserState);
291+ const runtimeSnapshotsByKey = new Map<string, BrowserBridgeShellRuntimeSnapshot>();
292 const statusCounts = {
293 fresh: 0,
294 stale: 0,
295 lost: 0
296 };
297
298+ for (const client of browserState.clients) {
299+ for (const runtime of client.shell_runtime) {
300+ runtimeSnapshotsByKey.set(`${client.client_id}\u0000${runtime.platform}`, runtime);
301+ }
302+ }
303+
304 for (const record of records) {
305 const status = resolveBrowserRecordStatus(record);
306
307@@ -2660,6 +2800,10 @@ async function buildBrowserStatusData(context: LocalApiRequestContext): Promise<
308 claudeSelection.requestHook == null
309 ? null
310 : serializeBrowserRequestHookSnapshot(claudeSelection.requestHook),
311+ shell_runtime:
312+ claudeSelection.shellRuntime == null
313+ ? null
314+ : serializeBrowserShellRuntimeSnapshot(claudeSelection.shellRuntime),
315 supported: true
316 },
317 filters: summarizeBrowserFilters(filters),
318@@ -2704,6 +2848,11 @@ async function buildBrowserStatusData(context: LocalApiRequestContext): Promise<
319 active_records: records.filter((record) => record.activeConnection != null).length,
320 matched_records: records.length,
321 persisted_only_records: records.filter((record) => record.view === "persisted_only").length,
322+ runtime_counts: {
323+ actual: [...runtimeSnapshotsByKey.values()].filter((runtime) => runtime.actual.exists).length,
324+ desired: [...runtimeSnapshotsByKey.values()].filter((runtime) => runtime.desired.exists).length,
325+ drift: [...runtimeSnapshotsByKey.values()].filter((runtime) => runtime.drift.aligned === false).length
326+ },
327 status_counts: statusCounts
328 }
329 };
330@@ -2956,10 +3105,20 @@ function buildBrowserActionContract(origin: string): JsonObject {
331 request_body: {
332 action:
333 "必填字符串。当前正式支持 plugin_status、request_credentials、tab_open、tab_focus、tab_reload、tab_restore、ws_reconnect、controller_reload。",
334- platform: "tab_open、tab_focus、request_credentials、tab_restore 建议带非空平台字符串;当前正式平台仍是 claude。",
335+ platform: "tab_open、tab_focus、tab_reload、request_credentials、tab_restore 建议带非空平台字符串;当前正式平台仍是 claude。",
336 clientId: "可选字符串;指定目标 Firefox bridge client。",
337 reason: "可选字符串;request_credentials、tab_reload、tab_restore、ws_reconnect、controller_reload 会原样透传给浏览器侧。"
338 },
339+ response_body: {
340+ accepted: "布尔值;插件是否接受了该动作。",
341+ completed: "布尔值;插件是否已经完成本轮动作执行。",
342+ failed: "布尔值;若执行失败或被拒绝则为 true。",
343+ reason: "可选字符串;失败原因、跳过原因或动作补充说明。",
344+ target: "目标摘要;包含 client / connection / platform 与请求目标。",
345+ result: "结果摘要;包含 desired / actual / drift / restored / skipped 等聚合计数。",
346+ results: "逐平台结果明细;会带 tab_id、skipped、restored 和 shell_runtime。",
347+ shell_runtime: "本次动作返回的最新 runtime 快照列表。"
348+ },
349 supported_actions: [...SUPPORTED_BROWSER_ACTIONS],
350 reserved_actions: [...RESERVED_BROWSER_ACTIONS],
351 examples: [
352@@ -2981,7 +3140,8 @@ function buildBrowserActionContract(origin: string): JsonObject {
353 ],
354 error_semantics: [
355 "503 browser_bridge_unavailable: 当前没有可用 Firefox bridge client。",
356- "409 browser_client_not_found: 指定的 clientId 当前未连接。"
357+ "409 browser_client_not_found: 指定的 clientId 当前未连接。",
358+ "504 browser_action_timeout: 插件未在约定时间内回传结构化 action_result。"
359 ]
360 };
361 }
362@@ -3082,8 +3242,9 @@ function buildBrowserHttpData(snapshot: ConductorRuntimeApiSnapshot, origin: str
363 },
364 notes: [
365 "Business-facing browser work now lands on POST /v1/browser/request; browser/plugin management lands on POST /v1/browser/actions.",
366- "GET /v1/browser remains the shared read model for login-state metadata and plugin connectivity.",
367+ "GET /v1/browser remains the shared read model for login-state metadata, plugin connectivity, shell_runtime, and the latest structured action_result per client.",
368 "The generic browser HTTP surface currently supports Claude only and expects a local Firefox bridge client.",
369+ "POST /v1/browser/actions now waits for the plugin to return a structured action_result instead of returning only a dispatch ack.",
370 "POST /v1/browser/request now supports buffered JSON and formal SSE event envelopes; POST /v1/browser/request/cancel cancels an in-flight browser request by requestId.",
371 "The /v1/browser/claude/* routes remain available as legacy wrappers during the migration window."
372 ]
373@@ -4163,7 +4324,56 @@ function buildBrowserSseErrorResponse(
374 );
375 }
376
377-function dispatchBrowserAction(
378+function buildBrowserActionDispatchResult(
379+ dispatch: {
380+ clientId: string;
381+ connectionId: string;
382+ dispatchedAt: number;
383+ requestId: string;
384+ type: string;
385+ },
386+ actionResult: BrowserBridgeActionResultSnapshot,
387+ input: {
388+ action: BrowserActionName;
389+ platform?: string | null;
390+ reason?: string | null;
391+ }
392+): BrowserActionDispatchResult {
393+ return {
394+ accepted: actionResult.accepted,
395+ action: input.action,
396+ client_id: dispatch.clientId,
397+ completed: actionResult.completed,
398+ connection_id: dispatch.connectionId,
399+ dispatched_at: dispatch.dispatchedAt,
400+ failed: actionResult.failed,
401+ platform: input.platform ?? actionResult.target.platform ?? null,
402+ reason: actionResult.reason ?? input.reason ?? null,
403+ request_id: dispatch.requestId,
404+ result: compactJsonObject({
405+ actual_count: actionResult.result.actual_count,
406+ desired_count: actionResult.result.desired_count,
407+ drift_count: actionResult.result.drift_count,
408+ failed_count: actionResult.result.failed_count,
409+ ok_count: actionResult.result.ok_count,
410+ platform_count: actionResult.result.platform_count,
411+ restored_count: actionResult.result.restored_count,
412+ skipped_reasons: actionResult.result.skipped_reasons
413+ }),
414+ results: actionResult.results.map(serializeBrowserActionResultItemSnapshot),
415+ shell_runtime: actionResult.shell_runtime.map(serializeBrowserShellRuntimeSnapshot),
416+ target: compactJsonObject({
417+ client_id: actionResult.target.client_id ?? dispatch.clientId,
418+ connection_id: actionResult.target.connection_id ?? dispatch.connectionId,
419+ platform: actionResult.target.platform ?? input.platform ?? undefined,
420+ requested_client_id: actionResult.target.requested_client_id ?? undefined,
421+ requested_platform: actionResult.target.requested_platform ?? input.platform ?? undefined
422+ }),
423+ type: dispatch.type
424+ };
425+}
426+
427+async function dispatchBrowserAction(
428 context: LocalApiRequestContext,
429 input: {
430 action: BrowserActionName;
431@@ -4171,82 +4381,44 @@ function dispatchBrowserAction(
432 platform?: string | null;
433 reason?: string | null;
434 }
435-): BrowserActionDispatchResult {
436+): Promise<BrowserActionDispatchResult> {
437 try {
438 switch (input.action) {
439 case "tab_open":
440 case "tab_focus": {
441- const receipt = requireBrowserBridge(context).openTab({
442+ const dispatch = requireBrowserBridge(context).openTab({
443 clientId: input.clientId,
444 platform: input.platform
445 });
446-
447- return {
448- action: input.action,
449- client_id: receipt.clientId,
450- connection_id: receipt.connectionId,
451- dispatched_at: receipt.dispatchedAt,
452- platform: input.platform ?? null,
453- status: "dispatched",
454- type: receipt.type
455- };
456+ return buildBrowserActionDispatchResult(dispatch, await dispatch.result, input);
457 }
458 case "tab_reload": {
459- const receipt = requireBrowserBridge(context).reload({
460+ const dispatch = requireBrowserBridge(context).reload({
461 clientId: input.clientId,
462+ platform: input.platform,
463 reason: input.reason
464 });
465-
466- return {
467- action: input.action,
468- client_id: receipt.clientId,
469- connection_id: receipt.connectionId,
470- dispatched_at: receipt.dispatchedAt,
471- platform: input.platform ?? null,
472- reason: input.reason ?? null,
473- status: "dispatched",
474- type: receipt.type
475- };
476+ return buildBrowserActionDispatchResult(dispatch, await dispatch.result, input);
477 }
478 case "request_credentials": {
479- const receipt = requireBrowserBridge(context).requestCredentials({
480+ const dispatch = requireBrowserBridge(context).requestCredentials({
481 clientId: input.clientId,
482 platform: input.platform,
483 reason: input.reason
484 });
485-
486- return {
487- action: input.action,
488- client_id: receipt.clientId,
489- connection_id: receipt.connectionId,
490- dispatched_at: receipt.dispatchedAt,
491- platform: input.platform ?? null,
492- reason: input.reason ?? null,
493- status: "dispatched",
494- type: receipt.type
495- };
496+ return buildBrowserActionDispatchResult(dispatch, await dispatch.result, input);
497 }
498 case "plugin_status":
499 case "ws_reconnect":
500 case "controller_reload":
501 case "tab_restore": {
502- const receipt = requireBrowserBridge(context).dispatchPluginAction({
503+ const dispatch = requireBrowserBridge(context).dispatchPluginAction({
504 action: input.action,
505 clientId: input.clientId,
506 platform: input.platform,
507 reason: input.reason
508 });
509-
510- return {
511- action: input.action,
512- client_id: receipt.clientId,
513- connection_id: receipt.connectionId,
514- dispatched_at: receipt.dispatchedAt,
515- platform: input.platform ?? null,
516- reason: input.reason ?? null,
517- status: "dispatched",
518- type: receipt.type
519- };
520+ return buildBrowserActionDispatchResult(dispatch, await dispatch.result, input);
521 }
522 }
523 } catch (error) {
524@@ -4533,7 +4705,7 @@ async function handleBrowserActions(context: LocalApiRequestContext): Promise<Co
525 return buildSuccessEnvelope(
526 context.requestId,
527 200,
528- dispatchBrowserAction(context, {
529+ await dispatchBrowserAction(context, {
530 action,
531 clientId,
532 platform,
533@@ -4707,19 +4879,16 @@ async function handleBrowserRequestCancel(context: LocalApiRequestContext): Prom
534
535 async function handleBrowserClaudeOpen(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
536 const body = readBodyObject(context.request, true);
537- const dispatch = dispatchBrowserAction(context, {
538+ const dispatch = await dispatchBrowserAction(context, {
539 action: "tab_open",
540 clientId: readOptionalStringBodyField(body, "clientId", "client_id"),
541 platform: BROWSER_CLAUDE_PLATFORM
542 });
543
544 return buildSuccessEnvelope(context.requestId, 200, {
545- client_id: dispatch.client_id,
546- connection_id: dispatch.connection_id,
547- dispatched_at: dispatch.dispatched_at,
548+ ...dispatch,
549 open_url: BROWSER_CLAUDE_ROOT_URL,
550- platform: BROWSER_CLAUDE_PLATFORM,
551- type: dispatch.type
552+ platform: BROWSER_CLAUDE_PLATFORM
553 });
554 }
555
556@@ -4823,7 +4992,7 @@ async function handleBrowserClaudeCurrent(context: LocalApiRequestContext): Prom
557 async function handleBrowserClaudeReload(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
558 const body = readBodyObject(context.request, true);
559 const reason = readOptionalStringBodyField(body, "reason") ?? "browser_http_reload";
560- const dispatch = dispatchBrowserAction(context, {
561+ const dispatch = await dispatchBrowserAction(context, {
562 action: "tab_reload",
563 clientId: readOptionalStringBodyField(body, "clientId", "client_id"),
564 platform: BROWSER_CLAUDE_PLATFORM,
565@@ -4831,12 +5000,9 @@ async function handleBrowserClaudeReload(context: LocalApiRequestContext): Promi
566 });
567
568 return buildSuccessEnvelope(context.requestId, 200, {
569- client_id: dispatch.client_id,
570- connection_id: dispatch.connection_id,
571- dispatched_at: dispatch.dispatched_at,
572+ ...dispatch,
573 platform: BROWSER_CLAUDE_PLATFORM,
574- reason,
575- type: dispatch.type
576+ reason
577 });
578 }
579
+3,
-3
1@@ -134,7 +134,7 @@
2
3 | 方法 | 路径 | 说明 |
4 | --- | --- | --- |
5-| `GET` | `/v1/browser` | 读取活跃 Firefox bridge、插件在线状态、持久化登录态记录和 `fresh` / `stale` / `lost` 状态 |
6+| `GET` | `/v1/browser` | 读取活跃 Firefox bridge、插件在线状态、最新 `shell_runtime` / `last_action_result`、持久化登录态记录和 `fresh` / `stale` / `lost` 状态 |
7 | `POST` | `/v1/browser/actions` | 派发通用 browser/plugin 管理动作;当前正式支持 `request_credentials`、`tab_open`、`tab_focus`、`tab_reload`、`plugin_status`、`ws_reconnect`、`controller_reload`、`tab_restore` |
8 | `POST` | `/v1/browser/request` | 发起通用 browser HTTP 代发请求;当前正式支持 Claude 的 buffered 与 SSE 请求 |
9 | `POST` | `/v1/browser/request/cancel` | 取消请求或流;会向对应 Firefox client 派发 `request_cancel` |
10@@ -157,8 +157,8 @@ Browser 面约定:
11 - `POST /v1/browser/request` 要求 `platform`;若 `platform=claude` 且省略 `path`,可用 `prompt` 走 Claude completion 兼容模式
12 - `POST /v1/browser/request` 支持 `responseMode=buffered` 和 `responseMode=sse`
13 - SSE 响应固定用 `stream_open`、`stream_event`、`stream_end`、`stream_error` 作为 event name;`stream_event` 带递增 `seq`
14-- `POST /v1/browser/actions` 当前正式支持 `request_credentials`、`tab_open`、`tab_focus`、`tab_reload`、`plugin_status`、`ws_reconnect`、`controller_reload`、`tab_restore`
15-- `GET /v1/browser` 会回显当前风控默认值和运行时 target/platform 状态,便于观察抖动、限流、退避和熔断
16+- `POST /v1/browser/actions` 当前正式支持 `request_credentials`、`tab_open`、`tab_focus`、`tab_reload`、`plugin_status`、`ws_reconnect`、`controller_reload`、`tab_restore`,并返回结构化 `action_result`
17+- `GET /v1/browser` 会回显当前风控默认值、最新 `shell_runtime` / `last_action_result` 和运行时 target/platform 状态,便于观察抖动、限流、退避和熔断
18 - `/ws/firefox` 只在本地 listener 上可用,不是公网产品接口
19 - `request` 的 Claude prompt 模式和 `current` 辅助读只有在 `mini` 上已有活跃 Firefox bridge client,且 Claude 页面已捕获有效凭证和 endpoint 时才可用
20 - 如果当前没有活跃 Firefox client,会返回清晰的 `503` JSON 错误
+4,
-2
1@@ -69,7 +69,7 @@
2
3 | 方法 | 路径 | 作用 |
4 | --- | --- | --- |
5-| `GET` | `/v1/browser` | 返回 Firefox bridge 在线状态、插件摘要和浏览器登录态持久化记录 |
6+| `GET` | `/v1/browser` | 返回 Firefox bridge 在线状态、插件摘要、最新 `shell_runtime` / `last_action_result` 和浏览器登录态持久化记录 |
7 | `GET` | `/v1/system/state` | 返回当前 automation mode、leader、queue、active runs |
8
9 ### Browser / Plugin 管理
10@@ -95,6 +95,8 @@ browser/plugin 管理约定:
11 - 当前正式平台仍是 `claude`
12 - 如果没有活跃 Firefox bridge client,会返回 `503`
13 - 如果指定了不存在的 `clientId`,会返回 `409`
14+- `POST /v1/browser/actions` 会等待插件回传结构化 `action_result`,返回 `accepted` / `completed` / `failed` / `reason` / `target` / `result` / `shell_runtime`
15+- `GET /v1/browser` 会同步暴露当前 `shell_runtime` 和每个 client 最近一次结构化 `action_result`
16 - browser 业务请求不在本节;请改读 [`business-interfaces.md`](./business-interfaces.md) 和 `POST /v1/browser/request`
17
18 ### 控制动作
19@@ -228,7 +230,7 @@ curl -X POST "${BASE_URL}/v1/files/write" \
20 - 当前控制面是单节点 `mini`
21 - 控制动作默认作用于当前唯一活动节点
22 - browser/plugin 管理动作已经纳入 control;当前正式支持 `request_credentials`、`tab_open`、`tab_focus`、`tab_reload`、`plugin_status`、`ws_reconnect`、`controller_reload`、`tab_restore`
23-- 当前 `/v1/browser/actions` 返回稳定的 dispatch ack;浏览器端最终 runtime 结果仍以 `GET /v1/browser` 和插件侧状态为准
24+- 当前 `/v1/browser/actions` 返回结构化插件动作结果;`GET /v1/browser` 会继续提供最新 `shell_runtime` 和最近一次 `action_result` 读面
25 - Codex 会话能力不在本文件主讨论范围;它通过 `/v1/codex/*` 代理到独立 `codexd`
26 - 业务查询不在本文件讨论范围内
27 - 业务类接口见 [`business-interfaces.md`](./business-interfaces.md)
+70,
-2
1@@ -40,6 +40,7 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
2 | `hello` | 注册当前 Firefox client,声明 `clientId`、`nodeType`、`nodeCategory`、`nodePlatform` |
3 | `state_request` | 主动请求最新 snapshot |
4 | `action_request` | 请求执行 `pause` / `resume` / `drain` |
5+| `action_result` | 回传 browser/plugin 管理动作的结构化执行结果;带 `accepted` / `completed` / `failed` / `reason` / `result` / `shell_runtime` |
6 | `credentials` | 上送账号、凭证指纹、新鲜度和脱敏 header 名称摘要;server 只持久化最小元数据 |
7 | `api_endpoints` | 上送当前可代发的 endpoint 列表及其 `endpoint_metadata` |
8 | `api_response` | 对服务端下发的 `api_request` 回包,按 `id` 做 request-response 关联 |
9@@ -61,7 +62,7 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
10 | `api_request` | 由 server 发起、浏览器代发的 API 请求;buffered 模式回 `api_response`,SSE 模式回 `stream_*` |
11 | `request_cancel` | 请求浏览器取消当前 `api_request` 或流 |
12 | `request_credentials` | 提示浏览器重新发送 `credentials` |
13-| `reload` | 指示插件管理页重载当前 controller 页面 |
14+| `reload` | legacy 重载消息;不带 `platform` 时等价 `controller_reload`,带 `platform` 时等价 `tab_reload` |
15 | `error` | 非法 JSON、未知消息类型或未实现消息 |
16
17 ## 关键 payload
18@@ -89,7 +90,7 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
19 "wsUrl": "ws://100.71.210.78:4317/ws/firefox",
20 "localApiBase": "http://100.71.210.78:4317",
21 "supports": {
22- "inbound": ["hello", "state_request", "action_request", "credentials", "api_endpoints", "client_log", "api_response", "stream_open", "stream_event", "stream_end", "stream_error"],
23+ "inbound": ["hello", "state_request", "action_request", "action_result", "credentials", "api_endpoints", "client_log", "api_response", "stream_open", "stream_event", "stream_end", "stream_error"],
24 "outbound": ["hello_ack", "state_snapshot", "action_result", "open_tab", "plugin_status", "ws_reconnect", "controller_reload", "tab_restore", "api_request", "request_cancel", "request_credentials", "reload", "error"]
25 }
26 }
27@@ -176,6 +177,65 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
28 }
29 ```
30
31+### browser / plugin `action_result`
32+
33+当 server 通过 `open_tab` / `plugin_status` / `request_credentials` / `reload` / `tab_restore` 等正式管理消息下发动作后,浏览器会回传:
34+
35+```json
36+{
37+ "type": "action_result",
38+ "requestId": "action-browser-1",
39+ "action": "tab_restore",
40+ "command_type": "tab_restore",
41+ "accepted": true,
42+ "completed": true,
43+ "failed": false,
44+ "reason": null,
45+ "target": {
46+ "platform": "claude",
47+ "requested_platform": "claude"
48+ },
49+ "result": {
50+ "platform_count": 1,
51+ "ok_count": 1,
52+ "failed_count": 0,
53+ "restored_count": 1,
54+ "desired_count": 1,
55+ "actual_count": 1,
56+ "drift_count": 0,
57+ "skipped_reasons": []
58+ },
59+ "results": [
60+ {
61+ "platform": "claude",
62+ "ok": true,
63+ "restored": true,
64+ "tab_id": 321,
65+ "shell_runtime": {
66+ "platform": "claude",
67+ "desired": { "exists": true },
68+ "actual": { "exists": true, "tab_id": 321 },
69+ "drift": { "aligned": true, "needs_restore": false, "unexpected_actual": false, "reason": "aligned" }
70+ }
71+ }
72+ ],
73+ "shell_runtime": [
74+ {
75+ "platform": "claude",
76+ "desired": { "exists": true },
77+ "actual": { "exists": true, "tab_id": 321 },
78+ "drift": { "aligned": true, "needs_restore": false, "unexpected_actual": false, "reason": "aligned" }
79+ }
80+ ]
81+}
82+```
83+
84+server 行为:
85+
86+- 用 `requestId` 关联对应 HTTP `POST /v1/browser/actions`
87+- 把最新 `shell_runtime` 合并进当前 client 视图
88+- 把最近一次结构化 `action_result` 暴露给 `GET /v1/browser`
89+
90 ### `credentials`
91
92 ```json
93@@ -200,6 +260,7 @@ server 行为:
94
95 - 把 `account`、`credential_fingerprint`、`freshness`、`captured_at`、`last_seen_at` 写入持久化登录态记录
96 - `headers` 只用于保留名称和数量;这些值应当已经是脱敏占位符
97+- `shell_runtime` 会并入当前活跃 client 的内存 runtime 视图,并透传到 `GET /v1/browser`
98 - 不在 `state_snapshot` 或 `GET /v1/browser` 中回显原始 `cookie` / `token` / header 值
99
100 ### `api_endpoints`
101@@ -229,6 +290,7 @@ server 行为:
102 server 行为:
103
104 - 把 endpoint 列表和 `endpoint_metadata` 写入持久化端点记录
105+- 如果 payload 带 `shell_runtime`,会同步刷新该平台的当前 runtime 视图
106 - `GET /v1/browser` 会把这些持久化记录和当前活跃 WS 连接视图合并
107 - client 断开或 daemon 重启后,最近一次元数据仍可通过 `/v1/browser` 读取
108
109@@ -348,6 +410,8 @@ SSE 请求示例:
110 ```json
111 {
112 "type": "open_tab",
113+ "requestId": "action-browser-1",
114+ "action": "tab_open",
115 "platform": "claude"
116 }
117 ```
118@@ -362,6 +426,8 @@ SSE 请求示例:
119 ```json
120 {
121 "type": "request_credentials",
122+ "requestId": "action-browser-2",
123+ "action": "request_credentials",
124 "platform": "claude",
125 "reason": "hello"
126 }
127@@ -377,6 +443,8 @@ SSE 请求示例:
128 ```json
129 {
130 "type": "reload",
131+ "requestId": "action-browser-3",
132+ "action": "controller_reload",
133 "reason": "operator_requested_reload"
134 }
135 ```
+6,
-10
1@@ -9,12 +9,12 @@
2 - 主线基线:`main@07895cd`
3 - 任务文档已统一收口到 `tasks/`
4 - 当前活动任务见 `tasks/TASK_OVERVIEW.md`
5-- `T-S001` 到 `T-S024` 已经完成
6+- `T-S001` 到 `T-S025` 已经完成
7
8 ## 当前状态分类
9
10-- `已完成`:`T-S001` 到 `T-S024`
11-- `当前 TODO`:`T-S025`
12+- `已完成`:`T-S001` 到 `T-S025`
13+- `当前 TODO`:无高优先级主线任务
14 - `低优先级 TODO`:`4318/status-api` 兼容层删旧与解耦
15
16 当前新的主需求文档:
17@@ -65,7 +65,7 @@
18
19 当前策略:
20
21-- 当前主线任务是 `T-S025`:收口插件管理闭环与真实 Firefox 验收
22+- 当前没有高优先级主线 blocker
23 - 当前不把大文件拆分当作主线 blocker
24 - 以下重构工作顺延到下一轮专门重构任务:
25 - `apps/conductor-daemon/src/local-api.ts`
26@@ -108,6 +108,7 @@
27 - `T-S022`:Firefox 插件侧已完成空壳页 runtime、`desired/actual` 状态模型和插件管理类 payload 准备
28 - `T-S023`:通用 browser request / cancel / SSE 链路、`stream_*` 事件模型和首版浏览器风控策略已经接入 `conductor` 与 Firefox bridge
29 - `T-S024`:README、API / Firefox / runtime 文档、browser smoke 和任务状态视图已同步到正式主线口径
30+- `T-S025`:`shell_runtime`、结构化 `action_result` 和控制读面已接入主线;唯一残余风险是当前机器缺少 `Firefox.app`,因此未完成真实 Firefox 手工 smoke
31 - 根级 `pnpm smoke` 已进主线,覆盖 runtime public-api compatibility、legacy absence、codexd e2e 和 browser-control e2e smoke
32
33 ## 4318 依赖盘点与结论
34@@ -139,10 +140,5 @@
35 - `status-api` 的终局已经先收口到“保留为 opt-in 兼容层”;真正删除它之前,还要先清 `4318` 调用方并拆掉当前构建时复用
36 - 风控状态当前仍是进程内内存态;`conductor` 重启后,限流、退避和熔断计数会重置
37 - 正式 browser HTTP relay 当前仍只支持 Claude;其它平台目前只有空壳页和元数据链路,没有接入通用 request / SSE 合同
38-- 这轮还没跑真实 Firefox 手工 smoke,因此“手动关 tab -> tab_restore -> WS 重连后状态回报”的浏览器端闭环仍未实测
39-- `conductor` 还不会消费新增的 `shell_runtime` 字段,也没有 richer 的插件管理动作结果合同;这两项是 `T-S025` 的主收口内容
40+- 当前机器未发现 `Firefox.app`,因此尚未执行“手动关 tab -> tab_restore -> WS 重连后状态回报恢复”的真实 Firefox 手工 smoke;这是当前唯一环境型残余风险
41 - 当前多个源码文件已超过常规体量,但拆分重构已明确延后到浏览器桥接主线收口之后再做
42-
43-## 下一步任务
44-
45-- `T-S025`:收口插件管理闭环与真实 Firefox 验收
+10,
-1
1@@ -72,6 +72,15 @@ browser.runtime.sendMessage({
2 });
3 ```
4
5+通过本地 WS 接到正式管理动作时,插件还会回传结构化 `action_result`,至少包含:
6+
7+- `accepted`
8+- `completed`
9+- `failed`
10+- `reason`
11+- `results`
12+- `shell_runtime`
13+
14 读取当前 runtime:
15
16 ```js
17@@ -108,7 +117,7 @@ browser.runtime.sendMessage({
18 - `actual`
19 - `drift`
20
21-当前 `conductor` 还不会消费这部分字段,但插件已经按稳定 payload 发出,后续服务端可直接接入。
22+当前 `conductor` 已消费这部分字段,并会把它暴露到 `GET /v1/browser` 和最近一次 `action_result` 读面。
23
24 ## 明确不上报的敏感值
25
+191,
-11
1@@ -3094,22 +3094,132 @@ function normalizePluginManagementAction(value) {
2 }
3 }
4
5+function readPluginActionRequestId(message) {
6+ return trimToNull(message?.requestId) || trimToNull(message?.request_id) || trimToNull(message?.id);
7+}
8+
9+function collectPluginActionShellRuntime(results = [], platform = null) {
10+ const runtimeByPlatform = new Map();
11+
12+ for (const entry of Array.isArray(results) ? results : []) {
13+ const targetPlatform = trimToNull(entry?.platform);
14+ const shellRuntime = isRecord(entry?.shell_runtime)
15+ ? entry.shell_runtime
16+ : targetPlatform
17+ ? buildPlatformRuntimeSnapshot(targetPlatform)
18+ : null;
19+
20+ if (shellRuntime?.platform) {
21+ runtimeByPlatform.set(shellRuntime.platform, shellRuntime);
22+ }
23+ }
24+
25+ if (platform && !runtimeByPlatform.has(platform)) {
26+ runtimeByPlatform.set(platform, buildPlatformRuntimeSnapshot(platform));
27+ }
28+
29+ if (!platform && runtimeByPlatform.size === 0) {
30+ for (const target of PLATFORM_ORDER) {
31+ runtimeByPlatform.set(target, buildPlatformRuntimeSnapshot(target));
32+ }
33+ }
34+
35+ return [...runtimeByPlatform.values()].sort((left, right) =>
36+ String(left?.platform || "").localeCompare(String(right?.platform || ""))
37+ );
38+}
39+
40+function buildPluginActionResultPayload(actionResult, options = {}) {
41+ const requestId = trimToNull(options.requestId) || trimToNull(actionResult?.requestId);
42+ if (!requestId) return null;
43+
44+ const action = normalizePluginManagementAction(options.action || actionResult?.action)
45+ || trimToNull(options.action)
46+ || trimToNull(actionResult?.action)
47+ || "plugin_status";
48+ const requestedPlatform = trimToNull(options.platform || actionResult?.platform);
49+ const normalizedResults = (Array.isArray(actionResult?.results) ? actionResult.results : []).map((entry) => {
50+ const targetPlatform = trimToNull(entry?.platform);
51+ const shellRuntime = isRecord(entry?.shell_runtime)
52+ ? entry.shell_runtime
53+ : targetPlatform
54+ ? buildPlatformRuntimeSnapshot(targetPlatform)
55+ : null;
56+
57+ return {
58+ ok: entry?.ok !== false,
59+ platform: targetPlatform,
60+ restored: typeof entry?.restored === "boolean" ? entry.restored : null,
61+ shell_runtime: shellRuntime,
62+ skipped: trimToNull(entry?.skipped) || null,
63+ tab_id: Number.isInteger(entry?.tabId) ? entry.tabId : Number.isInteger(entry?.tab_id) ? entry.tab_id : null
64+ };
65+ });
66+ const shellRuntime = collectPluginActionShellRuntime(normalizedResults, requestedPlatform);
67+ const skippedReasons = [...new Set(normalizedResults.map((entry) => entry.skipped).filter(Boolean))].sort((left, right) =>
68+ String(left).localeCompare(String(right))
69+ );
70+
71+ return {
72+ type: "action_result",
73+ requestId,
74+ action,
75+ command_type: trimToNull(options.commandType) || trimToNull(actionResult?.commandType) || action,
76+ accepted: options.accepted !== false,
77+ completed: options.completed !== false,
78+ failed: options.failed === true || normalizedResults.some((entry) => entry.ok === false),
79+ reason: trimToNull(options.reason) || trimToNull(actionResult?.reason) || null,
80+ target: {
81+ platform: requestedPlatform,
82+ requested_client_id: state.clientId,
83+ requested_platform: requestedPlatform
84+ },
85+ result: {
86+ actual_count: shellRuntime.filter((entry) => entry?.actual?.exists).length,
87+ desired_count: shellRuntime.filter((entry) => entry?.desired?.exists).length,
88+ drift_count: shellRuntime.filter((entry) => entry?.drift?.aligned === false).length,
89+ failed_count: normalizedResults.filter((entry) => entry.ok === false).length,
90+ ok_count: normalizedResults.filter((entry) => entry.ok).length,
91+ platform_count: new Set([
92+ ...normalizedResults.map((entry) => entry.platform).filter(Boolean),
93+ ...shellRuntime.map((entry) => entry.platform).filter(Boolean)
94+ ]).size,
95+ restored_count: normalizedResults.filter((entry) => entry.restored === true).length,
96+ skipped_reasons: skippedReasons
97+ },
98+ results: normalizedResults,
99+ shell_runtime: shellRuntime
100+ };
101+}
102+
103+function sendPluginActionResult(actionResult, options = {}) {
104+ const payload = buildPluginActionResultPayload(actionResult, options);
105+ if (!payload) return false;
106+ return wsSend(payload);
107+}
108+
109 function extractPluginManagementMessage(message) {
110 const messageType = String(message?.type || "").trim().toLowerCase();
111 const platform = trimToNull(message?.platform);
112+ const explicitAction = normalizePluginManagementAction(message?.action);
113+ const requestId = readPluginActionRequestId(message);
114
115 if (messageType === "open_tab") {
116 return {
117- action: platform ? "tab_focus" : "tab_open",
118+ action: explicitAction || (platform ? "tab_focus" : "tab_open"),
119+ commandType: "open_tab",
120 platform,
121+ requestId,
122 source: "ws_open_tab"
123 };
124 }
125
126 if (messageType === "reload") {
127 return {
128- action: "controller_reload",
129- platform: null,
130+ action: explicitAction || (platform ? "tab_reload" : "controller_reload"),
131+ commandType: "reload",
132+ platform,
133+ requestId,
134 source: "ws_reload"
135 };
136 }
137@@ -3118,16 +3228,19 @@ function extractPluginManagementMessage(message) {
138 if (directAction) {
139 return {
140 action: directAction,
141+ commandType: messageType,
142 platform,
143+ requestId,
144 source: "ws_direct"
145 };
146 }
147
148- const action = normalizePluginManagementAction(message?.action);
149- if (action && ["plugin_action", "browser_action", "tab_action", "action_request"].includes(messageType)) {
150+ if (explicitAction && ["plugin_action", "browser_action", "tab_action", "action_request"].includes(messageType)) {
151 return {
152- action,
153+ action: explicitAction,
154+ commandType: messageType,
155 platform,
156+ requestId,
157 source: messageType
158 };
159 }
160@@ -3184,8 +3297,10 @@ async function runPluginManagementAction(action, options = {}) {
161 break;
162 case "ws_reconnect":
163 addLog("info", "正在重连本地 WS", false);
164- closeWsConnection();
165- connectWs({ silentWhenDisabled: true });
166+ setTimeout(() => {
167+ closeWsConnection();
168+ connectWs({ silentWhenDisabled: true });
169+ }, 80);
170 break;
171 case "controller_reload":
172 setControllerRuntimeState({
173@@ -3311,10 +3426,19 @@ async function runPluginManagementAction(action, options = {}) {
174 addLog("info", `插件动作 ${methodName} 已执行`, false);
175 }
176
177+ const normalizedResults = results.map((entry) => ({
178+ ...entry,
179+ shell_runtime: isRecord(entry?.shell_runtime)
180+ ? entry.shell_runtime
181+ : entry?.platform
182+ ? buildPlatformRuntimeSnapshot(entry.platform)
183+ : null
184+ }));
185+
186 return {
187 action: methodName,
188 platform: trimToNull(options.platform),
189- results,
190+ results: normalizedResults,
191 snapshot: buildPluginStatusPayload()
192 };
193 }
194@@ -3729,8 +3853,30 @@ function connectWs(options = {}) {
195 platform: pluginAction.platform,
196 source: pluginAction.source,
197 reason: trimToNull(message.reason) || "ws_plugin_action"
198+ }).then((result) => {
199+ sendPluginActionResult(result, {
200+ action: pluginAction.action,
201+ commandType: pluginAction.commandType,
202+ platform: pluginAction.platform,
203+ requestId: pluginAction.requestId
204+ });
205 }).catch((error) => {
206- addLog("error", `插件动作 ${pluginAction.action} 失败:${error.message}`, false);
207+ const messageText = error instanceof Error ? error.message : String(error);
208+ addLog("error", `插件动作 ${pluginAction.action} 失败:${messageText}`, false);
209+ sendPluginActionResult({
210+ action: pluginAction.action,
211+ platform: pluginAction.platform,
212+ results: []
213+ }, {
214+ accepted: true,
215+ action: pluginAction.action,
216+ commandType: pluginAction.commandType,
217+ completed: true,
218+ failed: true,
219+ platform: pluginAction.platform,
220+ reason: messageText,
221+ requestId: pluginAction.requestId
222+ });
223 });
224 return;
225 }
226@@ -3804,7 +3950,41 @@ function connectWs(options = {}) {
227 break;
228 }
229 case "request_credentials":
230- sendCredentialSnapshot(message.platform || null, true);
231+ try {
232+ const requestedPlatform = trimToNull(message.platform);
233+ sendCredentialSnapshot(requestedPlatform || null, true);
234+ sendPluginActionResult({
235+ action: "request_credentials",
236+ platform: requestedPlatform,
237+ results: getTargetPlatforms(requestedPlatform).map((target) => ({
238+ ok: true,
239+ platform: target,
240+ shell_runtime: buildPlatformRuntimeSnapshot(target)
241+ }))
242+ }, {
243+ action: "request_credentials",
244+ commandType: "request_credentials",
245+ platform: requestedPlatform,
246+ requestId: readPluginActionRequestId(message)
247+ });
248+ } catch (error) {
249+ const messageText = error instanceof Error ? error.message : String(error);
250+ addLog("error", `刷新凭证快照失败:${messageText}`, false);
251+ sendPluginActionResult({
252+ action: "request_credentials",
253+ platform: trimToNull(message.platform),
254+ results: []
255+ }, {
256+ accepted: true,
257+ action: "request_credentials",
258+ commandType: "request_credentials",
259+ completed: true,
260+ failed: true,
261+ platform: trimToNull(message.platform),
262+ reason: messageText,
263+ requestId: readPluginActionRequestId(message)
264+ });
265+ }
266 break;
267 case "error":
268 setWsState({
+40,
-1
1@@ -23,7 +23,10 @@
2
3 ## 当前状态
4
5-- `TODO`
6+- `已完成(2026-03-26)`
7+- `2026-03-26`:代码闭环已接通;`shell_runtime`、结构化 `action_result`、`/v1/browser` 与 `/describe/control` 已同步落地。
8+- `2026-03-26`:自动化验证已通过,包括 `apps/conductor-daemon/src/index.test.js` 和 `tests/browser/browser-control-e2e-smoke.test.mjs`。
9+- `2026-03-26`:真实 Firefox 手工 smoke 在当前机器上受阻;未发现 `Firefox.app`(已检查 `/Applications` 与 `~/Applications`),因此无法在本环境完成“真实 Firefox 手工验收”。
10
11 ## 建议分支名
12
13@@ -146,6 +149,42 @@
14 - `pnpm -C /Users/george/code/baa-conductor smoke`
15 - `git -C /Users/george/code/baa-conductor diff --check`
16
17+## 实施结果
18+
19+- `conductor-daemon` 现在会从 Firefox 插件 `credentials`、`api_endpoints` 和 `action_result` payload 中消费 `shell_runtime`,并把它挂进当前 client 视图、Claude 读面和 merged browser records。
20+- `POST /v1/browser/actions` 不再只返回 dispatch ack;现在会等待插件回传结构化 `action_result`,并统一暴露:
21+ - `accepted`
22+ - `completed`
23+ - `failed`
24+ - `reason`
25+ - `target`
26+ - `result`
27+ - `results`
28+ - `shell_runtime`
29+- Firefox 插件侧现在会为正式 browser/plugin 管理动作回传结构化 `action_result`;legacy `reload` 也会按是否带 `platform` 区分 `controller_reload` 与 `tab_reload`。
30+- `/v1/browser` 现已带出:
31+ - `current_client.shell_runtime`
32+ - `current_client.last_action_result`
33+ - `claude.shell_runtime`
34+ - `records[].live.shell_runtime`
35+
36+## 自动化验证
37+
38+- `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build`
39+- `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js`
40+- `node --test /Users/george/code/baa-conductor/tests/browser/browser-control-e2e-smoke.test.mjs`
41+
42+## 真实 Firefox 手工 Smoke 记录
43+
44+- 日期:`2026-03-26`
45+- 目标步骤:
46+ - 手动关闭平台 tab
47+ - `tab_restore`
48+ - WS 重连
49+ - 状态回报恢复
50+- 结果:当前环境阻塞,未执行
51+- 阻塞原因:本机未发现可启动的 `Firefox.app`(已检查 `/Applications`、`~/Applications` 和 Spotlight `org.mozilla.firefox` bundle id),因此无法在这台机器上完成真实 Firefox 手工 smoke。
52+
53 ## 交付要求
54
55 完成后请说明:
+6,
-9
1@@ -13,8 +13,8 @@
2
3 ## 状态分类
4
5-- `已完成`:`T-S001` 到 `T-S024`
6-- `当前 TODO`:`T-S025`
7+- `已完成`:`T-S001` 到 `T-S025`
8+- `当前 TODO`:无高优先级主线任务
9 - `低优先级 TODO`:`4318/status-api` 兼容层删旧与解耦
10
11 当前新的主需求文档:
12@@ -50,6 +50,7 @@
13 22. [`T-S022.md`](./T-S022.md):实现 Firefox 空壳页 runtime 与插件管理动作
14 23. [`T-S023.md`](./T-S023.md):打通通用 browser request / cancel / SSE 链路与 `conductor` 风控策略
15 24. [`T-S024.md`](./T-S024.md):回写正式文档、补 browser smoke 并同步主线状态
16+25. [`T-S025.md`](./T-S025.md):收口插件管理闭环与真实 Firefox 验收
17
18 当前主线已经额外收口:
19
20@@ -62,7 +63,7 @@
21
22 ## 当前活动任务
23
24-- [`T-S025.md`](./T-S025.md):收口插件管理闭环与真实 Firefox 验收
25+- 当前没有高优先级活动任务卡;如需继续推进,直接新开后续任务
26
27 ## 当前主线收口情况
28
29@@ -76,6 +77,7 @@
30 6. [`T-S022.md`](./T-S022.md):已完成,实现 Firefox 空壳页 runtime 与插件管理动作
31 7. [`T-S023.md`](./T-S023.md):已完成,通用 browser request / cancel / SSE 链路和首版风控策略已接入主线
32 8. [`T-S024.md`](./T-S024.md):已完成,README / docs / smoke / 状态视图已经同步到正式口径
33+9. [`T-S025.md`](./T-S025.md):已完成,`shell_runtime`、结构化 `action_result` 和控制读面已接入;唯一剩余风险是当前机器缺少 `Firefox.app`,未完成真实手工 smoke
34
35 建议并行关系:
36
37@@ -91,12 +93,7 @@
38 - 风控状态当前仍是进程内内存态;`conductor` 重启后,限流、退避和熔断计数会重置
39 - 正式 browser HTTP relay 当前仍只支持 Claude;其它平台目前只有空壳页和元数据链路,没有接入通用 request / SSE 合同
40 - runtime smoke 仍依赖仓库根已有 `state/`、`runs/`、`worktrees/`、`logs/launchd/`、`logs/codexd/`、`tmp/` 等本地运行目录;这是现有脚本前提,不是本轮功能回归
41-- 这轮还没跑真实 Firefox 手工 smoke,因此“手动关 tab -> `tab_restore` -> WS 重连后状态回报”的浏览器端闭环仍未实测
42-- `conductor` 还不会消费新增的 `shell_runtime` 字段,也没有 richer 的插件管理动作结果合同;这两项是 `T-S025` 的主收口内容
43-
44-下一步主线任务:
45-
46-1. [`T-S025.md`](./T-S025.md):收口插件管理闭环与真实 Firefox 验收
47+- 当前机器未发现 `Firefox.app`,因此尚未执行“手动关 tab -> `tab_restore` -> WS 重连后状态回报恢复”的真实 Firefox 手工 smoke;这是当前唯一残余风险
48
49 ## 低优先级 TODO
50
+150,
-11
1@@ -218,6 +218,88 @@ function assertNoSecretLeak(text, secrets) {
2 }
3 }
4
5+function buildShellRuntime(platform, overrides = {}) {
6+ return {
7+ platform,
8+ desired: {
9+ exists: true,
10+ shell_url: "https://claude.ai/",
11+ source: "smoke",
12+ reason: "smoke_test",
13+ updated_at: 1710000002000,
14+ last_action: "tab_open",
15+ last_action_at: 1710000002100
16+ },
17+ actual: {
18+ exists: true,
19+ tab_id: 321,
20+ url: "https://claude.ai/chats/smoke",
21+ title: "Smoke Claude",
22+ window_id: 91,
23+ active: true,
24+ status: "complete",
25+ discarded: false,
26+ hidden: false,
27+ healthy: true,
28+ issue: null,
29+ last_seen_at: 1710000002200,
30+ last_ready_at: 1710000002300,
31+ candidate_tab_id: null,
32+ candidate_url: null
33+ },
34+ drift: {
35+ aligned: true,
36+ needs_restore: false,
37+ unexpected_actual: false,
38+ reason: "aligned"
39+ },
40+ ...overrides
41+ };
42+}
43+
44+function sendPluginActionResult(socket, input) {
45+ const shellRuntime = input.shell_runtime ?? (input.platform ? [buildShellRuntime(input.platform)] : []);
46+ const results =
47+ input.results
48+ ?? shellRuntime.map((runtime) => ({
49+ ok: true,
50+ platform: runtime.platform,
51+ restored: input.restored ?? false,
52+ shell_runtime: runtime,
53+ skipped: input.skipped ?? null,
54+ tab_id: runtime.actual.tab_id
55+ }));
56+
57+ socket.send(
58+ JSON.stringify({
59+ type: "action_result",
60+ requestId: input.requestId,
61+ action: input.action,
62+ command_type: input.commandType ?? input.type ?? input.action,
63+ accepted: input.accepted ?? true,
64+ completed: input.completed ?? true,
65+ failed: input.failed ?? false,
66+ reason: input.reason ?? null,
67+ target: {
68+ platform: input.platform ?? null,
69+ requested_platform: input.platform ?? null
70+ },
71+ result: {
72+ actual_count: shellRuntime.filter((runtime) => runtime.actual.exists).length,
73+ desired_count: shellRuntime.filter((runtime) => runtime.desired.exists).length,
74+ drift_count: shellRuntime.filter((runtime) => runtime.drift.aligned === false).length,
75+ failed_count: results.filter((entry) => entry.ok === false).length,
76+ ok_count: results.filter((entry) => entry.ok).length,
77+ platform_count: shellRuntime.length,
78+ restored_count: results.filter((entry) => entry.restored === true).length,
79+ skipped_reasons: results.map((entry) => entry.skipped).filter(Boolean)
80+ },
81+ results,
82+ shell_runtime: shellRuntime
83+ })
84+ );
85+}
86+
87 test("browser control e2e smoke covers metadata read surface plus Claude relay", async () => {
88 const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-control-e2e-smoke-"));
89 const runtime = new ConductorRuntime(
90@@ -267,6 +349,7 @@ test("browser control e2e smoke covers metadata read surface plus Claude relay",
91 cookie: "session=1",
92 "x-csrf-token": "csrf-smoke"
93 },
94+ shell_runtime: buildShellRuntime("claude"),
95 timestamp: 1710000001000
96 })
97 );
98@@ -293,7 +376,8 @@ test("browser control e2e smoke covers metadata read surface plus Claude relay",
99 first_seen_at: 1710000001200,
100 last_seen_at: 1710000002000
101 }
102- ]
103+ ],
104+ shell_runtime: buildShellRuntime("claude")
105 })
106 );
107 await client.queue.next(
108@@ -305,8 +389,10 @@ test("browser control e2e smoke covers metadata read surface plus Claude relay",
109 assert.equal(browserStatus.payload.data.bridge.client_count, 1);
110 assert.equal(browserStatus.payload.data.current_client.client_id, "firefox-browser-control-smoke");
111 assert.equal(browserStatus.payload.data.claude.ready, true);
112+ assert.equal(browserStatus.payload.data.claude.shell_runtime.platform, "claude");
113 assert.equal(browserStatus.payload.data.records[0].view, "active_and_persisted");
114 assert.equal(browserStatus.payload.data.records[0].live.credentials.header_count, 3);
115+ assert.equal(browserStatus.payload.data.records[0].live.shell_runtime.platform, "claude");
116 assert.equal(browserStatus.payload.data.records[0].persisted.credential_fingerprint, "fp-smoke-claude");
117 assert.deepEqual(browserStatus.payload.data.records[0].persisted.endpoints, [
118 "GET /api/organizations",
119@@ -316,7 +402,7 @@ test("browser control e2e smoke covers metadata read surface plus Claude relay",
120 assert.equal(browserStatus.payload.data.summary.status_counts.fresh, 1);
121 assertNoSecretLeak(browserStatus.text, ["csrf-smoke", "session=1"]);
122
123- const openResult = await fetchJson(`${baseUrl}/v1/browser/claude/open`, {
124+ const openResultPromise = fetchJson(`${baseUrl}/v1/browser/claude/open`, {
125 method: "POST",
126 headers: {
127 "content-type": "application/json"
128@@ -325,14 +411,21 @@ test("browser control e2e smoke covers metadata read surface plus Claude relay",
129 client_id: "firefox-browser-control-smoke"
130 })
131 });
132- assert.equal(openResult.response.status, 200);
133- assert.equal(openResult.payload.data.platform, "claude");
134- assert.equal(openResult.payload.data.client_id, "firefox-browser-control-smoke");
135
136 const openMessage = await client.queue.next((message) => message.type === "open_tab");
137 assert.equal(openMessage.platform, "claude");
138+ sendPluginActionResult(client.socket, {
139+ action: "tab_open",
140+ platform: "claude",
141+ requestId: openMessage.requestId
142+ });
143+ const openResult = await openResultPromise;
144+ assert.equal(openResult.response.status, 200);
145+ assert.equal(openResult.payload.data.platform, "claude");
146+ assert.equal(openResult.payload.data.client_id, "firefox-browser-control-smoke");
147+ assert.equal(openResult.payload.data.accepted, true);
148
149- const pluginStatusResult = await fetchJson(`${baseUrl}/v1/browser/actions`, {
150+ const pluginStatusPromise = fetchJson(`${baseUrl}/v1/browser/actions`, {
151 method: "POST",
152 headers: {
153 "content-type": "application/json"
154@@ -342,15 +435,23 @@ test("browser control e2e smoke covers metadata read surface plus Claude relay",
155 client_id: "firefox-browser-control-smoke"
156 })
157 });
158- assert.equal(pluginStatusResult.response.status, 200);
159- assert.equal(pluginStatusResult.payload.data.action, "plugin_status");
160
161 const pluginStatusMessage = await client.queue.next(
162 (message) => message.type === "plugin_status"
163 );
164 assert.equal(pluginStatusMessage.type, "plugin_status");
165+ sendPluginActionResult(client.socket, {
166+ action: "plugin_status",
167+ platform: "claude",
168+ requestId: pluginStatusMessage.requestId,
169+ type: "plugin_status"
170+ });
171+ const pluginStatusResult = await pluginStatusPromise;
172+ assert.equal(pluginStatusResult.response.status, 200);
173+ assert.equal(pluginStatusResult.payload.data.action, "plugin_status");
174+ assert.equal(pluginStatusResult.payload.data.result.platform_count, 1);
175
176- const tabRestoreResult = await fetchJson(`${baseUrl}/v1/browser/actions`, {
177+ const tabRestorePromise = fetchJson(`${baseUrl}/v1/browser/actions`, {
178 method: "POST",
179 headers: {
180 "content-type": "application/json"
181@@ -362,14 +463,52 @@ test("browser control e2e smoke covers metadata read surface plus Claude relay",
182 reason: "smoke-test"
183 })
184 });
185- assert.equal(tabRestoreResult.response.status, 200);
186- assert.equal(tabRestoreResult.payload.data.action, "tab_restore");
187
188 const tabRestoreMessage = await client.queue.next(
189 (message) => message.type === "tab_restore"
190 );
191 assert.equal(tabRestoreMessage.platform, "claude");
192 assert.equal(tabRestoreMessage.reason, "smoke-test");
193+ sendPluginActionResult(client.socket, {
194+ action: "tab_restore",
195+ platform: "claude",
196+ requestId: tabRestoreMessage.requestId,
197+ restored: true,
198+ shell_runtime: [
199+ buildShellRuntime("claude", {
200+ desired: {
201+ exists: true,
202+ shell_url: "https://claude.ai/",
203+ source: "smoke",
204+ reason: "smoke_test",
205+ updated_at: 1710000002400,
206+ last_action: "tab_restore",
207+ last_action_at: 1710000002400
208+ },
209+ actual: {
210+ exists: true,
211+ tab_id: 654,
212+ url: "https://claude.ai/chats/restored",
213+ title: "Restored Claude",
214+ window_id: 91,
215+ active: false,
216+ status: "complete",
217+ discarded: false,
218+ hidden: false,
219+ healthy: true,
220+ issue: null,
221+ last_seen_at: 1710000002450,
222+ last_ready_at: 1710000002460,
223+ candidate_tab_id: null,
224+ candidate_url: null
225+ }
226+ })
227+ ]
228+ });
229+ const tabRestoreResult = await tabRestorePromise;
230+ assert.equal(tabRestoreResult.response.status, 200);
231+ assert.equal(tabRestoreResult.payload.data.action, "tab_restore");
232+ assert.equal(tabRestoreResult.payload.data.result.restored_count, 1);
233
234 const browserStreamPromise = fetchText(`${baseUrl}/v1/browser/request`, {
235 method: "POST",