baa-conductor

git clone 

commit
85a27b6
parent
00dc4be
author
codex@macbookpro
date
2026-03-27 17:26:34 +0800 CST
feat: add browser final message relay and baa instruction center
30 files changed,  +2637, -58
M PROGRESS/2026-03-27-current-code-progress.md
+66, -19
  1@@ -2,12 +2,12 @@
  2 
  3 ## 结论摘要
  4 
  5-- 当前“已提交功能代码基线”仍可按 `main@25be868` 理解,主题是 `restore managed firefox shell tabs on startup`;在当前本地代码里又额外补上了 `BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 修复。
  6-- 当前浏览器桥接主线已经完成到“Firefox 本地 WS bridge + Claude / ChatGPT 代发 + 结构化 action_result + shell_runtime + 登录态持久化”的阶段。
  7+- 当前“已提交功能代码基线”仍可按 `main@25be868` 理解,主题是 `restore managed firefox shell tabs on startup`;在当前本地代码里又额外补上了 `BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 修复,以及 `T-S029`、`T-S030` 的功能落地。
  8+- 当前浏览器桥接主线已经完成到“Firefox 本地 WS bridge + Claude / ChatGPT 代发 + ChatGPT / Gemini 最终消息 raw relay + 结构化 action_result + shell_runtime + 登录态持久化”的阶段。
  9 - 代码和自动化测试都表明:`/describe/business`、`/describe/control`、`GET /v1/browser`、`POST /v1/browser/actions`、`POST /v1/browser/request`、`POST /v1/browser/request/cancel` 已经形成正式主链路。
 10-- 目前不应再把系统描述成“只有 Claude 专用页面路径”;当前应表述为“通用 browser surface 已落地,正式 relay 已覆盖 Claude 和 ChatGPT,Gemini 仍停留在空壳页和元数据链路”。
 11-- 当前仍不能写成“全部收尾完成”。剩余未闭项主要是:真实 Firefox 手工 smoke 未完成、风控状态仍是进程内内存态、ChatGPT 仍依赖真实浏览器登录态 / header 且没有 Claude 风格 prompt shortcut,以及 Gemini relay 仍留在下一波。
 12-- 此前拆出的后续任务卡里,`T-S027`、`T-S028` 已完成;当前主要剩余 `T-S026`。
 13+- 目前不应再把系统描述成“只有 Claude / ChatGPT request relay”;当前更准确的表述是“通用 browser surface 已落地,正式 request relay 已覆盖 Claude 和 ChatGPT,ChatGPT / Gemini 的 `browser.final_message` raw relay 也已接通,但这层仍只是最终消息中继,不等于 Gemini 正式 request relay 已转正”。
 14+- 当前仍不能写成“全部收尾完成”。剩余未闭项主要是:真实 Firefox 手工 smoke 未完成、风控状态仍是进程内内存态、ChatGPT 仍依赖真实浏览器登录态 / header 且没有 Claude 风格 prompt shortcut、ChatGPT / Gemini 最终消息提取仍有平台特定边界,以及 `browser.final_message` 尚未持久化也未直接接入 instruction parser。
 15+- 此前拆出的后续任务卡里,`T-S027`、`T-S028`、`T-S029`、`T-S030` 已完成;当前主要剩余 `T-S026`。
 16 
 17 ## 本次核对依据
 18 
 19@@ -26,9 +26,9 @@
 20 - `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build`
 21   - 结果:通过
 22 - `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js`
 23-  - 结果:`35/35` 通过
 24+  - 结果:`39/39` 通过
 25 - `node --test /Users/george/code/baa-conductor/tests/browser/browser-control-e2e-smoke.test.mjs`
 26-  - 结果:`3/3` 通过
 27+  - 结果:`6/6` 通过
 28 
 29 ## 当前已完成功能
 30 
 31@@ -109,6 +109,44 @@
 32   - `../docs/api/README.md`
 33   - `../docs/api/business-interfaces.md`
 34 
 35+### 12. `browser.final_message` raw relay 已接入 ChatGPT / Gemini
 36+
 37+- Firefox 插件当前会在最终 assistant message 完成后发出统一 `browser.final_message`。
 38+- `conductor-daemon` 当前会最小兼容接收这类消息,并把最近快照挂到 `GET /v1/browser -> current_client.final_messages[]`。
 39+- ChatGPT / Gemini 都已有 automated smoke:
 40+  - ChatGPT:等待 stream 完成后再上报,并抑制重复
 41+  - Gemini:只在最终文本拼接完成后上报,并保留 synthetic `assistant_message_id` 兜底
 42+- 证据:
 43+  - `../plugins/baa-firefox/final-message.js`
 44+  - `../plugins/baa-firefox/controller.js`
 45+  - `../apps/conductor-daemon/src/firefox-ws.ts`
 46+  - `../apps/conductor-daemon/src/browser-types.ts`
 47+  - `../tests/browser/browser-control-e2e-smoke.test.mjs`
 48+
 49+### 13. BAA instruction center Phase 1 已落地
 50+
 51+- `apps/conductor-daemon/src/instructions/` 已新增:
 52+  - `extract.ts`
 53+  - `parse.ts`
 54+  - `normalize.ts`
 55+  - `dedupe.ts`
 56+  - `policy.ts`
 57+  - `router.ts`
 58+  - `executor.ts`
 59+  - `loop.ts`
 60+- 当前已通过 synthetic assistant message 跑通:
 61+  - `extract -> parse -> normalize -> dedupe -> policy -> route -> execute`
 62+- 当前最小工具集已覆盖:
 63+  - `@conductor::describe`
 64+  - `@conductor::status`
 65+  - `@conductor::exec`
 66+  - `@conductor::files/read`
 67+  - `@conductor::files/write`
 68+- 证据:
 69+  - `../apps/conductor-daemon/src/instructions/`
 70+  - `../apps/conductor-daemon/src/index.ts`
 71+  - `../apps/conductor-daemon/src/index.test.js`
 72+
 73 ### 4. 浏览器风控策略已在 `conductor-daemon` 内实现
 74 
 75 - 默认策略已实装,不只是文档约定:
 76@@ -231,38 +269,47 @@
 77 - 当前不能把“已修复 bug”误写成“所有残余风险都已消失”。
 78 - 当前没有 open bug 卡,但仍保留若干非 bug 型残余风险和后续增强项。
 79 
 80-### 3. Gemini 仍未进入正式 browser relay 合同
 81+### 3. Gemini 已接入最终消息 raw relay,但提取仍是启发式
 82+
 83+- Gemini 当前已能通过 `browser.final_message` 上报最终 assistant message。
 84+- 但它的最终文本提取基于 `StreamGenerate` / `batchexecute` 风格 payload 的启发式解析,稳定性弱于 ChatGPT。
 85+- 当前保留 synthetic `assistant_message_id` 兜底,因此这层应表述为“已接通 raw relay,但平台提取稳定性仍有边界”,不是“Gemini 整个平台能力都已转正”。
 86+
 87+### 4. `browser.final_message` 当前只做最小兼容接收
 88 
 89-- `gemini` 在插件侧已有平台定义、空壳页和部分模板逻辑。
 90-- 但本轮正式验收和文档转正只覆盖 Claude 与 ChatGPT。
 91-- 因此当前应表述为:
 92-  - Claude:正式 relay 已可用,且保留 prompt shortcut / legacy helper
 93-  - ChatGPT:正式 relay 已可用,但仅限显式 `path` 的 raw request / SSE / cancel
 94-  - Gemini:仍停留在空壳页和元数据路径,留待下一波
 95+- `conductor-daemon` 当前已经 live 接收 `browser.final_message`,并保留最近快照。
 96+- 但这轮还没有把它落到持久化表,也没有直接接到 instruction parser / execution loop。
 97+- 因此当前更准确的口径是“消息中继和快照已落地,自动执行接线仍留在下一阶段”。
 98 
 99-### 4. 风控状态仍是进程内内存态
