baa-conductor

git clone 

commit
6391ada
parent
f8e2593
author
im_wower
date
2026-03-29 03:43:04 +0800 CST
feat: forward plugin diagnostic logs to conductor
7 files changed,  +351, -10
M apps/conductor-daemon/src/firefox-ws.ts
+70, -0
  1@@ -53,6 +53,7 @@ interface FirefoxWebSocketServerOptions {
  2   ingestLogDir?: string | null;
  3   instructionIngest?: BaaLiveInstructionIngest | null;
  4   now?: () => number;
  5+  pluginDiagnosticLogDir?: string | null;
  6   repository: ControlPlaneRepository;
  7   snapshotLoader: () => ConductorRuntimeSnapshot;
  8 }
  9@@ -116,6 +117,25 @@ function normalizeBrowserLoginStatus(value: unknown): BrowserBridgeLoginStatus |
 10   }
 11 }
 12 
 13+function normalizeDiagnosticLogLevel(value: unknown): "debug" | "error" | "info" | "warn" | null {
 14+  if (typeof value !== "string") {
 15+    return null;
 16+  }
 17+
 18+  switch (value.trim().toLowerCase()) {
 19+    case "debug":
 20+      return "debug";
 21+    case "error":
 22+      return "error";
 23+    case "info":
 24+      return "info";
 25+    case "warn":
 26+      return "warn";
 27+    default:
 28+      return null;
 29+  }
 30+}
 31+
 32 function asRecord(value: unknown): Record<string, unknown> | null {
 33   if (value === null || typeof value !== "object" || Array.isArray(value)) {
 34     return null;
 35@@ -990,6 +1010,7 @@ export class ConductorFirefoxWebSocketServer {
 36   private readonly ingestLogDir: string | null;
 37   private readonly instructionIngest: BaaLiveInstructionIngest | null;
 38   private readonly now: () => number;
 39+  private readonly pluginDiagnosticLogDir: string | null;
 40   private readonly repository: ControlPlaneRepository;
 41   private readonly snapshotLoader: () => ConductorRuntimeSnapshot;
 42   private readonly connections = new Set<FirefoxWebSocketConnection>();
 43@@ -1004,6 +1025,7 @@ export class ConductorFirefoxWebSocketServer {
 44     this.ingestLogDir = options.ingestLogDir ?? null;
 45     this.instructionIngest = options.instructionIngest ?? null;
 46     this.now = options.now ?? (() => Math.floor(Date.now() / 1000));
 47+    this.pluginDiagnosticLogDir = options.pluginDiagnosticLogDir ?? null;
 48     this.repository = options.repository;
 49     this.snapshotLoader = options.snapshotLoader;
 50     const commandBroker = new FirefoxCommandBroker({
 51@@ -1199,6 +1221,9 @@ export class ConductorFirefoxWebSocketServer {
 52         return;
 53       case "client_log":
 54         return;
 55+      case "plugin_diagnostic_log":
 56+        this.handlePluginDiagnosticLog(connection, message);
 57+        return;
 58       case "browser.final_message":
 59         await this.handleBrowserFinalMessage(connection, message);
 60         return;
 61@@ -1265,6 +1290,7 @@ export class ConductorFirefoxWebSocketServer {
 62           "credentials",
 63           "api_endpoints",
 64           "client_log",
 65+          "plugin_diagnostic_log",
 66           "browser.final_message",
 67           "api_response",
 68           "stream_open",
 69@@ -1702,6 +1728,50 @@ export class ConductorFirefoxWebSocketServer {
 70     }
 71   }
 72 
 73+  private handlePluginDiagnosticLog(
 74+    connection: FirefoxWebSocketConnection,
 75+    message: Record<string, unknown>
 76+  ): void {
 77+    const level = normalizeDiagnosticLogLevel(message.level);
 78+    const text = readFirstString(message, ["text"]);
 79+
 80+    if (level == null || text == null) {
 81+      return;
 82+    }
 83+
 84+    const rawTimestamp = readFirstString(message, ["ts", "timestamp"]);
 85+    const parsedTimestamp = rawTimestamp == null ? Number.NaN : Date.parse(rawTimestamp);
 86+    const timestamp = Number.isNaN(parsedTimestamp)
 87+      ? new Date(this.getNextTimestampMilliseconds()).toISOString()
 88+      : new Date(parsedTimestamp).toISOString();
 89+
 90+    this.writePluginDiagnosticLog({
 91+      client_id: readFirstString(message, ["client_id", "clientId"]) ?? connection.getClientId(),
 92+      level,
 93+      text,
 94+      ts: timestamp,
 95+      type: "plugin_diagnostic_log"
 96+    });
 97+  }
 98+
 99+  private writePluginDiagnosticLog(entry: Record<string, unknown>): void {
100+    if (this.pluginDiagnosticLogDir == null) {
101+      return;
102+    }
103+
104+    try {
105+      const timestamp = readFirstString(entry, ["ts"]);
106+      const parsedTimestamp = timestamp == null ? Number.NaN : Date.parse(timestamp);
107+      const date = Number.isNaN(parsedTimestamp)
108+        ? new Date().toISOString().slice(0, 10)
109+        : new Date(parsedTimestamp).toISOString().slice(0, 10);
110+      const filePath = join(this.pluginDiagnosticLogDir, `${date}.jsonl`);
111+      appendFileSync(filePath, JSON.stringify(entry) + "\n");
112+    } catch (error) {
113+      console.error(`[baa-plugin-log] write failed: ${String(error)}`);
114+    }
115+  }
116+
117   private handleApiResponse(
118     connection: FirefoxWebSocketConnection,
119     message: Record<string, unknown>
M apps/conductor-daemon/src/index.test.js
+67, -0
 1@@ -5683,6 +5683,73 @@ test("ConductorRuntime exposes /v1/browser Claude HTTP routes over the local Fir
 2   }
 3 });
 4 
 5+test("ConductorRuntime writes plugin diagnostic logs received over the Firefox bridge", async () => {
 6+  const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-plugin-log-state-"));
 7+  const logsDir = mkdtempSync(join(tmpdir(), "baa-conductor-plugin-log-output-"));
 8+  const runtime = new ConductorRuntime(
 9+    {
10+      nodeId: "mini-main",
11+      host: "mini",
12+      role: "primary",
13+      controlApiBase: "https://control.example.test",
14+      localApiBase: "http://127.0.0.1:0",
15+      sharedToken: "replace-me",
16+      paths: {
17+        logsDir,
18+        runsDir: "/tmp/runs",
19+        stateDir
20+      }
21+    },
22+    {
23+      autoStartLoops: false,
24+      now: () => 100
25+    }
26+  );
27+
28+  let client = null;
29+
30+  try {
31+    const snapshot = await runtime.start();
32+    client = await connectFirefoxBridgeClient(snapshot.controlApi.firefoxWsUrl, "firefox-plugin-log");
33+
34+    client.socket.send(
35+      JSON.stringify({
36+        type: "plugin_diagnostic_log",
37+        ts: "2026-03-29T03:30:00.000Z",
38+        level: "info",
39+        text: "[PAGE] interceptor_active platform=chatgpt tab=17 / source=page-interceptor",
40+        client_id: "firefox-plugin-log"
41+      })
42+    );
43+
44+    const logPath = join(logsDir, "baa-plugin", "2026-03-29.jsonl");
45+    const entry = await waitForCondition(async () => {
46+      assert.equal(existsSync(logPath), true);
47+      const lines = readFileSync(logPath, "utf8").trim().split("\n");
48+      assert.equal(lines.length, 1);
49+      return JSON.parse(lines[0]);
50+    });
51+
52+    assert.equal(entry.type, "plugin_diagnostic_log");
53+    assert.equal(entry.ts, "2026-03-29T03:30:00.000Z");
54+    assert.equal(entry.level, "info");
55+    assert.equal(entry.text, "[PAGE] interceptor_active platform=chatgpt tab=17 / source=page-interceptor");
56+    assert.equal(entry.client_id, "firefox-plugin-log");
57+  } finally {
58+    client?.queue.stop();
59+    client?.socket.close(1000, "done");
60+    await runtime.stop();
61+    rmSync(logsDir, {
62+      force: true,
63+      recursive: true
64+    });
65+    rmSync(stateDir, {
66+      force: true,
67+      recursive: true
68+    });
69+  }
70+});
71+
72 test("ConductorRuntime persists browser metadata across disconnect and restart without leaking raw credentials", async () => {
73   const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-browser-persistence-"));
74   const createRuntime = () =>
M apps/conductor-daemon/src/index.ts
+20, -4
 1@@ -749,7 +749,8 @@ class ConductorLocalHttpServer {
 2     artifactInlineThreshold: number,
 3     artifactSummaryLength: number,
 4     browserRequestPolicyOptions: BrowserRequestPolicyControllerOptions = {},
 5-    ingestLogDir: string | null = null
 6+    ingestLogDir: string | null = null,
 7+    pluginDiagnosticLogDir: string | null = null
 8   ) {
 9     this.artifactStore = artifactStore;
10     this.browserRequestPolicy = new BrowserRequestPolicyController(browserRequestPolicyOptions);
11@@ -797,6 +798,7 @@ class ConductorLocalHttpServer {
12       ingestLogDir,
13       instructionIngest,
14       now: this.now,
15+      pluginDiagnosticLogDir,
16       repository: this.repository,
17       snapshotLoader: this.snapshotLoader
18     });
19@@ -1606,13 +1608,25 @@ function resolvePathConfig(paths?: Partial<ConductorRuntimePaths>): ConductorRun
20 }
21 
22 function resolveIngestLogDir(logsDir: string | null): string | null {
23+  return resolveLogSubdir(logsDir, "baa-ingest", "baa-ingest-log");
24+}
25+
26+function resolvePluginDiagnosticLogDir(logsDir: string | null): string | null {
27+  return resolveLogSubdir(logsDir, "baa-plugin", "baa-plugin-log");
28+}
29+
30+function resolveLogSubdir(
31+  logsDir: string | null,
32+  subdir: string,
33+  label: string
34+): string | null {
35   const base = logsDir ?? "logs";
36-  const dir = join(base, "baa-ingest");
37+  const dir = join(base, subdir);
38 
39   try {
40     mkdirSync(dir, { recursive: true });
41   } catch (error) {
42-    console.error(`[baa-ingest-log] failed to create log directory ${dir}: ${String(error)}`);
43+    console.error(`[${label}] failed to create log directory ${dir}: ${String(error)}`);
44     return null;
45   }
46 
47@@ -2171,6 +2185,7 @@ export class ConductorRuntime {
48       now: this.now
49     });
50     const ingestLogDir = resolveIngestLogDir(this.config.paths.logsDir);
51+    const pluginDiagnosticLogDir = resolvePluginDiagnosticLogDir(this.config.paths.logsDir);
52     this.localApiServer =
53       this.config.localApiBase == null
54         ? null
55@@ -2188,7 +2203,8 @@ export class ConductorRuntime {
56             this.config.artifactInlineThreshold,
57             this.config.artifactSummaryLength,
58             options.browserRequestPolicyOptions,
59-            ingestLogDir
60+            ingestLogDir,
61+            pluginDiagnosticLogDir
62           );
63 
64     // D1 sync worker — silently skipped when env vars are not set.
M plugins/baa-firefox/content-script.js
+31, -0
 1@@ -28,6 +28,20 @@ function trimToNull(value) {
 2   return normalized === "" ? null : normalized;
 3 }
 4 
 5+function sendDiagnosticLog(eventName, detail = {}) {
 6+  const normalizedEventName = trimToNull(eventName);
 7+  if (!normalizedEventName) {
 8+    return;
 9+  }
10+
11+  sendBridgeMessage("baa_diagnostic_log", {
12+    ...(detail || {}),
13+    event: normalizedEventName,
14+    source: trimToNull(detail?.source) || "content-script",
15+    url: trimToNull(detail?.url) || location.href
16+  });
17+}
18+
19 function normalizeMode(value) {
20   const normalized = String(value || "").trim().toLowerCase();
21 
22@@ -820,6 +834,17 @@ function handleProxyResponse(event) {
23   sendBridgeMessage("baa_page_proxy_response", event.detail);
24 }
25 
26+function handleDiagnosticEvent(event) {
27+  const detail = event?.detail && typeof event.detail === "object" ? event.detail : {};
28+  const eventName = trimToNull(detail.event);
29+  if (!eventName) {
30+    return;
31+  }
32+
33+  try { console.log("[BAA-CS]", "diagnostic", eventName, detail.platform, detail.method, detail.url?.slice(0, 120)); } catch (_) {}
34+  sendDiagnosticLog(eventName, detail);
35+}
36+
37 function handleRuntimeMessage(message) {
38   if (!message || typeof message !== "object") return undefined;
39 
40@@ -849,6 +874,7 @@ function handleRuntimeMessage(message) {
41 window.addEventListener("__baa_ready__", handlePageReady);
42 window.addEventListener("__baa_net__", handlePageNetwork);
43 window.addEventListener("__baa_sse__", handlePageSse);
44+window.addEventListener("__baa_diagnostic__", handleDiagnosticEvent);
45 window.addEventListener("__baa_proxy_response__", handleProxyResponse);
46 browser.runtime.onMessage.addListener(handleRuntimeMessage);
47 
48@@ -859,12 +885,17 @@ sendBridgeMessage("baa_page_bridge_ready", {
49   url: location.href,
50   source: "content-script"
51 });
52+sendDiagnosticLog("page_bridge_ready", {
53+  source: "content-script",
54+  url: location.href
55+});
56 
57 const contentScriptRuntime = {
58   dispose() {
59     window.removeEventListener("__baa_ready__", handlePageReady);
60     window.removeEventListener("__baa_net__", handlePageNetwork);
61     window.removeEventListener("__baa_sse__", handlePageSse);
62+    window.removeEventListener("__baa_diagnostic__", handleDiagnosticEvent);
63     window.removeEventListener("__baa_proxy_response__", handleProxyResponse);
64     browser.runtime.onMessage.removeListener(handleRuntimeMessage);
65     pageControlOverlayRuntime?.dispose?.();
M plugins/baa-firefox/controller.js
+111, -0
  1@@ -79,6 +79,8 @@ const FORBIDDEN_PROXY_HEADER_NAMES = new Set([
  2   "referer",
  3   "user-agent"
  4 ]);
  5+const DIAGNOSTIC_LOG_DEBUG_PREFIXES = ["[FM-", "[SSE]"];
  6+const DIAGNOSTIC_LOG_DEBUG_EVENT_RE = /\b(page_bridge_ready|interceptor_active|fetch_intercepted|sse_stream_start|sse_stream_done)\b/u;
  7 
  8 function hostnameMatches(hostname, hosts) {
  9   return hosts.some((host) => hostname === host || hostname.endsWith(`.${host}`));
 10@@ -2205,6 +2207,10 @@ function addLog(level, text, sendRemote = true) {
 11   if (state.logs.length > LOG_LIMIT) state.logs.shift();
 12   render();
 13 
 14+  try {
 15+    sendPluginDiagnosticLog(level, text);
 16+  } catch (_) {}
 17+
 18   if (sendRemote) {
 19     wsSend({
 20       type: "client_log",
 21@@ -2215,6 +2221,41 @@ function addLog(level, text, sendRemote = true) {
 22   }
 23 }
 24 
 25+function shouldForwardDiagnosticLog(level, text) {
 26+  const normalizedLevel = trimToNull(level);
 27+  const normalizedText = trimToNull(text);
 28+
 29+  if (!normalizedLevel || !normalizedText) {
 30+    return false;
 31+  }
 32+
 33+  switch (normalizedLevel.toLowerCase()) {
 34+    case "error":
 35+    case "warn":
 36+    case "info":
 37+      return true;
 38+    case "debug":
 39+      return DIAGNOSTIC_LOG_DEBUG_PREFIXES.some((prefix) => normalizedText.startsWith(prefix))
 40+        || DIAGNOSTIC_LOG_DEBUG_EVENT_RE.test(normalizedText);
 41+    default:
 42+      return false;
 43+  }
 44+}
 45+
 46+function sendPluginDiagnosticLog(level, text) {
 47+  if (!shouldForwardDiagnosticLog(level, text)) {
 48+    return false;
 49+  }
 50+
 51+  return wsSend({
 52+    type: "plugin_diagnostic_log",
 53+    ts: new Date().toISOString(),
 54+    level,
 55+    text,
 56+    client_id: trimToNull(state.clientId)
 57+  });
 58+}
 59+
 60 function normalizePath(url) {
 61   try {
 62     const parsed = new URL(url);
 63@@ -5600,6 +5641,73 @@ function getObservedPagePlatform(sender, fallbackPlatform = null) {
 64   return detectPlatformFromUrl(senderUrl) || fallbackPlatform || null;
 65 }
 66 
 67+function buildPageDiagnosticLogText(data, sender, context = null) {
 68+  const eventName = trimToNull(data?.event);
 69+
 70+  if (!eventName) {
 71+    return null;
 72+  }
 73+
 74+  const platform = context?.platform
 75+    || getObservedPagePlatform(sender, trimToNull(data?.platform) || null)
 76+    || "unknown";
 77+  const tabId = Number.isInteger(context?.tabId)
 78+    ? context.tabId
 79+    : (Number.isInteger(sender?.tab?.id) ? sender.tab.id : null);
 80+  const method = trimToNull(data?.method);
 81+  const source = trimToNull(data?.source) || "page";
 82+  const url = trimToNull(data?.url) || context?.senderUrl || sender?.tab?.url || "";
 83+  const parts = [`[PAGE] ${eventName}`, `platform=${platform}`];
 84+
 85+  if (tabId != null) {
 86+    parts.push(`tab=${tabId}`);
 87+  }
 88+
 89+  if (method) {
 90+    parts.push(method);
 91+  }
 92+
 93+  if (url) {
 94+    parts.push(normalizePath(url));
 95+  }
 96+
 97+  parts.push(`source=${source}`);
 98+
 99+  if (eventName === "sse_stream_done") {
100+    const duration = Number.isFinite(data?.duration) ? Math.max(0, Math.round(data.duration)) : null;
101+    parts.push(`duration=${duration == null ? "-" : duration}ms`);
102+  }
103+
104+  switch (eventName) {
105+    case "page_bridge_ready":
106+    case "interceptor_active":
107+    case "fetch_intercepted":
108+    case "sse_stream_start":
109+    case "sse_stream_done":
110+      return parts.join(" ");
111+    default:
112+      return null;
113+  }
114+}
115+
116+function handlePageDiagnosticLog(data, sender) {
117+  const senderUrl = sender?.tab?.url || data?.url || "";
118+  const context = getSenderContext(sender, detectPlatformFromUrl(senderUrl) || trimToNull(data?.platform) || null);
119+
120+  if (context) {
121+    syncPageControlFromContext(context, {
122+      conversationId: extractObservedConversationId(context.platform, data, context)
123+    });
124+  }
125+
126+  const text = buildPageDiagnosticLogText(data, sender, context);
127+  if (!text) {
128+    return;
129+  }
130+
131+  addLog("debug", text, false);
132+}
133+
134 function getObservedPageConversationId(context, pageControl) {
135   return trimToNull(context?.conversationId) || trimToNull(pageControl?.conversationId) || null;
136 }
137@@ -6885,6 +6993,9 @@ function registerRuntimeListeners() {
138       case "baa_page_sse":
139         handlePageSse(message.data, sender);
140         break;
141+      case "baa_diagnostic_log":
142+        handlePageDiagnosticLog(message.data, sender);
143+        break;
144       case "baa_page_proxy_response":
145         handlePageProxyResponse(message.data, sender);
146         break;
M plugins/baa-firefox/page-interceptor.js
+30, -2
 1@@ -191,6 +191,13 @@
 2     emit("__baa_sse__", detail, rule);
 3   }
 4 
 5+  function emitDiagnostic(event, detail = {}, rule = pageRule) {
 6+    emit("__baa_diagnostic__", {
 7+      event,
 8+      ...detail
 9+    }, rule);
10+  }
11+
12   function isForbiddenProxyHeader(name) {
13     const lower = String(name || "").toLowerCase();
14     return lower === "accept-encoding"
15@@ -211,6 +218,10 @@
16   }, pageRule);
17 
18   try { console.log("[BAA]", "interceptor_active", pageRule.platform, location.href.slice(0, 120)); } catch (_) {}
19+  emitDiagnostic("interceptor_active", {
20+    source: "page-interceptor",
21+    url: location.href
22+  }, pageRule);
23 
24   function trimBodyValue(body) {
25     try {
26@@ -255,6 +266,11 @@
27 
28   async function streamSse(url, method, requestBody, response, startedAt, rule) {
29     try { console.log("[BAA]", "sse_stream_start", method, url.slice(0, 120)); } catch (_) {}
30+    emitDiagnostic("sse_stream_start", {
31+      method,
32+      source: "page-interceptor",
33+      url
34+    }, rule);
35     try {
36       const clone = response.clone();
37       if (!clone.body) return;
38@@ -293,14 +309,21 @@
39         }, rule);
40       }
41 
42-      try { console.log("[BAA]", "sse_stream_done", method, url.slice(0, 120), "duration=" + (Date.now() - startedAt) + "ms"); } catch (_) {}
43+      const duration = Date.now() - startedAt;
44+      try { console.log("[BAA]", "sse_stream_done", method, url.slice(0, 120), "duration=" + duration + "ms"); } catch (_) {}
45+      emitDiagnostic("sse_stream_done", {
46+        duration,
47+        method,
48+        source: "page-interceptor",
49+        url
50+      }, rule);
51       emitSse({
52         url,
53         method,
54         reqBody: requestBody,
55         done: true,
56         ts: Date.now(),
57-        duration: Date.now() - startedAt
58+        duration
59       }, rule);
60     } catch (error) {
61       emitSse({
62@@ -656,6 +679,11 @@
63     const startedAt = Date.now();
64     const reqHeaders = readHeaders(init && init.headers ? init.headers : (input instanceof Request ? input.headers : null));
65     const reqBody = await readRequestBody(input, init);
66+    emitDiagnostic("fetch_intercepted", {
67+      method,
68+      source: "page-interceptor",
69+      url
70+    }, context.rule);
71 
72     try {
73       const response = await originalFetch.apply(this, arguments);
M tasks/T-S054.md
+22, -4
 1@@ -2,7 +2,7 @@
 2 
 3 ## 状态
 4 
 5-- 当前状态:`待开始`
 6+- 当前状态:`已完成`
 7 - 规模预估:`S`
 8 - 依赖任务:`T-S053`(已完成)
 9 - 建议执行者:`Claude`(需要理解插件 WS 协议和 conductor 日志写入)
10@@ -141,21 +141,39 @@ content-script.js 中的 `[BAA-CS]` 日志和 page-interceptor.js 中的 `[BAA]`
11 
12 ### 开始执行
13 
14-- 执行者:
15-- 开始时间:
16+- 执行者:Codex
17+- 开始时间:2026-03-29
18 - 状态变更:`待开始` → `进行中`
19 
20 ### 完成摘要
21 
22-- 完成时间:
23+- 完成时间:2026-03-29
24 - 状态变更:`进行中` → `已完成`
25 - 修改了哪些文件:
26+  - `plugins/baa-firefox/controller.js` — `addLog` 增加 `plugin_diagnostic_log` 转发,筛选 debug 级别关键事件,新增 `baa_diagnostic_log` 处理与页面诊断文本格式化
27+  - `plugins/baa-firefox/content-script.js` — 新增 `baa_diagnostic_log` runtime 消息发送,桥接 `__baa_diagnostic__`,并在 content-script 就绪时上报 `page_bridge_ready`
28+  - `plugins/baa-firefox/page-interceptor.js` — 新增 `__baa_diagnostic__` CustomEvent,转发 `interceptor_active`、`fetch_intercepted`、`sse_stream_start`、`sse_stream_done`
29+  - `apps/conductor-daemon/src/firefox-ws.ts` — 接收 `plugin_diagnostic_log`,按消息时间写入 `logs/baa-plugin/YYYY-MM-DD.jsonl`
30+  - `apps/conductor-daemon/src/index.ts` — conductor 启动时创建 `logs/baa-plugin` 目录并注入 Firefox WS server
31+  - `apps/conductor-daemon/src/index.test.js` — 增加 WS 诊断日志落盘测试
32 - 核心实现思路:
33+  - 插件端采用 `page-interceptor -> content-script -> controller -> WS` 的桥接链路,把 MAIN world 里的关键诊断事件带回后台页
34+  - controller 保持现有内存日志,同时在 `addLog` 内按级别筛选并发送 `plugin_diagnostic_log`,debug 只放行 final-message、SSE 生命周期和页面桥接关键事件
35+  - conductor 端把 `plugin_diagnostic_log` 规范化后按天追加写入 JSONL;写失败只打 stderr,不影响主流程
36 - 跑了哪些测试:
37+  - `pnpm install`
38+  - `pnpm build`
39+  - `pnpm -C apps/conductor-daemon test`
40+  - `node --check plugins/baa-firefox/controller.js`
41+  - `node --check plugins/baa-firefox/content-script.js`
42+  - `node --check plugins/baa-firefox/page-interceptor.js`
43 
44 ### 执行过程中遇到的问题
45 
46 > 记录执行过程中遇到的阻塞、环境问题、临时绕过方案等。合并时由合并者判断是否需要修复或建新任务。
47 
48+- worktree 初始未安装依赖,`pnpm build` 时 `pnpm exec tsc` 报 `tsc not found`;执行 `pnpm install` 后恢复正常。
49+
50 ### 剩余风险
51 
52+- 自动化验证覆盖了 WS 落盘与 daemon 行为,但插件重载后在真实 Firefox + ChatGPT 页面上的手动验证仍需按任务文档走一遍,确认 `interceptor_active` / `sse_stream_*` / final-message 相关事件都能稳定落盘。