baa-conductor

git clone 

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
M apps/conductor-daemon/src/browser-types.ts
+97, -4
  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 }
M apps/conductor-daemon/src/firefox-bridge.ts
+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   }
M apps/conductor-daemon/src/firefox-ws.ts
+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   }
M apps/conductor-daemon/src/index.test.js
+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();
M apps/conductor-daemon/src/local-api.ts
+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 
M docs/api/README.md
+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 错误
M docs/api/control-interfaces.md
+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)
M docs/api/firefox-local-ws.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 ```
M plans/STATUS_SUMMARY.md
+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 验收
M plugins/baa-firefox/README.md
+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 
M plugins/baa-firefox/controller.js
+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({
M tasks/T-S025.md
+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 完成后请说明:
M tasks/TASK_OVERVIEW.md
+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 
M tests/browser/browser-control-e2e-smoke.test.mjs
+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",