100+### 5. 风控状态仍是进程内内存态
101 
102 - 默认策略本身已实现。
103 - 但策略运行态计数并未持久化,进程重启后限流 / 退避 / 熔断状态会重置。
104 
105-### 5. stale `inFlight` 自愈已落地,但仍保留极长静默请求误判风险
106+### 6. stale `inFlight` 自愈已落地,但仍保留极长静默请求误判风险
107 
108 - 当前代码已经能在后台定期 sweep 明显 stale 的 lease,并让同一 `target` 的 waiter 继续推进。
109 - 这轮为避免误杀,默认只在 `5min` 无活跃更新后才回收 slot,并依赖 `lease.touch(...)` 标记关键活跃点。
110 - 如果未来出现“健康但长时间完全静默”的超长 buffered 请求,理论上仍存在被保守阈值误判的风险;当前文档里应把它写成残余边界,而不是“完全没有自愈”。
111 
112-### 6. `ws_reconnect` 的自动化验证还不是 Firefox 真实 reconnect 生命周期验收
113+### 7. `ws_reconnect` 的自动化验证还不是 Firefox 真实 reconnect 生命周期验收
114 
115 - 当前 smoke 覆盖的是 conductor 侧 `action_result` 语义透传。
116 - 真实 Firefox 扩展运行环境里的 reconnect 生命周期本身,仍依赖后续 `hello` / 状态同步来体现“真正重连完成”。
117 - 这一层现有设计未扩改,因此仍建议在真实 Firefox 环境里补手工 smoke。
118 
119-### 7. ChatGPT 已转正,但仍保留平台前提边界
120+### 8. ChatGPT 已转正,但仍保留平台前提边界
121 
122 - ChatGPT 现在已正式接入 `/v1/browser/request` 的显式 `path` buffered / SSE / cancel。
123 - 但它仍依赖浏览器里真实捕获到的有效登录态 / header,不是“无前提可用”的平台。
124 - 另外,ChatGPT 也没有 Claude 风格的 prompt shortcut;当前正式支持面仍是 raw relay,不是 prompt helper。
125 
126+### 9. instruction dedupe 仍是内存态,且只覆盖本机精确 target
127+
128+- Phase 1 instruction center 当前已能稳定生成 dedupe key,并避免同一条 assistant message replay 重复执行。
129+- 但 dedupe 目前仍是进程内内存态,进程重启后不会保留。
130+- 当前路由也只做本机精确 target,跨节点和多轮闭环还没接。
131+
132 ## 已拆出的后续任务
133 
134 - `T-S026`:真实 Firefox 手工 smoke 与验收记录
135@@ -284,4 +331,4 @@
136 
137 如果只写一段给外部协作者看,可以用下面这版:
138 
139-> 当前代码已经完成单节点 `mini` 主接口收口,以及 Firefox 本地 bridge 下的 Claude / ChatGPT browser relay 主链路。`GET /v1/browser`、`POST /v1/browser/actions`、`POST /v1/browser/request`、`POST /v1/browser/request/cancel`、正式 SSE、结构化 `action_result`、`shell_runtime`、登录态元数据持久化,以及 `BUG-012` 的 stale `inFlight` 自愈清扫都已落地;`BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 也已在当前代码中修复,并已通过 `conductor-daemon build`、`index.test.js`(35/35)和 browser-control e2e smoke(3/3)。当前剩余缺口主要是:真实 Firefox 手工 smoke 未完成、Gemini relay 尚未正式化,以及风控运行态仍是进程内内存态。
140+> 当前代码已经完成单节点 `mini` 主接口收口,以及 Firefox 本地 bridge 下的 Claude / ChatGPT request relay 主链路、ChatGPT / Gemini 最终消息 raw relay 和 conductor 侧 BAA instruction center Phase 1。`GET /v1/browser`、`POST /v1/browser/actions`、`POST /v1/browser/request`、`POST /v1/browser/request/cancel`、正式 SSE、结构化 `action_result`、`shell_runtime`、登录态元数据持久化、`browser.final_message` 最近快照,以及 `BUG-012` 的 stale `inFlight` 自愈清扫都已落地;`BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 也已在当前代码中修复,并已通过 `conductor-daemon build`、`index.test.js`(39/39)和 browser-control e2e smoke(6/6)。当前剩余缺口主要是:真实 Firefox 手工 smoke 未完成、ChatGPT / Gemini 最终消息提取仍有平台边界、`browser.final_message` 尚未持久化也未直接接入 instruction parser,以及风控和 instruction dedupe 运行态仍是进程内内存态。
M apps/conductor-daemon/src/browser-types.ts
+9, -0
 1@@ -72,6 +72,14 @@ export interface BrowserBridgeShellRuntimeSnapshot {
 2   platform: string;
 3 }
 4 
 5+export interface BrowserBridgeFinalMessageSnapshot {
 6+  assistant_message_id: string;
 7+  conversation_id: string | null;
 8+  observed_at: number;
 9+  platform: string;
10+  raw_text: string;
11+}
12+
13 export interface BrowserBridgeActionResultTargetSnapshot {
14   client_id: string | null;
15   connection_id: string | null;
16@@ -120,6 +128,7 @@ export interface BrowserBridgeClientSnapshot {
17   connected_at: number;
18   connection_id: string;
19   credentials: BrowserBridgeCredentialSnapshot[];
20+  final_messages: BrowserBridgeFinalMessageSnapshot[];
21   last_action_result: BrowserBridgeActionResultSnapshot | null;
22   last_message_at: number;
23   node_category: string | null;
M apps/conductor-daemon/src/firefox-ws.ts
+75, -0
  1@@ -15,6 +15,7 @@ import type {
  2   BrowserBridgeActionResultTargetSnapshot,
  3   BrowserBridgeClientSnapshot,
  4   BrowserBridgeEndpointMetadataSnapshot,
  5+  BrowserBridgeFinalMessageSnapshot,
  6   BrowserBridgeLoginStatus,
  7   BrowserBridgeShellRuntimeSnapshot,
  8   BrowserBridgeStateSnapshot
  9@@ -78,6 +79,7 @@ interface FirefoxBrowserSession {
 10   clientId: string | null;
 11   connectedAt: number;
 12   credentials: Map<string, FirefoxBrowserCredentialSummary>;
 13+  finalMessages: BrowserBridgeFinalMessageSnapshot[];
 14   id: string;
 15   lastActionResult: BrowserBridgeActionResultSnapshot | null;
 16   lastMessageAt: number;
 17@@ -599,6 +601,7 @@ class FirefoxWebSocketConnection {
 18       clientId: null,
 19       connectedAt: now,
 20       credentials: new Map(),
 21+      finalMessages: [],
 22       id: randomUUID(),
 23       lastActionResult: null,
 24       lastMessageAt: now,
 25@@ -685,6 +688,32 @@ class FirefoxWebSocketConnection {
 26     this.session.lastActionResult = result;
 27   }
 28 
 29+  addFinalMessage(message: BrowserBridgeFinalMessageSnapshot): void {
 30+    const dedupeKey = [
 31+      message.platform,
 32+      message.conversation_id ?? "",
 33+      message.assistant_message_id,
 34+      message.raw_text
 35+    ].join("|");
 36+    const existingIndex = this.session.finalMessages.findIndex((entry) =>
 37+      [
 38+        entry.platform,
 39+        entry.conversation_id ?? "",
 40+        entry.assistant_message_id,
 41+        entry.raw_text
 42+      ].join("|") === dedupeKey
 43+    );
 44+
 45+    if (existingIndex >= 0) {
 46+      this.session.finalMessages.splice(existingIndex, 1);
 47+    }
 48+
 49+    this.session.finalMessages.push(message);
 50+    if (this.session.finalMessages.length > 10) {
 51+      this.session.finalMessages.splice(0, this.session.finalMessages.length - 10);
 52+    }
 53+  }
 54+
 55   touch(): void {
 56     this.session.lastMessageAt = this.server.getNextTimestampMilliseconds();
 57   }
 58@@ -734,6 +763,7 @@ class FirefoxWebSocketConnection {
 59       connected_at: this.session.connectedAt,
 60       connection_id: this.session.id,
 61       credentials,
 62+      final_messages: this.session.finalMessages.map((entry) => ({ ...entry })),
 63       last_action_result: this.session.lastActionResult,
 64       last_message_at: this.session.lastMessageAt,
 65       node_category: this.session.nodeCategory,
 66@@ -1108,6 +1138,9 @@ export class ConductorFirefoxWebSocketServer {
 67         return;
 68       case "client_log":
 69         return;
 70+      case "browser.final_message":
 71+        await this.handleBrowserFinalMessage(connection, message);
 72+        return;
 73       case "api_request":
 74         this.sendError(
 75           connection,
 76@@ -1171,6 +1204,7 @@ export class ConductorFirefoxWebSocketServer {
 77           "credentials",
 78           "api_endpoints",
 79           "client_log",
 80+          "browser.final_message",
 81           "api_response",
 82           "stream_open",
 83           "stream_event",
 84@@ -1480,6 +1514,47 @@ export class ConductorFirefoxWebSocketServer {
 85     });
 86   }
 87 
 88+  private async handleBrowserFinalMessage(
 89+    connection: FirefoxWebSocketConnection,
 90+    message: Record<string, unknown>
 91+  ): Promise<void> {
 92+    const platform = readFirstString(message, ["platform"]);
 93+    const assistantMessageId = readFirstString(message, ["assistant_message_id", "assistantMessageId", "message_id"]);
 94+    const rawText = typeof message.raw_text === "string"
 95+      ? message.raw_text.trim()
 96+      : (typeof message.rawText === "string" ? message.rawText.trim() : "");
 97+
 98+    if (platform == null) {
 99+      this.sendError(connection, "invalid_message", "browser.final_message requires a platform field.");
100+      return;
101+    }
102+
103+    if (assistantMessageId == null) {
104+      this.sendError(
105+        connection,
106+        "invalid_message",
107+        "browser.final_message requires a non-empty assistant_message_id field."
108+      );
109+      return;
110+    }
111+
112+    if (!rawText) {
113+      this.sendError(connection, "invalid_message", "browser.final_message requires a non-empty raw_text field.");
114+      return;
115+    }
116+
117+    connection.addFinalMessage({
118+      assistant_message_id: assistantMessageId,
119+      conversation_id: readFirstString(message, ["conversation_id", "conversationId"]),
120+      observed_at:
121+        readOptionalTimestampMilliseconds(message, ["observed_at", "observedAt"])
122+        ?? this.getNowMilliseconds(),
123+      platform,
124+      raw_text: rawText
125+    });
126+    await this.broadcastStateSnapshot("browser.final_message");
127+  }
128+
129   private handleApiResponse(
130     connection: FirefoxWebSocketConnection,
131     message: Record<string, unknown>
M apps/conductor-daemon/src/index.test.js
+268, -1
  1@@ -1,7 +1,7 @@
  2 import assert from "node:assert/strict";
  3 import { EventEmitter } from "node:events";
  4 import { createServer } from "node:http";
  5-import { mkdtempSync, rmSync } from "node:fs";
  6+import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
  7 import { createConnection } from "node:net";
  8 import { homedir, tmpdir } from "node:os";
  9 import { join } from "node:path";
 10@@ -10,11 +10,16 @@ import test from "node:test";
 11 import { ConductorLocalControlPlane } from "../dist/local-control-plane.js";
 12 import { FirefoxCommandBroker } from "../dist/firefox-bridge.js";
 13 import {
 14+  BaaInstructionCenter,
 15+  BaaInstructionCenterError,
 16   BrowserRequestPolicyController,
 17   ConductorDaemon,
 18   ConductorRuntime,
 19   createFetchControlApiClient,
 20+  extractBaaInstructionBlocks,
 21   handleConductorHttpRequest,
 22+  normalizeBaaInstruction,
 23+  parseBaaInstructionBlock,
 24   parseConductorCliRequest,
 25   writeHttpResponse
 26 } from "../dist/index.js";
 27@@ -473,6 +478,268 @@ function parseJsonBody(response) {
 28   return JSON.parse(response.body);
 29 }
 30 
 31+test(
 32+  "BAA instruction extraction and parsing supports the four parameter forms and ignores ordinary code fences",
 33+  () => {
 34+    const message = [
 35+      "Ignore the ordinary code block first.",
 36+      "```js",
 37+      "@conductor::exec::printf 'ignore-me'",
 38+      "```",
 39+      "",
 40+      "```baa",
 41+      "@conductor::describe",
 42+      "```",
 43+      "",
 44+      "```baa",
 45+      "@conductor::exec::printf 'inline-string'",
 46+      "```",
 47+      "",
 48+      "```baa",
 49+      '@conductor::files/read::{"cwd":"/tmp","path":"README.md"}',
 50+      "```",
 51+      "",
 52+      "```baa",
 53+      "@conductor::exec",
 54+      "printf 'multiline-body'",
 55+      "pwd",
 56+      "```"
 57+    ].join("\n");
 58+
 59+    const blocks = extractBaaInstructionBlocks(message);
 60+
 61+    assert.equal(blocks.length, 4);
 62+    assert.deepEqual(
 63+      blocks.map((block) => block.blockIndex),
 64+      [0, 1, 2, 3]
 65+    );
 66+
 67+    const parsed = blocks.map((block) => parseBaaInstructionBlock(block));
 68+
 69+    assert.equal(parsed[0].target, "conductor");
 70+    assert.equal(parsed[0].tool, "describe");
 71+    assert.equal(parsed[0].paramsKind, "none");
 72+    assert.equal(parsed[0].params, null);
 73+
 74+    assert.equal(parsed[1].tool, "exec");
 75+    assert.equal(parsed[1].paramsKind, "inline_string");
 76+    assert.equal(parsed[1].params, "printf 'inline-string'");
 77+
 78+    assert.equal(parsed[2].tool, "files/read");
 79+    assert.equal(parsed[2].paramsKind, "inline_json");
 80+    assert.deepEqual(parsed[2].params, {
 81+      cwd: "/tmp",
 82+      path: "README.md"
 83+    });
 84+
 85+    assert.equal(parsed[3].tool, "exec");
 86+    assert.equal(parsed[3].paramsKind, "body");
 87+    assert.equal(parsed[3].params, "printf 'multiline-body'\npwd");
 88+  }
 89+);
 90+
 91+test("BAA instruction normalization keeps auditable fields and stable dedupe keys", () => {
 92+  const source = {
 93+    assistantMessageId: "msg-001",
 94+    conversationId: "conv-001",
 95+    platform: "claude"
 96+  };
 97+  const left = normalizeBaaInstruction(source, {
 98+    blockIndex: 0,
 99+    params: {
100+      path: "/tmp/demo.txt",
101+      overwrite: true,
102+      content: "hello"
103+    },
104+    paramsKind: "inline_json",
105+    rawBlock: "```baa\n@conductor::files/write::{}\n```",
106+    rawInstruction: '@conductor::files/write::{"path":"/tmp/demo.txt","overwrite":true,"content":"hello"}',
107+    target: "conductor",
108+    tool: "files/write"
109+  });
110+  const right = normalizeBaaInstruction(source, {
111+    blockIndex: 0,
112+    params: {
113+      content: "hello",
114+      path: "/tmp/demo.txt",
115+      overwrite: true
116+    },
117+    paramsKind: "inline_json",
118+    rawBlock: "```baa\n@conductor::files/write::{}\n```",
119+    rawInstruction: '@conductor::files/write::{"content":"hello","path":"/tmp/demo.txt","overwrite":true}',
120+    target: "conductor",
121+    tool: "files/write"
122+  });
123+
124+  assert.equal(left.dedupeKey, right.dedupeKey);
125+  assert.equal(left.instructionId, right.instructionId);
126+  assert.deepEqual(left.dedupeBasis, {
127+    assistant_message_id: "msg-001",
128+    block_index: 0,
129+    conversation_id: "conv-001",
130+    params: {
131+      content: "hello",
132+      overwrite: true,
133+      path: "/tmp/demo.txt"
134+    },
135+    platform: "claude",
136+    target: "conductor",
137+    tool: "files/write",
138+    version: "baa.v1"
139+  });
140+});
141+
142+test("BaaInstructionCenter runs the Phase 1 execution loop and dedupes replayed messages", async () => {
143+  const { controlPlane, repository, sharedToken, snapshot } = await createLocalApiFixture();
144+  const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-instruction-center-"));
145+  const readableFilePath = join(hostOpsDir, "seed.txt");
146+  const localApiContext = {
147+    fetchImpl: globalThis.fetch,
148+    repository,
149+    sharedToken,
150+    snapshotLoader: () => snapshot
151+  };
152+  const center = new BaaInstructionCenter({
153+    localApiContext
154+  });
155+  const message = [
156+    "Run the minimal Phase 1 toolset.",
157+    "```baa",
158+    "@conductor::describe",
159+    "```",
160+    "",
161+    "```baa",
162+    "@conductor::status",
163+    "```",
164+    "",
165+    "```baa",
166+    `@conductor::exec::{"command":"printf 'instruction-loop-ok'","cwd":${JSON.stringify(hostOpsDir)}}`,
167+    "```",
168+    "",
169+    "```baa",
170+    `@conductor::files/write::{"path":"written.txt","cwd":${JSON.stringify(hostOpsDir)},"content":"hello from baa","overwrite":true,"createParents":true}`,
171+    "```",
172+    "",
173+    "```baa",
174+    `@conductor::files/read::{"path":"seed.txt","cwd":${JSON.stringify(hostOpsDir)}}`,
175+    "```"
176+  ].join("\n");
177+
178+  writeFileSync(readableFilePath, "seed from fixture", "utf8");
179+
180+  try {
181+    const firstPass = await center.processAssistantMessage({
182+      assistantMessageId: "msg-loop-1",
183+      conversationId: "conv-loop-1",
184+      platform: "claude",
185+      text: message
186+    });
187+
188+    assert.equal(firstPass.status, "executed");
189+    assert.equal(firstPass.duplicates.length, 0);
190+    assert.equal(firstPass.instructions.length, 5);
191+    assert.equal(firstPass.executions.length, 5);
192+
193+    const describeExecution = firstPass.executions.find((execution) => execution.tool === "describe");
194+    assert.ok(describeExecution);
195+    assert.equal(describeExecution.ok, true);
196+    assert.equal(describeExecution.data.name, "baa-conductor-daemon");
197+
198+    const statusExecution = firstPass.executions.find((execution) => execution.tool === "status");
199+    assert.ok(statusExecution);
200+    assert.equal(statusExecution.ok, true);
201+    assert.equal(statusExecution.data.mode, "running");
202+    assert.equal(statusExecution.data.activeRuns, 1);
203+
204+    const execExecution = firstPass.executions.find((execution) => execution.tool === "exec");
205+    assert.ok(execExecution);
206+    assert.equal(execExecution.ok, true);
207+    assert.equal(execExecution.data.operation, "exec");
208+    assert.equal(execExecution.data.result.stdout, "instruction-loop-ok");
209+
210+    const writeExecution = firstPass.executions.find(
211+      (execution) => execution.tool === "files/write"
212+    );
213+    assert.ok(writeExecution);
214+    assert.equal(writeExecution.ok, true);
215+    assert.equal(writeExecution.data.operation, "files/write");
216+    assert.equal(writeExecution.data.result.created, true);
217+
218+    const readExecution = firstPass.executions.find(
219+      (execution) => execution.tool === "files/read"
220+    );
221+    assert.ok(readExecution);
222+    assert.equal(readExecution.ok, true);
223+    assert.equal(readExecution.data.operation, "files/read");
224+    assert.equal(readExecution.data.result.content, "seed from fixture");
225+
226+    const replayPass = await center.processAssistantMessage({
227+      assistantMessageId: "msg-loop-1",
228+      conversationId: "conv-loop-1",
229+      platform: "claude",
230+      text: message
231+    });
232+
233+    assert.equal(replayPass.status, "duplicate_only");
234+    assert.equal(replayPass.duplicates.length, 5);
235+    assert.equal(replayPass.executions.length, 0);
236+  } finally {
237+    controlPlane.close();
238+    rmSync(hostOpsDir, {
239+      force: true,
240+      recursive: true
241+    });
242+  }
243+});
244+
245+test("BaaInstructionCenter fails closed before execution when a batch contains an unsupported instruction", async () => {
246+  const { controlPlane, repository, sharedToken, snapshot } = await createLocalApiFixture();
247+  const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-instruction-center-fail-closed-"));
248+  const blockedFilePath = join(hostOpsDir, "should-not-exist.txt");
249+  const center = new BaaInstructionCenter({
250+    localApiContext: {
251+      fetchImpl: globalThis.fetch,
252+      repository,
253+      sharedToken,
254+      snapshotLoader: () => snapshot
255+    }
256+  });
257+  const message = [
258+    "```baa",
259+    `@conductor::files/write::{"path":"should-not-exist.txt","cwd":${JSON.stringify(hostOpsDir)},"content":"must not run","overwrite":true}`,
260+    "```",
261+    "",
262+    "```baa",
263+    "@browser.chatgpt::send::draw a cat",
264+    "```"
265+  ].join("\n");
266+
267+  try {
268+    await assert.rejects(
269+      () =>
270+        center.processAssistantMessage({
271+          assistantMessageId: "msg-fail-closed-1",
272+          conversationId: "conv-fail-closed-1",
273+          platform: "claude",
274+          text: message
275+        }),
276+      (error) => {
277+        assert.ok(error instanceof BaaInstructionCenterError);
278+        assert.equal(error.stage, "policy");
279+        assert.equal(error.blockIndex, 1);
280+        return true;
281+      }
282+    );
283+    assert.equal(existsSync(blockedFilePath), false);
284+  } finally {
285+    controlPlane.close();
286+    rmSync(hostOpsDir, {
287+      force: true,
288+      recursive: true
289+    });
290+  }
291+});
292+
293 function createManualTimerScheduler() {
294   let now = 0;
295   let nextId = 1;
M apps/conductor-daemon/src/index.ts
+1, -0
1@@ -40,6 +40,7 @@ export {
2   type BrowserRequestPolicyControllerOptions
3 } from "./browser-request-policy.js";
4 export { handleConductorHttpRequest } from "./local-api.js";
5+export * from "./instructions/index.js";
6 
7 export type ConductorRole = "primary" | "standby";
8 export type ConductorLeadershipRole = "leader" | "standby";
A apps/conductor-daemon/src/instructions/dedupe.ts
+55, -0
 1@@ -0,0 +1,55 @@
 2+import { createHash } from "node:crypto";
 3+
 4+import type {
 5+  BaaJsonValue,
 6+  BaaInstructionDedupeBasis,
 7+  BaaInstructionEnvelope,
 8+  BaaInstructionSourceMessage,
 9+  BaaParsedInstruction
10+} from "./types.js";
11+import { sortBaaJsonValue, stableStringifyBaaJson } from "./types.js";
12+
13+export interface BaaInstructionDeduper {
14+  add(instruction: BaaInstructionEnvelope): Promise<void> | void;
15+  has(dedupeKey: string): Promise<boolean> | boolean;
16+}
17+
18+export class InMemoryBaaInstructionDeduper implements BaaInstructionDeduper {
19+  private readonly keys = new Set<string>();
20+
21+  add(instruction: BaaInstructionEnvelope): void {
22+    this.keys.add(instruction.dedupeKey);
23+  }
24+
25+  clear(): void {
26+    this.keys.clear();
27+  }
28+
29+  has(dedupeKey: string): boolean {
30+    return this.keys.has(dedupeKey);
31+  }
32+}
33+
34+export function buildBaaInstructionDedupeBasis(
35+  source: BaaInstructionSourceMessage,
36+  instruction: BaaParsedInstruction
37+): BaaInstructionDedupeBasis {
38+  return {
39+    assistant_message_id: source.assistantMessageId,
40+    block_index: instruction.blockIndex,
41+    conversation_id: source.conversationId,
42+    params: sortBaaJsonValue(instruction.params),
43+    platform: source.platform,
44+    target: instruction.target,
45+    tool: instruction.tool,
46+    version: "baa.v1"
47+  };
48+}
49+
50+export function buildBaaInstructionDedupeKey(basis: BaaInstructionDedupeBasis): string {
51+  return `sha256:${createHash("sha256").update(stableStringifyBaaJson(sortBaaJsonValue(basis as unknown as BaaJsonValue))).digest("hex")}`;
52+}
53+
54+export function buildBaaInstructionId(dedupeKey: string): string {
55+  return `inst_${dedupeKey.replace(/^sha256:/u, "").slice(0, 16)}`;
56+}
A apps/conductor-daemon/src/instructions/executor.ts
+110, -0
  1@@ -0,0 +1,110 @@
  2+import {
  3+  handleConductorHttpRequest,
  4+  type ConductorLocalApiContext
  5+} from "../local-api.js";
  6+import type {
  7+  BaaInstructionEnvelope,
  8+  BaaInstructionExecutionResult,
  9+  BaaInstructionRoute,
 10+  BaaJsonValue
 11+} from "./types.js";
 12+import { isBaaJsonValue } from "./types.js";
 13+
 14+function toExecutionFailure(
 15+  instruction: BaaInstructionEnvelope,
 16+  route: BaaInstructionRoute,
 17+  message: string,
 18+  error = "execution_failed",
 19+  details: BaaJsonValue | null = null
 20+): BaaInstructionExecutionResult {
 21+  return {
 22+    data: null,
 23+    dedupeKey: instruction.dedupeKey,
 24+    details,
 25+    error,
 26+    httpStatus: 500,
 27+    instructionId: instruction.instructionId,
 28+    message,
 29+    ok: false,
 30+    requestId: null,
 31+    route: {
 32+      key: route.key,
 33+      method: route.method,
 34+      path: route.path
 35+    },
 36+    target: instruction.target,
 37+    tool: instruction.tool
 38+  };
 39+}
 40+
 41+function normalizeJsonBodyValue(value: unknown): BaaJsonValue | null {
 42+  return isBaaJsonValue(value) ? value : null;
 43+}
 44+
 45+export async function executeBaaInstruction(
 46+  instruction: BaaInstructionEnvelope,
 47+  route: BaaInstructionRoute,
 48+  context: ConductorLocalApiContext
 49+): Promise<BaaInstructionExecutionResult> {
 50+  try {
 51+    const headers: Record<string, string> = {
 52+      "content-type": "application/json"
 53+    };
 54+
 55+    if (route.requiresSharedToken && context.sharedToken) {
 56+      headers.authorization = `Bearer ${context.sharedToken}`;
 57+    }
 58+
 59+    const response = await handleConductorHttpRequest(
 60+      {
 61+        body: route.body == null ? null : JSON.stringify(route.body),
 62+        headers,
 63+        method: route.method,
 64+        path: route.path
 65+      },
 66+      context
 67+    );
 68+
 69+    let parsedBody: unknown = null;
 70+
 71+    try {
 72+      parsedBody = response.body.trim() === "" ? null : JSON.parse(response.body);
 73+    } catch (error) {
 74+      const message = error instanceof Error ? error.message : String(error);
 75+      return toExecutionFailure(
 76+        instruction,
 77+        route,
 78+        `Failed to parse local API response JSON: ${message}`,
 79+        "invalid_local_api_response",
 80+        normalizeJsonBodyValue(response.body)
 81+      );
 82+    }
 83+
 84+    const payload =
 85+      parsedBody != null && typeof parsedBody === "object" && !Array.isArray(parsedBody)
 86+        ? (parsedBody as Record<string, unknown>)
 87+        : null;
 88+
 89+    return {
 90+      data: payload?.ok === true ? normalizeJsonBodyValue(payload.data) : null,
 91+      dedupeKey: instruction.dedupeKey,
 92+      details: normalizeJsonBodyValue(payload?.details),
 93+      error: payload?.ok === false && typeof payload.error === "string" ? payload.error : null,
 94+      httpStatus: response.status,
 95+      instructionId: instruction.instructionId,
 96+      message: typeof payload?.message === "string" ? payload.message : null,
 97+      ok: payload?.ok === true,
 98+      requestId: typeof payload?.request_id === "string" ? payload.request_id : null,
 99+      route: {
100+        key: route.key,
101+        method: route.method,
102+        path: route.path
103+      },
104+      target: instruction.target,
105+      tool: instruction.tool
106+    };
107+  } catch (error) {
108+    const message = error instanceof Error ? error.message : String(error);
109+    return toExecutionFailure(instruction, route, message);
110+  }
111+}
A apps/conductor-daemon/src/instructions/extract.ts
+63, -0
 1@@ -0,0 +1,63 @@
 2+import type { BaaExtractedBlock } from "./types.js";
 3+
 4+const CODE_FENCE_OPEN_PATTERN = /^[ \t]{0,3}```([^\n]*)$/u;
 5+const CODE_FENCE_CLOSE_PATTERN = /^[ \t]{0,3}```[ \t]*$/u;
 6+
 7+export class BaaInstructionExtractError extends Error {
 8+  readonly stage = "extract";
 9+
10+  constructor(message: string) {
11+    super(message);
12+  }
13+}
14+
15+export function extractBaaInstructionBlocks(text: string): BaaExtractedBlock[] {
16+  const normalizedText = text.replace(/\r\n?/gu, "\n");
17+  const lines = normalizedText.split("\n");
18+  const blocks: BaaExtractedBlock[] = [];
19+  let pending:
20+    | {
21+        contentLines: string[];
22+        isBaa: boolean;
23+        openingLine: string;
24+      }
25+    | null = null;
26+
27+  for (const line of lines) {
28+    if (pending == null) {
29+      const match = line.match(CODE_FENCE_OPEN_PATTERN);
30+
31+      if (!match) {
32+        continue;
33+      }
34+
35+      pending = {
36+        contentLines: [],
37+        isBaa: (match[1] ?? "").trim() === "baa",
38+        openingLine: line
39+      };
40+      continue;
41+    }
42+
43+    if (CODE_FENCE_CLOSE_PATTERN.test(line)) {
44+      if (pending.isBaa) {
45+        blocks.push({
46+          blockIndex: blocks.length,
47+          content: pending.contentLines.join("\n"),
48+          rawBlock: `${pending.openingLine}\n${pending.contentLines.join("\n")}\n${line}`
49+        });
50+      }
51+
52+      pending = null;
53+      continue;
54+    }
55+
56+    pending.contentLines.push(line);
57+  }
58+
59+  if (pending?.isBaa) {
60+    throw new BaaInstructionExtractError("Unterminated ```baa code block.");
61+  }
62+
63+  return blocks;
64+}
A apps/conductor-daemon/src/instructions/index.ts
+9, -0
 1@@ -0,0 +1,9 @@
 2+export * from "./types.js";
 3+export * from "./extract.js";
 4+export * from "./parse.js";
 5+export * from "./dedupe.js";
 6+export * from "./normalize.js";
 7+export * from "./policy.js";
 8+export * from "./router.js";
 9+export * from "./executor.js";
10+export * from "./loop.js";
A apps/conductor-daemon/src/instructions/loop.ts
+174, -0
  1@@ -0,0 +1,174 @@
  2+import type { ConductorLocalApiContext } from "../local-api.js";
  3+
  4+import { InMemoryBaaInstructionDeduper, type BaaInstructionDeduper } from "./dedupe.js";
  5+import { executeBaaInstruction } from "./executor.js";
  6+import { extractBaaInstructionBlocks } from "./extract.js";
  7+import { normalizeBaaInstruction } from "./normalize.js";
  8+import { parseBaaInstructionBlock } from "./parse.js";
  9+import { evaluateBaaInstructionPolicy } from "./policy.js";
 10+import { routeBaaInstruction } from "./router.js";
 11+import type {
 12+  BaaAssistantMessageInput,
 13+  BaaInstructionEnvelope,
 14+  BaaInstructionProcessResult,
 15+  BaaInstructionRoute
 16+} from "./types.js";
 17+
 18+type BaaInstructionStage = "extract" | "normalize" | "parse" | "policy" | "route";
 19+
 20+export class BaaInstructionCenterError extends Error {
 21+  readonly blockIndex: number | null;
 22+  readonly stage: BaaInstructionStage;
 23+
 24+  constructor(stage: BaaInstructionStage, message: string, blockIndex: number | null = null) {
 25+    super(message);
 26+    this.blockIndex = blockIndex;
 27+    this.stage = stage;
 28+  }
 29+}
 30+
 31+export interface BaaInstructionCenterOptions {
 32+  deduper?: BaaInstructionDeduper;
 33+  localApiContext: ConductorLocalApiContext;
 34+}
 35+
 36+export class BaaInstructionCenter {
 37+  private readonly deduper: BaaInstructionDeduper;
 38+  private readonly localApiContext: ConductorLocalApiContext;
 39+
 40+  constructor(options: BaaInstructionCenterOptions) {
 41+    this.deduper = options.deduper ?? new InMemoryBaaInstructionDeduper();
 42+    this.localApiContext = options.localApiContext;
 43+  }
 44+
 45+  async processAssistantMessage(
 46+    input: BaaAssistantMessageInput
 47+  ): Promise<BaaInstructionProcessResult> {
 48+    const blocks = this.extract(input.text);
 49+
 50+    if (blocks.length === 0) {
 51+      return {
 52+        blocks,
 53+        duplicates: [],
 54+        executions: [],
 55+        instructions: [],
 56+        status: "no_instructions"
 57+      };
 58+    }
 59+
 60+    const instructions = this.normalize(input, blocks);
 61+    const duplicates: BaaInstructionEnvelope[] = [];
 62+    const pending: BaaInstructionEnvelope[] = [];
 63+
 64+    for (const instruction of instructions) {
 65+      if (await this.deduper.has(instruction.dedupeKey)) {
 66+        duplicates.push(instruction);
 67+        continue;
 68+      }
 69+
 70+      pending.push(instruction);
 71+    }
 72+
 73+    if (pending.length === 0) {
 74+      return {
 75+        blocks,
 76+        duplicates,
 77+        executions: [],
 78+        instructions,
 79+        status: "duplicate_only"
 80+      };
 81+    }
 82+
 83+    const routedInstructions = this.preflight(pending);
 84+
 85+    for (const instruction of pending) {
 86+      await this.deduper.add(instruction);
 87+    }
 88+
 89+    const executions = await Promise.all(
 90+      routedInstructions.map(({ instruction, route }) =>
 91+        executeBaaInstruction(instruction, route, this.localApiContext)
 92+      )
 93+    );
 94+
 95+    return {
 96+      blocks,
 97+      duplicates,
 98+      executions,
 99+      instructions,
100+      status: "executed"
101+    };
102+  }
103+
104+  private extract(text: string) {
105+    try {
106+      return extractBaaInstructionBlocks(text);
107+    } catch (error) {
108+      const message = error instanceof Error ? error.message : String(error);
109+      throw new BaaInstructionCenterError("extract", message);
110+    }
111+  }
112+
113+  private normalize(input: BaaAssistantMessageInput, blocks: BaaInstructionProcessResult["blocks"]) {
114+    try {
115+      return blocks.map((block) =>
116+        normalizeBaaInstruction(
117+          {
118+            assistantMessageId: input.assistantMessageId,
119+            conversationId: input.conversationId,
120+            platform: input.platform
121+          },
122+          parseBaaInstructionBlock(block)
123+        )
124+      );
125+    } catch (error) {
126+      if (error instanceof BaaInstructionCenterError) {
127+        throw error;
128+      }
129+
130+      const message = error instanceof Error ? error.message : String(error);
131+      const blockIndex =
132+        error != null &&
133+        typeof error === "object" &&
134+        "blockIndex" in error &&
135+        typeof (error as { blockIndex?: unknown }).blockIndex === "number"
136+          ? (error as { blockIndex: number }).blockIndex
137+          : null;
138+      const stage =
139+        error != null &&
140+        typeof error === "object" &&
141+        "stage" in error &&
142+        (error as { stage?: unknown }).stage === "parse"
143+          ? "parse"
144+          : "normalize";
145+
146+      throw new BaaInstructionCenterError(stage, message, blockIndex);
147+    }
148+  }
149+
150+  private preflight(
151+    instructions: BaaInstructionEnvelope[]
152+  ): Array<{ instruction: BaaInstructionEnvelope; route: BaaInstructionRoute }> {
153+    return instructions.map((instruction) => {
154+      const decision = evaluateBaaInstructionPolicy(instruction);
155+
156+      if (!decision.ok) {
157+        throw new BaaInstructionCenterError(
158+          "policy",
159+          decision.message ?? "BAA instruction was denied by policy.",
160+          instruction.blockIndex
161+        );
162+      }
163+
164+      try {
165+        return {
166+          instruction,
167+          route: routeBaaInstruction(instruction)
168+        };
169+      } catch (error) {
170+        const message = error instanceof Error ? error.message : String(error);
171+        throw new BaaInstructionCenterError("route", message, instruction.blockIndex);
172+      }
173+    });
174+  }
175+}
A apps/conductor-daemon/src/instructions/normalize.ts
+50, -0
 1@@ -0,0 +1,50 @@
 2+import {
 3+  buildBaaInstructionDedupeBasis,
 4+  buildBaaInstructionDedupeKey,
 5+  buildBaaInstructionId
 6+} from "./dedupe.js";
 7+import type {
 8+  BaaInstructionEnvelope,
 9+  BaaInstructionSourceMessage,
10+  BaaParsedInstruction
11+} from "./types.js";
12+
13+function requireNonEmptyField(name: string, value: string): string {
14+  const normalized = value.trim();
15+
16+  if (normalized === "") {
17+    throw new Error(`${name} must be a non-empty string.`);
18+  }
19+
20+  return normalized;
21+}
22+
23+export function normalizeBaaInstruction(
24+  source: BaaInstructionSourceMessage,
25+  instruction: BaaParsedInstruction
26+): BaaInstructionEnvelope {
27+  const normalizedSource = {
28+    assistantMessageId: requireNonEmptyField("assistantMessageId", source.assistantMessageId),
29+    conversationId: requireNonEmptyField("conversationId", source.conversationId),
30+    platform: requireNonEmptyField("platform", source.platform)
31+  };
32+  const dedupeBasis = buildBaaInstructionDedupeBasis(normalizedSource, instruction);
33+  const dedupeKey = buildBaaInstructionDedupeKey(dedupeBasis);
34+
35+  return {
36+    assistantMessageId: normalizedSource.assistantMessageId,
37+    blockIndex: instruction.blockIndex,
38+    conversationId: normalizedSource.conversationId,
39+    dedupeBasis,
40+    dedupeKey,
41+    envelopeVersion: "baa.v1",
42+    instructionId: buildBaaInstructionId(dedupeKey),
43+    params: instruction.params,
44+    paramsKind: instruction.paramsKind,
45+    platform: normalizedSource.platform,
46+    rawBlock: instruction.rawBlock,
47+    rawInstruction: instruction.rawInstruction,
48+    target: instruction.target,
49+    tool: instruction.tool
50+  };
51+}
A apps/conductor-daemon/src/instructions/parse.ts
+125, -0
  1@@ -0,0 +1,125 @@
  2+import type {
  3+  BaaExtractedBlock,
  4+  BaaInstructionParams,
  5+  BaaInstructionParamsKind,
  6+  BaaParsedInstruction
  7+} from "./types.js";
  8+import { isBaaJsonValue } from "./types.js";
  9+
 10+const INSTRUCTION_HEADER_PATTERN =
 11+  /^@(?<target>[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*)::(?<tool>[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*)(?:::(?<inline>.*))?$/u;
 12+
 13+export class BaaInstructionParseError extends Error {
 14+  readonly blockIndex: number;
 15+  readonly stage = "parse";
 16+
 17+  constructor(blockIndex: number, message: string) {
 18+    super(message);
 19+    this.blockIndex = blockIndex;
 20+  }
 21+}
 22+
 23+function normalizeInlineParams(
 24+  blockIndex: number,
 25+  rawInlineParams: string
 26+): { params: BaaInstructionParams; paramsKind: BaaInstructionParamsKind } {
 27+  const trimmedInlineParams = rawInlineParams.trim();
 28+
 29+  if (trimmedInlineParams.startsWith("{") || trimmedInlineParams.startsWith("[")) {
 30+    try {
 31+      const parsed = JSON.parse(trimmedInlineParams) as unknown;
 32+
 33+      if (!isBaaJsonValue(parsed)) {
 34+        throw new Error("Inline JSON params must resolve to a JSON value.");
 35+      }
 36+
 37+      return {
 38+        params: parsed,
 39+        paramsKind: "inline_json"
 40+      };
 41+    } catch (error) {
 42+      const message = error instanceof Error ? error.message : String(error);
 43+      throw new BaaInstructionParseError(
 44+        blockIndex,
 45+        `Failed to parse inline JSON params: ${message}`
 46+      );
 47+    }
 48+  }
 49+
 50+  return {
 51+    params: rawInlineParams,
 52+    paramsKind: "inline_string"
 53+  };
 54+}
 55+
 56+export function parseBaaInstructionBlock(block: BaaExtractedBlock): BaaParsedInstruction {
 57+  const instructionText = block.content.replace(/\r\n?/gu, "\n");
 58+  const lines = instructionText.split("\n");
 59+  const firstLine = lines[0]?.trim() ?? "";
 60+
 61+  if (firstLine === "") {
 62+    throw new BaaInstructionParseError(block.blockIndex, "BAA instruction block is empty.");
 63+  }
 64+
 65+  const headerMatch = firstLine.match(INSTRUCTION_HEADER_PATTERN);
 66+
 67+  if (!headerMatch?.groups) {
 68+    throw new BaaInstructionParseError(
 69+      block.blockIndex,
 70+      `Invalid BAA instruction header: ${firstLine}`
 71+    );
 72+  }
 73+
 74+  const target = headerMatch.groups.target;
 75+  const tool = headerMatch.groups.tool;
 76+  const rawInlineParams = headerMatch.groups.inline;
 77+  const rawBody = lines.length > 1 ? lines.slice(1).join("\n") : null;
 78+  const hasBody = rawBody != null && rawBody.trim() !== "";
 79+
 80+  if (!target || !tool) {
 81+    throw new BaaInstructionParseError(block.blockIndex, "BAA instruction target and tool are required.");
 82+  }
 83+
 84+  if (rawInlineParams != null && hasBody) {
 85+    throw new BaaInstructionParseError(
 86+      block.blockIndex,
 87+      "BAA instruction cannot mix inline params with a multiline body."
 88+    );
 89+  }
 90+
 91+  if (rawInlineParams != null) {
 92+    const normalizedInlineParams = normalizeInlineParams(block.blockIndex, rawInlineParams);
 93+
 94+    return {
 95+      blockIndex: block.blockIndex,
 96+      params: normalizedInlineParams.params,
 97+      paramsKind: normalizedInlineParams.paramsKind,
 98+      rawBlock: block.rawBlock,
 99+      rawInstruction: instructionText,
100+      target,
101+      tool
102+    };
103+  }
104+
105+  if (hasBody) {
106+    return {
107+      blockIndex: block.blockIndex,
108+      params: rawBody!,
109+      paramsKind: "body",
110+      rawBlock: block.rawBlock,
111+      rawInstruction: instructionText,
112+      target,
113+      tool
114+    };
115+  }
116+
117+  return {
118+    blockIndex: block.blockIndex,
119+    params: null,
120+    paramsKind: "none",
121+    rawBlock: block.rawBlock,
122+    rawInstruction: instructionText,
123+    target,
124+    tool
125+  };
126+}
A apps/conductor-daemon/src/instructions/policy.ts
+44, -0
 1@@ -0,0 +1,44 @@
 2+import type { BaaInstructionEnvelope } from "./types.js";
 3+
 4+const SUPPORTED_TARGETS = new Set(["conductor", "system"]);
 5+const SUPPORTED_TOOLS = new Set([
 6+  "describe",
 7+  "describe/business",
 8+  "describe/control",
 9+  "exec",
10+  "files/read",
11+  "files/write",
12+  "status"
13+]);
14+
15+export interface BaaInstructionPolicyDecision {
16+  code: string | null;
17+  message: string | null;
18+  ok: boolean;
19+}
20+
21+export function evaluateBaaInstructionPolicy(
22+  instruction: BaaInstructionEnvelope
23+): BaaInstructionPolicyDecision {
24+  if (!SUPPORTED_TARGETS.has(instruction.target)) {
25+    return {
26+      code: "unsupported_target",
27+      message: `Target "${instruction.target}" is not supported in Phase 1.`,
28+      ok: false
29+    };
30+  }
31+
32+  if (!SUPPORTED_TOOLS.has(instruction.tool)) {
33+    return {
34+      code: "unsupported_tool",
35+      message: `Tool "${instruction.tool}" is not supported in Phase 1.`,
36+      ok: false
37+    };
38+  }
39+
40+  return {
41+    code: null,
42+    message: null,
43+    ok: true
44+  };
45+}
A apps/conductor-daemon/src/instructions/router.ts
+200, -0
  1@@ -0,0 +1,200 @@
  2+import type {
  3+  BaaInstructionEnvelope,
  4+  BaaInstructionParams,
  5+  BaaInstructionRoute,
  6+  BaaJsonObject
  7+} from "./types.js";
  8+import { isBaaJsonObject } from "./types.js";
  9+
 10+export class BaaInstructionRouteError extends Error {
 11+  readonly blockIndex: number;
 12+  readonly stage = "route";
 13+
 14+  constructor(blockIndex: number, message: string) {
 15+    super(message);
 16+    this.blockIndex = blockIndex;
 17+  }
 18+}
 19+
 20+function requireNonEmptyStringParam(
 21+  instruction: BaaInstructionEnvelope,
 22+  label: string,
 23+  allowNewlines = false
 24+): string {
 25+  if (typeof instruction.params !== "string") {
 26+    throw new BaaInstructionRouteError(
 27+      instruction.blockIndex,
 28+      `${instruction.target}::${instruction.tool} requires ${label} as a string.`
 29+    );
 30+  }
 31+
 32+  const normalized = instruction.params.trim();
 33+
 34+  if (normalized === "") {
 35+    throw new BaaInstructionRouteError(
 36+      instruction.blockIndex,
 37+      `${instruction.target}::${instruction.tool} requires a non-empty ${label}.`
 38+    );
 39+  }
 40+
 41+  if (!allowNewlines && normalized.includes("\n")) {
 42+    throw new BaaInstructionRouteError(
 43+      instruction.blockIndex,
 44+      `${instruction.target}::${instruction.tool} does not accept multiline ${label}.`
 45+    );
 46+  }
 47+
 48+  return normalized;
 49+}
 50+
 51+function requireJsonObjectParams(instruction: BaaInstructionEnvelope): BaaJsonObject {
 52+  if (!isBaaJsonObject(instruction.params)) {
 53+    throw new BaaInstructionRouteError(
 54+      instruction.blockIndex,
 55+      `${instruction.target}::${instruction.tool} requires JSON object params.`
 56+    );
 57+  }
 58+
 59+  return instruction.params;
 60+}
 61+
 62+function requireNoParams(instruction: BaaInstructionEnvelope): void {
 63+  if (instruction.paramsKind !== "none") {
 64+    throw new BaaInstructionRouteError(
 65+      instruction.blockIndex,
 66+      `${instruction.target}::${instruction.tool} does not accept params.`
 67+    );
 68+  }
 69+}
 70+
 71+function normalizeExecBody(instruction: BaaInstructionEnvelope): BaaJsonObject {
 72+  if (typeof instruction.params === "string") {
 73+    return {
 74+      command: requireNonEmptyStringParam(instruction, "command", true)
 75+    };
 76+  }
 77+
 78+  const params = requireJsonObjectParams(instruction);
 79+  const command = params.command;
 80+
 81+  if (typeof command !== "string" || command.trim() === "") {
 82+    throw new BaaInstructionRouteError(
 83+      instruction.blockIndex,
 84+      `${instruction.target}::${instruction.tool} JSON params must include a non-empty "command".`
 85+    );
 86+  }
 87+
 88+  return params;
 89+}
 90+
 91+function normalizeFileReadBody(instruction: BaaInstructionEnvelope): BaaJsonObject {
 92+  if (typeof instruction.params === "string") {
 93+    return {
 94+      path: requireNonEmptyStringParam(instruction, "path")
 95+    };
 96+  }
 97+
 98+  const params = requireJsonObjectParams(instruction);
 99+  const path = params.path;
100+
101+  if (typeof path !== "string" || path.trim() === "") {
102+    throw new BaaInstructionRouteError(
103+      instruction.blockIndex,
104+      `${instruction.target}::${instruction.tool} JSON params must include a non-empty "path".`
105+    );
106+  }
107+
108+  return params;
109+}
110+
111+function normalizeFileWriteBody(instruction: BaaInstructionEnvelope): BaaJsonObject {
112+  const params = requireJsonObjectParams(instruction);
113+  const path = params.path;
114+  const content = params.content;
115+
116+  if (typeof path !== "string" || path.trim() === "") {
117+    throw new BaaInstructionRouteError(
118+      instruction.blockIndex,
119+      `${instruction.target}::${instruction.tool} JSON params must include a non-empty "path".`
120+    );
121+  }
122+
123+  if (typeof content !== "string") {
124+    throw new BaaInstructionRouteError(
125+      instruction.blockIndex,
126+      `${instruction.target}::${instruction.tool} JSON params must include string "content".`
127+    );
128+  }
129+
130+  return params;
131+}
132+
133+export function routeBaaInstruction(instruction: BaaInstructionEnvelope): BaaInstructionRoute {
134+  switch (instruction.tool) {
135+    case "describe":
136+      requireNoParams(instruction);
137+      return {
138+        body: null,
139+        key: "local.describe",
140+        method: "GET",
141+        path: "/describe",
142+        requiresSharedToken: false
143+      };
144+    case "describe/business":
145+      requireNoParams(instruction);
146+      return {
147+        body: null,
148+        key: "local.describe.business",
149+        method: "GET",
150+        path: "/describe/business",
151+        requiresSharedToken: false
152+      };
153+    case "describe/control":
154+      requireNoParams(instruction);
155+      return {
156+        body: null,
157+        key: "local.describe.control",
158+        method: "GET",
159+        path: "/describe/control",
160+        requiresSharedToken: false
161+      };
162+    case "status":
163+      requireNoParams(instruction);
164+      return {
165+        body: null,
166+        key: "local.status",
167+        method: "GET",
168+        path: "/v1/status",
169+        requiresSharedToken: false
170+      };
171+    case "exec":
172+      return {
173+        body: normalizeExecBody(instruction),
174+        key: "local.exec",
175+        method: "POST",
176+        path: "/v1/exec",
177+        requiresSharedToken: true
178+      };
179+    case "files/read":
180+      return {
181+        body: normalizeFileReadBody(instruction),
182+        key: "local.files.read",
183+        method: "POST",
184+        path: "/v1/files/read",
185+        requiresSharedToken: true
186+      };
187+    case "files/write":
188+      return {
189+        body: normalizeFileWriteBody(instruction),
190+        key: "local.files.write",
191+        method: "POST",
192+        path: "/v1/files/write",
193+        requiresSharedToken: true
194+      };
195+    default:
196+      throw new BaaInstructionRouteError(
197+        instruction.blockIndex,
198+        `No Phase 1 route exists for tool "${instruction.tool}".`
199+      );
200+  }
201+}
A apps/conductor-daemon/src/instructions/types.ts
+143, -0
  1@@ -0,0 +1,143 @@
  2+export type BaaJsonValue = boolean | number | null | string | BaaJsonValue[] | BaaJsonObject;
  3+
  4+export interface BaaJsonObject {
  5+  [key: string]: BaaJsonValue;
  6+}
  7+
  8+export type BaaInstructionParams = BaaJsonValue;
  9+export type BaaInstructionParamsKind = "body" | "inline_json" | "inline_string" | "none";
 10+export type BaaInstructionProcessStatus = "duplicate_only" | "executed" | "no_instructions";
 11+
 12+export interface BaaExtractedBlock {
 13+  blockIndex: number;
 14+  content: string;
 15+  rawBlock: string;
 16+}
 17+
 18+export interface BaaParsedInstruction {
 19+  blockIndex: number;
 20+  params: BaaInstructionParams;
 21+  paramsKind: BaaInstructionParamsKind;
 22+  rawBlock: string;
 23+  rawInstruction: string;
 24+  target: string;
 25+  tool: string;
 26+}
 27+
 28+export interface BaaInstructionSourceMessage {
 29+  assistantMessageId: string;
 30+  conversationId: string;
 31+  platform: string;
 32+}
 33+
 34+export interface BaaInstructionDedupeBasis {
 35+  assistant_message_id: string;
 36+  block_index: number;
 37+  conversation_id: string;
 38+  params: BaaInstructionParams;
 39+  platform: string;
 40+  target: string;
 41+  tool: string;
 42+  version: "baa.v1";
 43+}
 44+
 45+export interface BaaInstructionEnvelope extends BaaInstructionSourceMessage {
 46+  blockIndex: number;
 47+  dedupeBasis: BaaInstructionDedupeBasis;
 48+  dedupeKey: string;
 49+  envelopeVersion: "baa.v1";
 50+  instructionId: string;
 51+  params: BaaInstructionParams;
 52+  paramsKind: BaaInstructionParamsKind;
 53+  rawBlock: string;
 54+  rawInstruction: string;
 55+  target: string;
 56+  tool: string;
 57+}
 58+
 59+export interface BaaInstructionRoute {
 60+  body: BaaJsonObject | null;
 61+  key: string;
 62+  method: "GET" | "POST";
 63+  path: string;
 64+  requiresSharedToken: boolean;
 65+}
 66+
 67+export interface BaaInstructionExecutionResult {
 68+  data: BaaJsonValue | null;
 69+  dedupeKey: string;
 70+  details: BaaJsonValue | null;
 71+  error: string | null;
 72+  httpStatus: number;
 73+  instructionId: string;
 74+  message: string | null;
 75+  ok: boolean;
 76+  requestId: string | null;
 77+  route: {
 78+    key: string;
 79+    method: "GET" | "POST";
 80+    path: string;
 81+  };
 82+  target: string;
 83+  tool: string;
 84+}
 85+
 86+export interface BaaAssistantMessageInput extends BaaInstructionSourceMessage {
 87+  text: string;
 88+}
 89+
 90+export interface BaaInstructionProcessResult {
 91+  blocks: BaaExtractedBlock[];
 92+  duplicates: BaaInstructionEnvelope[];
 93+  executions: BaaInstructionExecutionResult[];
 94+  instructions: BaaInstructionEnvelope[];
 95+  status: BaaInstructionProcessStatus;
 96+}
 97+
 98+export function isBaaJsonValue(value: unknown): value is BaaJsonValue {
 99+  if (value == null) {
100+    return true;
101+  }
102+
103+  switch (typeof value) {
104+    case "boolean":
105+    case "string":
106+      return true;
107+    case "number":
108+      return Number.isFinite(value);
109+    case "object":
110+      if (Array.isArray(value)) {
111+        return value.every((entry) => isBaaJsonValue(entry));
112+      }
113+
114+      return Object.values(value as Record<string, unknown>).every((entry) => isBaaJsonValue(entry));
115+    default:
116+      return false;
117+  }
118+}
119+
120+export function isBaaJsonObject(value: BaaInstructionParams): value is BaaJsonObject {
121+  return value != null && typeof value === "object" && !Array.isArray(value);
122+}
123+
124+export function sortBaaJsonValue<T extends BaaJsonValue>(value: T): T {
125+  if (Array.isArray(value)) {
126+    return value.map((entry) => sortBaaJsonValue(entry)) as T;
127+  }
128+
129+  if (value != null && typeof value === "object") {
130+    const result: BaaJsonObject = {};
131+
132+    for (const key of Object.keys(value).sort((left, right) => left.localeCompare(right))) {
133+      result[key] = sortBaaJsonValue((value as BaaJsonObject)[key]!);
134+    }
135+
136+    return result as T;
137+  }
138+
139+  return value;
140+}
141+
142+export function stableStringifyBaaJson(value: BaaJsonValue): string {
143+  return JSON.stringify(sortBaaJsonValue(value));
144+}
M apps/conductor-daemon/src/local-api.ts
+1, -0
1@@ -3170,6 +3170,7 @@ function buildFirefoxWebSocketData(snapshot: ConductorRuntimeApiSnapshot): JsonO
2       "credentials",
3       "api_endpoints",
4       "client_log",
5+      "browser.final_message",
6       "api_response",
7       "stream_open",
8       "stream_event",
M docs/api/firefox-local-ws.md
+24, -1
 1@@ -43,6 +43,7 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
 2 | `action_result` | 回传 browser/plugin 管理动作的结构化执行结果;带 `accepted` / `completed` / `failed` / `reason` / `result` / `shell_runtime` |
 3 | `credentials` | 上送账号、凭证指纹、新鲜度和脱敏 header 名称摘要;server 只持久化最小元数据 |
 4 | `api_endpoints` | 上送当前可代发的 endpoint 列表及其 `endpoint_metadata` |
 5+| `browser.final_message` | 上送 ChatGPT / Gemini 最终 assistant message 的 raw relay;只传完整最终文本,不在插件里做 parser |
 6 | `api_response` | 对服务端下发的 `api_request` 回包,按 `id` 做 request-response 关联 |
 7 | `stream_open` | 对服务端下发的 SSE `api_request` 回传流已打开,附带 `stream_id` 和上游状态码 |
 8 | `stream_event` | 回传单个流事件;每条都带递增 `seq` |
 9@@ -90,7 +91,7 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
10   "wsUrl": "ws://100.71.210.78:4317/ws/firefox",
11   "localApiBase": "http://100.71.210.78:4317",
12   "supports": {
13-    "inbound": ["hello", "state_request", "action_request", "action_result", "credentials", "api_endpoints", "client_log", "api_response", "stream_open", "stream_event", "stream_end", "stream_error"],
14+    "inbound": ["hello", "state_request", "action_request", "action_result", "credentials", "api_endpoints", "client_log", "browser.final_message", "api_response", "stream_open", "stream_event", "stream_end", "stream_error"],
15     "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"]
16   }
17 }
18@@ -130,6 +131,7 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
19           "client_id": "firefox-ab12cd",
20           "node_platform": "firefox",
21           "credentials": [],
22+          "final_messages": [],
23           "request_hooks": []
24         }
25       ]
26@@ -142,6 +144,7 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
27 
28 - `snapshot.system` 直接复用 `GET /v1/system/state` 的合同
29 - `snapshot.browser.clients[].credentials` 只回传 `account`、`credential_fingerprint`、`freshness`、`header_count` 和时间戳
30+- `snapshot.browser.clients[].final_messages` 只保留当前活跃 bridge client 最近观测到的最终消息,不写入当前持久化表
31 - `snapshot.browser.clients[].request_hooks` 只回传 endpoint 列表、`endpoint_metadata` 和更新时间
32 
33 ### `action_request`
34@@ -230,6 +233,26 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
35 }
36 ```
37 
38+### `browser.final_message`
39+
40+```json
41+{
42+  "type": "browser.final_message",
43+  "platform": "chatgpt",
44+  "conversation_id": "conv_demo",
45+  "assistant_message_id": "msg_demo",
46+  "raw_text": "@conductor::describe",
47+  "observed_at": 1760000012000
48+}
49+```
50+
51+约束:
52+
53+- `raw_text` 必须是完整最终文本,不是 stream chunk
54+- 插件必须在 streaming 完成后再发送,不得在半截 stream 提前上报
55+- `assistant_message_id` 允许退化为平台内等价稳定字段,但最终仍统一放进同名字段
56+- 当前 server 只做接收和最近快照保留,不在这层做 BAA 提取或执行
57+
58 server 行为:
59 
60 - 用 `requestId` 关联对应 HTTP `POST /v1/browser/actions`
M docs/baa-instruction-system-v5/docs/06-integration-with-current-baa-conductor.md
+37, -0
 1@@ -224,3 +224,40 @@ packages/schemas/src/
 2 - 插件很薄
 3 - 结果交付稳定
 4 - 审计 / 去重 / 重试都在 conductor
 5+
 6+## 6.9 当前 Phase 1 落地点
 7+
 8+当前 repo 已先把 service-side instruction center 落进:
 9+
10+```text
11+apps/conductor-daemon/src/instructions/
12+  extract.ts
13+  parse.ts
14+  normalize.ts
15+  dedupe.ts
16+  policy.ts
17+  router.ts
18+  executor.ts
19+  loop.ts
20+```
21+
22+### 当前 Phase 1 行为
23+
24+- 输入是完整 assistant final text,不依赖 streaming 中间态
25+- conductor 只提取 ` ```baa `,普通 Markdown / 普通代码块全部忽略
26+- 先完成 parse + normalize + dedupe + policy + route,再执行;任一前置阶段失败都会 fail-closed
27+- executor 直接复用现有 conductor local API,而不是重新造一套 shell/file 执行器
28+
29+### 当前已接通的工具
30+
31+- `@conductor::describe`
32+- `@conductor::status`
33+- `@conductor::exec`
34+- `@conductor::files/read`
35+- `@conductor::files/write`
36+
37+### 当前仍显式留空的部分
38+
39+- Firefox 插件到 conductor 的 `browser.final_message` 真实桥接
40+- artifact / manifest / delivery plan / upload receipt
41+- `browser.*` / `codex` / `node.*` / `pool.*` / `role.*` 的进一步路由
M docs/firefox/README.md
+25, -2
 1@@ -8,13 +8,14 @@
 2 
 3 ## 正式模型
 4 
 5-当前主线已经收口到三件事:
 6+当前主线已经收口到四件事:
 7 
 8 1. 每个平台维持一个空壳 shell tab,作为登录环境和同源请求来源环境
 9 2. 插件通过本地 `/ws/firefox` 上报登录态元数据、凭证指纹和端点元数据
10 3. 真正的上游请求仍由浏览器本地代发,`conductor` 只负责调度、持久化元数据和暴露读面
11+4. ChatGPT / Gemini 的最终 assistant message 会以 `browser.final_message` 形式做 raw relay
12 
13-页面对话 UI、消息回读、DOM 自动化不是浏览器桥接的正式主能力。当前仍保留的 `GET /v1/browser/claude/current` 只是 Claude relay 的辅助读接口。
14+页面对话 UI、消息回读、DOM 自动化不是浏览器桥接的正式主能力。`browser.final_message` 只负责最终文本原样转发,不在插件里做 BAA parser。当前仍保留的 `GET /v1/browser/claude/current` 只是 Claude relay 的辅助读接口。
15 
16 ## 固定入口
17 
18@@ -68,6 +69,11 @@
19 - 页面会话内容
20 - 页面 UI 状态
21 
22+补充说明:
23+
24+- `browser.final_message.raw_text` 属于 live WS relay,不进入当前持久化表
25+- 当前服务端只保留活跃 bridge client 的最近最终消息快照,用于后续 instruction center 接入
26+
27 `GET /v1/browser` 会把活跃 WS 连接和持久化记录合并成统一读面,并暴露 `fresh` / `stale` / `lost`。
28 
29 ## 给 AI / CLI 的最短入口
30@@ -138,11 +144,26 @@
31 }
32 ```
33 
34+最终消息 raw relay:
35+
36+```json
37+{
38+  "type": "browser.final_message",
39+  "platform": "chatgpt",
40+  "conversation_id": "conv_demo",
41+  "assistant_message_id": "msg_demo",
42+  "raw_text": "@conductor::describe",
43+  "observed_at": 1760000012000
44+}
45+```
46+
47 说明:
48 
49 - `headers` 只保留脱敏占位符,用于让服务端知道 header 名称和数量
50 - 原始凭证值仍只停留在浏览器本地,用于后续同源代发
51 - client 断开或流量长时间老化后,持久化记录仍可读,但会从 `fresh` 变成 `stale` / `lost`
52+- `browser.final_message` 只在最终完成态上报完整文本,不会在 streaming 半截上报
53+- 若平台暂时拿不到原生稳定 message id,插件会退化到等价稳定字段后再放进 `assistant_message_id`
54 
55 ## 浏览器本地代发
56 
57@@ -196,11 +217,13 @@
58 - `fresh` / `stale` / `lost` 状态变化
59 - 读接口不泄露原始凭证值
60 - Claude / ChatGPT 通用 browser request / cancel、正式 SSE 和 Claude legacy wrapper 的最小浏览器本地代发闭环
61+- ChatGPT / Gemini 的 `browser.final_message` raw relay 与最近快照可见性
62 
63 随后插件会继续上送:
64 
65 - `credentials`
66 - `api_endpoints`
67+- `browser.final_message`
68 - `client_log`
69 - `api_response`
70 - `stream_open`
M plans/BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md
+8, -1
 1@@ -2,7 +2,7 @@
 2 
 3 ## 状态
 4 
 5-- `待落地(对应 T-S029)`
 6+- `已落地(T-S029 已实现)`
 7 - 优先级:`high`
 8 - 记录时间:`2026-03-27`
 9 
10@@ -76,6 +76,7 @@
11 - `assistant_message_id` 允许首版按平台能力退化;如果页面暂时取不到稳定 message id,至少要提供当前轮次稳定去重所需的等价字段
12 - `conversation_id` 允许为空,但应尽量提供
13 - `observed_at` 使用插件本地时间戳,单位毫秒
14+- 首版 server 侧只要求 live 接收与最近快照保留,不要求同时落库
15 
16 ## 插件侧需求
17 
18@@ -121,3 +122,9 @@
19 - streaming 半截、重复重放、页面重渲染不会导致同一消息多次上报
20 - 插件仍保持薄层,不引入 BAA 解析逻辑
21 - 文档已同步到 `docs/firefox/`、`plans/`、`tasks/`
22+
23+## 当前平台边界与残余风险
24+
25+- ChatGPT 当前主要依赖 conversation SSE 结构;如果页面后续调整 payload 形态,需要同步修改提取器
26+- Gemini 当前基于 `StreamGenerate` / `batchexecute` 风格 payload 的启发式解析来抽取最终文本;稳定性弱于 ChatGPT,因此保留 synthetic `assistant_message_id` 兜底
27+- conductor 侧这轮只完成 `browser.final_message` 的最小兼容接收和最近快照保留;当前不落持久化表,也没有直接接入 instruction parser / execution loop
M plans/BAA_INSTRUCTION_CENTER_REQUIREMENTS.md
+60, -1
 1@@ -2,7 +2,7 @@
 2 
 3 ## 状态
 4 
 5-- `待落地(对应 T-S030)`
 6+- `已实现 Phase 1 最小闭环(2026-03-27,对应 T-S030)`
 7 - 优先级:`high`
 8 - 记录时间:`2026-03-27`
 9 
10@@ -39,6 +39,52 @@
11 - 首版先做精确 target,不做 `pool.*` / `role.*`
12 - 首版先做最小工具集,不把 artifact / upload barrier 一次性塞进同一任务
13 
14+## 已落地结果
15+
16+### conductor 侧模块
17+
18+已新增:
19+
20+```text
21+apps/conductor-daemon/src/instructions/
22+  extract.ts
23+  parse.ts
24+  normalize.ts
25+  dedupe.ts
26+  policy.ts
27+  router.ts
28+  executor.ts
29+  loop.ts
30+```
31+
32+并从 `apps/conductor-daemon/src/index.ts` 统一导出,方便后续把浏览器 `final_message` relay 直接接到同一套核心逻辑。
33+
34+### 当前已覆盖能力
35+
36+- 只提取 ` ```baa `,普通代码块忽略
37+- 四种参数形式全部可解析:
38+  - 单行字符串
39+  - 单行 JSON
40+  - 多行 body
41+  - 无参数
42+- 标准化 envelope 内保留最小可审计字段,并生成稳定 `dedupe_key`
43+- 当前精确 target 只允许:
44+  - `conductor`
45+  - `system`
46+- 当前最小工具集已打通:
47+  - `@conductor::describe`
48+  - `@conductor::status`
49+  - `@conductor::exec`
50+  - `@conductor::files/read`
51+  - `@conductor::files/write`
52+
53+### 当前验证方式
54+
55+- 单元测试覆盖提取、解析、normalize、dedupe 稳定性
56+- conductor 侧集成测试使用 synthetic complete assistant text 驱动最小执行闭环
57+- 同一条 assistant message replay 时会被 dedupe,避免重复执行
58+- 任一指令在 policy / route 阶段失败时,整批 fail-closed,不会半解析半执行
59+
60 ## 首版范围
61 
62 ### 输入
63@@ -128,6 +174,13 @@ apps/conductor-daemon/src/instructions/
64 - 本需求负责 conductor 侧 instruction center
65 - 为了并行推进,本需求必须允许先用 synthetic message / fixture 跑通,而不阻塞于浏览器端最终消息中继
66 
67+## 刻意留到下一阶段
68+
69+- 浏览器 `final_message` 真实 relay 接线仍由 `T-S029` 收口
70+- artifact materialization / manifest / upload receipt / delivery plan 仍未并入本阶段
71+- `pool.*` / `role.*` 逻辑路由和多节点分发仍未开启
72+- browser / codex / node.* 等非本机 target 仍未接入本轮 executor
73+
74 ## 验收条件
75 
76 - parser 能稳定提取 ` ```baa `,并忽略普通代码块
77@@ -136,3 +189,9 @@ apps/conductor-daemon/src/instructions/
78 - 最小工具集能通过标准化指令跑通执行闭环
79 - 插件侧仍没有 parser 逻辑
80 - 文档已同步到 `plans/`、`tasks/` 和必要的 `docs/baa-instruction-system-v5/`
81+
82+## 当前残余边界
83+
84+- dedupe 目前仍是进程内内存态,进程重启后不会保留
85+- 当前只做本机精确 target,跨节点分发和多轮闭环还没接
86+- 当前 instruction center 仍主要通过 synthetic assistant message / fixture 驱动验证,尚未把 Firefox bridge live `browser.final_message` 输入直接接到 `processAssistantMessage(...)`
M plans/STATUS_SUMMARY.md
+11, -15
 1@@ -8,18 +8,16 @@
 2 
 3 - 浏览器控制主链路收口基线:`main@07895cd`
 4 - 最近功能代码提交:`main@25be868`(启动时自动恢复受管 Firefox shell tabs)
 5-- `2026-03-27` 当前本地代码已额外完成 `BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 修复
 6+- `2026-03-27` 当前本地代码已额外完成 `BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 修复,以及 `T-S029`、`T-S030` 落地
 7 - 任务文档已统一收口到 `tasks/`
 8 - 当前活动任务见 `tasks/TASK_OVERVIEW.md`
 9-- `T-S001` 到 `T-S025`、`T-S027`、`T-S028` 已经完成,`T-BUG-011`、`T-BUG-012`、`T-BUG-014` 也已完成
10+- `T-S001` 到 `T-S030` 已经完成,`T-BUG-011`、`T-BUG-012`、`T-BUG-014` 也已完成
11 
12 ## 当前状态分类
13 
14-- `已完成`:`T-S001` 到 `T-S025`、`T-S027`、`T-S028`,以及 `T-BUG-011`、`T-BUG-012`、`T-BUG-014`
15+- `已完成`:`T-S001` 到 `T-S030`,以及 `T-BUG-011`、`T-BUG-012`、`T-BUG-014`
16 - `当前 TODO`:
17   - `T-S026` 真实 Firefox 手工 smoke 与验收记录
18-  - `T-S029` ChatGPT / Gemini 最终消息观察与 `browser.final_message` raw relay
19-  - `T-S030` BAA 指令解析中心 Phase 1 与最小执行闭环
20 - `待处理缺陷`:当前无 open bug backlog(见 `bugs/README.md`)
21 - `低优先级 TODO`:`4318/status-api` 兼容层删旧与解耦
22 
23@@ -84,8 +82,6 @@
24 
25 - 当前最高优先级剩余任务按顺序是:
26   - `T-S026`:真实 Firefox 手工 smoke 与验收记录
27-  - `T-S029`:补 ChatGPT / Gemini 最终消息观察与 `browser.final_message` raw relay
28-  - `T-S030`:收口 BAA 指令解析中心 Phase 1 与最小执行闭环
29 - 当前 bug backlog 单独留在 `bugs/` 目录,不把它和主线需求任务混写
30 - 当前不把大文件拆分当作主线 blocker
31 - 以下重构工作顺延到下一轮专门重构任务:
32@@ -98,7 +94,7 @@
33 ## 当前缺陷 backlog
34 
35 - 当前 open bug backlog:无
36-- 当前没有 bug fix 正在主线开发中;当前下一波主线任务顺序是 `T-S026 -> (T-S029 || T-S030)`
37+- 当前没有 bug fix 正在主线开发中;当前下一波主线任务顺序是 `T-S026`
38 
39 ## 低优先级 TODO
40 
41@@ -166,16 +162,16 @@
42 - `pnpm verify:mini` 只收口 on-node 静态检查和运行态探针,不替代会话级 smoke
43 - `status-api` 的终局已经先收口到“保留为 opt-in 兼容层”;真正删除它之前,还要先清 `4318` 调用方并拆掉当前构建时复用
44 - 风控状态当前仍是进程内内存态;`conductor` 重启后,限流、退避和熔断计数会重置
45-- 正式 browser HTTP relay 现已正式验收 Claude 与 ChatGPT;其中 ChatGPT 收口为显式 `path` 的 raw relay,Gemini 继续留在下一波
46+- 正式 browser HTTP relay 现已正式验收 Claude 与 ChatGPT;Gemini 当前新增的是最终消息 raw relay,不是 `/v1/browser/request` 正式支持面
47 - 当前 open bug backlog 已清空
48 - 当前主线下一波任务是:
49   - `T-S026`:真实 Firefox 手工 smoke 与验收记录
50-- BAA 下一波工程任务已拆成两个并行方向:
51-  - `T-S029`:插件侧 ChatGPT / Gemini 最终消息观察与 `browser.final_message` raw relay
52-  - `T-S030`:conductor 侧 BAA 指令解析中心与最小执行闭环
53-- 这两个方向默认并行推进,但边界要保持稳定:
54-  - `T-S029` 不把 parser 放回插件
55-  - `T-S030` 允许先用 synthetic final message / fixture 跑通,不阻塞于浏览器端 relay 完工
56+- `T-S029`、`T-S030` 已完成,当前 BAA 第一阶段已具备 ChatGPT / Gemini 最终消息 raw relay、`browser.final_message` 最近快照保留,以及 conductor 侧 instruction center Phase 1 最小闭环
57+- 当前 BAA 仍保留这些边界:
58+  - ChatGPT 当前主要依赖 conversation SSE 结构;如果页面 payload 形态变化,需要同步修改提取器
59+  - Gemini 最终文本提取当前基于 `StreamGenerate` / `batchexecute` 风格 payload 的启发式解析,稳定性弱于 ChatGPT,因此保留 synthetic `assistant_message_id` 兜底
60+  - `browser.final_message` 当前只做最小兼容接收和最近快照保留,不落当前持久化表,也没有直接接入 instruction parser
61+  - instruction dedupe 目前仍是进程内内存态,且只做本机精确 target;跨节点和多轮闭环还没接
62 - `BUG-012` 这轮修复已补上 stale `inFlight` 自愈清扫;当前残余边界是“健康但长时间完全静默”的超长 buffered 请求,理论上仍可能被 `5min` idle 阈值误判
63 - ChatGPT 当前仍依赖浏览器里真实捕获到的有效登录态 / header,且没有 Claude 风格 prompt shortcut;这是当前正式支持面的已知边界
64 - `BUG-014` 的自动化验证目前覆盖的是 conductor 侧语义透传,不是 Firefox 扩展真实运行环境里的 reconnect 生命周期;真实“重连完成”仍依赖后续 `hello` / 状态同步
M plugins/baa-firefox/README.md
+9, -3
 1@@ -1,12 +1,13 @@
 2 # BAA Firefox
 3 
 4-Firefox 插件的正式能力已经收口到三件事:
 5+Firefox 插件的正式能力已经收口到四件事:
 6 
 7 - 为 Claude / ChatGPT / Gemini 维持单个平台单空壳页
 8 - 向本地 `conductor` 上报登录态元数据
 9 - 在浏览器本地持有原始凭证并代发 API 请求
10+- 在 ChatGPT / Gemini 上观察最终 assistant message,并通过 `browser.final_message` 做 raw relay
11 
12-页面对话 UI、会话回读、消息态观察不再是正式能力。
13+页面对话 UI、会话回读和 DOM 自动化不再是正式能力。`browser.final_message` 只负责最终文本转发,不负责 BAA parser。
14 
15 ## 当前默认连接
16 
17@@ -128,9 +129,14 @@ browser.runtime.sendMessage({
18 - 原始 `cookie`
19 - 原始 `authorization` / `token`
20 - 原始 header 值
21-- 页面会话内容
22 - 页面 UI 状态
23 
24+补充:
25+
26+- 插件会 live 发送 `browser.final_message.raw_text`
27+- 这层只保留最终 assistant message 原文,不上传中间 stream chunk
28+- 当前服务端只做接收和最近快照保留,不把它写进现有浏览器元数据持久化表
29+
30 `network_log` / `sse_event` 这类诊断路径不再向 WS 回传请求头值。
31 
32 ## 浏览器本地代发边界
M plugins/baa-firefox/controller.html
+1, -0
1@@ -103,6 +103,7 @@
2     </section>
3   </main>
4 
5+  <script src="final-message.js"></script>
6   <script src="controller.js"></script>
7 </body>
8 </html>
M plugins/baa-firefox/controller.js
+73, -0
  1@@ -165,6 +165,7 @@ const PLATFORMS = {
  2 const PLATFORM_ORDER = Object.keys(PLATFORMS);
  3 const PLATFORM_REQUEST_URL_PATTERNS = PLATFORM_ORDER.flatMap((platform) => PLATFORMS[platform].requestUrlPatterns || PLATFORMS[platform].urlPatterns);
  4 const pendingProxyRequests = new Map();
  5+const FINAL_MESSAGE_HELPERS = globalThis.BAAFinalMessage || null;
  6 
  7 const state = {
  8   clientId: null,
  9@@ -200,6 +201,7 @@ const state = {
 10   geminiSendTemplate: null,
 11   claudeState: createDefaultClaudeState(),
 12   controllerRuntime: createDefaultControllerRuntimeState(),
 13+  finalMessageRelayObservers: createPlatformMap((platform) => createFinalMessageRelayObserver(platform)),
 14   logs: []
 15 };
 16 
 17@@ -865,6 +867,14 @@ function createPlatformMap(factory) {
 18   return out;
 19 }
 20 
 21+function createFinalMessageRelayObserver(platform) {
 22+  if (platform !== "chatgpt" && platform !== "gemini") {
 23+    return null;
 24+  }
 25+
 26+  return FINAL_MESSAGE_HELPERS?.createRelayState(platform) || null;
 27+}
 28+
 29 function cloneHeaderMap(value) {
 30   return value && typeof value === "object" && !Array.isArray(value) ? { ...value } : {};
 31 }
 32@@ -4455,6 +4465,67 @@ function resolvePlatformFromRequest(details) {
 33   return findTrackedPlatformByTabId(details.tabId) || null;
 34 }
 35 
 36+function getObservedPagePlatform(sender, fallbackPlatform = null) {
 37+  const senderUrl = sender?.tab?.url || "";
 38+  return detectPlatformFromUrl(senderUrl) || fallbackPlatform || null;
 39+}
 40+
 41+function relayObservedFinalMessage(platform, relay, source = "page_observed") {
 42+  const observer = state.finalMessageRelayObservers[platform];
 43+  if (!observer || !relay?.payload) return false;
 44+
 45+  if (!wsSend(relay.payload)) {
 46+    addLog("warn", `${platformLabel(platform)} 最终消息未能转发(WS 未连接)`, false);
 47+    return false;
 48+  }
 49+
 50+  FINAL_MESSAGE_HELPERS?.rememberRelay(observer, relay);
 51+  addLog(
 52+    "info",
 53+    `${platformLabel(platform)} 最终消息已转发 assistant=${relay.payload.assistant_message_id} source=${source}`,
 54+    false
 55+  );
 56+  return true;
 57+}
 58+
 59+function observeFinalMessageFromPageNetwork(data, sender) {
 60+  if (!FINAL_MESSAGE_HELPERS || !data || data.source === "proxy" || typeof data.url !== "string") {
 61+    return;
 62+  }
 63+
 64+  const platform = getObservedPagePlatform(sender, data.platform || null);
 65+  const observer = platform ? state.finalMessageRelayObservers[platform] : null;
 66+  if (!observer) return;
 67+
 68+  const relay = FINAL_MESSAGE_HELPERS.observeNetwork(observer, data, {
 69+    observedAt: Date.now(),
 70+    pageUrl: sender?.tab?.url || ""
 71+  });
 72+
 73+  if (relay) {
 74+    relayObservedFinalMessage(platform, relay, "page_network");
 75+  }
 76+}
 77+
 78+function observeFinalMessageFromPageSse(data, sender) {
 79+  if (!FINAL_MESSAGE_HELPERS || !data || data.source === "proxy" || typeof data.url !== "string") {
 80+    return;
 81+  }
 82+
 83+  const platform = getObservedPagePlatform(sender, data.platform || null);
 84+  const observer = platform ? state.finalMessageRelayObservers[platform] : null;
 85+  if (!observer) return;
 86+
 87+  const relay = FINAL_MESSAGE_HELPERS.observeSse(observer, data, {
 88+    observedAt: Date.now(),
 89+    pageUrl: sender?.tab?.url || ""
 90+  });
 91+
 92+  if (relay) {
 93+    relayObservedFinalMessage(platform, relay, "page_sse");
 94+  }
 95+}
 96+
 97 function setClaudeBusy(isBusy, reason = null) {
 98   updateClaudeState({
 99     busy: !!isBusy,
100@@ -4528,6 +4599,7 @@ function applyObservedClaudeSse(data, tabId) {
101 }
102 
103 function handlePageNetwork(data, sender) {
104+  observeFinalMessageFromPageNetwork(data, sender);
105   const context = getSenderContext(sender, data?.platform || null);
106   if (!context || !data || !data.url || !data.method) return;
107   const observedHeaders = Object.keys(data.reqHeaders || {}).length > 0
108@@ -4551,6 +4623,7 @@ function handlePageNetwork(data, sender) {
109 }
110 
111 function handlePageSse(data, sender) {
112+  observeFinalMessageFromPageSse(data, sender);
113   const context = getSenderContext(sender, data?.platform || null);
114   if (!context || !data || !data.url) return;
115 
A plugins/baa-firefox/final-message.js
+692, -0
  1@@ -0,0 +1,692 @@
  2+(function initBaaFinalMessage(globalScope) {
  3+  const CHATGPT_TERMINAL_STATUSES = new Set([
  4+    "completed",
  5+    "finished",
  6+    "finished_successfully",
  7+    "incomplete",
  8+    "max_tokens",
  9+    "stopped"
 10+  ]);
 11+  const RECENT_RELAY_LIMIT = 20;
 12+  const MAX_WALK_DEPTH = 8;
 13+  const MAX_WALK_NODES = 400;
 14+
 15+  function isRecord(value) {
 16+    return value !== null && typeof value === "object" && !Array.isArray(value);
 17+  }
 18+
 19+  function trimToNull(value) {
 20+    return typeof value === "string" && value.trim() ? value.trim() : null;
 21+  }
 22+
 23+  function parseJson(text) {
 24+    if (typeof text !== "string" || !text.trim()) return null;
 25+
 26+    try {
 27+      return JSON.parse(text);
 28+    } catch (_) {
 29+      return null;
 30+    }
 31+  }
 32+
 33+  function simpleHash(input) {
 34+    const text = String(input || "");
 35+    let hash = 2166136261;
 36+
 37+    for (let index = 0; index < text.length; index += 1) {
 38+      hash ^= text.charCodeAt(index);
 39+      hash = Math.imul(hash, 16777619);
 40+    }
 41+
 42+    return (hash >>> 0).toString(16).padStart(8, "0");
 43+  }
 44+
 45+  function normalizeUrlForSignature(url) {
 46+    const raw = trimToNull(url);
 47+    if (!raw) return "-";
 48+
 49+    try {
 50+      const parsed = new URL(raw, "https://platform.invalid/");
 51+      return `${parsed.origin}${parsed.pathname || "/"}${parsed.search || ""}`;
 52+    } catch (_) {
 53+      return raw;
 54+    }
 55+  }
 56+
 57+  function normalizeMessageText(value) {
 58+    if (typeof value !== "string") return null;
 59+
 60+    const normalized = value
 61+      .replace(/\r\n?/gu, "\n")
 62+      .replace(/[ \t]+\n/gu, "\n")
 63+      .replace(/\u200b/gu, "")
 64+      .trim();
 65+
 66+    return normalized ? normalized : null;
 67+  }
 68+
 69+  function flattenTextFragments(value, depth = 0) {
 70+    if (depth > 5 || value == null) return [];
 71+
 72+    if (typeof value === "string") {
 73+      const text = normalizeMessageText(value);
 74+      return text ? [text] : [];
 75+    }
 76+
 77+    if (Array.isArray(value)) {
 78+      return value.flatMap((entry) => flattenTextFragments(entry, depth + 1));
 79+    }
 80+
 81+    if (!isRecord(value)) {
 82+      return [];
 83+    }
 84+
 85+    const out = [];
 86+    for (const key of ["text", "value", "content", "parts", "segments"]) {
 87+      if (!Object.prototype.hasOwnProperty.call(value, key)) continue;
 88+      out.push(...flattenTextFragments(value[key], depth + 1));
 89+    }
 90+    return out;
 91+  }
 92+
 93+  function extractChatgptConversationIdFromUrl(url) {
 94+    const raw = trimToNull(url);
 95+    if (!raw) return null;
 96+
 97+    try {
 98+      const parsed = new URL(raw, "https://chatgpt.com/");
 99+      const pathname = parsed.pathname || "/";
100+      const match = pathname.match(/\/c\/([^/?#]+)/u);
101+      if (match?.[1]) return match[1];
102+
103+      return trimToNull(parsed.searchParams.get("conversation_id"));
104+    } catch (_) {
105+      return null;
106+    }
107+  }
108+
109+  function extractGeminiConversationIdFromUrl(url) {
110+    const raw = trimToNull(url);
111+    if (!raw) return null;
112+
113+    try {
114+      const parsed = new URL(raw, "https://gemini.google.com/");
115+      const pathname = parsed.pathname || "/";
116+      const match = pathname.match(/\/app\/([^/?#]+)/u);
117+      if (match?.[1]) return match[1];
118+
119+      return trimToNull(parsed.searchParams.get("conversation_id"));
120+    } catch (_) {
121+      return null;
122+    }
123+  }
124+
125+  function extractChatgptConversationIdFromReqBody(reqBody) {
126+    const parsed = parseJson(reqBody);
127+    if (!isRecord(parsed)) return null;
128+    return trimToNull(parsed.conversation_id) || trimToNull(parsed.conversationId) || null;
129+  }
130+
131+  function extractGeminiPromptFromReqBody(reqBody) {
132+    if (typeof reqBody !== "string" || !reqBody) return null;
133+
134+    try {
135+      const params = new URLSearchParams(reqBody);
136+      const outerPayload = params.get("f.req");
137+      if (!outerPayload) return null;
138+
139+      const outer = JSON.parse(outerPayload);
140+      if (!Array.isArray(outer) || typeof outer[1] !== "string") return null;
141+
142+      const inner = JSON.parse(outer[1]);
143+      if (!Array.isArray(inner) || !Array.isArray(inner[0])) return null;
144+
145+      return trimToNull(inner[0][0]);
146+    } catch (_) {
147+      return null;
148+    }
149+  }
150+
151+  function parseSseChunkPayload(chunk) {
152+    const source = String(chunk || "");
153+    const dataLines = source
154+      .split("\n")
155+      .filter((line) => line.startsWith("data:"))
156+      .map((line) => line.slice(5).trimStart());
157+    const payloadText = dataLines.join("\n").trim();
158+
159+    if (!payloadText || payloadText === "[DONE]") {
160+      return null;
161+    }
162+
163+    return parseJson(payloadText) || parseJson(source.trim()) || null;
164+  }
165+
166+  function extractChatgptMessageText(message) {
167+    if (!isRecord(message)) return null;
168+
169+    if (Array.isArray(message.content?.parts)) {
170+      const parts = message.content.parts
171+        .flatMap((entry) => flattenTextFragments(entry))
172+        .filter(Boolean);
173+      return normalizeMessageText(parts.join("\n"));
174+    }
175+
176+    if (typeof message.content?.text === "string") {
177+      return normalizeMessageText(message.content.text);
178+    }
179+
180+    if (typeof message.text === "string") {
181+      return normalizeMessageText(message.text);
182+    }
183+
184+    const fragments = flattenTextFragments(message.content);
185+    return normalizeMessageText(fragments.join("\n"));
186+  }
187+
188+  function buildChatgptCandidate(message, envelope, path, context) {
189+    if (!isRecord(message)) return null;
190+
191+    const role = trimToNull(message.author?.role) || trimToNull(message.role);
192+    if (role !== "assistant") {
193+      return null;
194+    }
195+
196+    const rawText = extractChatgptMessageText(message);
197+    if (!rawText) {
198+      return null;
199+    }
200+
201+    const status = trimToNull(message.status)?.toLowerCase() || null;
202+    const metadata = isRecord(message.metadata) ? message.metadata : {};
203+    const terminal = Boolean(
204+      (status && CHATGPT_TERMINAL_STATUSES.has(status))
205+      || message.end_turn === true
206+      || metadata.is_complete === true
207+      || trimToNull(metadata.finish_details?.type)
208+      || trimToNull(metadata.finish_details?.stop)
209+    );
210+    const conversationId =
211+      trimToNull(envelope?.conversation_id)
212+      || trimToNull(envelope?.conversationId)
213+      || trimToNull(message.conversation_id)
214+      || trimToNull(message.conversationId)
215+      || extractChatgptConversationIdFromReqBody(context.reqBody)
216+      || extractChatgptConversationIdFromUrl(context.pageUrl || context.url)
217+      || null;
218+    const assistantMessageId =
219+      trimToNull(message.id)
220+      || trimToNull(message.message_id)
221+      || trimToNull(message.messageId)
222+      || trimToNull(envelope?.message_id)
223+      || trimToNull(envelope?.messageId)
224+      || null;
225+
226+    let score = rawText.length;
227+    if (assistantMessageId) score += 120;
228+    if (conversationId) score += 80;
229+    if (terminal) score += 160;
230+    if ((path || "").includes(".message")) score += 40;
231+
232+    return {
233+      assistantMessageId,
234+      conversationId,
235+      rawText,
236+      score
237+    };
238+  }
239+
240+  function collectChatgptCandidates(root, context) {
241+    const candidates = [];
242+    const queue = [{ value: root, path: "", depth: 0 }];
243+    let walked = 0;
244+
245+    while (queue.length > 0 && walked < MAX_WALK_NODES) {
246+      const current = queue.shift();
247+      walked += 1;
248+      if (!current) continue;
249+
250+      const { depth, path, value } = current;
251+      if (depth > MAX_WALK_DEPTH || value == null) {
252+        continue;
253+      }
254+
255+      if (Array.isArray(value)) {
256+        for (let index = 0; index < value.length && index < 32; index += 1) {
257+          queue.push({
258+            value: value[index],
259+            path: `${path}[${index}]`,
260+            depth: depth + 1
261+          });
262+        }
263+        continue;
264+      }
265+
266+      if (!isRecord(value)) {
267+        continue;
268+      }
269+
270+      const directCandidate = buildChatgptCandidate(value, value, path, context);
271+      if (directCandidate) {
272+        candidates.push(directCandidate);
273+      }
274+
275+      if (isRecord(value.message)) {
276+        const wrappedCandidate = buildChatgptCandidate(value.message, value, `${path}.message`, context);
277+        if (wrappedCandidate) {
278+          candidates.push(wrappedCandidate);
279+        }
280+      }
281+
282+      for (const [key, child] of Object.entries(value).slice(0, 40)) {
283+        queue.push({
284+          value: child,
285+          path: path ? `${path}.${key}` : key,
286+          depth: depth + 1
287+        });
288+      }
289+    }
290+
291+    return candidates.sort((left, right) =>
292+      (right.score - left.score) || (right.rawText.length - left.rawText.length)
293+    )[0] || null;
294+  }
295+
296+  function mergeCandidates(current, next) {
297+    if (!next) return current;
298+    if (!current) return { ...next };
299+
300+    return {
301+      assistantMessageId: next.assistantMessageId || current.assistantMessageId || null,
302+      conversationId: next.conversationId || current.conversationId || null,
303+      rawText:
304+        (next.rawText && next.rawText.length >= (current.rawText || "").length)
305+          ? next.rawText
306+          : current.rawText,
307+      score: Math.max(Number(current.score) || 0, Number(next.score) || 0)
308+    };
309+  }
310+
311+  function extractChatgptCandidateFromChunk(chunk, context) {
312+    const payload = parseSseChunkPayload(chunk);
313+    if (!payload) return null;
314+    return collectChatgptCandidates(payload, context);
315+  }
316+
317+  function extractChatgptCandidateFromText(text, context) {
318+    const parsed = parseJson(text);
319+    if (parsed != null) {
320+      return collectChatgptCandidates(parsed, context);
321+    }
322+
323+    let merged = null;
324+    for (const block of String(text || "").split(/\n\n+/u)) {
325+      merged = mergeCandidates(merged, extractChatgptCandidateFromChunk(block, context));
326+    }
327+    return merged;
328+  }
329+
330+  function looksLikeUrl(text) {
331+    return /^https?:\/\//iu.test(text);
332+  }
333+
334+  function looksIdLike(text) {
335+    if (!text || /\s/u.test(text) || looksLikeUrl(text)) return false;
336+    return /^[A-Za-z0-9:_./-]{6,120}$/u.test(text);
337+  }
338+
339+  function readGeminiLineRoots(text) {
340+    const roots = [];
341+    const source = String(text || "")
342+      .replace(/^\)\]\}'\s*/u, "")
343+      .trim();
344+
345+    if (!source) {
346+      return roots;
347+    }
348+
349+    const wholePayload = parseJson(source);
350+    if (wholePayload != null) {
351+      roots.push(wholePayload);
352+    }
353+
354+    for (const rawLine of source.split(/\r?\n/u)) {
355+      const line = rawLine.trim();
356+      if (!line || /^\d+$/u.test(line)) continue;
357+
358+      const parsedLine = parseJson(line);
359+      if (parsedLine != null) {
360+        roots.push(parsedLine);
361+      }
362+    }
363+
364+    return roots;
365+  }
366+
367+  function collectGeminiIdCandidate(target, kind, value) {
368+    const normalized = trimToNull(value);
369+    if (!normalized || !looksIdLike(normalized)) return;
370+    target.push({
371+      kind,
372+      score: kind === "message" ? 120 : 100,
373+      value: normalized
374+    });
375+  }
376+
377+  function scoreGeminiTextCandidate(text, path, prompt) {
378+    if (!text) return -1;
379+
380+    const normalizedText = normalizeMessageText(text);
381+    if (!normalizedText) return -1;
382+
383+    if (prompt && normalizedText === prompt) return -1;
384+    if (prompt && normalizedText.length < 120 && prompt.includes(normalizedText)) return -1;
385+    if (looksLikeUrl(normalizedText)) return -1;
386+    if (looksIdLike(normalizedText)) return -1;
387+    if (/^(wrb\.fr|generic|di|af\.httprm)$/iu.test(normalizedText)) return -1;
388+    if (/^[\[\]{}",:0-9.\s_-]+$/u.test(normalizedText)) return -1;
389+
390+    const lowerPath = String(path || "").toLowerCase();
391+    let score = Math.min(normalizedText.length, 220);
392+
393+    if (/\s/u.test(normalizedText)) score += 60;
394+    if (/[A-Za-z\u4e00-\u9fff]/u.test(normalizedText)) score += 50;
395+    if (/\n/u.test(normalizedText)) score += 30;
396+    if (/(text|content|message|response|answer|markdown|candidate)/u.test(lowerPath)) score += 80;
397+    if (/(prompt|query|request|input|user)/u.test(lowerPath)) score -= 90;
398+    if (/(url|image|token|safety|metadata|source)/u.test(lowerPath)) score -= 40;
399+
400+    return score;
401+  }
402+
403+  function maybeParseNestedJson(text) {
404+    const normalized = trimToNull(text);
405+    if (!normalized || normalized.length > 200_000) return null;
406+    if (!/^(?:\[|\{)/u.test(normalized)) return null;
407+    return parseJson(normalized);
408+  }
409+
410+  function walkGeminiValue(value, context, bucket, path = "", depth = 0, state = { walked: 0 }) {
411+    if (depth > MAX_WALK_DEPTH || state.walked >= MAX_WALK_NODES || value == null) {
412+      return;
413+    }
414+
415+    state.walked += 1;
416+
417+    if (typeof value === "string") {
418+      const nested = maybeParseNestedJson(value);
419+      if (nested != null) {
420+        walkGeminiValue(nested, context, bucket, `${path}.$json`, depth + 1, state);
421+      }
422+
423+      const normalized = normalizeMessageText(value);
424+      if (!normalized) return;
425+
426+      const score = scoreGeminiTextCandidate(normalized, path, context.prompt);
427+      if (score > 0) {
428+        bucket.texts.push({
429+          path,
430+          score,
431+          text: normalized
432+        });
433+      }
434+      return;
435+    }
436+
437+    if (Array.isArray(value)) {
438+      for (let index = 0; index < value.length && index < 40; index += 1) {
439+        walkGeminiValue(value[index], context, bucket, `${path}[${index}]`, depth + 1, state);
440+      }
441+      return;
442+    }
443+
444+    if (!isRecord(value)) {
445+      return;
446+    }
447+
448+    for (const [key, child] of Object.entries(value).slice(0, 40)) {
449+      const nextPath = path ? `${path}.${key}` : key;
450+      const lowerKey = key.toLowerCase();
451+
452+      if (typeof child === "string") {
453+        if (/(conversation|conv|chat).*id/u.test(lowerKey)) {
454+          collectGeminiIdCandidate(bucket.conversationIds, "conversation", child);
455+        } else if (/(message|response|candidate|turn).*id/u.test(lowerKey)) {
456+          collectGeminiIdCandidate(bucket.messageIds, "message", child);
457+        } else if (lowerKey === "id") {
458+          collectGeminiIdCandidate(bucket.genericIds, "generic", child);
459+        }
460+      }
461+
462+      walkGeminiValue(child, context, bucket, nextPath, depth + 1, state);
463+    }
464+  }
465+
466+  function pickBestId(primary, secondary = []) {
467+    const source = [...primary, ...secondary]
468+      .sort((left, right) => right.score - left.score)
469+      .map((entry) => entry.value);
470+
471+    return trimToNull(source[0]) || null;
472+  }
473+
474+  function extractGeminiCandidateFromText(text, context) {
475+    const roots = readGeminiLineRoots(text);
476+    const bucket = {
477+      conversationIds: [],
478+      genericIds: [],
479+      messageIds: [],
480+      texts: []
481+    };
482+    const geminiContext = {
483+      pageUrl: context.pageUrl || context.url || "",
484+      prompt: extractGeminiPromptFromReqBody(context.reqBody)
485+    };
486+
487+    if (roots.length > 0) {
488+      for (const root of roots) {
489+        walkGeminiValue(root, geminiContext, bucket);
490+      }
491+    } else {
492+      walkGeminiValue(String(text || ""), geminiContext, bucket);
493+    }
494+
495+    const bestText = bucket.texts.sort((left, right) =>
496+      (right.score - left.score) || (right.text.length - left.text.length)
497+    )[0] || null;
498+
499+    if (!bestText?.text) {
500+      return null;
501+    }
502+
503+    return {
504+      assistantMessageId: pickBestId(bucket.messageIds, bucket.genericIds),
505+      conversationId: extractGeminiConversationIdFromUrl(geminiContext.pageUrl) || pickBestId(bucket.conversationIds),
506+      rawText: bestText.text,
507+      score: bestText.score
508+    };
509+  }
510+
511+  function createRelayState(platform) {
512+    return {
513+      activeStream: null,
514+      platform,
515+      recentRelayKeys: []
516+    };
517+  }
518+
519+  function isRelevantStreamUrl(platform, url) {
520+    const lower = String(url || "").toLowerCase();
521+
522+    if (platform === "chatgpt") {
523+      return lower.includes("/conversation");
524+    }
525+
526+    if (platform === "gemini") {
527+      return lower.includes("streamgenerate")
528+        || lower.includes("generatecontent")
529+        || lower.includes("modelresponse")
530+        || lower.includes("bardchatui");
531+    }
532+
533+    return false;
534+  }
535+
536+  function buildStreamSignature(state, detail, meta) {
537+    return [
538+      state.platform,
539+      normalizeUrlForSignature(detail.url),
540+      simpleHash(detail.reqBody || ""),
541+      normalizeUrlForSignature(meta.pageUrl || "")
542+    ].join("|");
543+  }
544+
545+  function ensureActiveStream(state, detail, meta) {
546+    const signature = buildStreamSignature(state, detail, meta);
547+
548+    if (!state.activeStream || state.activeStream.signature !== signature) {
549+      state.activeStream = {
550+        chunks: [],
551+        latestCandidate: null,
552+        pageUrl: meta.pageUrl || "",
553+        reqBody: detail.reqBody || "",
554+        signature,
555+        url: detail.url || ""
556+      };
557+    }
558+
559+    return state.activeStream;
560+  }
561+
562+  function buildRelayEnvelope(platform, candidate, observedAt) {
563+    const rawText = normalizeMessageText(candidate?.rawText);
564+    if (!rawText) return null;
565+
566+    const conversationId = trimToNull(candidate?.conversationId) || null;
567+    const assistantMessageId =
568+      trimToNull(candidate?.assistantMessageId)
569+      || `synthetic_${simpleHash(`${platform}|${conversationId || "-"}|${rawText}`)}`;
570+    const dedupeKey = `${platform}|${conversationId || "-"}|${assistantMessageId}|${rawText}`;
571+
572+    return {
573+      dedupeKey,
574+      payload: {
575+        type: "browser.final_message",
576+        platform,
577+        conversation_id: conversationId,
578+        assistant_message_id: assistantMessageId,
579+        raw_text: rawText,
580+        observed_at: Number.isFinite(observedAt) ? Math.round(observedAt) : Date.now()
581+      }
582+    };
583+  }
584+
585+  function hasSeenRelay(state, relay) {
586+    if (!relay?.dedupeKey) return false;
587+    return state.recentRelayKeys.includes(relay.dedupeKey);
588+  }
589+
590+  function rememberRelay(state, relay) {
591+    if (!relay?.dedupeKey || hasSeenRelay(state, relay)) {
592+      return false;
593+    }
594+
595+    state.recentRelayKeys.push(relay.dedupeKey);
596+    if (state.recentRelayKeys.length > RECENT_RELAY_LIMIT) {
597+      state.recentRelayKeys.splice(0, state.recentRelayKeys.length - RECENT_RELAY_LIMIT);
598+    }
599+    return true;
600+  }
601+
602+  function observeSse(state, detail, meta = {}) {
603+    if (!state || !detail || detail.source === "proxy" || !isRelevantStreamUrl(state.platform, detail.url)) {
604+      return null;
605+    }
606+
607+    if (detail.error) {
608+      state.activeStream = null;
609+      return null;
610+    }
611+
612+    const stream = ensureActiveStream(state, detail, meta);
613+    const context = {
614+      pageUrl: stream.pageUrl,
615+      reqBody: stream.reqBody,
616+      url: stream.url
617+    };
618+
619+    if (typeof detail.chunk === "string" && detail.chunk) {
620+      stream.chunks.push(detail.chunk);
621+
622+      if (state.platform === "chatgpt") {
623+        stream.latestCandidate = mergeCandidates(
624+          stream.latestCandidate,
625+          extractChatgptCandidateFromChunk(detail.chunk, context)
626+        );
627+      }
628+    }
629+
630+    if (detail.done !== true) {
631+      return null;
632+    }
633+
634+    const fullText = stream.chunks.join("\n\n");
635+    const finalCandidate = state.platform === "chatgpt"
636+      ? extractChatgptCandidateFromText(fullText, context)
637+      : extractGeminiCandidateFromText(fullText, context);
638+    const relay = buildRelayEnvelope(
639+      state.platform,
640+      mergeCandidates(stream.latestCandidate, finalCandidate),
641+      meta.observedAt
642+    );
643+
644+    state.activeStream = null;
645+    if (!relay || hasSeenRelay(state, relay)) {
646+      return null;
647+    }
648+
649+    return relay;
650+  }
651+
652+  function observeNetwork(state, detail, meta = {}) {
653+    if (!state || !detail || detail.source === "proxy" || !isRelevantStreamUrl(state.platform, detail.url)) {
654+      return null;
655+    }
656+
657+    if (typeof detail.resBody !== "string" || !detail.resBody) {
658+      return null;
659+    }
660+
661+    const context = {
662+      pageUrl: meta.pageUrl || "",
663+      reqBody: detail.reqBody || "",
664+      url: detail.url || ""
665+    };
666+    const candidate = state.platform === "chatgpt"
667+      ? extractChatgptCandidateFromText(detail.resBody, context)
668+      : extractGeminiCandidateFromText(detail.resBody, context);
669+    const relay = buildRelayEnvelope(state.platform, candidate, meta.observedAt);
670+
671+    if (!relay || hasSeenRelay(state, relay)) {
672+      return null;
673+    }
674+
675+    return relay;
676+  }
677+
678+  const api = {
679+    createRelayState,
680+    extractChatgptCandidateFromChunk,
681+    extractChatgptCandidateFromText,
682+    extractGeminiCandidateFromText,
683+    observeNetwork,
684+    observeSse,
685+    rememberRelay
686+  };
687+
688+  if (typeof module !== "undefined" && module.exports) {
689+    module.exports = api;
690+  }
691+
692+  globalScope.BAAFinalMessage = api;
693+})(typeof globalThis !== "undefined" ? globalThis : this);
M tasks/T-S029.md
+15, -0
 1@@ -130,3 +130,18 @@
 2 - 跑了哪些测试
 3 - 是否有平台特定边界
 4 - 还有哪些剩余风险
 5+
 6+## 完成回写(2026-03-27)
 7+
 8+- 已完成:
 9+  - Firefox 插件已补上 ChatGPT / Gemini 最终 assistant message 观察,并统一发送 `browser.final_message`
10+  - bridge 已最小兼容接收 `browser.final_message`,并把最近快照暴露到 `GET /v1/browser`
11+  - automated smoke 已覆盖 ChatGPT 流结束后再上报、Gemini 最终文本提取,以及 recent snapshot 保留
12+- 平台特定边界:
13+  - ChatGPT 当前主要依赖 conversation SSE 结构;如果页面后续更换 payload 形态,需要同步调整提取器
14+  - Gemini 最终文本提取当前基于 `StreamGenerate` / `batchexecute` 风格 payload 的启发式解析,稳定性弱于 ChatGPT,因此保留 synthetic `assistant_message_id` 兜底
15+  - conductor 侧本轮只做最小兼容接收和最近快照保留,不落当前持久化表,也没有直接接入 instruction parser
16+- 实际验证:
17+  - `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build`:通过
18+  - `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js`:`39/39` 通过
19+  - `node --test /Users/george/code/baa-conductor/tests/browser/browser-control-e2e-smoke.test.mjs`:`6/6` 通过
M tasks/T-S030.md
+15, -0
 1@@ -142,3 +142,18 @@
 2 - 跑了哪些测试
 3 - 有没有刻意留到下一阶段的能力
 4 - 还有哪些剩余风险
 5+
 6+## 完成回写(2026-03-27)
 7+
 8+- 已完成:
 9+  - 已新增 `apps/conductor-daemon/src/instructions/` Phase 1 模块,并从 `apps/conductor-daemon/src/index.ts` 统一导出
10+  - 已打通 `extract -> parse -> normalize -> dedupe -> policy -> route -> execute` 最小闭环
11+  - synthetic assistant message / fixture 自动化已覆盖四种参数形式、稳定 dedupe key、fail-closed,以及最小工具集执行
12+- 刻意保留到下一阶段:
13+  - dedupe 目前仍是进程内内存态,进程重启后不会保留
14+  - 当前只做本机精确 target;跨节点分发和多轮闭环还没接
15+  - 当前 instruction center 仍主要通过 synthetic assistant message 驱动验证,尚未把 Firefox bridge live `browser.final_message` 直接接入执行闭环
16+- 实际验证:
17+  - `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build`:通过
18+  - `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js`:`39/39` 通过
19+  - `node --test /Users/george/code/baa-conductor/tests/browser/browser-control-e2e-smoke.test.mjs`:`6/6` 通过
M tasks/TASK_OVERVIEW.md
+13, -15
 1@@ -11,15 +11,13 @@
 2 - 当前任务卡都放在本目录
 3 - 浏览器控制主链路收口基线:`main@07895cd`
 4 - 最近功能代码提交:`main@25be868`(启动时自动恢复受管 Firefox shell tabs)
 5-- `2026-03-27` 当前本地代码已额外完成 `BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 修复
 6+- `2026-03-27` 当前本地代码已额外完成 `BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 修复,以及 `T-S029`、`T-S030` 落地
 7 
 8 ## 状态分类
 9 
10-- `已完成`:`T-S001` 到 `T-S025`、`T-S027`、`T-S028`,以及 `T-BUG-011`、`T-BUG-012`、`T-BUG-014`
11+- `已完成`:`T-S001` 到 `T-S030`,以及 `T-BUG-011`、`T-BUG-012`、`T-BUG-014`
12 - `当前 TODO`:
13   - `T-S026` 真实 Firefox 手工 smoke 与验收记录
14-  - `T-S029` ChatGPT / Gemini 最终消息观察与 `browser.final_message` raw relay
15-  - `T-S030` BAA 指令解析中心 Phase 1 与最小执行闭环
16 - `待处理缺陷`:当前无 open bug backlog(见 `../bugs/README.md`)
17 - `低优先级 TODO`:`4318/status-api` 兼容层删旧与解耦
18 
19@@ -66,12 +64,12 @@
20 28. [`T-BUG-014.md`](./T-BUG-014.md):修正 `ws_reconnect` 的 `completed` 语义,并补 smoke 断言
21 29. [`T-S027.md`](./T-S027.md):补 `browser-request-policy` stale `inFlight` 自愈清扫
22 30. [`T-S028.md`](./T-S028.md):收口 ChatGPT browser relay 到正式合同
23+31. [`T-S029.md`](./T-S029.md):补 ChatGPT / Gemini 最终消息观察与 `browser.final_message` raw relay
24+32. [`T-S030.md`](./T-S030.md):收口 BAA 指令解析中心 Phase 1 与最小执行闭环
25 
26 ## 已准备的后续任务
27 
28 - [`T-S026.md`](./T-S026.md):真实 Firefox 手工 smoke 与验收记录
29-- [`T-S029.md`](./T-S029.md):补 ChatGPT / Gemini 最终消息观察与 `browser.final_message` raw relay
30-- [`T-S030.md`](./T-S030.md):收口 BAA 指令解析中心 Phase 1 与最小执行闭环
31 
32 当前主线已经额外收口:
33 
34@@ -87,8 +85,6 @@
35 
36 - 当前高优先级剩余任务按顺序是:
37   - [`T-S026.md`](./T-S026.md):真实 Firefox 手工 smoke 与验收记录
38-  - [`T-S029.md`](./T-S029.md):补 ChatGPT / Gemini 最终消息观察与 `browser.final_message` raw relay
39-  - [`T-S030.md`](./T-S030.md):收口 BAA 指令解析中心 Phase 1 与最小执行闭环
40 - 当前没有正在执行中的缺陷修复卡;如需继续推进工程改动,优先从残余风险或后续增强项开新卡
41 
42 ## 当前主线收口情况
43@@ -130,16 +126,18 @@
44 当前已知主线遗留:
45 
46 - 风控状态当前仍是进程内内存态;`conductor` 重启后,限流、退避和熔断计数会重置
47-- 正式 browser HTTP relay 现已正式验收 Claude 与 ChatGPT;其中 ChatGPT 收口为显式 `path` 的 raw relay,Gemini 继续留在下一波
48+- 正式 browser HTTP relay 现已正式验收 Claude 与 ChatGPT;Gemini 当前新增的是最终消息 raw relay,不是 `/v1/browser/request` 正式支持面
49 - 当前 open bug backlog 已清空
50 - 当前主线剩余任务是:
51   - [`T-S026.md`](./T-S026.md):补真实 Firefox 手工 smoke 与验收记录
52-- BAA 下一波工程任务已拆成两个并行方向:
53-  - [`T-S029.md`](./T-S029.md):插件侧 ChatGPT / Gemini 最终消息观察与 `browser.final_message` raw relay
54-  - [`T-S030.md`](./T-S030.md):conductor 侧 BAA 指令解析中心与最小执行闭环
55-- 这两个方向默认并行推进,但边界要保持稳定:
56-  - `T-S029` 以插件侧最终消息 raw relay 为主,不把 parser 放回插件
57-  - `T-S030` 以 synthetic final message / fixture 先跑通,不阻塞于浏览器端 relay 完工
58+- `T-S029`、`T-S030` 已完成,当前 BAA 第一阶段已具备:
59+  - ChatGPT / Gemini 最终消息 raw relay 与 `browser.final_message` 快照保留
60+  - conductor 侧 instruction center Phase 1 最小闭环
61+- 当前保留的 BAA 边界是:
62+  - ChatGPT 当前主要依赖 conversation SSE 结构;页面 payload 形态变化后需要同步调整提取器
63+  - Gemini 最终文本提取当前基于 `StreamGenerate` / `batchexecute` 风格 payload 的启发式解析,稳定性弱于 ChatGPT,因此保留 synthetic `assistant_message_id` 兜底
64+  - `browser.final_message` 当前只做最小兼容接收和最近快照保留,不落当前持久化表,也没有直接接入 instruction parser
65+  - instruction dedupe 目前仍是进程内内存态,且只做本机精确 target;跨节点和多轮闭环还没接
66 - `BUG-012` 这轮修复已补上 stale `inFlight` 自愈清扫;当前残余边界是“健康但长时间完全静默”的超长 buffered 请求,理论上仍可能被 `5min` idle 阈值误判
67 - ChatGPT 当前仍依赖浏览器里真实捕获到的有效登录态 / header,且没有 Claude 风格 prompt shortcut;这是当前正式支持面的已知边界,不是 regression
68 - `BUG-014` 的自动化验证目前覆盖的是 conductor 侧语义透传,不是 Firefox 扩展真实运行环境里的 reconnect 生命周期;真实“重连完成”仍依赖后续 `hello` / 状态同步
M tests/browser/browser-control-e2e-smoke.test.mjs
+261, -0
  1@@ -1,11 +1,19 @@
  2 import assert from "node:assert/strict";
  3 import { mkdtempSync, rmSync } from "node:fs";
  4+import { createRequire } from "node:module";
  5 import { tmpdir } from "node:os";
  6 import { join } from "node:path";
  7 import test from "node:test";
  8 
  9 import { ConductorRuntime } from "../../apps/conductor-daemon/dist/index.js";
 10 
 11+const require = createRequire(import.meta.url);
 12+const {
 13+  createRelayState,
 14+  observeSse,
 15+  rememberRelay
 16+} = require("../../plugins/baa-firefox/final-message.js");
 17+
 18 function createWebSocketMessageQueue(socket) {
 19   const messages = [];
 20   const waiters = [];
 21@@ -326,6 +334,128 @@ function sendPluginActionResult(socket, input) {
 22   );
 23 }
 24 
 25+test("final message relay observer waits for ChatGPT stream completion and suppresses duplicates", () => {
 26+  const relayState = createRelayState("chatgpt");
 27+  const pageUrl = "https://chatgpt.com/c/conv-chatgpt-smoke";
 28+  const url = "https://chatgpt.com/backend-api/conversation";
 29+
 30+  const firstRelay = observeSse(
 31+    relayState,
 32+    {
 33+      url,
 34+      reqBody: JSON.stringify({
 35+        conversation_id: "conv-chatgpt-smoke"
 36+      }),
 37+      chunk: 'data: {"conversation_id":"conv-chatgpt-smoke","message":{"id":"msg-chatgpt-smoke","author":{"role":"assistant"},"status":"in_progress","content":{"content_type":"text","parts":["half way there"]}}}',
 38+      done: false,
 39+      source: "page"
 40+    },
 41+    {
 42+      observedAt: 1_710_000_001_000,
 43+      pageUrl
 44+    }
 45+  );
 46+  assert.equal(firstRelay, null);
 47+
 48+  const completedRelay = observeSse(
 49+    relayState,
 50+    {
 51+      url,
 52+      reqBody: JSON.stringify({
 53+        conversation_id: "conv-chatgpt-smoke"
 54+      }),
 55+      chunk: 'data: {"conversation_id":"conv-chatgpt-smoke","message":{"id":"msg-chatgpt-smoke","author":{"role":"assistant"},"status":"finished_successfully","end_turn":true,"content":{"content_type":"text","parts":["final ChatGPT answer"]}}}',
 56+      done: true,
 57+      source: "page"
 58+    },
 59+    {
 60+      observedAt: 1_710_000_002_000,
 61+      pageUrl
 62+    }
 63+  );
 64+  assert.ok(completedRelay);
 65+  assert.equal(completedRelay.payload.type, "browser.final_message");
 66+  assert.equal(completedRelay.payload.platform, "chatgpt");
 67+  assert.equal(completedRelay.payload.conversation_id, "conv-chatgpt-smoke");
 68+  assert.equal(completedRelay.payload.assistant_message_id, "msg-chatgpt-smoke");
 69+  assert.equal(completedRelay.payload.raw_text, "final ChatGPT answer");
 70+
 71+  rememberRelay(relayState, completedRelay);
 72+
 73+  const duplicateRelay = observeSse(
 74+    relayState,
 75+    {
 76+      url,
 77+      reqBody: JSON.stringify({
 78+        conversation_id: "conv-chatgpt-smoke"
 79+      }),
 80+      chunk: 'data: {"conversation_id":"conv-chatgpt-smoke","message":{"id":"msg-chatgpt-smoke","author":{"role":"assistant"},"status":"finished_successfully","end_turn":true,"content":{"content_type":"text","parts":["final ChatGPT answer"]}}}',
 81+      done: true,
 82+      source: "page"
 83+    },
 84+    {
 85+      observedAt: 1_710_000_003_000,
 86+      pageUrl
 87+    }
 88+  );
 89+  assert.equal(duplicateRelay, null);
 90+});
 91+
 92+test("final message relay observer extracts Gemini final text only after stream completion", () => {
 93+  const relayState = createRelayState("gemini");
 94+  const pageUrl = "https://gemini.google.com/app/conv-gemini-smoke";
 95+  const url = "https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate";
 96+  const reqBody = new URLSearchParams({
 97+    "f.req": JSON.stringify([
 98+      null,
 99+      JSON.stringify([["Prompt from user"]])
100+    ])
101+  }).toString();
102+  const partialChunk = JSON.stringify([
103+    ["wrb.fr", "req-smoke", JSON.stringify([["partial response"]]), null, null, null, "generic"]
104+  ]);
105+  const finalChunk = JSON.stringify([
106+    ["wrb.fr", "req-smoke", JSON.stringify([["Gemini final answer with two lines.\n\nSecond paragraph."]]), null, null, null, "generic"]
107+  ]);
108+
109+  const firstRelay = observeSse(
110+    relayState,
111+    {
112+      url,
113+      reqBody,
114+      chunk: partialChunk,
115+      done: false,
116+      source: "page"
117+    },
118+    {
119+      observedAt: 1_710_000_004_000,
120+      pageUrl
121+    }
122+  );
123+  assert.equal(firstRelay, null);
124+
125+  const completedRelay = observeSse(
126+    relayState,
127+    {
128+      url,
129+      reqBody,
130+      chunk: finalChunk,
131+      done: true,
132+      source: "page"
133+    },
134+    {
135+      observedAt: 1_710_000_005_000,
136+      pageUrl
137+    }
138+  );
139+  assert.ok(completedRelay);
140+  assert.equal(completedRelay.payload.type, "browser.final_message");
141+  assert.equal(completedRelay.payload.platform, "gemini");
142+  assert.equal(completedRelay.payload.conversation_id, "conv-gemini-smoke");
143+  assert.equal(completedRelay.payload.raw_text, "Gemini final answer with two lines.\n\nSecond paragraph.");
144+  assert.match(completedRelay.payload.assistant_message_id, /^(?:synthetic_)?[A-Za-z0-9:_-]+/u);
145+});
146+
147 test("browser control e2e smoke covers metadata read surface plus Claude and ChatGPT relay", async () => {
148   const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-control-e2e-smoke-"));
149   const runtime = new ConductorRuntime(
150@@ -1114,6 +1244,137 @@ test("browser control e2e smoke covers metadata read surface plus Claude and Cha
151   }
152 });
153 
154+test("browser control e2e smoke accepts browser.final_message and keeps recent relay snapshots", async () => {
155+  const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-final-message-smoke-"));
156+  const runtime = new ConductorRuntime(
157+    {
158+      nodeId: "mini-main",
159+      host: "mini",
160+      role: "primary",
161+      controlApiBase: "https://conductor.example.test",
162+      localApiBase: "http://127.0.0.1:0",
163+      sharedToken: "replace-me",
164+      paths: {
165+        runsDir: "/tmp/runs",
166+        stateDir
167+      }
168+    },
169+    {
170+      autoStartLoops: false,
171+      now: () => 100
172+    }
173+  );
174+
175+  let client = null;
176+
177+  try {
178+    const snapshot = await runtime.start();
179+    client = await connectFirefoxBridgeClient(
180+      snapshot.controlApi.firefoxWsUrl,
181+      "firefox-final-message-smoke"
182+    );
183+
184+    client.socket.send(
185+      JSON.stringify({
186+        type: "browser.final_message",
187+        platform: "chatgpt",
188+        conversation_id: "conv-chatgpt-final-smoke",
189+        assistant_message_id: "msg-chatgpt-final-smoke",
190+        raw_text: "final ChatGPT browser relay",
191+        observed_at: 1_710_000_006_000
192+      })
193+    );
194+
195+    const firstSnapshot = await client.queue.next(
196+      (message) =>
197+        message.type === "state_snapshot"
198+        && message.reason === "browser.final_message"
199+        && message.snapshot.browser.clients.some((entry) =>
200+          entry.client_id === "firefox-final-message-smoke"
201+          && entry.final_messages.some((finalMessage) =>
202+            finalMessage.platform === "chatgpt"
203+            && finalMessage.assistant_message_id === "msg-chatgpt-final-smoke"
204+            && finalMessage.raw_text === "final ChatGPT browser relay"
205+          )
206+        )
207+    );
208+    const firstClient = firstSnapshot.snapshot.browser.clients.find(
209+      (entry) => entry.client_id === "firefox-final-message-smoke"
210+    );
211+    assert.ok(firstClient);
212+    assert.equal(firstClient.final_messages.length, 1);
213+
214+    client.socket.send(
215+      JSON.stringify({
216+        type: "browser.final_message",
217+        platform: "chatgpt",
218+        conversation_id: "conv-chatgpt-final-smoke",
219+        assistant_message_id: "msg-chatgpt-final-smoke",
220+        raw_text: "final ChatGPT browser relay",
221+        observed_at: 1_710_000_006_500
222+      })
223+    );
224+
225+    const duplicateSnapshot = await client.queue.next(
226+      (message) =>
227+        message.type === "state_snapshot"
228+        && message.reason === "browser.final_message"
229+        && message.snapshot.browser.clients.some((entry) =>
230+          entry.client_id === "firefox-final-message-smoke"
231+          && entry.final_messages.some((finalMessage) =>
232+            finalMessage.assistant_message_id === "msg-chatgpt-final-smoke"
233+          )
234+        )
235+    );
236+    const duplicateClient = duplicateSnapshot.snapshot.browser.clients.find(
237+      (entry) => entry.client_id === "firefox-final-message-smoke"
238+    );
239+    assert.ok(duplicateClient);
240+    assert.equal(duplicateClient.final_messages.length, 1);
241+
242+    client.socket.send(
243+      JSON.stringify({
244+        type: "browser.final_message",
245+        platform: "gemini",
246+        conversation_id: "conv-gemini-final-smoke",
247+        assistant_message_id: "synthetic_gemini_smoke",
248+        raw_text: "final Gemini browser relay",
249+        observed_at: 1_710_000_007_000
250+      })
251+    );
252+
253+    const secondSnapshot = await client.queue.next(
254+      (message) =>
255+        message.type === "state_snapshot"
256+        && message.reason === "browser.final_message"
257+        && message.snapshot.browser.clients.some((entry) =>
258+          entry.client_id === "firefox-final-message-smoke"
259+          && entry.final_messages.some((finalMessage) =>
260+            finalMessage.platform === "gemini"
261+            && finalMessage.raw_text === "final Gemini browser relay"
262+          )
263+        )
264+    );
265+    const secondClient = secondSnapshot.snapshot.browser.clients.find(
266+      (entry) => entry.client_id === "firefox-final-message-smoke"
267+    );
268+    assert.ok(secondClient);
269+    assert.equal(secondClient.final_messages.length, 2);
270+  } finally {
271+    client?.queue.stop();
272+
273+    if (client?.socket && client.socket.readyState < WebSocket.CLOSING) {
274+      client.socket.close(1000, "done");
275+    }
276+
277+    await runtime.stop();
278+    rmSync(stateDir, {
279+      force: true,
280+      recursive: true
281+    });
282+  }
283+});
284+
285 test("browser control e2e smoke keeps persisted browser metadata readable across disconnect and restart", async () => {
286   const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-control-persistence-smoke-"));
287   const createRuntime = () =>