baa-conductor

git clone 

commit
5cdeb5d
parent
85a27b6
author
codex@macbookpro
date
2026-03-27 18:40:31 +0800 CST
feat: add live instruction ingest and artifact center core
30 files changed,  +2754, -40
M PROGRESS/2026-03-27-current-code-progress.md
+49, -14
  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` 修复,以及 `T-S029`、`T-S030` 的功能落地。
  6-- 当前浏览器桥接主线已经完成到“Firefox 本地 WS bridge + Claude / ChatGPT 代发 + ChatGPT / Gemini 最终消息 raw relay + 结构化 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`、`T-S031`、`T-S032` 的功能落地。
  8+- 当前浏览器桥接主线已经完成到“Firefox 本地 WS bridge + Claude / ChatGPT 代发 + ChatGPT / Gemini 最终消息 raw relay + live instruction ingest + 结构化 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 / ChatGPT request relay”;当前更准确的表述是“通用 browser surface 已落地,正式 request relay 已覆盖 Claude 和 ChatGPT,ChatGPT / Gemini 的 `browser.final_message` raw relay 也已接通,但这层仍只是最终消息中继,不等于 Gemini 正式 request relay 已转正”。
 11-- 当前仍不能写成“全部收尾完成”。剩余未闭项主要是:真实 Firefox 手工 smoke 未完成、风控状态仍是进程内内存态、ChatGPT 仍依赖真实浏览器登录态 / header 且没有 Claude 风格 prompt shortcut、ChatGPT / Gemini 最终消息提取仍有平台特定边界,以及 `browser.final_message` 尚未持久化也未直接接入 instruction parser。
 12-- 此前拆出的后续任务卡里,`T-S027`、`T-S028`、`T-S029`、`T-S030` 已完成;当前主要剩余 `T-S026`。
 13+- 当前仍不能写成“全部收尾完成”。剩余未闭项主要是:真实 Firefox 手工 smoke 未完成、风控状态仍是进程内内存态、ChatGPT 仍依赖真实浏览器登录态 / header 且没有 Claude 风格 prompt shortcut、ChatGPT / Gemini 最终消息提取仍有平台特定边界,以及 live message dedupe / instruction dedupe 仍未持久化。
 14+- 此前拆出的后续任务卡里,`T-S027`、`T-S028`、`T-S029`、`T-S030`、`T-S031`、`T-S032` 已完成;当前主要剩余 `T-S026`。
 15 
 16 ## 本次核对依据
 17 
 18@@ -26,7 +26,7 @@
 19 - `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build`
 20   - 结果:通过
 21 - `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js`
 22-  - 结果:`39/39` 通过
 23+  - 结果:`44/44` 通过
 24 - `node --test /Users/george/code/baa-conductor/tests/browser/browser-control-e2e-smoke.test.mjs`
 25   - 结果:`6/6` 通过
 26 
 27@@ -147,6 +147,40 @@
 28   - `../apps/conductor-daemon/src/index.ts`
 29   - `../apps/conductor-daemon/src/index.test.js`
 30 
 31+### 14. live `browser.final_message` 已接入 instruction center
 32+
 33+- `conductor-daemon` 当前会在收到 `browser.final_message` 后直接进入 live instruction ingest。
 34+- 当前读面已暴露:
 35+  - Firefox WS `state_snapshot.snapshot.browser.instruction_ingest`
 36+  - `GET /v1/browser -> data.instruction_ingest`
 37+- 自动化已覆盖:
 38+  - 普通消息忽略
 39+  - replay 去重
 40+  - 缺少 `conversation_id` 可容忍
 41+  - live final message 触发执行并暴露最近摘要
 42+- 证据:
 43+  - `../apps/conductor-daemon/src/instructions/ingest.ts`
 44+  - `../apps/conductor-daemon/src/firefox-ws.ts`
 45+  - `../apps/conductor-daemon/src/local-api.ts`
 46+  - `../apps/conductor-daemon/src/index.test.js`
 47+
 48+### 15. service-side artifact center core 已落地
 49+
 50+- `apps/conductor-daemon/src/artifacts/` 当前已新增:
 51+  - `types.ts`
 52+  - `materialize.ts`
 53+  - `manifest.ts`
 54+  - `delivery-plan.ts`
 55+- synthetic execution result 已覆盖:
 56+  - `exec` -> artifact + manifest + delivery plan
 57+  - `files/read` small / large 的不同 delivery 策略
 58+  - 多结果稳定排序
 59+  - 缺少可选字段不崩整批 materialize
 60+- 证据:
 61+  - `../apps/conductor-daemon/src/artifacts/`
 62+  - `../apps/conductor-daemon/src/artifacts.test.js`
 63+  - `../apps/conductor-daemon/src/index.test.js`
 64+
 65 ### 4. 浏览器风控策略已在 `conductor-daemon` 内实现
 66 
 67 - 默认策略已实装,不只是文档约定:
 68@@ -275,11 +309,10 @@
 69 - 但它的最终文本提取基于 `StreamGenerate` / `batchexecute` 风格 payload 的启发式解析,稳定性弱于 ChatGPT。
 70 - 当前保留 synthetic `assistant_message_id` 兜底,因此这层应表述为“已接通 raw relay,但平台提取稳定性仍有边界”,不是“Gemini 整个平台能力都已转正”。
 71 
 72-### 4. `browser.final_message` 当前只做最小兼容接收
 73+### 4. live message dedupe 和 instruction dedupe 仍是内存态
 74 
 75-- `conductor-daemon` 当前已经 live 接收 `browser.final_message`,并保留最近快照。
 76-- 但这轮还没有把它落到持久化表,也没有直接接到 instruction parser / execution loop。
 77-- 因此当前更准确的口径是“消息中继和快照已落地,自动执行接线仍留在下一阶段”。
 78+- `browser.final_message` 当前已经 live 接入 instruction center,但 live message dedupe 和 instruction dedupe 都还是进程内内存态。
 79+- 因此进程重启后,这两类 dedupe 状态都不会保留。
 80 
 81 ### 5. 风控状态仍是进程内内存态
 82 
 83@@ -304,11 +337,13 @@
 84 - 但它仍依赖浏览器里真实捕获到的有效登录态 / header,不是“无前提可用”的平台。
 85 - 另外,ChatGPT 也没有 Claude 风格的 prompt shortcut;当前正式支持面仍是 raw relay,不是 prompt helper。
 86 
 87-### 9. instruction dedupe 仍是内存态,且只覆盖本机精确 target
 88+### 9. 当前摘要只保留最近一次 live ingest / execute,且仍只覆盖 Phase 1 exact target
 89 
 90-- Phase 1 instruction center 当前已能稳定生成 dedupe key,并避免同一条 assistant message replay 重复执行。
 91-- 但 dedupe 目前仍是进程内内存态,进程重启后不会保留。
 92-- 当前路由也只做本机精确 target,跨节点和多轮闭环还没接。
 93+- 当前 live ingest 读面只保留最近一次 ingest / execute 摘要,不落库。
 94+- 当前仍只允许 Phase 1 精确 target:
 95+  - `conductor`
 96+  - `system`
 97+- 虽然 service-side artifact center core 已落地,但 live 路径还没有把执行结果真正接到 artifact / upload / inject / send。
 98 
 99 ## 已拆出的后续任务
100 
101@@ -331,4 +366,4 @@
102 
103 如果只写一段给外部协作者看,可以用下面这版:
104 
105-> 当前代码已经完成单节点 `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 运行态仍是进程内内存态。
106+> 当前代码已经完成单节点 `mini` 主接口收口,以及 Firefox 本地 bridge 下的 Claude / ChatGPT request relay 主链路、ChatGPT / Gemini 最终消息 raw relay、conductor 侧 live instruction ingest,以及 service-side artifact center core。`GET /v1/browser`、`POST /v1/browser/actions`、`POST /v1/browser/request`、`POST /v1/browser/request/cancel`、正式 SSE、结构化 `action_result`、`shell_runtime`、登录态元数据持久化、`browser.final_message` 最近快照、`instruction_ingest` 最近摘要,以及 `BUG-012` 的 stale `inFlight` 自愈清扫都已落地;`BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 也已在当前代码中修复,并已通过 `conductor-daemon build`、`index.test.js`(44/44)和 browser-control e2e smoke(6/6)。当前剩余缺口主要是:真实 Firefox 手工 smoke 未完成、ChatGPT / Gemini 最终消息提取仍有平台边界、live message dedupe / instruction dedupe 仍是内存态、最近摘要不落库,以及 live 路径还没有真正接到 artifact / upload / inject / send。
A apps/conductor-daemon/src/artifacts.test.js
+327, -0
  1@@ -0,0 +1,327 @@
  2+import assert from "node:assert/strict";
  3+import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
  4+import { tmpdir } from "node:os";
  5+import { join } from "node:path";
  6+import test from "node:test";
  7+
  8+import {
  9+  buildBaaArtifactManifest,
 10+  buildBaaDeliveryPlan,
 11+  materializeBaaExecutionArtifacts,
 12+  renderBaaArtifactIndexText
 13+} from "../dist/index.js";
 14+
 15+function createExecutionResult(overrides = {}) {
 16+  return {
 17+    data: null,
 18+    dedupeKey: "dedupe-default",
 19+    details: null,
 20+    error: null,
 21+    httpStatus: 200,
 22+    instructionId: "inst_default",
 23+    message: null,
 24+    ok: true,
 25+    requestId: "req-default",
 26+    route: {
 27+      key: "local.default",
 28+      method: "POST",
 29+      path: "/v1/default"
 30+    },
 31+    target: "conductor",
 32+    tool: "status",
 33+    ...overrides
 34+  };
 35+}
 36+
 37+test("artifact materializer turns exec output into artifact, manifest, and delivery plan uploads", async () => {
 38+  const outputDir = mkdtempSync(join(tmpdir(), "baa-conductor-artifacts-exec-"));
 39+
 40+  try {
 41+    const materialization = await materializeBaaExecutionArtifacts(
 42+      [
 43+        {
 44+          blockIndex: 2,
 45+          execution: createExecutionResult({
 46+            data: {
 47+              ok: true,
 48+              operation: "exec",
 49+              input: {
 50+                command: "printf 'hello artifact'",
 51+                cwd: "/tmp",
 52+                maxBufferBytes: 1024,
 53+                timeoutMs: 2000
 54+              },
 55+              result: {
 56+                durationMs: 4,
 57+                exitCode: 0,
 58+                finishedAt: "2026-03-27T12:00:01.000Z",
 59+                signal: null,
 60+                startedAt: "2026-03-27T12:00:00.000Z",
 61+                stderr: "",
 62+                stdout: "hello artifact",
 63+                timedOut: false
 64+              }
 65+            },
 66+            dedupeKey: "dedupe-exec-1",
 67+            instructionId: "inst_exec_01",
 68+            requestId: "req-exec-1",
 69+            route: {
 70+              key: "local.exec",
 71+              method: "POST",
 72+              path: "/v1/exec"
 73+            },
 74+            tool: "exec"
 75+          })
 76+        }
 77+      ],
 78+      {
 79+        artifactOnlyTextBytes: 2000,
 80+        inlineTextBytes: 200,
 81+        outputDir,
 82+        roundId: "r03",
 83+        traceId: "trace_artifacts"
 84+      }
 85+    );
 86+
 87+    assert.equal(materialization.results[0].deliveryMode, "inline_and_artifact");
 88+    assert.equal(materialization.artifacts.length, 1);
 89+    assert.match(materialization.results[0].summary, /exec succeeded/);
 90+    assert.equal(existsSync(materialization.artifacts[0].localPath), true);
 91+    assert.match(readFileSync(materialization.artifacts[0].localPath, "utf8"), /\[stdout\]\nhello artifact/);
 92+
 93+    const manifestBundle = await buildBaaArtifactManifest(materialization);
 94+    const indexText = renderBaaArtifactIndexText(materialization, manifestBundle);
 95+    const plan = buildBaaDeliveryPlan({
 96+      conversationId: "conv_exec",
 97+      indexText,
 98+      manifestBundle,
 99+      materialization,
100+      target: "browser.claude"
101+    });
102+
103+    assert.equal(plan.uploads.length, 2);
104+    assert.deepEqual(plan.pendingBarriers, ["upload_receipt"]);
105+    assert.equal(plan.receiptBarrierImplemented, false);
106+    assert.match(indexText, /instruction_id: inst_exec_01/);
107+    assert.match(indexText, /baa-result_trace-artifacts_r03_b02_exec_conductor_ok\.log/);
108+    assert.match(indexText, /baa-manifest_trace-artifacts_r03\.json/);
109+  } finally {
110+    rmSync(outputDir, {
111+      force: true,
112+      recursive: true
113+    });
114+  }
115+});
116+
117+test("files/read small and large payloads do not collapse to the same delivery strategy", async () => {
118+  const outputDir = mkdtempSync(join(tmpdir(), "baa-conductor-artifacts-files-read-"));
119+  const largeContent = `HEADER\n${"0123456789".repeat(700)}\nTAIL_MARKER`;
120+
121+  try {
122+    const materialization = await materializeBaaExecutionArtifacts(
123+      [
124+        {
125+          blockIndex: 1,
126+          execution: createExecutionResult({
127+            data: {
128+              ok: true,
129+              operation: "files/read",
130+              input: {
131+                cwd: "/tmp",
132+                encoding: "utf8",
133+                path: "small.txt"
134+              },
135+              result: {
136+                absolutePath: "/tmp/small.txt",
137+                content: "small text\n",
138+                encoding: "utf8",
139+                modifiedAt: "2026-03-27T12:00:00.000Z",
140+                sizeBytes: 11
141+              }
142+            },
143+            dedupeKey: "dedupe-read-small",
144+            instructionId: "inst_read_small",
145+            route: {
146+              key: "local.files.read",
147+              method: "POST",
148+              path: "/v1/files/read"
149+            },
150+            tool: "files/read"
151+          })
152+        },
153+        {
154+          blockIndex: 2,
155+          execution: createExecutionResult({
156+            data: {
157+              ok: true,
158+              operation: "files/read",
159+              input: {
160+                cwd: "/tmp",
161+                encoding: "utf8",
162+                path: "large.txt"
163+              },
164+              result: {
165+                absolutePath: "/tmp/large.txt",
166+                content: largeContent,
167+                encoding: "utf8",
168+                modifiedAt: "2026-03-27T12:00:00.000Z",
169+                sizeBytes: largeContent.length
170+              }
171+            },
172+            dedupeKey: "dedupe-read-large",
173+            instructionId: "inst_read_large",
174+            route: {
175+              key: "local.files.read",
176+              method: "POST",
177+              path: "/v1/files/read"
178+            },
179+            tool: "files/read"
180+          })
181+        }
182+      ],
183+      {
184+        artifactOnlyTextBytes: 1024,
185+        inlineTextBytes: 128,
186+        outputDir,
187+        roundId: "r05",
188+        traceId: "trace_files"
189+      }
190+    );
191+    const manifestBundle = await buildBaaArtifactManifest(materialization);
192+    const indexText = renderBaaArtifactIndexText(materialization, manifestBundle);
193+    const plan = buildBaaDeliveryPlan({
194+      indexText,
195+      manifestBundle,
196+      materialization,
197+      target: "browser.chatgpt"
198+    });
199+
200+    assert.equal(materialization.results[0].deliveryMode, "inline");
201+    assert.equal(materialization.results[0].artifacts.length, 0);
202+    assert.equal(materialization.results[1].deliveryMode, "artifact_only");
203+    assert.equal(materialization.results[1].artifacts.length, 1);
204+    assert.match(indexText, /small text/);
205+    assert.match(indexText, /baa-result_trace-files_r05_b02_files-read_conductor_ok\.txt/);
206+    assert.doesNotMatch(indexText, /TAIL_MARKER/);
207+    assert.equal(plan.uploads.length, 2);
208+  } finally {
209+    rmSync(outputDir, {
210+      force: true,
211+      recursive: true
212+    });
213+  }
214+});
215+
216+test("manifest order stays stable across multiple results and missing optional fields do not crash materialization", async () => {
217+  const outputDir = mkdtempSync(join(tmpdir(), "baa-conductor-artifacts-order-"));
218+
219+  try {
220+    const materialization = await materializeBaaExecutionArtifacts(
221+      [
222+        {
223+          blockIndex: 3,
224+          execution: createExecutionResult({
225+            data: {
226+              daemon: {
227+                leaseState: "leader"
228+              },
229+              runtime: {
230+                started: true
231+              }
232+            },
233+            dedupeKey: "dedupe-status",
234+            instructionId: "inst_status",
235+            message: null,
236+            requestId: null,
237+            route: {
238+              key: "local.status",
239+              method: "GET",
240+              path: "/v1/status"
241+            },
242+            tool: "status"
243+          })
244+        },
245+        {
246+          blockIndex: 1,
247+          execution: createExecutionResult({
248+            data: {
249+              endpoints: [
250+                {
251+                  method: "GET",
252+                  path: "/describe/control"
253+                }
254+              ],
255+              surface: "control"
256+            },
257+            dedupeKey: "dedupe-describe",
258+            instructionId: "inst_describe",
259+            route: {
260+              key: "local.describe.control",
261+              method: "GET",
262+              path: "/describe/control"
263+            },
264+            tool: "describe/control"
265+          })
266+        },
267+        {
268+          blockIndex: 2,
269+          execution: createExecutionResult({
270+            data: {
271+              ok: true,
272+              operation: "files/write",
273+              input: {
274+                content: "hello",
275+                createParents: false,
276+                cwd: "/tmp",
277+                encoding: "utf8",
278+                overwrite: true,
279+                path: "written.txt"
280+              },
281+              result: {
282+                absolutePath: "/tmp/written.txt",
283+                bytesWritten: 5,
284+                created: true,
285+                encoding: "utf8",
286+                modifiedAt: "2026-03-27T12:00:00.000Z"
287+              }
288+            },
289+            dedupeKey: "dedupe-write",
290+            details: null,
291+            instructionId: "inst_write",
292+            requestId: null,
293+            route: {
294+              key: "local.files.write",
295+              method: "POST",
296+              path: "/v1/files/write"
297+            },
298+            tool: "files/write"
299+          })
300+        }
301+      ],
302+      {
303+        artifactOnlyTextBytes: 512,
304+        inlineTextBytes: 32,
305+        outputDir,
306+        roundId: "r07",
307+        traceId: "trace_order"
308+      }
309+    );
310+    const manifestBundle = await buildBaaArtifactManifest(materialization);
311+
312+    assert.deepEqual(
313+      materialization.results.map((entry) => entry.instructionId),
314+      ["inst_describe", "inst_write", "inst_status"]
315+    );
316+    assert.deepEqual(
317+      manifestBundle.manifest.results.map((entry) => entry.blockIndex),
318+      [1, 2, 3]
319+    );
320+    assert.equal(manifestBundle.manifest.resultCount, 3);
321+    assert.equal(manifestBundle.manifest.notes.length >= 2, true);
322+  } finally {
323+    rmSync(outputDir, {
324+      force: true,
325+      recursive: true
326+    });
327+  }
328+});
A apps/conductor-daemon/src/artifacts/delivery-plan.ts
+42, -0
 1@@ -0,0 +1,42 @@
 2+import type {
 3+  BaaDeliveryPlan,
 4+  BaaDeliveryUploadItem,
 5+  BuildBaaDeliveryPlanInput
 6+} from "./types.js";
 7+
 8+function toUploadItem(artifact: {
 9+  artifactId: string;
10+  filename: string;
11+  localPath: string;
12+  mimeType: string;
13+}): BaaDeliveryUploadItem {
14+  return {
15+    artifactId: artifact.artifactId,
16+    filename: artifact.filename,
17+    localPath: artifact.localPath,
18+    mimeType: artifact.mimeType
19+  };
20+}
21+
22+export function buildBaaDeliveryPlan(input: BuildBaaDeliveryPlanInput): BaaDeliveryPlan {
23+  const uploads = input.materialization.artifacts.map((artifact) => toUploadItem(artifact));
24+
25+  if (uploads.length > 0) {
26+    uploads.push(toUploadItem(input.manifestBundle.artifact));
27+  }
28+
29+  return {
30+    autoSend: input.autoSend ?? false,
31+    conversationId: input.conversationId ?? null,
32+    manifestId: input.manifestBundle.manifest.manifestId,
33+    messageText: input.indexText,
34+    pendingBarriers: uploads.length === 0 ? [] : ["upload_receipt"],
35+    planId: `plan_${input.materialization.traceId}_${input.materialization.roundId}`,
36+    receiptBarrierImplemented: false,
37+    roundId: input.materialization.roundId,
38+    target: input.target,
39+    traceId: input.materialization.traceId,
40+    uploads,
41+    version: "baa.delivery-plan.v1"
42+  };
43+}
A apps/conductor-daemon/src/artifacts/index.ts
+4, -0
1@@ -0,0 +1,4 @@
2+export * from "./types.js";
3+export * from "./materialize.js";
4+export * from "./manifest.js";
5+export * from "./delivery-plan.js";
A apps/conductor-daemon/src/artifacts/manifest.ts
+150, -0
  1@@ -0,0 +1,150 @@
  2+import { createHash } from "node:crypto";
  3+import { writeFile } from "node:fs/promises";
  4+import { join } from "node:path";
  5+
  6+import type {
  7+  BaaArtifactManifest,
  8+  BaaArtifactManifestBundle,
  9+  BaaArtifactMaterialization,
 10+  BaaMaterializedArtifactRef
 11+} from "./types.js";
 12+
 13+function bytesOf(text: string): number {
 14+  return Buffer.byteLength(text, "utf8");
 15+}
 16+
 17+function sanitizeSegment(value: string): string {
 18+  const normalized = value.trim().toLowerCase().replace(/[^a-z0-9]+/gu, "-");
 19+  const collapsed = normalized.replace(/-+/gu, "-").replace(/^-|-$/gu, "");
 20+  return collapsed === "" ? "unknown" : collapsed;
 21+}
 22+
 23+function toManifestArtifactEntry(artifact: BaaMaterializedArtifactRef) {
 24+  return {
 25+    artifactId: artifact.artifactId,
 26+    blockIndex: artifact.blockIndex,
 27+    filename: artifact.filename,
 28+    instructionId: artifact.instructionId,
 29+    kind: artifact.kind,
 30+    localPath: artifact.localPath,
 31+    mimeType: artifact.mimeType,
 32+    sequence: artifact.sequence,
 33+    sha256: artifact.sha256,
 34+    sizeBytes: artifact.sizeBytes,
 35+    target: artifact.target,
 36+    tool: artifact.tool
 37+  };
 38+}
 39+
 40+function toManifestResultEntry(materialization: BaaArtifactMaterialization) {
 41+  return materialization.results.map((result) => ({
 42+    artifactIds: result.artifacts.map((artifact) => artifact.artifactId),
 43+    blockIndex: result.blockIndex,
 44+    dedupeKey: result.dedupeKey,
 45+    deliveryMode: result.deliveryMode,
 46+    errorCode: result.errorCode,
 47+    httpStatus: result.httpStatus,
 48+    instructionId: result.instructionId,
 49+    ok: result.ok,
 50+    requestId: result.requestId,
 51+    route: {
 52+      key: result.route.key,
 53+      method: result.route.method,
 54+      path: result.route.path
 55+    },
 56+    sequence: result.sequence,
 57+    summary: result.summary,
 58+    target: result.target,
 59+    tool: result.tool
 60+  }));
 61+}
 62+
 63+export async function buildBaaArtifactManifest(
 64+  materialization: BaaArtifactMaterialization
 65+): Promise<BaaArtifactManifestBundle> {
 66+  const manifest: BaaArtifactManifest = {
 67+    artifactCount: materialization.artifacts.length,
 68+    artifacts: materialization.artifacts.map((artifact) => toManifestArtifactEntry(artifact)),
 69+    createdAt: materialization.createdAt,
 70+    manifestId: `mf_${sanitizeSegment(materialization.traceId)}_${sanitizeSegment(materialization.roundId)}`,
 71+    notes: [
 72+      "Service-side artifact materialization, manifest generation, and delivery-plan generation are implemented in conductor.",
 73+      "Browser upload/download execution and upload receipt barrier are not implemented in this phase."
 74+    ],
 75+    resultCount: materialization.results.length,
 76+    results: toManifestResultEntry(materialization),
 77+    roundId: materialization.roundId,
 78+    traceId: materialization.traceId,
 79+    version: "baa.manifest.v1"
 80+  };
 81+  const content = `${JSON.stringify(manifest, null, 2)}\n`;
 82+  const filename = `baa-manifest_${sanitizeSegment(materialization.traceId)}_${sanitizeSegment(materialization.roundId)}.json`;
 83+  const localPath = join(materialization.outputDir, filename);
 84+
 85+  await writeFile(localPath, content, "utf8");
 86+
 87+  return {
 88+    artifact: {
 89+      artifactId: manifest.manifestId,
 90+      blockIndex: null,
 91+      createdAt: materialization.createdAt,
 92+      filename,
 93+      instructionId: null,
 94+      kind: "manifest",
 95+      localPath,
 96+      mimeType: "application/json",
 97+      sequence: materialization.results.length,
 98+      sha256: createHash("sha256").update(content).digest("hex"),
 99+      sizeBytes: bytesOf(content),
100+      target: null,
101+      tool: null
102+    },
103+    manifest
104+  };
105+}
106+
107+export function renderBaaArtifactIndexText(
108+  materialization: BaaArtifactMaterialization,
109+  manifestBundle: BaaArtifactManifestBundle
110+): string {
111+  const lines = [
112+    "[BAA Result Index]",
113+    `- trace_id: ${materialization.traceId}`,
114+    `- round_id: ${materialization.roundId}`,
115+    `- manifest_id: ${manifestBundle.manifest.manifestId}`
116+  ];
117+
118+  for (const result of materialization.results) {
119+    lines.push("");
120+    lines.push(`- instruction_id: ${result.instructionId}`);
121+    lines.push(`- tool: ${result.target}::${result.tool}`);
122+    lines.push(`- status: ${result.ok ? "ok" : "failed"}`);
123+    lines.push(`- delivery_mode: ${result.deliveryMode}`);
124+    lines.push(`- summary: ${result.summary}`);
125+
126+    if (result.deliveryMode === "inline" && result.inlineText != null) {
127+      lines.push("- inline_text:");
128+      lines.push("```text");
129+      lines.push(result.inlineText.trimEnd());
130+      lines.push("```");
131+    } else if (result.deliveryMode === "inline_and_artifact") {
132+      const excerpt = result.previewText ?? result.inlineText;
133+
134+      if (excerpt != null && excerpt.trim() !== "") {
135+        lines.push("- excerpt:");
136+        lines.push("```text");
137+        lines.push(excerpt.trimEnd());
138+        lines.push("```");
139+      }
140+    }
141+
142+    if (result.artifacts.length > 0) {
143+      lines.push(`- artifacts: ${result.artifacts.map((artifact) => artifact.filename).join(", ")}`);
144+    }
145+  }
146+
147+  lines.push("");
148+  lines.push(`- manifest: ${manifestBundle.artifact.filename}`);
149+
150+  return `${lines.join("\n")}\n`;
151+}
A apps/conductor-daemon/src/artifacts/materialize.ts
+712, -0
  1@@ -0,0 +1,712 @@
  2+import { createHash } from "node:crypto";
  3+import { mkdir, writeFile } from "node:fs/promises";
  4+import { join } from "node:path";
  5+
  6+import {
  7+  sortBaaJsonValue,
  8+  type BaaInstructionExecutionResult,
  9+  type BaaJsonValue
 10+} from "../instructions/types.js";
 11+import type {
 12+  BaaArtifactContentKind,
 13+  BaaArtifactKind,
 14+  BaaArtifactMaterialization,
 15+  BaaArtifactMaterializeCandidate,
 16+  BaaArtifactMaterializeOptions,
 17+  BaaDeliveryMode,
 18+  BaaMaterializedArtifactRef,
 19+  BaaMaterializedResult
 20+} from "./types.js";
 21+
 22+export const DEFAULT_INLINE_TEXT_BYTES = 600;
 23+export const DEFAULT_ARTIFACT_ONLY_TEXT_BYTES = 4_096;
 24+export const DEFAULT_PREVIEW_CHARS = 240;
 25+
 26+interface JsonRecord {
 27+  [key: string]: BaaJsonValue;
 28+}
 29+
 30+interface Thresholds {
 31+  artifactOnlyTextBytes: number;
 32+  inlineTextBytes: number;
 33+  previewChars: number;
 34+}
 35+
 36+interface PreparedCandidate {
 37+  blockIndex: number | null;
 38+  execution: BaaInstructionExecutionResult;
 39+  sequence: number;
 40+}
 41+
 42+interface ArtifactSpec {
 43+  content: string;
 44+  extension: string;
 45+  kind: BaaArtifactKind;
 46+  mimeType: string;
 47+}
 48+
 49+interface MaterializedDraft {
 50+  artifactSpec: ArtifactSpec | null;
 51+  contentBytes: number;
 52+  contentKind: BaaArtifactContentKind;
 53+  deliveryMode: BaaDeliveryMode;
 54+  errorCode: string | null;
 55+  inlineText: string | null;
 56+  ok: boolean;
 57+  previewText: string | null;
 58+  summary: string;
 59+}
 60+
 61+interface TextDeliveryDecision {
 62+  deliveryMode: BaaDeliveryMode;
 63+  inlineText: string | null;
 64+  previewText: string | null;
 65+  shouldArtifact: boolean;
 66+}
 67+
 68+function asRecord(value: BaaJsonValue | null): JsonRecord | null {
 69+  if (value == null || typeof value !== "object" || Array.isArray(value)) {
 70+    return null;
 71+  }
 72+
 73+  return value as JsonRecord;
 74+}
 75+
 76+function readBooleanValue(record: JsonRecord | null, key: string): boolean | null {
 77+  const value = record?.[key];
 78+  return typeof value === "boolean" ? value : null;
 79+}
 80+
 81+function readNumberValue(record: JsonRecord | null, key: string): number | null {
 82+  const value = record?.[key];
 83+  return typeof value === "number" && Number.isFinite(value) ? value : null;
 84+}
 85+
 86+function readObjectValue(record: JsonRecord | null, key: string): JsonRecord | null {
 87+  return asRecord(record?.[key] ?? null);
 88+}
 89+
 90+function readStringValue(record: JsonRecord | null, key: string): string | null {
 91+  const value = record?.[key];
 92+  return typeof value === "string" ? value : null;
 93+}
 94+
 95+function bytesOf(text: string): number {
 96+  return Buffer.byteLength(text, "utf8");
 97+}
 98+
 99+function buildArtifactId(traceId: string, roundId: string, artifactSequence: number): string {
100+  return `art_${sanitizeSegment(traceId)}_${sanitizeSegment(roundId)}_${String(artifactSequence).padStart(3, "0")}`;
101+}
102+
103+function buildArtifactFilename(
104+  traceId: string,
105+  roundId: string,
106+  blockIndex: number | null,
107+  sequence: number,
108+  tool: string,
109+  target: string,
110+  ok: boolean,
111+  extension: string
112+): string {
113+  const blockLabel =
114+    blockIndex == null
115+      ? `s${String(sequence + 1).padStart(2, "0")}`
116+      : `b${String(blockIndex).padStart(2, "0")}`;
117+
118+  return [
119+    "baa-result",
120+    sanitizeSegment(traceId),
121+    sanitizeSegment(roundId),
122+    blockLabel,
123+    sanitizeSegment(tool),
124+    sanitizeSegment(target),
125+    ok ? "ok" : "fail"
126+  ].join("_") + `.${extension}`;
127+}
128+
129+function buildDefaultSummary(execution: BaaInstructionExecutionResult): string {
130+  const detail = truncateText(execution.message ?? execution.error ?? "completed", 96);
131+  return execution.ok ? `${execution.tool} completed: ${detail}` : `${execution.tool} failed: ${detail}`;
132+}
133+
134+function buildFailureText(execution: BaaInstructionExecutionResult): string {
135+  const sections: string[] = [];
136+
137+  if (execution.message) {
138+    sections.push(`message: ${execution.message}`);
139+  }
140+
141+  if (execution.error) {
142+    sections.push(`error: ${execution.error}`);
143+  }
144+
145+  if (execution.details != null) {
146+    sections.push(`[details]\n${stablePrettyJson(execution.details).trimEnd()}`);
147+  }
148+
149+  return sections.join("\n\n");
150+}
151+
152+function buildStructuredSummary(execution: BaaInstructionExecutionResult, data: BaaJsonValue | null): string {
153+  const root = asRecord(data);
154+
155+  if (execution.tool === "status") {
156+    const daemon = readObjectValue(root, "daemon");
157+    const runtime = readObjectValue(root, "runtime");
158+    const leaseState = readStringValue(daemon, "leaseState") ?? "unknown";
159+    const started = readBooleanValue(runtime, "started");
160+    return `status returned daemon=${leaseState}, runtime.started=${started == null ? "unknown" : String(started)}`;
161+  }
162+
163+  const surface = readStringValue(root, "surface");
164+  const endpoints = root?.endpoints;
165+  const endpointCount = Array.isArray(endpoints) ? endpoints.length : null;
166+  const baseSummary =
167+    surface == null
168+      ? `${execution.tool} returned structured JSON`
169+      : `${execution.tool} returned surface=${surface}`;
170+
171+  return endpointCount == null ? baseSummary : `${baseSummary} (${endpointCount} endpoints)`;
172+}
173+
174+function createTimestamp(input: string | undefined): string {
175+  if (typeof input === "string" && input.trim() !== "") {
176+    return input;
177+  }
178+
179+  return new Date().toISOString();
180+}
181+
182+function decideTextDelivery(
183+  text: string,
184+  thresholds: Thresholds,
185+  options: {
186+    alwaysArtifact?: boolean;
187+    binaryLike?: boolean;
188+  } = {}
189+): TextDeliveryDecision {
190+  const binaryLike = options.binaryLike ?? looksBinaryLike(text);
191+  const contentBytes = bytesOf(text);
192+
193+  if (contentBytes === 0) {
194+    return {
195+      deliveryMode: "inline",
196+      inlineText: null,
197+      previewText: null,
198+      shouldArtifact: options.alwaysArtifact === true
199+    };
200+  }
201+
202+  if (binaryLike) {
203+    return {
204+      deliveryMode: "artifact_only",
205+      inlineText: null,
206+      previewText: null,
207+      shouldArtifact: true
208+    };
209+  }
210+
211+  if (contentBytes <= thresholds.inlineTextBytes) {
212+    if (options.alwaysArtifact === true) {
213+      return {
214+        deliveryMode: "inline_and_artifact",
215+        inlineText: text,
216+        previewText: null,
217+        shouldArtifact: true
218+      };
219+    }
220+
221+    return {
222+      deliveryMode: "inline",
223+      inlineText: text,
224+      previewText: null,
225+      shouldArtifact: false
226+    };
227+  }
228+
229+  if (contentBytes <= thresholds.artifactOnlyTextBytes) {
230+    return {
231+      deliveryMode: "inline_and_artifact",
232+      inlineText: null,
233+      previewText: truncateText(text, thresholds.previewChars),
234+      shouldArtifact: true
235+    };
236+  }
237+
238+  return {
239+    deliveryMode: "artifact_only",
240+    inlineText: null,
241+    previewText: null,
242+    shouldArtifact: true
243+  };
244+}
245+
246+function extractErrorCode(
247+  execution: BaaInstructionExecutionResult,
248+  record: JsonRecord | null
249+): string | null {
250+  const errorRecord = readObjectValue(record, "error");
251+  return readStringValue(errorRecord, "code") ?? execution.error;
252+}
253+
254+async function createArtifact(
255+  prepared: PreparedCandidate,
256+  draft: MaterializedDraft,
257+  artifactSequence: number,
258+  options: BaaArtifactMaterializeOptions,
259+  createdAt: string
260+): Promise<BaaMaterializedArtifactRef | null> {
261+  const spec = draft.artifactSpec;
262+
263+  if (spec == null) {
264+    return null;
265+  }
266+
267+  const artifactId = buildArtifactId(options.traceId, options.roundId, artifactSequence);
268+  const filename = buildArtifactFilename(
269+    options.traceId,
270+    options.roundId,
271+    prepared.blockIndex,
272+    prepared.sequence,
273+    prepared.execution.tool,
274+    prepared.execution.target,
275+    draft.ok,
276+    spec.extension
277+  );
278+  const localPath = join(options.outputDir, filename);
279+
280+  await writeFile(localPath, spec.content, "utf8");
281+
282+  return {
283+    artifactId,
284+    blockIndex: prepared.blockIndex,
285+    createdAt,
286+    filename,
287+    instructionId: prepared.execution.instructionId,
288+    kind: spec.kind,
289+    localPath,
290+    mimeType: spec.mimeType,
291+    sequence: prepared.sequence,
292+    sha256: createHash("sha256").update(spec.content).digest("hex"),
293+    sizeBytes: bytesOf(spec.content),
294+    target: prepared.execution.target,
295+    tool: prepared.execution.tool
296+  };
297+}
298+
299+function isNonNegativeInteger(value: number | null | undefined): value is number {
300+  return typeof value === "number" && Number.isInteger(value) && value >= 0;
301+}
302+
303+function looksBinaryLike(text: string): boolean {
304+  if (text.includes("\0")) {
305+    return true;
306+  }
307+
308+  let controlCount = 0;
309+
310+  for (const char of text) {
311+    const codePoint = char.codePointAt(0);
312+
313+    if (codePoint == null) {
314+      continue;
315+    }
316+
317+    if ((codePoint >= 0 && codePoint < 32 && codePoint !== 9 && codePoint !== 10 && codePoint !== 13) || codePoint === 127) {
318+      controlCount += 1;
319+    }
320+  }
321+
322+  return controlCount >= 16 && controlCount / Math.max(text.length, 1) > 0.05;
323+}
324+
325+function normalizeThresholds(options: BaaArtifactMaterializeOptions): Thresholds {
326+  const inlineTextBytes =
327+    isNonNegativeInteger(options.inlineTextBytes) && options.inlineTextBytes > 0
328+      ? options.inlineTextBytes
329+      : DEFAULT_INLINE_TEXT_BYTES;
330+  const requestedArtifactOnlyBytes =
331+    isNonNegativeInteger(options.artifactOnlyTextBytes) && options.artifactOnlyTextBytes > 0
332+      ? options.artifactOnlyTextBytes
333+      : DEFAULT_ARTIFACT_ONLY_TEXT_BYTES;
334+
335+  return {
336+    artifactOnlyTextBytes: Math.max(requestedArtifactOnlyBytes, inlineTextBytes + 1),
337+    inlineTextBytes,
338+    previewChars:
339+      isNonNegativeInteger(options.previewChars) && options.previewChars > 3
340+        ? options.previewChars
341+        : DEFAULT_PREVIEW_CHARS
342+  };
343+}
344+
345+function prepareCandidates(
346+  inputs: readonly BaaArtifactMaterializeCandidate[]
347+): PreparedCandidate[] {
348+  return inputs
349+    .map((input, index) => ({
350+      blockIndex: isNonNegativeInteger(input.blockIndex) ? input.blockIndex : null,
351+      execution: input.execution,
352+      sequence: isNonNegativeInteger(input.sequence) ? input.sequence : index
353+    }))
354+    .sort((left, right) => {
355+      const leftBlock = left.blockIndex ?? Number.MAX_SAFE_INTEGER;
356+      const rightBlock = right.blockIndex ?? Number.MAX_SAFE_INTEGER;
357+
358+      if (leftBlock !== rightBlock) {
359+        return leftBlock - rightBlock;
360+      }
361+
362+      if (left.sequence !== right.sequence) {
363+        return left.sequence - right.sequence;
364+      }
365+
366+      const instructionOrder = left.execution.instructionId.localeCompare(right.execution.instructionId);
367+
368+      if (instructionOrder !== 0) {
369+        return instructionOrder;
370+      }
371+
372+      const toolOrder = left.execution.tool.localeCompare(right.execution.tool);
373+
374+      if (toolOrder !== 0) {
375+        return toolOrder;
376+      }
377+
378+      return left.execution.target.localeCompare(right.execution.target);
379+    });
380+}
381+
382+function sanitizeSegment(value: string): string {
383+  const normalized = value.trim().toLowerCase().replace(/[^a-z0-9]+/gu, "-");
384+  const collapsed = normalized.replace(/-+/gu, "-").replace(/^-|-$/gu, "");
385+  return collapsed === "" ? "unknown" : collapsed;
386+}
387+
388+function stablePrettyJson(value: BaaJsonValue): string {
389+  return `${JSON.stringify(sortBaaJsonValue(value), null, 2)}\n`;
390+}
391+
392+function truncateText(text: string, maxChars: number): string {
393+  if (text.length <= maxChars) {
394+    return text;
395+  }
396+
397+  return `${text.slice(0, Math.max(maxChars - 3, 1))}...`;
398+}
399+
400+function materializeDescribeLike(
401+  execution: BaaInstructionExecutionResult,
402+  thresholds: Thresholds
403+): MaterializedDraft {
404+  if (execution.data == null) {
405+    return materializeGeneric(execution, thresholds);
406+  }
407+
408+  const content = stablePrettyJson(execution.data);
409+  const decision = decideTextDelivery(content, thresholds);
410+
411+  return {
412+    artifactSpec: decision.shouldArtifact
413+      ? {
414+          content,
415+          extension: "json",
416+          kind: "payload",
417+          mimeType: "application/json"
418+        }
419+      : null,
420+    contentBytes: bytesOf(content),
421+    contentKind: "structured_json",
422+    deliveryMode: decision.deliveryMode,
423+    errorCode: execution.error,
424+    inlineText: decision.inlineText,
425+    ok: execution.ok,
426+    previewText: decision.previewText,
427+    summary: buildStructuredSummary(execution, execution.data)
428+  };
429+}
430+
431+function materializeExec(
432+  execution: BaaInstructionExecutionResult,
433+  thresholds: Thresholds
434+): MaterializedDraft {
435+  const record = asRecord(execution.data);
436+  const input = readObjectValue(record, "input");
437+  const result = readObjectValue(record, "result");
438+  const operationOk = readBooleanValue(record, "ok") ?? execution.ok;
439+  const command = readStringValue(input, "command") ?? "unknown command";
440+  const cwd = readStringValue(input, "cwd");
441+  const exitCode = readNumberValue(result, "exitCode");
442+  const stderr = readStringValue(result, "stderr") ?? "";
443+  const stdout = readStringValue(result, "stdout") ?? "";
444+  const timedOut = readBooleanValue(result, "timedOut") ?? false;
445+  const sections = [`$ ${command}`];
446+
447+  if (cwd != null) {
448+    sections.push(`cwd: ${cwd}`);
449+  }
450+
451+  if (stdout !== "") {
452+    sections.push(`[stdout]\n${stdout}`);
453+  }
454+
455+  if (stderr !== "") {
456+    sections.push(`[stderr]\n${stderr}`);
457+  }
458+
459+  if (stdout === "" && stderr === "") {
460+    sections.push("[output]\n(no stdout or stderr)");
461+  }
462+
463+  const content = `${sections.join("\n\n")}\n`;
464+  const decision = decideTextDelivery(content, thresholds, {
465+    alwaysArtifact: true,
466+    binaryLike: false
467+  });
468+  const statusDetail = timedOut
469+    ? "timeout"
470+    : exitCode == null
471+      ? extractErrorCode(execution, record) ?? "unknown"
472+      : `exitCode=${exitCode}`;
473+
474+  return {
475+    artifactSpec: {
476+      content,
477+      extension: "log",
478+      kind: "log",
479+      mimeType: "text/plain"
480+    },
481+    contentBytes: bytesOf(content),
482+    contentKind: "text",
483+    deliveryMode: decision.deliveryMode,
484+    errorCode: extractErrorCode(execution, record),
485+    inlineText: decision.inlineText,
486+    ok: operationOk,
487+    previewText: decision.previewText,
488+    summary: operationOk
489+      ? `exec succeeded (${statusDetail}): ${truncateText(command, 96)}`
490+      : `exec failed (${statusDetail}): ${truncateText(command, 96)}`
491+  };
492+}
493+
494+function materializeFileRead(
495+  execution: BaaInstructionExecutionResult,
496+  thresholds: Thresholds
497+): MaterializedDraft {
498+  const record = asRecord(execution.data);
499+  const input = readObjectValue(record, "input");
500+  const result = readObjectValue(record, "result");
501+  const operationOk = readBooleanValue(record, "ok") ?? execution.ok;
502+  const content = readStringValue(result, "content") ?? "";
503+  const absolutePath = readStringValue(result, "absolutePath") ?? readStringValue(input, "path") ?? "unknown";
504+  const reportedSizeBytes = readNumberValue(result, "sizeBytes");
505+  const contentBytes = content === "" ? 0 : bytesOf(content);
506+  const binaryLike = looksBinaryLike(content);
507+  const errorCode = extractErrorCode(execution, record);
508+
509+  if (!operationOk || content === "") {
510+    return {
511+      artifactSpec: null,
512+      contentBytes: 0,
513+      contentKind: "none",
514+      deliveryMode: "inline",
515+      errorCode,
516+      inlineText: null,
517+      ok: operationOk,
518+      previewText: null,
519+      summary: operationOk
520+        ? `files/read completed with no content: ${absolutePath}`
521+        : `files/read failed${errorCode == null ? "" : ` (${errorCode})`}: ${absolutePath}`
522+    };
523+  }
524+
525+  const decision = decideTextDelivery(content, thresholds, {
526+    binaryLike
527+  });
528+
529+  return {
530+    artifactSpec: decision.shouldArtifact
531+      ? {
532+          content,
533+          extension: binaryLike ? "bin" : "txt",
534+          kind: "payload",
535+          mimeType: binaryLike ? "application/octet-stream" : "text/plain"
536+        }
537+      : null,
538+    contentBytes,
539+    contentKind: binaryLike ? "binary_like_text" : "text",
540+    deliveryMode: decision.deliveryMode,
541+    errorCode,
542+    inlineText: decision.inlineText,
543+    ok: operationOk,
544+    previewText: decision.previewText,
545+    summary: binaryLike
546+      ? `files/read returned binary-like text: ${absolutePath} (${reportedSizeBytes ?? contentBytes} B)`
547+      : `files/read succeeded: ${absolutePath} (${reportedSizeBytes ?? contentBytes} B)`
548+  };
549+}
550+
551+function materializeFileWrite(
552+  execution: BaaInstructionExecutionResult
553+): MaterializedDraft {
554+  const record = asRecord(execution.data);
555+  const input = readObjectValue(record, "input");
556+  const result = readObjectValue(record, "result");
557+  const operationOk = readBooleanValue(record, "ok") ?? execution.ok;
558+  const absolutePath = readStringValue(result, "absolutePath") ?? readStringValue(input, "path") ?? "unknown";
559+  const bytesWritten = readNumberValue(result, "bytesWritten");
560+  const created = readBooleanValue(result, "created");
561+  const errorCode = extractErrorCode(execution, record);
562+
563+  return {
564+    artifactSpec: null,
565+    contentBytes: 0,
566+    contentKind: "none",
567+    deliveryMode: "inline",
568+    errorCode,
569+    inlineText: null,
570+    ok: operationOk,
571+    previewText: null,
572+    summary: operationOk
573+      ? `files/write succeeded: ${absolutePath} (${bytesWritten ?? 0} B, ${created === true ? "created" : "updated"})`
574+      : `files/write failed${errorCode == null ? "" : ` (${errorCode})`}: ${absolutePath}`
575+  };
576+}
577+
578+function materializeGeneric(
579+  execution: BaaInstructionExecutionResult,
580+  thresholds: Thresholds
581+): MaterializedDraft {
582+  if (execution.data != null) {
583+    return materializeDescribeLike(execution, thresholds);
584+  }
585+
586+  const failureText = buildFailureText(execution);
587+
588+  if (failureText !== "") {
589+    const decision = decideTextDelivery(failureText, thresholds);
590+
591+    return {
592+      artifactSpec: decision.shouldArtifact
593+        ? {
594+            content: `${failureText}\n`,
595+            extension: "txt",
596+            kind: "summary",
597+            mimeType: "text/plain"
598+          }
599+        : null,
600+      contentBytes: bytesOf(failureText),
601+      contentKind: "text",
602+      deliveryMode: decision.deliveryMode,
603+      errorCode: execution.error,
604+      inlineText: decision.inlineText,
605+      ok: execution.ok,
606+      previewText: decision.previewText,
607+      summary: buildDefaultSummary(execution)
608+    };
609+  }
610+
611+  return {
612+    artifactSpec: null,
613+    contentBytes: 0,
614+    contentKind: "none",
615+    deliveryMode: "inline",
616+    errorCode: execution.error,
617+    inlineText: null,
618+    ok: execution.ok,
619+    previewText: null,
620+    summary: buildDefaultSummary(execution)
621+  };
622+}
623+
624+function materializePreparedCandidate(
625+  prepared: PreparedCandidate,
626+  thresholds: Thresholds
627+): MaterializedDraft {
628+  switch (prepared.execution.tool) {
629+    case "exec":
630+      return materializeExec(prepared.execution, thresholds);
631+    case "files/read":
632+      return materializeFileRead(prepared.execution, thresholds);
633+    case "files/write":
634+      return materializeFileWrite(prepared.execution);
635+    case "describe":
636+    case "status":
637+      return materializeDescribeLike(prepared.execution, thresholds);
638+    default:
639+      return prepared.execution.tool.startsWith("describe/")
640+        ? materializeDescribeLike(prepared.execution, thresholds)
641+        : materializeGeneric(prepared.execution, thresholds);
642+  }
643+}
644+
645+export async function materializeBaaExecutionArtifacts(
646+  inputs: readonly BaaArtifactMaterializeCandidate[],
647+  options: BaaArtifactMaterializeOptions
648+): Promise<BaaArtifactMaterialization> {
649+  const createdAt = createTimestamp(options.createdAt);
650+  const thresholds = normalizeThresholds(options);
651+  const preparedCandidates = prepareCandidates(inputs);
652+  const artifacts: BaaMaterializedArtifactRef[] = [];
653+  const results: BaaMaterializedResult[] = [];
654+  let artifactSequence = 0;
655+
656+  await mkdir(options.outputDir, {
657+    recursive: true
658+  });
659+
660+  for (const prepared of preparedCandidates) {
661+    const draft = materializePreparedCandidate(prepared, thresholds);
662+    const artifact =
663+      draft.artifactSpec == null
664+        ? null
665+        : await createArtifact(prepared, draft, ++artifactSequence, options, createdAt);
666+    const resultArtifacts = artifact == null ? [] : [artifact];
667+
668+    if (artifact != null) {
669+      artifacts.push(artifact);
670+    }
671+
672+    results.push({
673+      artifacts: resultArtifacts,
674+      audit: {
675+        contentBytes: draft.contentBytes,
676+        contentKind: draft.contentKind,
677+        hasArtifact: artifact != null,
678+        localApiOk: prepared.execution.ok,
679+        operationOk: draft.ok
680+      },
681+      blockIndex: prepared.blockIndex,
682+      dedupeKey: prepared.execution.dedupeKey,
683+      deliveryMode: draft.deliveryMode,
684+      errorCode: draft.errorCode,
685+      httpStatus: prepared.execution.httpStatus,
686+      inlineText: draft.inlineText,
687+      instructionId: prepared.execution.instructionId,
688+      ok: draft.ok,
689+      previewText: draft.previewText,
690+      requestId: prepared.execution.requestId,
691+      route: {
692+        key: prepared.execution.route.key,
693+        method: prepared.execution.route.method,
694+        path: prepared.execution.route.path
695+      },
696+      sequence: prepared.sequence,
697+      summary: draft.summary,
698+      target: prepared.execution.target,
699+      tool: prepared.execution.tool
700+    });
701+  }
702+
703+  return {
704+    artifactCount: artifacts.length,
705+    artifacts,
706+    createdAt,
707+    outputDir: options.outputDir,
708+    results,
709+    roundId: options.roundId,
710+    traceId: options.traceId,
711+    version: "baa.artifacts.v1"
712+  };
713+}
A apps/conductor-daemon/src/artifacts/types.ts
+166, -0
  1@@ -0,0 +1,166 @@
  2+import type { BaaInstructionExecutionResult } from "../instructions/types.js";
  3+
  4+export type BaaArtifactContentKind = "binary_like_text" | "none" | "structured_json" | "text";
  5+export type BaaArtifactKind = "log" | "manifest" | "payload" | "summary";
  6+export type BaaDeliveryBarrier = "upload_receipt";
  7+export type BaaDeliveryMode = "artifact_only" | "inline" | "inline_and_artifact";
  8+
  9+export interface BaaArtifactMaterializeCandidate {
 10+  blockIndex?: number | null;
 11+  execution: BaaInstructionExecutionResult;
 12+  sequence?: number | null;
 13+}
 14+
 15+export interface BaaArtifactMaterializeOptions {
 16+  artifactOnlyTextBytes?: number;
 17+  createdAt?: string;
 18+  inlineTextBytes?: number;
 19+  outputDir: string;
 20+  previewChars?: number;
 21+  roundId: string;
 22+  traceId: string;
 23+}
 24+
 25+export interface BaaMaterializedArtifactRef {
 26+  artifactId: string;
 27+  blockIndex: number | null;
 28+  createdAt: string;
 29+  filename: string;
 30+  instructionId: string | null;
 31+  kind: BaaArtifactKind;
 32+  localPath: string;
 33+  mimeType: string;
 34+  sequence: number;
 35+  sha256: string;
 36+  sizeBytes: number;
 37+  target: string | null;
 38+  tool: string | null;
 39+}
 40+
 41+export interface BaaMaterializedResultAudit {
 42+  contentBytes: number;
 43+  contentKind: BaaArtifactContentKind;
 44+  hasArtifact: boolean;
 45+  localApiOk: boolean;
 46+  operationOk: boolean;
 47+}
 48+
 49+export interface BaaMaterializedResult {
 50+  artifacts: BaaMaterializedArtifactRef[];
 51+  audit: BaaMaterializedResultAudit;
 52+  blockIndex: number | null;
 53+  dedupeKey: string;
 54+  deliveryMode: BaaDeliveryMode;
 55+  errorCode: string | null;
 56+  httpStatus: number;
 57+  inlineText: string | null;
 58+  instructionId: string;
 59+  ok: boolean;
 60+  previewText: string | null;
 61+  requestId: string | null;
 62+  route: {
 63+    key: string;
 64+    method: "GET" | "POST";
 65+    path: string;
 66+  };
 67+  sequence: number;
 68+  summary: string;
 69+  target: string;
 70+  tool: string;
 71+}
 72+
 73+export interface BaaArtifactMaterialization {
 74+  artifactCount: number;
 75+  artifacts: BaaMaterializedArtifactRef[];
 76+  createdAt: string;
 77+  outputDir: string;
 78+  results: BaaMaterializedResult[];
 79+  roundId: string;
 80+  traceId: string;
 81+  version: "baa.artifacts.v1";
 82+}
 83+
 84+export interface BaaArtifactManifestArtifactEntry {
 85+  artifactId: string;
 86+  blockIndex: number | null;
 87+  filename: string;
 88+  instructionId: string | null;
 89+  kind: BaaArtifactKind;
 90+  localPath: string;
 91+  mimeType: string;
 92+  sequence: number;
 93+  sha256: string;
 94+  sizeBytes: number;
 95+  target: string | null;
 96+  tool: string | null;
 97+}
 98+
 99+export interface BaaArtifactManifestResultEntry {
100+  artifactIds: string[];
101+  blockIndex: number | null;
102+  dedupeKey: string;
103+  deliveryMode: BaaDeliveryMode;
104+  errorCode: string | null;
105+  httpStatus: number;
106+  instructionId: string;
107+  ok: boolean;
108+  requestId: string | null;
109+  route: {
110+    key: string;
111+    method: "GET" | "POST";
112+    path: string;
113+  };
114+  sequence: number;
115+  summary: string;
116+  target: string;
117+  tool: string;
118+}
119+
120+export interface BaaArtifactManifest {
121+  artifactCount: number;
122+  artifacts: BaaArtifactManifestArtifactEntry[];
123+  createdAt: string;
124+  manifestId: string;
125+  notes: string[];
126+  resultCount: number;
127+  results: BaaArtifactManifestResultEntry[];
128+  roundId: string;
129+  traceId: string;
130+  version: "baa.manifest.v1";
131+}
132+
133+export interface BaaArtifactManifestBundle {
134+  artifact: BaaMaterializedArtifactRef;
135+  manifest: BaaArtifactManifest;
136+}
137+
138+export interface BaaDeliveryUploadItem {
139+  artifactId: string;
140+  filename: string;
141+  localPath: string;
142+  mimeType: string;
143+}
144+
145+export interface BaaDeliveryPlan {
146+  autoSend: boolean;
147+  conversationId: string | null;
148+  manifestId: string;
149+  messageText: string;
150+  pendingBarriers: BaaDeliveryBarrier[];
151+  planId: string;
152+  receiptBarrierImplemented: boolean;
153+  roundId: string;
154+  target: string;
155+  traceId: string;
156+  uploads: BaaDeliveryUploadItem[];
157+  version: "baa.delivery-plan.v1";
158+}
159+
160+export interface BuildBaaDeliveryPlanInput {
161+  autoSend?: boolean;
162+  conversationId?: string | null;
163+  indexText: string;
164+  manifestBundle: BaaArtifactManifestBundle;
165+  materialization: BaaArtifactMaterialization;
166+  target: string;
167+}
M apps/conductor-daemon/src/browser-types.ts
+3, -0
 1@@ -1,3 +1,5 @@
 2+import type { BaaLiveInstructionIngestSnapshot } from "./instructions/ingest.js";
 3+
 4 export type BrowserBridgeLoginStatus = "fresh" | "stale" | "lost";
 5 
 6 export interface BrowserBridgeEndpointMetadataSnapshot {
 7@@ -143,6 +145,7 @@ export interface BrowserBridgeStateSnapshot {
 8   active_connection_id: string | null;
 9   client_count: number;
10   clients: BrowserBridgeClientSnapshot[];
11+  instruction_ingest: BaaLiveInstructionIngestSnapshot;
12   ws_path: string;
13   ws_url: string | null;
14 }
M apps/conductor-daemon/src/firefox-ws.ts
+26, -2
 1@@ -20,6 +20,7 @@ import type {
 2   BrowserBridgeShellRuntimeSnapshot,
 3   BrowserBridgeStateSnapshot
 4 } from "./browser-types.js";
 5+import type { BaaLiveInstructionIngest } from "./instructions/ingest.js";
 6 import { buildSystemStateData, setAutomationMode } from "./local-api.js";
 7 import type { ConductorRuntimeSnapshot } from "./index.js";
 8 
 9@@ -43,6 +44,7 @@ type FirefoxWsAction = "pause" | "resume" | "drain";
10 
11 interface FirefoxWebSocketServerOptions {
12   baseUrlLoader: () => string;
13+  instructionIngest?: BaaLiveInstructionIngest | null;
14   now?: () => number;
15   repository: ControlPlaneRepository;
16   snapshotLoader: () => ConductorRuntimeSnapshot;
17@@ -945,6 +947,7 @@ class FirefoxWebSocketConnection {
18 export class ConductorFirefoxWebSocketServer {
19   private readonly baseUrlLoader: () => string;
20   private readonly bridgeService: FirefoxBridgeService;
21+  private readonly instructionIngest: BaaLiveInstructionIngest | null;
22   private readonly now: () => number;
23   private readonly repository: ControlPlaneRepository;
24   private readonly snapshotLoader: () => ConductorRuntimeSnapshot;
25@@ -957,6 +960,7 @@ export class ConductorFirefoxWebSocketServer {
26 
27   constructor(options: FirefoxWebSocketServerOptions) {
28     this.baseUrlLoader = options.baseUrlLoader;
29+    this.instructionIngest = options.instructionIngest ?? null;
30     this.now = options.now ?? (() => Math.floor(Date.now() / 1000));
31     this.repository = options.repository;
32     this.snapshotLoader = options.snapshotLoader;
33@@ -1543,7 +1547,7 @@ export class ConductorFirefoxWebSocketServer {
34       return;
35     }
36 
37-    connection.addFinalMessage({
38+    const finalMessage = {
39       assistant_message_id: assistantMessageId,
40       conversation_id: readFirstString(message, ["conversation_id", "conversationId"]),
41       observed_at:
42@@ -1551,8 +1555,24 @@ export class ConductorFirefoxWebSocketServer {
43         ?? this.getNowMilliseconds(),
44       platform,
45       raw_text: rawText
46-    });
47+    };
48+
49+    connection.addFinalMessage(finalMessage);
50     await this.broadcastStateSnapshot("browser.final_message");
51+
52+    if (this.instructionIngest == null) {
53+      return;
54+    }
55+
56+    await this.instructionIngest.ingestAssistantFinalMessage({
57+      assistantMessageId: finalMessage.assistant_message_id,
58+      conversationId: finalMessage.conversation_id,
59+      observedAt: finalMessage.observed_at,
60+      platform: finalMessage.platform,
61+      source: "browser.final_message",
62+      text: finalMessage.raw_text
63+    });
64+    await this.broadcastStateSnapshot("instruction_ingest");
65   }
66 
67   private handleApiResponse(
68@@ -1778,6 +1798,10 @@ export class ConductorFirefoxWebSocketServer {
69       active_connection_id: activeClient?.connectionId ?? null,
70       client_count: clients.length,
71       clients,
72+      instruction_ingest: this.instructionIngest?.getSnapshot() ?? {
73+        last_execute: null,
74+        last_ingest: null
75+      },
76       ws_path: FIREFOX_WS_PATH,
77       ws_url: this.getUrl()
78     };
M apps/conductor-daemon/src/index.test.js
+222, -1
  1@@ -1,17 +1,19 @@
  2 import assert from "node:assert/strict";
  3 import { EventEmitter } from "node:events";
  4 import { createServer } from "node:http";
  5-import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
  6+import { existsSync, mkdtempSync, readFileSync, 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 import test from "node:test";
 11 
 12+import "./artifacts.test.js";
 13 import { ConductorLocalControlPlane } from "../dist/local-control-plane.js";
 14 import { FirefoxCommandBroker } from "../dist/firefox-bridge.js";
 15 import {
 16   BaaInstructionCenter,
 17   BaaInstructionCenterError,
 18+  BaaLiveInstructionIngest,
 19   BrowserRequestPolicyController,
 20   ConductorDaemon,
 21   ConductorRuntime,
 22@@ -740,6 +742,106 @@ test("BaaInstructionCenter fails closed before execution when a batch contains a
 23   }
 24 });
 25 
 26+test("BaaLiveInstructionIngest ignores plain messages, dedupes replayed browser final messages, and tolerates missing conversation ids", async () => {
 27+  const { controlPlane, repository, sharedToken, snapshot } = await createLocalApiFixture();
 28+  const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-live-instruction-ingest-"));
 29+  const dedupeFilePath = join(hostOpsDir, "dedupe-count.txt");
 30+  const ingest = new BaaLiveInstructionIngest({
 31+    localApiContext: {
 32+      fetchImpl: globalThis.fetch,
 33+      repository,
 34+      sharedToken,
 35+      snapshotLoader: () => snapshot
 36+    },
 37+    now: () => 1_710_000_100_000
 38+  });
 39+  const executableMessage = [
 40+    "```baa",
 41+    `@conductor::exec::{"command":"printf 'live-hit\\n' >> dedupe-count.txt","cwd":${JSON.stringify(hostOpsDir)}}`,
 42+    "```"
 43+  ].join("\n");
 44+
 45+  try {
 46+    const ignored = await ingest.ingestAssistantFinalMessage({
 47+      assistantMessageId: "msg-live-plain",
 48+      conversationId: null,
 49+      observedAt: 1_710_000_001_000,
 50+      platform: "chatgpt",
 51+      source: "browser.final_message",
 52+      text: [
 53+        "plain markdown must stay inert",
 54+        "```js",
 55+        "console.log('no baa');",
 56+        "```"
 57+      ].join("\n")
 58+    });
 59+
 60+    assert.equal(ignored.summary.status, "ignored_no_instructions");
 61+    assert.equal(ignored.summary.execution_count, 0);
 62+    assert.equal(ignored.summary.conversation_id, null);
 63+
 64+    const firstPass = await ingest.ingestAssistantFinalMessage({
 65+      assistantMessageId: "msg-live-exec",
 66+      conversationId: null,
 67+      observedAt: 1_710_000_002_000,
 68+      platform: "chatgpt",
 69+      source: "browser.final_message",
 70+      text: executableMessage
 71+    });
 72+
 73+    assert.equal(firstPass.summary.status, "executed");
 74+    assert.equal(firstPass.summary.execution_count, 1);
 75+    assert.equal(firstPass.summary.execution_ok_count, 1);
 76+    assert.equal(firstPass.summary.instruction_tools[0], "conductor::exec");
 77+    assert.equal(firstPass.summary.conversation_id, null);
 78+    assert.equal(readFileSync(dedupeFilePath, "utf8"), "live-hit\n");
 79+
 80+    const replayPass = await ingest.ingestAssistantFinalMessage({
 81+      assistantMessageId: "msg-live-exec",
 82+      conversationId: "conv-should-not-matter",
 83+      observedAt: 1_710_000_003_000,
 84+      platform: "chatgpt",
 85+      source: "browser.final_message",
 86+      text: executableMessage
 87+    });
 88+
 89+    assert.equal(replayPass.summary.status, "duplicate_message");
 90+    assert.equal(replayPass.summary.execution_count, 0);
 91+    assert.equal(readFileSync(dedupeFilePath, "utf8"), "live-hit\n");
 92+
 93+    const failed = await ingest.ingestAssistantFinalMessage({
 94+      assistantMessageId: "msg-live-fail-closed",
 95+      conversationId: null,
 96+      observedAt: 1_710_000_004_000,
 97+      platform: "chatgpt",
 98+      source: "browser.final_message",
 99+      text: [
100+        "```baa",
101+        `@conductor::files/write::{"path":"should-not-exist.txt","cwd":${JSON.stringify(hostOpsDir)},"content":"must not run","overwrite":true}`,
102+        "```",
103+        "",
104+        "```baa",
105+        "@browser.chatgpt::send::should fail closed",
106+        "```"
107+      ].join("\n")
108+    });
109+
110+    assert.equal(failed.summary.status, "failed");
111+    assert.equal(failed.summary.error_stage, "policy");
112+    assert.equal(failed.summary.error_block_index, 1);
113+    assert.equal(existsSync(join(hostOpsDir, "should-not-exist.txt")), false);
114+
115+    assert.equal(ingest.getSnapshot().last_ingest?.assistant_message_id, "msg-live-fail-closed");
116+    assert.equal(ingest.getSnapshot().last_execute?.status, "failed");
117+  } finally {
118+    controlPlane.close();
119+    rmSync(hostOpsDir, {
120+      force: true,
121+      recursive: true
122+    });
123+  }
124+});
125+
126 function createManualTimerScheduler() {
127   let now = 0;
128   let nextId = 1;
129@@ -4129,6 +4231,125 @@ test("ConductorRuntime exposes Firefox outbound bridge commands and api request
130   }
131 });
132 
133+test("ConductorRuntime routes browser.final_message into live instruction ingest and exposes the latest summary", async () => {
134+  const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-final-message-ingest-"));
135+  const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-conductor-final-message-host-"));
136+  const runtime = new ConductorRuntime(
137+    {
138+      nodeId: "mini-main",
139+      host: "mini",
140+      role: "primary",
141+      controlApiBase: "https://control.example.test",
142+      localApiBase: "http://127.0.0.1:0",
143+      sharedToken: "replace-me",
144+      paths: {
145+        runsDir: "/tmp/runs",
146+        stateDir
147+      }
148+    },
149+    {
150+      autoStartLoops: false,
151+      now: () => 100
152+    }
153+  );
154+
155+  let client = null;
156+
157+  try {
158+    const snapshot = await runtime.start();
159+    const baseUrl = snapshot.controlApi.localApiBase;
160+    const messageText = [
161+      "```baa",
162+      "@conductor::describe",
163+      "```",
164+      "",
165+      "```baa",
166+      `@conductor::exec::{"command":"printf 'ws-live\\n' >> final-message-ingest.txt","cwd":${JSON.stringify(hostOpsDir)}}`,
167+      "```"
168+    ].join("\n");
169+
170+    client = await connectFirefoxBridgeClient(snapshot.controlApi.firefoxWsUrl, "firefox-final-message-ingest");
171+
172+    client.socket.send(
173+      JSON.stringify({
174+        type: "browser.final_message",
175+        platform: "chatgpt",
176+        assistant_message_id: "msg-final-message-ingest",
177+        raw_text: messageText,
178+        observed_at: 1_710_000_010_000
179+      })
180+    );
181+
182+    const executedSnapshot = await client.queue.next(
183+      (message) =>
184+        message.type === "state_snapshot"
185+        && message.reason === "instruction_ingest"
186+        && message.snapshot.browser.instruction_ingest.last_ingest?.assistant_message_id === "msg-final-message-ingest"
187+        && message.snapshot.browser.instruction_ingest.last_ingest?.status === "executed"
188+    );
189+
190+    assert.equal(
191+      executedSnapshot.snapshot.browser.instruction_ingest.last_ingest.conversation_id,
192+      null
193+    );
194+    assert.deepEqual(
195+      executedSnapshot.snapshot.browser.instruction_ingest.last_ingest.instruction_tools,
196+      ["conductor::describe", "conductor::exec"]
197+    );
198+    assert.equal(
199+      executedSnapshot.snapshot.browser.instruction_ingest.last_ingest.execution_ok_count,
200+      2
201+    );
202+    assert.equal(readFileSync(join(hostOpsDir, "final-message-ingest.txt"), "utf8"), "ws-live\n");
203+
204+    const browserStatusResponse = await fetch(`${baseUrl}/v1/browser`);
205+    assert.equal(browserStatusResponse.status, 200);
206+    const browserStatusPayload = await browserStatusResponse.json();
207+    assert.equal(browserStatusPayload.data.instruction_ingest.last_ingest.status, "executed");
208+    assert.equal(
209+      browserStatusPayload.data.instruction_ingest.last_execute.assistant_message_id,
210+      "msg-final-message-ingest"
211+    );
212+
213+    client.socket.send(
214+      JSON.stringify({
215+        type: "browser.final_message",
216+        platform: "chatgpt",
217+        conversation_id: "conv-replayed",
218+        assistant_message_id: "msg-final-message-ingest",
219+        raw_text: messageText,
220+        observed_at: 1_710_000_010_500
221+      })
222+    );
223+
224+    const duplicateSnapshot = await client.queue.next(
225+      (message) =>
226+        message.type === "state_snapshot"
227+        && message.reason === "instruction_ingest"
228+        && message.snapshot.browser.instruction_ingest.last_ingest?.assistant_message_id === "msg-final-message-ingest"
229+        && message.snapshot.browser.instruction_ingest.last_ingest?.status === "duplicate_message"
230+    );
231+
232+    assert.equal(
233+      duplicateSnapshot.snapshot.browser.instruction_ingest.last_execute.status,
234+      "executed"
235+    );
236+    assert.equal(readFileSync(join(hostOpsDir, "final-message-ingest.txt"), "utf8"), "ws-live\n");
237+  } finally {
238+    client?.queue.stop();
239+    client?.socket.close(1000, "done");
240+    await runtime.stop();
241+    rmSync(stateDir, {
242+      force: true,
243+      recursive: true
244+    });
245+    rmSync(hostOpsDir, {
246+      force: true,
247+      recursive: true
248+    });
249+  }
250+});
251+
252 test("ConductorRuntime exposes /v1/browser Claude HTTP routes over the local Firefox bridge", async () => {
253   const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-browser-http-"));
254   const runtime = new ConductorRuntime(
M apps/conductor-daemon/src/index.ts
+14, -0
 1@@ -20,6 +20,7 @@ import {
 2   type BrowserRequestPolicyControllerOptions
 3 } from "./browser-request-policy.js";
 4 import type { FirefoxBridgeService } from "./firefox-bridge.js";
 5+import { BaaLiveInstructionIngest } from "./instructions/ingest.js";
 6 import { handleConductorHttpRequest as handleConductorLocalHttpRequest } from "./local-api.js";
 7 import { ConductorLocalControlPlane } from "./local-control-plane.js";
 8 
 9@@ -40,6 +41,7 @@ export {
10   type BrowserRequestPolicyControllerOptions
11 } from "./browser-request-policy.js";
12 export { handleConductorHttpRequest } from "./local-api.js";
13+export * from "./artifacts/index.js";
14 export * from "./instructions/index.js";
15 
16 export type ConductorRole = "primary" | "standby";
17@@ -708,8 +710,20 @@ class ConductorLocalHttpServer {
18     this.snapshotLoader = snapshotLoader;
19     this.version = version;
20     this.resolvedBaseUrl = localApiBase;
21+    const instructionIngest = new BaaLiveInstructionIngest({
22+      localApiContext: {
23+        fetchImpl: this.fetchImpl,
24+        now: this.now,
25+        repository: this.repository,
26+        sharedToken: this.sharedToken,
27+        snapshotLoader: this.snapshotLoader,
28+        version: this.version
29+      },
30+      now: () => this.now() * 1000
31+    });
32     this.firefoxWebSocketServer = new ConductorFirefoxWebSocketServer({
33       baseUrlLoader: () => this.resolvedBaseUrl,
34+      instructionIngest,
35       now: this.now,
36       repository: this.repository,
37       snapshotLoader: this.snapshotLoader
M apps/conductor-daemon/src/instructions/index.ts
+1, -0
1@@ -7,3 +7,4 @@ export * from "./policy.js";
2 export * from "./router.js";
3 export * from "./executor.js";
4 export * from "./loop.js";
5+export * from "./ingest.js";
A apps/conductor-daemon/src/instructions/ingest.ts
+315, -0
  1@@ -0,0 +1,315 @@
  2+import { createHash } from "node:crypto";
  3+
  4+import type { ConductorLocalApiContext } from "../local-api.js";
  5+
  6+import {
  7+  BaaInstructionCenter,
  8+  BaaInstructionCenterError,
  9+  type BaaInstructionCenterOptions
 10+} from "./loop.js";
 11+import type { BaaInstructionProcessResult, BaaInstructionProcessStatus } from "./types.js";
 12+import { stableStringifyBaaJson } from "./types.js";
 13+
 14+export type BaaLiveInstructionIngestStatus =
 15+  | "duplicate_message"
 16+  | "duplicate_only"
 17+  | "executed"
 18+  | "failed"
 19+  | "ignored_no_instructions";
 20+
 21+export interface BaaLiveInstructionIngestInput {
 22+  assistantMessageId: string;
 23+  conversationId?: string | null;
 24+  observedAt?: number | null;
 25+  platform: string;
 26+  source: "browser.final_message";
 27+  text: string;
 28+}
 29+
 30+export interface BaaLiveInstructionIngestSummary {
 31+  assistant_message_id: string;
 32+  block_count: number;
 33+  conversation_id: string | null;
 34+  duplicate_instruction_count: number;
 35+  duplicate_tools: string[];
 36+  error_block_index: number | null;
 37+  error_message: string | null;
 38+  error_stage: string | null;
 39+  executed_tools: string[];
 40+  execution_count: number;
 41+  execution_failed_count: number;
 42+  execution_ok_count: number;
 43+  ingested_at: number;
 44+  instruction_count: number;
 45+  instruction_tools: string[];
 46+  message_dedupe_key: string;
 47+  observed_at: number | null;
 48+  platform: string;
 49+  source: "browser.final_message";
 50+  status: BaaLiveInstructionIngestStatus;
 51+}
 52+
 53+export interface BaaLiveInstructionIngestSnapshot {
 54+  last_execute: BaaLiveInstructionIngestSummary | null;
 55+  last_ingest: BaaLiveInstructionIngestSummary | null;
 56+}
 57+
 58+export interface BaaLiveInstructionIngestResult {
 59+  processResult: BaaInstructionProcessResult | null;
 60+  summary: BaaLiveInstructionIngestSummary;
 61+}
 62+
 63+export interface BaaLiveInstructionMessageDeduper {
 64+  add(dedupeKey: string): Promise<void> | void;
 65+  has(dedupeKey: string): Promise<boolean> | boolean;
 66+}
 67+
 68+export interface BaaLiveInstructionIngestOptions {
 69+  center?: BaaInstructionCenter;
 70+  localApiContext?: ConductorLocalApiContext;
 71+  messageDeduper?: BaaLiveInstructionMessageDeduper;
 72+  now?: () => number;
 73+}
 74+
 75+export class InMemoryBaaLiveInstructionMessageDeduper implements BaaLiveInstructionMessageDeduper {
 76+  private readonly keys = new Set<string>();
 77+
 78+  add(dedupeKey: string): void {
 79+    this.keys.add(dedupeKey);
 80+  }
 81+
 82+  clear(): void {
 83+    this.keys.clear();
 84+  }
 85+
 86+  has(dedupeKey: string): boolean {
 87+    return this.keys.has(dedupeKey);
 88+  }
 89+}
 90+
 91+function buildInstructionDescriptor(target: string, tool: string): string {
 92+  return `${target}::${tool}`;
 93+}
 94+
 95+function classifyProcessStatus(status: BaaInstructionProcessStatus): BaaLiveInstructionIngestStatus {
 96+  switch (status) {
 97+    case "duplicate_only":
 98+      return "duplicate_only";
 99+    case "executed":
100+      return "executed";
101+    case "no_instructions":
102+      return "ignored_no_instructions";
103+  }
104+}
105+
106+function shouldUpdateExecutionSummary(status: BaaLiveInstructionIngestStatus): boolean {
107+  return status === "duplicate_only" || status === "executed" || status === "failed";
108+}
109+
110+function normalizeOptionalString(value: string | null | undefined): string | null {
111+  if (typeof value !== "string") {
112+    return null;
113+  }
114+
115+  const normalized = value.trim();
116+  return normalized === "" ? null : normalized;
117+}
118+
119+export function buildBaaLiveInstructionMessageDedupeKey(input: {
120+  assistantMessageId: string;
121+  platform: string;
122+  text: string;
123+}): string {
124+  return `sha256:${createHash("sha256")
125+    .update(
126+      stableStringifyBaaJson({
127+        assistant_message_id: input.assistantMessageId,
128+        platform: input.platform,
129+        raw_text: input.text,
130+        version: "baa.live.v1"
131+      })
132+    )
133+    .digest("hex")}`;
134+}
135+
136+export class BaaLiveInstructionIngest {
137+  private readonly center: BaaInstructionCenter;
138+  private readonly messageDeduper: BaaLiveInstructionMessageDeduper;
139+  private readonly now: () => number;
140+  private lastExecute: BaaLiveInstructionIngestSummary | null = null;
141+  private lastIngest: BaaLiveInstructionIngestSummary | null = null;
142+  private readonly pendingKeys = new Set<string>();
143+
144+  constructor(options: BaaLiveInstructionIngestOptions) {
145+    if (options.center == null && options.localApiContext == null) {
146+      throw new Error("BaaLiveInstructionIngest requires either center or localApiContext.");
147+    }
148+
149+    this.center =
150+      options.center
151+      ?? new BaaInstructionCenter({
152+        localApiContext: options.localApiContext as BaaInstructionCenterOptions["localApiContext"]
153+      });
154+    this.messageDeduper = options.messageDeduper ?? new InMemoryBaaLiveInstructionMessageDeduper();
155+    this.now = options.now ?? Date.now;
156+  }
157+
158+  getSnapshot(): BaaLiveInstructionIngestSnapshot {
159+    return {
160+      last_execute: this.lastExecute == null ? null : { ...this.lastExecute },
161+      last_ingest: this.lastIngest == null ? null : { ...this.lastIngest }
162+    };
163+  }
164+
165+  async ingestAssistantFinalMessage(
166+    input: BaaLiveInstructionIngestInput
167+  ): Promise<BaaLiveInstructionIngestResult> {
168+    const messageDedupeKey = buildBaaLiveInstructionMessageDedupeKey({
169+      assistantMessageId: input.assistantMessageId,
170+      platform: input.platform,
171+      text: input.text
172+    });
173+    const baseSummary = {
174+      assistant_message_id: input.assistantMessageId,
175+      conversation_id: normalizeOptionalString(input.conversationId),
176+      ingested_at: this.now(),
177+      message_dedupe_key: messageDedupeKey,
178+      observed_at: typeof input.observedAt === "number" && Number.isFinite(input.observedAt) ? input.observedAt : null,
179+      platform: input.platform,
180+      source: input.source
181+    } as const;
182+
183+    if (this.pendingKeys.has(messageDedupeKey) || await this.messageDeduper.has(messageDedupeKey)) {
184+      const summary: BaaLiveInstructionIngestSummary = {
185+        ...baseSummary,
186+        block_count: 0,
187+        duplicate_instruction_count: 0,
188+        duplicate_tools: [],
189+        error_block_index: null,
190+        error_message: null,
191+        error_stage: null,
192+        executed_tools: [],
193+        execution_count: 0,
194+        execution_failed_count: 0,
195+        execution_ok_count: 0,
196+        instruction_count: 0,
197+        instruction_tools: [],
198+        status: "duplicate_message"
199+      };
200+      this.lastIngest = summary;
201+      return {
202+        processResult: null,
203+        summary
204+      };
205+    }
206+
207+    this.pendingKeys.add(messageDedupeKey);
208+
209+    try {
210+      const processResult = await this.center.processAssistantMessage({
211+        assistantMessageId: input.assistantMessageId,
212+        conversationId: normalizeOptionalString(input.conversationId),
213+        platform: input.platform,
214+        text: input.text
215+      });
216+      const summary = this.buildSuccessSummary(baseSummary, processResult);
217+
218+      await this.messageDeduper.add(messageDedupeKey);
219+      this.lastIngest = summary;
220+
221+      if (shouldUpdateExecutionSummary(summary.status)) {
222+        this.lastExecute = summary;
223+      }
224+
225+      return {
226+        processResult,
227+        summary
228+      };
229+    } catch (error) {
230+      const summary = this.buildFailureSummary(baseSummary, error);
231+
232+      if (error instanceof BaaInstructionCenterError) {
233+        await this.messageDeduper.add(messageDedupeKey);
234+      }
235+
236+      this.lastIngest = summary;
237+      this.lastExecute = summary;
238+
239+      return {
240+        processResult: null,
241+        summary
242+      };
243+    } finally {
244+      this.pendingKeys.delete(messageDedupeKey);
245+    }
246+  }
247+
248+  private buildFailureSummary(
249+    baseSummary: Pick<
250+      BaaLiveInstructionIngestSummary,
251+      | "assistant_message_id"
252+      | "conversation_id"
253+      | "ingested_at"
254+      | "message_dedupe_key"
255+      | "observed_at"
256+      | "platform"
257+      | "source"
258+    >,
259+    error: unknown
260+  ): BaaLiveInstructionIngestSummary {
261+    return {
262+      ...baseSummary,
263+      block_count: 0,
264+      duplicate_instruction_count: 0,
265+      duplicate_tools: [],
266+      error_block_index:
267+        error instanceof BaaInstructionCenterError ? error.blockIndex : null,
268+      error_message: error instanceof Error ? error.message : String(error),
269+      error_stage: error instanceof BaaInstructionCenterError ? error.stage : "internal",
270+      executed_tools: [],
271+      execution_count: 0,
272+      execution_failed_count: 0,
273+      execution_ok_count: 0,
274+      instruction_count: 0,
275+      instruction_tools: [],
276+      status: "failed"
277+    };
278+  }
279+
280+  private buildSuccessSummary(
281+    baseSummary: Pick<
282+      BaaLiveInstructionIngestSummary,
283+      | "assistant_message_id"
284+      | "conversation_id"
285+      | "ingested_at"
286+      | "message_dedupe_key"
287+      | "observed_at"
288+      | "platform"
289+      | "source"
290+    >,
291+    processResult: BaaInstructionProcessResult
292+  ): BaaLiveInstructionIngestSummary {
293+    return {
294+      ...baseSummary,
295+      block_count: processResult.blocks.length,
296+      duplicate_instruction_count: processResult.duplicates.length,
297+      duplicate_tools: processResult.duplicates.map((instruction) =>
298+        buildInstructionDescriptor(instruction.target, instruction.tool)
299+      ),
300+      error_block_index: null,
301+      error_message: null,
302+      error_stage: null,
303+      executed_tools: processResult.executions.map((execution) =>
304+        buildInstructionDescriptor(execution.target, execution.tool)
305+      ),
306+      execution_count: processResult.executions.length,
307+      execution_failed_count: processResult.executions.filter((execution) => execution.ok === false).length,
308+      execution_ok_count: processResult.executions.filter((execution) => execution.ok === true).length,
309+      instruction_count: processResult.instructions.length,
310+      instruction_tools: processResult.instructions.map((instruction) =>
311+        buildInstructionDescriptor(instruction.target, instruction.tool)
312+      ),
313+      status: classifyProcessStatus(processResult.status)
314+    };
315+  }
316+}
M apps/conductor-daemon/src/instructions/normalize.ts
+10, -1
 1@@ -19,13 +19,22 @@ function requireNonEmptyField(name: string, value: string): string {
 2   return normalized;
 3 }
 4 
 5+function normalizeOptionalStringField(value: string | null): string | null {
 6+  if (value == null) {
 7+    return null;
 8+  }
 9+
10+  const normalized = value.trim();
11+  return normalized === "" ? null : normalized;
12+}
13+
14 export function normalizeBaaInstruction(
15   source: BaaInstructionSourceMessage,
16   instruction: BaaParsedInstruction
17 ): BaaInstructionEnvelope {
18   const normalizedSource = {
19     assistantMessageId: requireNonEmptyField("assistantMessageId", source.assistantMessageId),
20-    conversationId: requireNonEmptyField("conversationId", source.conversationId),
21+    conversationId: normalizeOptionalStringField(source.conversationId),
22     platform: requireNonEmptyField("platform", source.platform)
23   };
24   const dedupeBasis = buildBaaInstructionDedupeBasis(normalizedSource, instruction);
M apps/conductor-daemon/src/instructions/types.ts
+2, -2
 1@@ -26,14 +26,14 @@ export interface BaaParsedInstruction {
 2 
 3 export interface BaaInstructionSourceMessage {
 4   assistantMessageId: string;
 5-  conversationId: string;
 6+  conversationId: string | null;
 7   platform: string;
 8 }
 9 
10 export interface BaaInstructionDedupeBasis {
11   assistant_message_id: string;
12   block_index: number;
13-  conversation_id: string;
14+  conversation_id: string | null;
15   params: BaaInstructionParams;
16   platform: string;
17   target: string;
M apps/conductor-daemon/src/local-api.ts
+61, -1
 1@@ -1633,13 +1633,30 @@ function createEmptyBrowserState(snapshot: ConductorRuntimeApiSnapshot): Browser
 2     active_connection_id: null,
 3     client_count: 0,
 4     clients: [],
 5+    instruction_ingest: createEmptyBrowserInstructionIngestSnapshot(),
 6     ws_path: "/ws/firefox",
 7     ws_url: snapshot.controlApi.firefoxWsUrl ?? null
 8   };
 9 }
10 
11+function createEmptyBrowserInstructionIngestSnapshot(): BrowserBridgeStateSnapshot["instruction_ingest"] {
12+  return {
13+    last_execute: null,
14+    last_ingest: null
15+  };
16+}
17+
18+function normalizeBrowserStateSnapshot(state: BrowserBridgeStateSnapshot): BrowserBridgeStateSnapshot {
19+  return {
20+    ...state,
21+    instruction_ingest: state.instruction_ingest ?? createEmptyBrowserInstructionIngestSnapshot()
22+  };
23+}
24+
25 function loadBrowserState(context: LocalApiRequestContext): BrowserBridgeStateSnapshot {
26-  return context.browserStateLoader() ?? createEmptyBrowserState(context.snapshotLoader());
27+  return normalizeBrowserStateSnapshot(
28+    context.browserStateLoader() ?? createEmptyBrowserState(context.snapshotLoader())
29+  );
30 }
31 
32 function selectClaudeBrowserClient(
33@@ -2281,6 +2298,48 @@ function serializeBrowserActionResultSnapshot(snapshot: BrowserBridgeActionResul
34   });
35 }
36 
37+function serializeBrowserInstructionIngestSummary(
38+  summary: BrowserBridgeStateSnapshot["instruction_ingest"]["last_ingest"]
39+): JsonObject | null {
40+  if (summary == null) {
41+    return null;
42+  }
43+
44+  return compactJsonObject({
45+    assistant_message_id: summary.assistant_message_id,
46+    block_count: summary.block_count,
47+    conversation_id: summary.conversation_id ?? undefined,
48+    duplicate_instruction_count: summary.duplicate_instruction_count,
49+    duplicate_tools: [...summary.duplicate_tools],
50+    error_block_index: summary.error_block_index ?? undefined,
51+    error_message: summary.error_message ?? undefined,
52+    error_stage: summary.error_stage ?? undefined,
53+    executed_tools: [...summary.executed_tools],
54+    execution_count: summary.execution_count,
55+    execution_failed_count: summary.execution_failed_count,
56+    execution_ok_count: summary.execution_ok_count,
57+    ingested_at: summary.ingested_at,
58+    instruction_count: summary.instruction_count,
59+    instruction_tools: [...summary.instruction_tools],
60+    message_dedupe_key: summary.message_dedupe_key,
61+    observed_at: summary.observed_at ?? undefined,
62+    platform: summary.platform,
63+    source: summary.source,
64+    status: summary.status
65+  });
66+}
67+
68+function serializeBrowserInstructionIngestSnapshot(
69+  snapshot: BrowserBridgeStateSnapshot["instruction_ingest"] | undefined
70+): JsonObject {
71+  const normalized = snapshot ?? createEmptyBrowserInstructionIngestSnapshot();
72+
73+  return {
74+    last_execute: serializeBrowserInstructionIngestSummary(normalized.last_execute),
75+    last_ingest: serializeBrowserInstructionIngestSummary(normalized.last_ingest)
76+  };
77+}
78+
79 function serializeBrowserClientSnapshot(snapshot: BrowserBridgeClientSnapshot): JsonObject {
80   return {
81     client_id: snapshot.client_id,
82@@ -2874,6 +2933,7 @@ async function buildBrowserStatusData(context: LocalApiRequestContext): Promise<
83       ws_path: browserState.ws_path,
84       ws_url: browserState.ws_url
85     },
86+    instruction_ingest: serializeBrowserInstructionIngestSnapshot(browserState.instruction_ingest),
87     current_client: currentClient == null ? null : serializeBrowserClientSnapshot(currentClient),
88     claude: {
89       credentials:
M apps/conductor-daemon/src/node-shims.d.ts
+26, -0
 1@@ -1,6 +1,7 @@
 2 declare class Buffer extends Uint8Array {
 3   static alloc(size: number): Buffer;
 4   static allocUnsafe(size: number): Buffer;
 5+  static byteLength(value: string, encoding?: string): number;
 6   static concat(chunks: readonly Uint8Array[]): Buffer;
 7   static from(value: string, encoding?: string): Buffer;
 8   copy(target: Uint8Array, targetStart?: number): number;
 9@@ -47,6 +48,30 @@ declare module "node:fs" {
10   export function readFileSync(path: string, encoding: string): string;
11 }
12 
13+declare module "node:fs/promises" {
14+  export interface FileOperationOptions {
15+    encoding?: string;
16+    flag?: string;
17+    mode?: number;
18+  }
19+
20+  export interface MakeDirectoryOptions {
21+    mode?: number;
22+    recursive?: boolean;
23+  }
24+
25+  export function mkdir(
26+    path: string,
27+    options?: MakeDirectoryOptions
28+  ): Promise<string | undefined>;
29+
30+  export function writeFile(
31+    path: string,
32+    data: string,
33+    options?: FileOperationOptions | string
34+  ): Promise<void>;
35+}
36+
37 declare module "node:http" {
38   import type { AddressInfo } from "node:net";
39 
40@@ -88,6 +113,7 @@ declare module "node:http" {
41 }
42 
43 declare module "node:path" {
44+  export function join(...paths: string[]): string;
45   export function resolve(...paths: string[]): string;
46 }
47 
M docs/api/firefox-local-ws.md
+15, -4
 1@@ -145,6 +145,9 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
 2 - `snapshot.system` 直接复用 `GET /v1/system/state` 的合同
 3 - `snapshot.browser.clients[].credentials` 只回传 `account`、`credential_fingerprint`、`freshness`、`header_count` 和时间戳
 4 - `snapshot.browser.clients[].final_messages` 只保留当前活跃 bridge client 最近观测到的最终消息,不写入当前持久化表
 5+- `snapshot.browser.instruction_ingest` 暴露最近一次 live ingest / execute 最小摘要:
 6+  - `last_ingest`
 7+  - `last_execute`
 8 - `snapshot.browser.clients[].request_hooks` 只回传 endpoint 列表、`endpoint_metadata` 和更新时间
 9 
10 ### `action_request`
11@@ -251,13 +254,21 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
12 - `raw_text` 必须是完整最终文本,不是 stream chunk
13 - 插件必须在 streaming 完成后再发送,不得在半截 stream 提前上报
14 - `assistant_message_id` 允许退化为平台内等价稳定字段,但最终仍统一放进同名字段
15-- 当前 server 只做接收和最近快照保留,不在这层做 BAA 提取或执行
16+- `conversation_id` 允许为空
17+- 当前 server 会把 live `browser.final_message` 直接送入 conductor 侧 instruction center
18+- 若消息不含 ` ```baa `,会被安全忽略
19+- 当前只允许 Phase 1 精确 target:
20+  - `conductor`
21+  - `system`
22+- 当前仍不包含 artifact materialization / upload / inject / send
23 
24 server 行为:
25 
26-- 用 `requestId` 关联对应 HTTP `POST /v1/browser/actions`
27-- 把最新 `shell_runtime` 合并进当前 client 视图
28-- 把最近一次结构化 `action_result` 暴露给 `GET /v1/browser`
29+- 把最新 final message 去重后写入当前 client 的最近快照
30+- 以 `platform + assistant_message_id + raw_text` 做 live message replay 抑制
31+- 把最近一次 ingest / execute 摘要暴露到:
32+  - `state_snapshot.snapshot.browser.instruction_ingest`
33+  - `GET /v1/browser`
34 
35 ### `credentials`
36 
M docs/baa-instruction-system-v5/docs/06-integration-with-current-baa-conductor.md
+5, -0
 1@@ -218,6 +218,11 @@ packages/schemas/src/
 2 4. 插件按 plan 上传
 3 5. 插件返回 receipt
 4 6. conductor 放行注入文本
 5+
 6+补充:
 7+
 8+- 当前 repo 已落地到第 2 步:Firefox WS `browser.final_message` 会直接进入 conductor instruction center
 9+- 当前还没有 artifact / upload / inject / send 闭环,最近结果只保留 live runtime 摘要
10 7. 插件注入并发送
11 
12 这样你就实现了:
M docs/baa-instruction-system-v5/docs/09-artifact-delivery-thin-plugin.md
+22, -0
 1@@ -78,6 +78,28 @@ raw execution result
 2 
 3 不建议把所有东西都强制变成 `.md`。
 4 
 5+## 9.6A 当前 repo 落地状态(2026-03-27)
 6+
 7+- `apps/conductor-daemon/src/artifacts/` 已补上 service-side core:
 8+  - `materialize.ts`
 9+  - `manifest.ts`
10+  - `delivery-plan.ts`
11+- 当前 conductor 已能用 synthetic execution result 稳定生成:
12+  - artifact refs
13+  - auditable manifest
14+  - `[BAA Result Index]` 文本
15+  - upload-aware delivery plan
16+- 当前最小支持结果类型:
17+  - `exec`
18+  - `files/read`
19+  - `files/write`
20+  - `describe`
21+  - `status`
22+- 当前仍未实现:
23+  - 插件实际 upload / download 执行
24+  - upload receipt barrier
25+  - 依赖 receipt 的自动注入 / 自动发送
26+
27 ## 9.7 上传确认 barrier
28 
29 文件被拖进输入框,不等于平台已经接收成功。
M docs/firefox/README.md
+10, -1
 1@@ -72,7 +72,11 @@
 2 补充说明:
 3 
 4 - `browser.final_message.raw_text` 属于 live WS relay,不进入当前持久化表
 5-- 当前服务端只保留活跃 bridge client 的最近最终消息快照,用于后续 instruction center 接入
 6+- `browser.final_message` 现在会直接进入 conductor 侧 `BaaInstructionCenter`;普通消息若不含 ` ```baa ` 会被安全忽略
 7+- 当前服务端会同时保留:
 8+  - 活跃 bridge client 的最近最终消息快照
 9+  - 最近一次 `instruction_ingest` / `execute` 最小摘要(见 `GET /v1/browser`)
10+- 当前仍没有 artifact packaging、upload、inject 或自动 send
11 
12 `GET /v1/browser` 会把活跃 WS 连接和持久化记录合并成统一读面,并暴露 `fresh` / `stale` / `lost`。
13 
14@@ -164,6 +168,11 @@
15 - client 断开或流量长时间老化后,持久化记录仍可读,但会从 `fresh` 变成 `stale` / `lost`
16 - `browser.final_message` 只在最终完成态上报完整文本,不会在 streaming 半截上报
17 - 若平台暂时拿不到原生稳定 message id,插件会退化到等价稳定字段后再放进 `assistant_message_id`
18+- 若 `raw_text` 含 ` ```baa `,当前只会按 Phase 1 边界执行精确 target:
19+  - `conductor`
20+  - `system`
21+- `conversation_id` 允许为空;当前 replay 去重至少覆盖 `platform + assistant_message_id + raw_text`
22+- 当前仍未接 artifact / upload / inject / send 闭环
23 
24 ## 浏览器本地代发
25 
A plans/BAA_ARTIFACT_CENTER_REQUIREMENTS.md
+96, -0
 1@@ -0,0 +1,96 @@
 2+# BAA Artifact Center 与 Delivery Plan Core 需求
 3+
 4+## 状态
 5+
 6+- `已完成(T-S032,2026-03-27)`
 7+- 优先级:`high`
 8+- 记录时间:`2026-03-27`
 9+
10+## 关联文档
11+
12+- [BAA_INSTRUCTION_SYSTEM.md](/Users/george/code/baa-conductor/plans/BAA_INSTRUCTION_SYSTEM.md)
13+- [BAA_INSTRUCTION_CENTER_REQUIREMENTS.md](/Users/george/code/baa-conductor/plans/BAA_INSTRUCTION_CENTER_REQUIREMENTS.md)
14+- [06-integration-with-current-baa-conductor.md](/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/docs/06-integration-with-current-baa-conductor.md)
15+- [07-rollout-plan.md](/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/docs/07-rollout-plan.md)
16+
17+## 背景
18+
19+当前 repo 已经具备:
20+
21+- `instructions/` Phase 1 最小执行闭环
22+- `browser.final_message` raw relay
23+
24+但执行结果当前仍停留在“直接返回原始结果摘要”的阶段,缺少 v5 明确要求的:
25+
26+- artifact materialization
27+- manifest / index text
28+- delivery plan
29+
30+如果不把这层建起来,后续大结果、并发多结果和薄插件上传/注入都没有稳定交付模型。
31+
32+## 核心结论
33+
34+- artifact center 应放在 conductor,不放在插件
35+- 首版先完成 service-side core:
36+  - artifact materializer
37+  - manifest builder
38+  - delivery plan builder
39+- 首版允许完全用 synthetic execution result / fixture 驱动,不阻塞于 live browser final message 接线
40+- 插件上传 / 下载 / 注入执行可留到后续任务,不强行并进本张卡
41+
42+## 首版范围
43+
44+- `apps/conductor-daemon/src/artifacts/` 新模块
45+- 执行结果到 artifact 的转换规则
46+- manifest / index text 生成
47+- delivery plan 数据结构与最小生成逻辑
48+- 自动化测试与文档回写
49+
50+## 当前落地摘要
51+
52+- `apps/conductor-daemon/src/artifacts/` 已新增:
53+  - `types.ts`
54+  - `materialize.ts`
55+  - `manifest.ts`
56+  - `delivery-plan.ts`
57+- 当前最小支持结果类型:
58+  - `exec`
59+  - `files/read`
60+  - `files/write`
61+  - `describe` / `describe/business` / `describe/control`
62+  - `status`
63+- synthetic execution result 已补自动化覆盖:
64+  - `exec` 产出 artifact + manifest + delivery plan
65+  - `files/read` 小结果 / 大结果有不同 delivery 策略
66+  - 多结果排序稳定,缺少可选字段不会让整批 materialize 崩掉
67+- 当前仍明确未落地:
68+  - Firefox 插件 upload / download 执行
69+  - upload receipt barrier
70+  - 浏览器侧 inject / send 主链
71+
72+## 建议最小支持的结果类型
73+
74+- `exec` 的 stdout / stderr 文本
75+- `files/read` 的文本内容
76+- `files/write` / `describe` / `status` 的结构化摘要
77+
78+## 当前明确不要求
79+
80+- 不要求这张卡里实现 Firefox 插件 upload / download
81+- 不要求这张卡里实现 receipt barrier
82+- 不要求这张卡里完成自动注入 / 自动发送
83+- 不要求这张卡里接多节点 / 逻辑 target / 任务池
84+
85+## 验收条件
86+
87+- synthetic execution results 能稳定 materialize 为 artifacts
88+- manifest 和 index text 稳定、可审计
89+- delivery plan 结构可稳定生成,且对大结果不会退化成整段直接塞聊天
90+- 文档已同步到 `plans/`、`tasks/` 和必要的 `docs/baa-instruction-system-v5/`
91+
92+## 当前预期残余边界
93+
94+- upload / download / receipt barrier 仍留到后续任务
95+- 首版 delivery plan 可以先面向单客户端、单轮交付
96+- 真正的浏览器注入链仍由后续插件任务接上
97+- 当前 live instruction ingest 路径还没有把执行结果真正接到 artifact / upload / inject / send
M plans/BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md
+3, -1
1@@ -127,4 +127,6 @@
2 
3 - ChatGPT 当前主要依赖 conversation SSE 结构;如果页面后续调整 payload 形态,需要同步修改提取器
4 - Gemini 当前基于 `StreamGenerate` / `batchexecute` 风格 payload 的启发式解析来抽取最终文本;稳定性弱于 ChatGPT,因此保留 synthetic `assistant_message_id` 兜底
5-- conductor 侧这轮只完成 `browser.final_message` 的最小兼容接收和最近快照保留;当前不落持久化表,也没有直接接入 instruction parser / execution loop
6+- conductor 侧现已把 `browser.final_message` 接到 live instruction ingest,但 live message dedupe 和 instruction dedupe 都还是进程内内存态,重启后不会保留
7+- 当前摘要只保留最近一次 live ingest / execute,不落当前持久化表
8+- 当前 live 路径仍只允许 Phase 1 精确 target,且还没有接 artifact / upload / inject / send
A plans/BAA_FINAL_MESSAGE_INGEST_REQUIREMENTS.md
+81, -0
 1@@ -0,0 +1,81 @@
 2+# BAA 最终消息实时接线需求
 3+
 4+## 状态
 5+
 6+- `已落地(T-S031 已实现)`
 7+- 优先级:`high`
 8+- 记录时间:`2026-03-27`
 9+
10+## 关联文档
11+
12+- [BAA_INSTRUCTION_SYSTEM.md](/Users/george/code/baa-conductor/plans/BAA_INSTRUCTION_SYSTEM.md)
13+- [BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md](/Users/george/code/baa-conductor/plans/BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md)
14+- [BAA_INSTRUCTION_CENTER_REQUIREMENTS.md](/Users/george/code/baa-conductor/plans/BAA_INSTRUCTION_CENTER_REQUIREMENTS.md)
15+- [06-integration-with-current-baa-conductor.md](/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/docs/06-integration-with-current-baa-conductor.md)
16+- [07-rollout-plan.md](/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/docs/07-rollout-plan.md)
17+
18+## 背景
19+
20+当前 repo 已经完成了两件前置工作:
21+
22+- Firefox 插件会把 ChatGPT / Gemini 的最终 assistant message 以 `browser.final_message` relay 给 conductor
23+- conductor 侧已经有可运行的 `instructions/` Phase 1 最小闭环,但当前仍主要通过 synthetic assistant message / fixture 驱动
24+
25+现在真正缺的不是 parser 本身,而是把这两段接起来:
26+
27+- `browser.final_message`
28+- `BaaInstructionCenter.processAssistantMessage(...)`
29+
30+如果这层不接,现有 BAA instruction center 仍然只是“本地可运行模块”,不是 browser bridge 主链路的一部分。
31+
32+## 核心结论
33+
34+- conductor 应在收到 live `browser.final_message` 时,按当前 Phase 1 能力直接进入 extract / parse / normalize / dedupe / policy / route / execute
35+- 首版只接本机精确 target:
36+  - `conductor`
37+  - `system`
38+- 首版不要求自动注入 / 自动发送 / delivery plan
39+- 首版允许只做内存态执行审计和最近结果快照,不要求同时落库
40+
41+## 首版范围
42+
43+- Firefox WS `browser.final_message` 到 instruction center 的实时接线
44+- live message 和 current synthetic path 共用同一套 instruction core
45+- live message replay 去重
46+- fail-closed 错误路径
47+- 最小可观察结果快照 / 读面诊断
48+- 文档与自动化测试
49+
50+## 处理原则
51+
52+- 只在最终 assistant message 上执行,不消费 stream 中间态
53+- 普通消息无 ` ```baa ` 时应安全忽略
54+- 同一条 final message replay 不得重复执行
55+- 任一 block 在 parse / policy / route 失败时,整批 fail-closed,不允许半执行
56+- 插件仍只负责 raw relay,不放 parser / executor
57+
58+## 当前明确不要求
59+
60+- 不要求本需求里实现 artifact materialization / manifest / delivery plan
61+- 不要求本需求里做 upload / download / inject / send
62+- 不要求本需求里补持久化 dedupe 或 execution journal
63+- 不要求本需求里扩到 `browser.*` / `codex` / `node.*` / `pool.*` / `role.*`
64+
65+## 验收条件
66+
67+- live `browser.final_message` 到达后,符合当前 Phase 1 范围的 ` ```baa ` 指令会进入 instruction center
68+- 无 ` ```baa ` 的 final message 不会误执行
69+- replay 同一条 final message 不会重复执行
70+- batch 内任一 unsupported / denied 指令会 fail-closed,且不会发生半执行
71+- 文档已同步到 `plans/`、`tasks/` 和必要的 `docs/`
72+
73+## 当前预期残余边界
74+
75+- live message dedupe 和 instruction dedupe 当前都还是进程内内存态,重启后不会保留
76+- 当前仍只做本机精确 target:
77+  - `conductor`
78+  - `system`
79+- 当前最近摘要只保留在 live runtime:
80+  - Firefox WS `state_snapshot.snapshot.browser.instruction_ingest`
81+  - `GET /v1/browser` → `data.instruction_ingest`
82+- 当前 live ingest 路径还没有把执行结果真正接到 artifact / upload / inject / send
M plans/BAA_INSTRUCTION_CENTER_REQUIREMENTS.md
+2, -1
1@@ -194,4 +194,5 @@ apps/conductor-daemon/src/instructions/
2 
3 - dedupe 目前仍是进程内内存态,进程重启后不会保留
4 - 当前只做本机精确 target,跨节点分发和多轮闭环还没接
5-- 当前 instruction center 仍主要通过 synthetic assistant message / fixture 驱动验证,尚未把 Firefox bridge live `browser.final_message` 输入直接接到 `processAssistantMessage(...)`
6+- 当前 Firefox bridge live `browser.final_message` 已接入 instruction center,但最近摘要只保留在 live runtime,不落库
7+- 当前 live 路径还没有把执行结果真正接到 artifact / upload / inject / send
M plans/STATUS_SUMMARY.md
+11, -6
 1@@ -8,14 +8,14 @@
 2 
 3 - 浏览器控制主链路收口基线:`main@07895cd`
 4 - 最近功能代码提交:`main@25be868`(启动时自动恢复受管 Firefox shell tabs)
 5-- `2026-03-27` 当前本地代码已额外完成 `BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 修复,以及 `T-S029`、`T-S030` 落地
 6+- `2026-03-27` 当前本地代码已额外完成 `BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 修复,以及 `T-S029`、`T-S030`、`T-S031`、`T-S032` 落地
 7 - 任务文档已统一收口到 `tasks/`
 8 - 当前活动任务见 `tasks/TASK_OVERVIEW.md`
 9-- `T-S001` 到 `T-S030` 已经完成,`T-BUG-011`、`T-BUG-012`、`T-BUG-014` 也已完成
10+- `T-S001` 到 `T-S032` 已经完成,`T-BUG-011`、`T-BUG-012`、`T-BUG-014` 也已完成
11 
12 ## 当前状态分类
13 
14-- `已完成`:`T-S001` 到 `T-S030`,以及 `T-BUG-011`、`T-BUG-012`、`T-BUG-014`
15+- `已完成`:`T-S001` 到 `T-S032`,以及 `T-BUG-011`、`T-BUG-012`、`T-BUG-014`
16 - `当前 TODO`:
17   - `T-S026` 真实 Firefox 手工 smoke 与验收记录
18 - `待处理缺陷`:当前无 open bug backlog(见 `bugs/README.md`)
19@@ -23,6 +23,8 @@
20 
21 当前新的主需求文档:
22 
23+- [`./BAA_FINAL_MESSAGE_INGEST_REQUIREMENTS.md`](./BAA_FINAL_MESSAGE_INGEST_REQUIREMENTS.md)
24+- [`./BAA_ARTIFACT_CENTER_REQUIREMENTS.md`](./BAA_ARTIFACT_CENTER_REQUIREMENTS.md)
25 - [`./BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md`](./BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md)
26 - [`./BAA_INSTRUCTION_CENTER_REQUIREMENTS.md`](./BAA_INSTRUCTION_CENTER_REQUIREMENTS.md)
27 - [`./BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md`](./BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md)
28@@ -77,6 +79,8 @@
29 9. `2026-03-27` 跟进修复:`BUG-017` 已修复,buffered 模式的 SSE 响应现在会返回结构化 `events` / `full_text`
30 10. `2026-03-27` 跟进任务:`T-S027` 已完成,browser request policy 已补 stale `inFlight` 自愈清扫、lease 活跃时间追踪和 `GET /v1/browser` 读面诊断字段
31 11. `2026-03-27` 跟进任务:`T-S028` 已完成,`platform=chatgpt` 的 `/v1/browser/request` 现已正式支持 path-based buffered / SSE / cancel,并已补到 automated smoke 与文档
32+12. `2026-03-27` 跟进任务:`T-S031` 已完成,`browser.final_message` 已接入 live instruction ingest,并把最近一次 ingest / execute 摘要暴露到 Firefox WS 与 `/v1/browser`
33+13. `2026-03-27` 跟进任务:`T-S032` 已完成,`conductor-daemon` 已补 service-side artifact center core:artifact materialize、manifest / index text 和 delivery plan
34 
35 当前策略:
36 
37@@ -166,12 +170,13 @@
38 - 当前 open bug backlog 已清空
39 - 当前主线下一波任务是:
40   - `T-S026`:真实 Firefox 手工 smoke 与验收记录
41-- `T-S029`、`T-S030` 已完成,当前 BAA 第一阶段已具备 ChatGPT / Gemini 最终消息 raw relay、`browser.final_message` 最近快照保留,以及 conductor 侧 instruction center Phase 1 最小闭环
42+- `T-S029`、`T-S030`、`T-S031`、`T-S032` 已完成,当前 BAA 已具备 ChatGPT / Gemini 最终消息 raw relay、`browser.final_message` 最近快照保留、conductor 侧 instruction center Phase 1 最小闭环与 live ingest,以及 artifact / manifest / delivery plan 服务端核心
43 - 当前 BAA 仍保留这些边界:
44   - ChatGPT 当前主要依赖 conversation SSE 结构;如果页面 payload 形态变化,需要同步修改提取器
45   - Gemini 最终文本提取当前基于 `StreamGenerate` / `batchexecute` 风格 payload 的启发式解析,稳定性弱于 ChatGPT,因此保留 synthetic `assistant_message_id` 兜底
46-  - `browser.final_message` 当前只做最小兼容接收和最近快照保留,不落当前持久化表,也没有直接接入 instruction parser
47-  - instruction dedupe 目前仍是进程内内存态,且只做本机精确 target;跨节点和多轮闭环还没接
48+  - live message dedupe 和 instruction dedupe 目前都还是进程内内存态,重启后不会保留
49+  - 当前摘要只保留最近一次 live ingest / execute,不落库
50+  - 当前仍只允许 Phase 1 精确 target `conductor` / `system`;service-side artifact center core 已有,但 live 路径还没有把执行结果真正接到 artifact / upload / inject / send
51 - `BUG-012` 这轮修复已补上 stale `inFlight` 自愈清扫;当前残余边界是“健康但长时间完全静默”的超长 buffered 请求,理论上仍可能被 `5min` idle 阈值误判
52 - ChatGPT 当前仍依赖浏览器里真实捕获到的有效登录态 / header,且没有 Claude 风格 prompt shortcut;这是当前正式支持面的已知边界
53 - `BUG-014` 的自动化验证目前覆盖的是 conductor 侧语义透传,不是 Firefox 扩展真实运行环境里的 reconnect 生命周期;真实“重连完成”仍依赖后续 `hello` / 状态同步
A tasks/T-S031.md
+162, -0
  1@@ -0,0 +1,162 @@
  2+# Task T-S031:把 live `browser.final_message` 接到 BAA instruction center
  3+
  4+## 直接给对话的提示词
  5+
  6+读 `/Users/george/code/baa-conductor/tasks/T-S031.md` 任务文档,完成开发任务。
  7+
  8+如需补背景,再读:
  9+
 10+- `/Users/george/code/baa-conductor/plans/BAA_FINAL_MESSAGE_INGEST_REQUIREMENTS.md`
 11+- `/Users/george/code/baa-conductor/plans/BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md`
 12+- `/Users/george/code/baa-conductor/plans/BAA_INSTRUCTION_CENTER_REQUIREMENTS.md`
 13+- `/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/docs/06-integration-with-current-baa-conductor.md`
 14+
 15+## 当前基线
 16+
 17+- 仓库:`/Users/george/code/baa-conductor`
 18+- 分支:`main`
 19+- 提交:`85a27b6`
 20+- 开工要求:如需新分支,从当前 `main` 新切
 21+
 22+## 建议分支名
 23+
 24+- `feat/browser-final-message-ingest`
 25+
 26+## 目标
 27+
 28+把 live `browser.final_message` 直接接到 conductor 侧 `BaaInstructionCenter`,让浏览器最终消息可以进入当前 Phase 1 的解析和执行闭环。
 29+
 30+## 背景
 31+
 32+`T-S029` 已经把 ChatGPT / Gemini 最终 assistant message relay 到 conductor,`T-S030` 也已经把 `instructions/` Phase 1 跑通。
 33+
 34+当前缺口是两者还没真正接线:`browser.final_message` 现在只做最近快照保留,没有直接进入 `processAssistantMessage(...)`。
 35+
 36+## 涉及仓库
 37+
 38+- `/Users/george/code/baa-conductor`
 39+
 40+## 范围
 41+
 42+- Firefox WS `browser.final_message` 实时接线
 43+- instruction center live ingest 路径
 44+- live message dedupe / fail-closed
 45+- 最小执行结果观察面
 46+- 自动化测试与文档回写
 47+
 48+## 路径约束
 49+
 50+- 首版只接本机精确 target:
 51+  - `conductor`
 52+  - `system`
 53+- 不要在这张卡里顺手做 artifact / manifest / delivery plan
 54+- 插件侧不加 parser,不改 `final-message.js` 提取逻辑,除非是修这个任务的直接 blocker
 55+
 56+## 推荐实现边界
 57+
 58+建议新增:
 59+
 60+- `apps/conductor-daemon/src/instructions/ingest.ts` 或等价 live ingest 封装
 61+- 最小执行摘要 / 最近结果快照结构
 62+
 63+建议放到:
 64+
 65+- `/Users/george/code/baa-conductor/apps/conductor-daemon/src/instructions/`
 66+- `/Users/george/code/baa-conductor/apps/conductor-daemon/src/`
 67+
 68+## 允许修改的目录
 69+
 70+- `/Users/george/code/baa-conductor/apps/conductor-daemon/src/`
 71+- `/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/`
 72+- `/Users/george/code/baa-conductor/docs/api/`
 73+- `/Users/george/code/baa-conductor/tasks/`
 74+- `/Users/george/code/baa-conductor/plans/`
 75+
 76+## 尽量不要修改
 77+
 78+- `/Users/george/code/baa-conductor/plugins/baa-firefox/`
 79+- `/Users/george/code/baa-conductor/tests/browser/`
 80+- `/Users/george/code/baa-conductor/packages/db/`
 81+- `/Users/george/code/baa-conductor/apps/status-api/`
 82+
 83+## 必须完成
 84+
 85+### 1. 打通 live final message ingest
 86+
 87+- `browser.final_message` 到达后能进入 `BaaInstructionCenter`
 88+- 无 ` ```baa ` 的消息必须安全忽略
 89+- replay 同一条 final message 不能重复执行
 90+
 91+### 2. 保持 fail-closed 和当前边界
 92+
 93+- batch 内任一 unsupported / denied 指令时,不能半执行
 94+- 当前仍只允许本机精确 target
 95+- 不要顺手扩到 `browser.*` / `pool.*` / `role.*`
 96+
 97+### 3. 补读面或审计摘要
 98+
 99+- 至少保留最近一次 ingest / execute 的最小摘要,便于诊断
100+- 文档要明确:当前还没有 artifact / upload / inject
101+
102+## 需要特别注意
103+
104+- 不要把这张卡扩成“自动注入和自动发送”
105+- 不要为了接 live ingest 重新改语法
106+- 和 `T-S032` 并行时,这张卡不要去实现 `apps/conductor-daemon/src/artifacts/`
107+
108+## 验收标准
109+
110+- live `browser.final_message` 能触发当前 Phase 1 范围内的指令执行
111+- 无 ` ```baa ` 的普通消息不会误执行
112+- replay 同一条 final message 不会重复执行
113+- unsupported / denied batch 会 fail-closed
114+- `git diff --check` 通过
115+
116+## 评测要求
117+
118+### 1. 正向评测
119+
120+- live `browser.final_message` 携带 `@conductor::describe` 可成功执行
121+- live `browser.final_message` 携带多条合法 ` ```baa ` block` 可按当前 Phase 1 规则执行
122+
123+### 2. 反向评测
124+
125+- 普通 Markdown / 普通代码块不能误触发执行
126+- replay 相同 `assistant_message_id + raw_text` 不能重复执行
127+
128+### 3. 边界评测
129+
130+- batch 中混入 unsupported 指令时必须 fail-closed
131+- 缺少 `conversation_id` 但 message identity 其余字段可用时,系统不能崩
132+
133+## 推荐验证命令
134+
135+- `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build`
136+- `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js`
137+- `node --test /Users/george/code/baa-conductor/tests/browser/browser-control-e2e-smoke.test.mjs`
138+- `git -C /Users/george/code/baa-conductor diff --check`
139+
140+## 交付要求
141+
142+完成后请说明:
143+
144+- 修改了哪些文件
145+- live ingest 是怎么接到 `BaaInstructionCenter` 的
146+- 最近执行摘要放在了哪里
147+- 跑了哪些测试
148+- 还有哪些剩余风险
149+
150+## 完成记录(2026-03-27)
151+
152+- 已完成:
153+  - `browser.final_message` 已接到 conductor 侧 live ingest 路径,并进入 `BaaInstructionCenter`
154+  - `/v1/browser` 与 Firefox WS `state_snapshot.snapshot.browser.instruction_ingest` 已暴露最近一次 live ingest / execute 摘要
155+  - automated test 已覆盖:普通消息忽略、replay 去重、缺少 `conversation_id` 可容忍、live final message 触发执行
156+- 当前残余风险:
157+  - live message dedupe 和 instruction dedupe 目前都还是进程内内存态,重启后不会保留
158+  - 摘要当前只保留最近一次 live ingest / execute,不落库
159+  - 当前仍只允许 Phase 1 精确 target `conductor` / `system`,且 live 路径还没有接 artifact / upload / inject / send
160+- 实际验证:
161+  - `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build`:通过
162+  - `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js`:`44/44` 通过
163+  - `node --test /Users/george/code/baa-conductor/tests/browser/browser-control-e2e-smoke.test.mjs`:`6/6` 通过
A tasks/T-S032.md
+172, -0
  1@@ -0,0 +1,172 @@
  2+# Task T-S032:补 BAA artifact center 与 delivery plan Phase 2 服务端核心
  3+
  4+## 直接给对话的提示词
  5+
  6+读 `/Users/george/code/baa-conductor/tasks/T-S032.md` 任务文档,完成开发任务。
  7+
  8+如需补背景,再读:
  9+
 10+- `/Users/george/code/baa-conductor/plans/BAA_ARTIFACT_CENTER_REQUIREMENTS.md`
 11+- `/Users/george/code/baa-conductor/plans/BAA_INSTRUCTION_SYSTEM.md`
 12+- `/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/docs/06-integration-with-current-baa-conductor.md`
 13+- `/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/docs/07-rollout-plan.md`
 14+
 15+## 当前基线
 16+
 17+- 仓库:`/Users/george/code/baa-conductor`
 18+- 分支:`main`
 19+- 提交:`85a27b6`
 20+- 开工要求:如需新分支,从当前 `main` 新切
 21+
 22+## 建议分支名
 23+
 24+- `feat/baa-artifact-center`
 25+
 26+## 目标
 27+
 28+在 conductor 里补上 `artifact materialize + manifest/index + delivery plan` 的 Phase 2 服务端核心,不把结果交付逻辑继续散落在调用方。
 29+
 30+## 背景
 31+
 32+当前 repo 已经有 `instructions/` Phase 1,但执行结果还没有进入统一 artifact / manifest / delivery 结构。
 33+
 34+后续如果要稳定支持大结果、并发多结果和薄插件上传/注入,就需要先把 service-side artifact center 搭起来。
 35+
 36+## 涉及仓库
 37+
 38+- `/Users/george/code/baa-conductor`
 39+
 40+## 范围
 41+
 42+- artifact materializer
 43+- manifest / index text builder
 44+- delivery plan builder
 45+- synthetic execution result 测试
 46+- 文档与任务状态回写
 47+
 48+## 路径约束
 49+
 50+- 当前只做 conductor 服务端核心
 51+- 不要在这张卡里实现 Firefox 插件 upload / download / inject
 52+- 应允许完全用 synthetic execution result / fixture 跑通,不阻塞于 `T-S031`
 53+
 54+## 推荐实现边界
 55+
 56+建议新增:
 57+
 58+- `apps/conductor-daemon/src/artifacts/types.ts`
 59+- `apps/conductor-daemon/src/artifacts/materialize.ts`
 60+- `apps/conductor-daemon/src/artifacts/manifest.ts`
 61+- `apps/conductor-daemon/src/artifacts/delivery-plan.ts`
 62+
 63+建议放到:
 64+
 65+- `/Users/george/code/baa-conductor/apps/conductor-daemon/src/artifacts/`
 66+- `/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/`
 67+
 68+## 允许修改的目录
 69+
 70+- `/Users/george/code/baa-conductor/apps/conductor-daemon/src/`
 71+- `/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/`
 72+- `/Users/george/code/baa-conductor/tasks/`
 73+- `/Users/george/code/baa-conductor/plans/`
 74+
 75+## 尽量不要修改
 76+
 77+- `/Users/george/code/baa-conductor/plugins/baa-firefox/`
 78+- `/Users/george/code/baa-conductor/tests/browser/`
 79+- `/Users/george/code/baa-conductor/packages/db/`
 80+- `/Users/george/code/baa-conductor/apps/status-api/`
 81+
 82+## 必须完成
 83+
 84+### 1. 建 artifact materializer
 85+
 86+- 能把最小执行结果稳定转成 artifact 结构
 87+- 至少覆盖 `exec`、`files/read`、`files/write` / `describe` / `status` 的摘要类结果
 88+
 89+### 2. 建 manifest / index / delivery plan
 90+
 91+- manifest 要可审计
 92+- index text 要可直接作为后续注入文本基础
 93+- delivery plan 不能把大结果退化成整段直接塞聊天
 94+
 95+### 3. 补自动化测试和文档
 96+
 97+- 用 synthetic execution result 跑通
 98+- 文档明确:当前还没有 upload / download / receipt barrier
 99+
100+## 需要特别注意
101+
102+- 不要把这张卡扩成插件上传实现
103+- 不要把这张卡扩成 live final message ingest
104+- 和 `T-S031` 并行时,这张卡不要去改 `firefox-ws.ts` / `final-message.js` 主路径
105+
106+## 验收标准
107+
108+- synthetic execution result 能稳定 materialize 为 artifacts
109+- manifest / index text / delivery plan 能稳定生成
110+- 大结果不会被默认整段直接塞进聊天
111+- `git diff --check` 通过
112+
113+## 评测要求
114+
115+### 1. 正向评测
116+
117+- `exec` 文本结果能产出 artifact + manifest entry + delivery plan
118+- `files/read` 文本结果能产出 artifact 或 inline/index 决策
119+
120+### 2. 反向评测
121+
122+- 小结果和大结果不能生成完全相同的 delivery 策略
123+- 不应该把二进制 / 大块文本默认当成纯 inline 注入
124+
125+### 3. 边界评测
126+
127+- 多结果并发时 manifest 顺序要稳定
128+- 某个结果缺少可选字段时不能让整批 materialize 崩掉
129+
130+## 推荐验证命令
131+
132+- `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build`
133+- `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js`
134+- `git -C /Users/george/code/baa-conductor diff --check`
135+
136+## 交付要求
137+
138+完成后请说明:
139+
140+- 修改了哪些文件
141+- artifact / manifest / delivery plan 是怎么设计的
142+- 当前最小支持了哪些结果类型
143+- 跑了哪些测试
144+- 还有哪些剩余风险
145+
146+## 完成记录(2026-03-27)
147+
148+- 已新增 `apps/conductor-daemon/src/artifacts/`:
149+  - `types.ts`
150+  - `materialize.ts`
151+  - `manifest.ts`
152+  - `delivery-plan.ts`
153+- 已通过 synthetic execution result 跑通:
154+  - `exec` -> artifact + manifest + delivery plan
155+  - `files/read` -> small/large 不同 delivery 策略
156+  - 多结果稳定排序
157+  - 缺少可选字段不崩整批 materialize
158+- 当前最小支持结果类型:
159+  - `exec`
160+  - `files/read`
161+  - `files/write`
162+  - `describe` / `describe/business` / `describe/control`
163+  - `status`
164+- 当前明确留到后续:
165+  - Firefox 插件 upload / download
166+  - upload receipt barrier
167+  - 依赖 receipt 的自动 inject / send
168+- 当前残余边界:
169+  - service-side artifact center core 已落地,但 live instruction ingest 路径还没有把执行结果真正接到 artifact / upload / inject / send
170+  - 当前仍只服务于 Phase 1 本机精确 target,不涉及跨节点和多轮闭环
171+- 实际验证:
172+  - `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build`:通过
173+  - `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js`:`44/44` 通过
M tasks/TASK_OVERVIEW.md
+12, -6
 1@@ -11,11 +11,11 @@
 2 - 当前任务卡都放在本目录
 3 - 浏览器控制主链路收口基线:`main@07895cd`
 4 - 最近功能代码提交:`main@25be868`(启动时自动恢复受管 Firefox shell tabs)
 5-- `2026-03-27` 当前本地代码已额外完成 `BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 修复,以及 `T-S029`、`T-S030` 落地
 6+- `2026-03-27` 当前本地代码已额外完成 `BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 修复,以及 `T-S029`、`T-S030`、`T-S031`、`T-S032` 落地
 7 
 8 ## 状态分类
 9 
10-- `已完成`:`T-S001` 到 `T-S030`,以及 `T-BUG-011`、`T-BUG-012`、`T-BUG-014`
11+- `已完成`:`T-S001` 到 `T-S032`,以及 `T-BUG-011`、`T-BUG-012`、`T-BUG-014`
12 - `当前 TODO`:
13   - `T-S026` 真实 Firefox 手工 smoke 与验收记录
14 - `待处理缺陷`:当前无 open bug backlog(见 `../bugs/README.md`)
15@@ -23,6 +23,8 @@
16 
17 当前新的主需求文档:
18 
19+- [`../plans/BAA_FINAL_MESSAGE_INGEST_REQUIREMENTS.md`](../plans/BAA_FINAL_MESSAGE_INGEST_REQUIREMENTS.md)
20+- [`../plans/BAA_ARTIFACT_CENTER_REQUIREMENTS.md`](../plans/BAA_ARTIFACT_CENTER_REQUIREMENTS.md)
21 - [`../plans/BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md`](../plans/BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md)
22 - [`../plans/BAA_INSTRUCTION_CENTER_REQUIREMENTS.md`](../plans/BAA_INSTRUCTION_CENTER_REQUIREMENTS.md)
23 - [`../plans/BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md`](../plans/BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md)
24@@ -66,6 +68,8 @@
25 30. [`T-S028.md`](./T-S028.md):收口 ChatGPT browser relay 到正式合同
26 31. [`T-S029.md`](./T-S029.md):补 ChatGPT / Gemini 最终消息观察与 `browser.final_message` raw relay
27 32. [`T-S030.md`](./T-S030.md):收口 BAA 指令解析中心 Phase 1 与最小执行闭环
28+33. [`T-S031.md`](./T-S031.md):把 live `browser.final_message` 接到 BAA instruction center
29+34. [`T-S032.md`](./T-S032.md):补 BAA artifact center 与 delivery plan Phase 2 服务端核心
30 
31 ## 已准备的后续任务
32 
33@@ -130,14 +134,16 @@
34 - 当前 open bug backlog 已清空
35 - 当前主线剩余任务是:
36   - [`T-S026.md`](./T-S026.md):补真实 Firefox 手工 smoke 与验收记录
37-- `T-S029`、`T-S030` 已完成,当前 BAA 第一阶段已具备:
38+- `T-S029`、`T-S030`、`T-S031`、`T-S032` 已完成,当前 BAA 已具备:
39   - ChatGPT / Gemini 最终消息 raw relay 与 `browser.final_message` 快照保留
40-  - conductor 侧 instruction center Phase 1 最小闭环
41+  - conductor 侧 instruction center Phase 1 最小闭环与 live ingest
42+  - conductor 侧 artifact / manifest / index text / delivery plan 服务端核心
43 - 当前保留的 BAA 边界是:
44   - ChatGPT 当前主要依赖 conversation SSE 结构;页面 payload 形态变化后需要同步调整提取器
45   - Gemini 最终文本提取当前基于 `StreamGenerate` / `batchexecute` 风格 payload 的启发式解析,稳定性弱于 ChatGPT,因此保留 synthetic `assistant_message_id` 兜底
46-  - `browser.final_message` 当前只做最小兼容接收和最近快照保留,不落当前持久化表,也没有直接接入 instruction parser
47-  - instruction dedupe 目前仍是进程内内存态,且只做本机精确 target;跨节点和多轮闭环还没接
48+  - live message dedupe 和 instruction dedupe 目前都还是进程内内存态,重启后不会保留
49+  - 当前摘要只保留最近一次 live ingest / execute,不落库
50+  - 当前仍只允许 Phase 1 精确 target `conductor` / `system`;service-side artifact center core 已有,但 live 路径还没有把执行结果真正接到 artifact / upload / inject / send
51 - `BUG-012` 这轮修复已补上 stale `inFlight` 自愈清扫;当前残余边界是“健康但长时间完全静默”的超长 buffered 请求,理论上仍可能被 `5min` idle 阈值误判
52 - ChatGPT 当前仍依赖浏览器里真实捕获到的有效登录态 / header,且没有 Claude 风格 prompt shortcut;这是当前正式支持面的已知边界,不是 regression
53 - `BUG-014` 的自动化验证目前覆盖的是 conductor 侧语义透传,不是 Firefox 扩展真实运行环境里的 reconnect 生命周期;真实“重连完成”仍依赖后续 `hello` / 状态同步
M tests/browser/browser-control-e2e-smoke.test.mjs
+33, -0
 1@@ -1304,6 +1304,17 @@ test("browser control e2e smoke accepts browser.final_message and keeps recent r
 2     assert.ok(firstClient);
 3     assert.equal(firstClient.final_messages.length, 1);
 4 
 5+    const firstIngestSnapshot = await client.queue.next(
 6+      (message) =>
 7+        message.type === "state_snapshot"
 8+        && message.reason === "instruction_ingest"
 9+        && message.snapshot.browser.instruction_ingest.last_ingest?.assistant_message_id === "msg-chatgpt-final-smoke"
10+    );
11+    assert.equal(
12+      firstIngestSnapshot.snapshot.browser.instruction_ingest.last_ingest.status,
13+      "ignored_no_instructions"
14+    );
15+
16     client.socket.send(
17       JSON.stringify({
18         type: "browser.final_message",
19@@ -1332,6 +1343,17 @@ test("browser control e2e smoke accepts browser.final_message and keeps recent r
20     assert.ok(duplicateClient);
21     assert.equal(duplicateClient.final_messages.length, 1);
22 
23+    const duplicateIngestSnapshot = await client.queue.next(
24+      (message) =>
25+        message.type === "state_snapshot"
26+        && message.reason === "instruction_ingest"
27+        && message.snapshot.browser.instruction_ingest.last_ingest?.assistant_message_id === "msg-chatgpt-final-smoke"
28+    );
29+    assert.equal(
30+      duplicateIngestSnapshot.snapshot.browser.instruction_ingest.last_ingest.status,
31+      "duplicate_message"
32+    );
33+
34     client.socket.send(
35       JSON.stringify({
36         type: "browser.final_message",
37@@ -1360,6 +1382,17 @@ test("browser control e2e smoke accepts browser.final_message and keeps recent r
38     );
39     assert.ok(secondClient);
40     assert.equal(secondClient.final_messages.length, 2);
41+
42+    const secondIngestSnapshot = await client.queue.next(
43+      (message) =>
44+        message.type === "state_snapshot"
45+        && message.reason === "instruction_ingest"
46+        && message.snapshot.browser.instruction_ingest.last_ingest?.assistant_message_id === "synthetic_gemini_smoke"
47+    );
48+    assert.equal(
49+      secondIngestSnapshot.snapshot.browser.instruction_ingest.last_ingest.status,
50+      "ignored_no_instructions"
51+    );
52   } finally {
53     client?.queue.stop();
54