baa-conductor

git clone 

commit
26bd7b6
parent
d1f0624
author
im_wower
date
2026-03-28 01:24:48 +0800 CST
feat: switch browser delivery to text-only flow
89 files changed,  +2636, -3043
M PROGRESS/2026-03-27-current-code-progress.md
+14, -14
 1@@ -6,8 +6,8 @@
 2 - 当前浏览器桥接主线已经完成到“Firefox 本地 WS bridge + Claude / ChatGPT 代发 + ChatGPT / Gemini 最终消息 raw relay + live instruction ingest + dedupe/journal 本地持久化 + artifact/upload/inject/send 主链 + 结构化 action_result + shell_runtime + 登录态持久化”的阶段。
 3 - 代码和自动化测试都表明:`/describe/business`、`/describe/control`、`GET /v1/browser`、`POST /v1/browser/actions`、`POST /v1/browser/request`、`POST /v1/browser/request/cancel` 已经形成正式主链路。
 4 - 目前不应再把系统描述成“只有 Claude / ChatGPT request relay”;当前更准确的表述是“通用 browser surface 已落地,正式 request relay 已覆盖 Claude 和 ChatGPT,ChatGPT / Gemini 的 `browser.final_message` raw relay 也已接通,但这层仍只是最终消息中继,不等于 Gemini 正式 request relay 已转正”。
 5-- 当前仍不能写成“全部收尾完成”。剩余未闭项主要是:真实 Firefox 手工 smoke 未完成、风控状态仍是进程内内存态、ChatGPT 仍依赖真实浏览器登录态 / header 且没有 Claude 风格 prompt shortcut、ChatGPT / Gemini 最终消息提取仍有平台特定边界,以及 BAA 仍只做到单节点本地持久化、有界 journal、单客户端单轮 delivery 首版和 Phase 1 exact target。
 6-- 此前拆出的后续任务卡里,`T-S027`、`T-S028`、`T-S029`、`T-S030`、`T-S031`、`T-S032`、`T-S033`、`T-S034` 已完成;当前主线剩余 `T-S026`,工程侧下一波任务是 `T-S035` 和 `T-S036`。
 7+- 当前仍不能写成“全部收尾完成”。剩余未闭项主要是:风控状态仍是进程内内存态、ChatGPT 仍依赖真实浏览器登录态 / header 且没有 Claude 风格 prompt shortcut、ChatGPT / Gemini 最终消息提取仍有平台特定边界,以及 BAA 仍只做到单节点本地持久化、有界 journal、单客户端单轮 delivery 首版和 Phase 1 exact target。
 8+- 此前拆出的后续任务卡里,`T-S026`、`T-S027`、`T-S028`、`T-S029`、`T-S030`、`T-S031`、`T-S032`、`T-S033`、`T-S034` 已完成;当前主线剩余任务是 `T-S035` 和 `T-S036`。
 9 
10 ## 本次核对依据
11 
12@@ -29,6 +29,8 @@
13   - 结果:`45/45` 通过
14 - `node --test /Users/george/code/baa-conductor/tests/browser/browser-control-e2e-smoke.test.mjs`
15   - 结果:`8/8` 通过
16+- 真实 Firefox 手工 smoke(`plugin_status`、`tab_open`、`tab_restore`、`ws_reconnect`、扩展重载自动恢复)
17+  - 结果:通过;`ws_reconnect` 额外完成 `disconnect_ms=3000`、`repeat_count=3`、`repeat_interval_ms=500` 的 3 轮稳定性验证
18 
19 ## 当前已完成功能
20 
21@@ -293,15 +295,15 @@
22 
23 ## 当前未完成 / 待复核
24 
25-### 1. 真实 Firefox 手工 smoke 仍未完成
26+### 1. 真实 Firefox 手工 smoke 已完成
27 
28-- 代码和任务文档里都没有新增“真实 Firefox.app 上手动关 tab -> tab_restore -> WS 重连 -> 状态恢复”的实测结论。
29-- 当前自动化 smoke 是 bridge / relay / persistence 层面的模拟 E2E,不等于真实 Firefox 桌面手工验收。
30+- `2026-03-27` 已在真实 `Firefox.app` 上完成“手动关 tab -> `tab_restore` -> WS 重连 -> 状态恢复”和“扩展重载后自动恢复”验收。
31+- `plugin_status`、`tab_open`、`tab_restore`、`ws_reconnect` 与扩展重载自动恢复都已通过;当前不再把“真机浏览器管理闭环未验证”列为残余风险。
32 
33-### 2. open bug backlog 当前已清空
34+### 2. open bug backlog 未清空
35 
36 - 当前不能把“已修复 bug”误写成“所有残余风险都已消失”。
37-- 当前没有 open bug 卡,但仍保留若干非 bug 型残余风险和后续增强项。
38+- open bug backlog 见 `../bugs/README.md`;截至本次核对仍保留 `BUG-018`、`BUG-019`、`BUG-020`。
39 
40 ### 3. Gemini 已接入最终消息 raw relay,但提取仍是启发式
41 
42@@ -326,11 +328,11 @@
43 - 这轮为避免误杀,默认只在 `5min` 无活跃更新后才回收 slot,并依赖 `lease.touch(...)` 标记关键活跃点。
44 - 如果未来出现“健康但长时间完全静默”的超长 buffered 请求,理论上仍存在被保守阈值误判的风险;当前文档里应把它写成残余边界,而不是“完全没有自愈”。
45 
46-### 7. `ws_reconnect` 的自动化验证还不是 Firefox 真实 reconnect 生命周期验收
47+### 7. `ws_reconnect` 自动化与真机验收都已覆盖,但完成判定仍依赖后续状态同步
48 
49-- 当前 smoke 覆盖的是 conductor 侧 `action_result` 语义透传。
50-- 真实 Firefox 扩展运行环境里的 reconnect 生命周期本身,仍依赖后续 `hello` / 状态同步来体现“真正重连完成”。
51-- 这一层现有设计未扩改,因此仍建议在真实 Firefox 环境里补手工 smoke。
52+- 当前 smoke 既覆盖 conductor 侧 `action_result` 语义透传,也已在真实 Firefox 扩展运行环境里完成手工 reconnect 验收。
53+- `ws_reconnect` 的设计仍是“HTTP 立即返回 `completed=false`,真正恢复靠后续 `hello` / 状态同步体现”;这属于当前合同语义,不再是未验收风险。
54+- 这轮额外补做了 `disconnect_ms=3000`、`repeat_count=3`、`repeat_interval_ms=500` 的 3 轮断开重连稳定性验证。
55 
56 ### 8. ChatGPT 已转正,但仍保留平台前提边界
57 
58@@ -354,13 +356,11 @@
59 
60 ## 已拆出的后续任务
61 
62-- `T-S026`:真实 Firefox 手工 smoke 与验收记录
63 - `T-S035`:加固插件侧 delivery adapter 与页面交付流程
64 - `T-S036`:补 artifact download 合同与 binary delivery 能力
65 
66 ## 下一波任务
67 
68-- `T-S026` 仍是主线第一优先级,因为这是当前唯一未完成的真实 Firefox 环境验收。
69 - `T-S035` 负责把 `Claude` / `ChatGPT` 的插件侧 `upload / inject / send` 从首版 DOM heuristic 收口成更稳定的 adapter 和 fail-closed 路径。
70 - `T-S036` 负责把当前本地 `download_url` + base64 JSON 的 artifact payload 合同升级到更适合 binary-safe delivery 的形式,同时保持现有 text/json 兼容。
71 
72@@ -381,4 +381,4 @@
73 
74 如果只写一段给外部协作者看,可以用下面这版:
75 
76-> 当前代码已经完成单节点 `mini` 主接口收口,以及 Firefox 本地 bridge 下的 Claude / ChatGPT request relay 主链路、ChatGPT / Gemini 最终消息 raw relay、conductor 侧 live instruction ingest、dedupe / execution journal 本地持久化,以及 artifact / upload / inject / send 主链。`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` 最近摘要、upload receipt barrier,以及 `BUG-012` 的 stale `inFlight` 自愈清扫都已落地;`BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 也已在当前代码中修复,并已通过 `conductor-daemon build`、`index.test.js`(45/45)和 browser-control e2e smoke(8/8)。当前剩余缺口主要是:真实 Firefox 手工 smoke 未完成、ChatGPT / Gemini 最终消息提取仍有平台边界、插件侧 upload / inject / send 仍是首版 DOM heuristic、artifact payload 仍以本地 `download_url` 的 base64 JSON 形式提供且未覆盖大二进制 / download 闭环、BAA 仍只做单节点本地持久化、execution journal 只保留最近窗口,以及 live 执行路径仍只覆盖 Phase 1 exact target。
77+> 当前代码已经完成单节点 `mini` 主接口收口,以及 Firefox 本地 bridge 下的 Claude / ChatGPT request relay 主链路、ChatGPT / Gemini 最终消息 raw relay、conductor 侧 live instruction ingest、dedupe / execution journal 本地持久化,以及 artifact / upload / inject / send 主链。`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` 最近摘要、upload receipt barrier,以及 `BUG-012` 的 stale `inFlight` 自愈清扫都已落地;`BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 也已在当前代码中修复,并已通过 `conductor-daemon build`、`index.test.js`(45/45)、browser-control e2e smoke(8/8)和 `2026-03-27` 的真实 Firefox 手工 smoke。当前剩余缺口主要是:ChatGPT / Gemini 最终消息提取仍有平台边界、插件侧 upload / inject / send 仍是首版 DOM heuristic、artifact payload 仍以本地 `download_url` 的 base64 JSON 形式提供且未覆盖大二进制 / download 闭环、BAA 仍只做单节点本地持久化、execution journal 只保留最近窗口,以及 live 执行路径仍只覆盖 Phase 1 exact target。
M README.md
+6, -0
 1@@ -72,9 +72,13 @@ scripts/
 2   runtime/
 3 plans/
 4   STATUS_SUMMARY.md
 5+  archive/
 6 tasks/
 7   TASK_OVERVIEW.md
 8+  archive/
 9 bugs/
10+  README.md
11+  archive/
12 docs/
13   api/
14   auth/
15@@ -84,6 +88,8 @@ docs/
16   runtime/
17 ```
18 
19+已完成的任务卡、需求文档和缺陷文档现在统一放在各自目录下的 `archive/` 子目录。
20+
21 ## 当前约定
22 
23 - 所有新接口设计默认先落 `mini` 本地 `4317`
M apps/conductor-daemon/src/artifacts.test.js
+99, -283
  1@@ -1,14 +1,9 @@
  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+  DEFAULT_BAA_DELIVERY_LINE_LIMIT,
 14+  renderBaaDeliveryMessageText
 15 } from "../dist/index.js";
 16 
 17 function createExecutionResult(overrides = {}) {
 18@@ -33,38 +28,34 @@ function createExecutionResult(overrides = {}) {
 19   };
 20 }
 21 
 22-test("artifact materializer turns exec output into artifact, manifest, and delivery plan uploads", async () => {
 23-  const outputDir = mkdtempSync(join(tmpdir(), "baa-conductor-artifacts-exec-"));
 24+function createProcessResult(executions, instructions = []) {
 25+  return {
 26+    blocks: [],
 27+    duplicates: [],
 28+    executions,
 29+    instructions,
 30+    status: executions.length === 0 ? "no_instructions" : "executed"
 31+  };
 32+}
 33 
 34-  try {
 35-    const materialization = await materializeBaaExecutionArtifacts(
 36-      [
 37-        {
 38-          blockIndex: 2,
 39-          execution: createExecutionResult({
 40+test("renderBaaDeliveryMessageText renders plain-text execution details", () => {
 41+  const rendered = renderBaaDeliveryMessageText(
 42+    {
 43+      assistantMessageId: "msg-render-plain",
 44+      conversationId: "conv-render-plain",
 45+      platform: "chatgpt",
 46+      processResult: createProcessResult(
 47+        [
 48+          createExecutionResult({
 49             data: {
 50-              ok: true,
 51-              operation: "exec",
 52-              input: {
 53-                command: "printf 'hello artifact'",
 54-                cwd: "/tmp",
 55-                maxBufferBytes: 1024,
 56-                timeoutMs: 2000
 57-              },
 58-              result: {
 59-                durationMs: 4,
 60-                exitCode: 0,
 61-                finishedAt: "2026-03-27T12:00:01.000Z",
 62-                signal: null,
 63-                startedAt: "2026-03-27T12:00:00.000Z",
 64-                stderr: "",
 65-                stdout: "hello artifact",
 66-                timedOut: false
 67-              }
 68+              b: 2,
 69+              a: 1
 70             },
 71-            dedupeKey: "dedupe-exec-1",
 72-            instructionId: "inst_exec_01",
 73-            requestId: "req-exec-1",
 74+            details: {
 75+              retryable: false
 76+            },
 77+            instructionId: "inst_render_01",
 78+            message: "command completed",
 79             route: {
 80               key: "local.exec",
 81               method: "POST",
 82@@ -72,256 +63,81 @@ test("artifact materializer turns exec output into artifact, manifest, and deliv
 83             },
 84             tool: "exec"
 85           })
 86-        }
 87-      ],
 88-      {
 89-        artifactOnlyTextBytes: 2000,
 90-        inlineTextBytes: 200,
 91-        outputDir,
 92-        roundId: "r03",
 93-        traceId: "trace_artifacts"
 94-      }
 95-    );
 96-
 97-    assert.equal(materialization.results[0].deliveryMode, "inline_and_artifact");
 98-    assert.equal(materialization.artifacts.length, 1);
 99-    assert.match(materialization.results[0].summary, /exec succeeded/);
100-    assert.equal(existsSync(materialization.artifacts[0].localPath), true);
101-    assert.match(readFileSync(materialization.artifacts[0].localPath, "utf8"), /\[stdout\]\nhello artifact/);
102-
103-    const manifestBundle = await buildBaaArtifactManifest(materialization);
104-    const indexText = renderBaaArtifactIndexText(materialization, manifestBundle);
105-    const plan = buildBaaDeliveryPlan({
106-      conversationId: "conv_exec",
107-      indexText,
108-      manifestBundle,
109-      materialization,
110-      target: "browser.claude"
111-    });
112-
113-    assert.equal(plan.uploads.length, 2);
114-    assert.deepEqual(plan.pendingBarriers, ["upload_receipt"]);
115-    assert.equal(plan.receiptBarrierImplemented, true);
116-    assert.match(indexText, /instruction_id: inst_exec_01/);
117-    assert.match(indexText, /baa-result_trace-artifacts_r03_b02_exec_conductor_ok\.log/);
118-    assert.match(indexText, /baa-manifest_trace-artifacts_r03\.json/);
119-  } finally {
120-    rmSync(outputDir, {
121-      force: true,
122-      recursive: true
123-    });
124-  }
125-});
126-
127-test("files/read small and large payloads do not collapse to the same delivery strategy", async () => {
128-  const outputDir = mkdtempSync(join(tmpdir(), "baa-conductor-artifacts-files-read-"));
129-  const largeContent = `HEADER\n${"0123456789".repeat(700)}\nTAIL_MARKER`;
130-
131-  try {
132-    const materialization = await materializeBaaExecutionArtifacts(
133-      [
134-        {
135-          blockIndex: 1,
136-          execution: createExecutionResult({
137-            data: {
138-              ok: true,
139-              operation: "files/read",
140-              input: {
141-                cwd: "/tmp",
142-                encoding: "utf8",
143-                path: "small.txt"
144-              },
145-              result: {
146-                absolutePath: "/tmp/small.txt",
147-                content: "small text\n",
148-                encoding: "utf8",
149-                modifiedAt: "2026-03-27T12:00:00.000Z",
150-                sizeBytes: 11
151-              }
152-            },
153-            dedupeKey: "dedupe-read-small",
154-            instructionId: "inst_read_small",
155-            route: {
156-              key: "local.files.read",
157-              method: "POST",
158-              path: "/v1/files/read"
159-            },
160-            tool: "files/read"
161-          })
162-        },
163-        {
164-          blockIndex: 2,
165-          execution: createExecutionResult({
166-            data: {
167-              ok: true,
168-              operation: "files/read",
169-              input: {
170-                cwd: "/tmp",
171-                encoding: "utf8",
172-                path: "large.txt"
173-              },
174-              result: {
175-                absolutePath: "/tmp/large.txt",
176-                content: largeContent,
177-                encoding: "utf8",
178-                modifiedAt: "2026-03-27T12:00:00.000Z",
179-                sizeBytes: largeContent.length
180-              }
181-            },
182-            dedupeKey: "dedupe-read-large",
183-            instructionId: "inst_read_large",
184-            route: {
185-              key: "local.files.read",
186-              method: "POST",
187-              path: "/v1/files/read"
188+        ],
189+        [
190+          {
191+            blockIndex: 3,
192+            dedupeBasis: {
193+              assistant_message_id: "msg-render-plain",
194+              block_index: 3,
195+              conversation_id: "conv-render-plain",
196+              params: null,
197+              platform: "chatgpt",
198+              target: "conductor",
199+              tool: "exec",
200+              version: "baa.v1"
201             },
202-            tool: "files/read"
203-          })
204-        }
205-      ],
206-      {
207-        artifactOnlyTextBytes: 1024,
208-        inlineTextBytes: 128,
209-        outputDir,
210-        roundId: "r05",
211-        traceId: "trace_files"
212-      }
213-    );
214-    const manifestBundle = await buildBaaArtifactManifest(materialization);
215-    const indexText = renderBaaArtifactIndexText(materialization, manifestBundle);
216-    const plan = buildBaaDeliveryPlan({
217-      indexText,
218-      manifestBundle,
219-      materialization,
220-      target: "browser.chatgpt"
221-    });
222+            dedupeKey: "dedupe-default",
223+            envelopeVersion: "baa.v1",
224+            instructionId: "inst_render_01",
225+            params: null,
226+            paramsKind: "none",
227+            platform: "chatgpt",
228+            rawBlock: "```baa```",
229+            rawInstruction: "@conductor::exec",
230+            target: "conductor",
231+            tool: "exec",
232+            assistantMessageId: "msg-render-plain",
233+            conversationId: "conv-render-plain"
234+          }
235+        ]
236+      )
237+    }
238+  );
239 
240-    assert.equal(materialization.results[0].deliveryMode, "inline");
241-    assert.equal(materialization.results[0].artifacts.length, 0);
242-    assert.equal(materialization.results[1].deliveryMode, "artifact_only");
243-    assert.equal(materialization.results[1].artifacts.length, 1);
244-    assert.match(indexText, /small text/);
245-    assert.match(indexText, /baa-result_trace-files_r05_b02_files-read_conductor_ok\.txt/);
246-    assert.doesNotMatch(indexText, /TAIL_MARKER/);
247-    assert.equal(plan.uploads.length, 2);
248-  } finally {
249-    rmSync(outputDir, {
250-      force: true,
251-      recursive: true
252-    });
253-  }
254+  assert.equal(rendered.executionCount, 1);
255+  assert.equal(rendered.messageTruncated, false);
256+  assert.match(rendered.messageText, /\[BAA 执行结果\]/u);
257+  assert.match(rendered.messageText, /assistant_message_id: msg-render-plain/u);
258+  assert.match(rendered.messageText, /block_index: 3/u);
259+  assert.match(rendered.messageText, /message:\ncommand completed/u);
260+  assert.match(rendered.messageText, /data:\n  a: 1\n  b: 2/u);
261+  assert.match(rendered.messageText, /details:\n  retryable: false/u);
262 });
263 
264-test("manifest order stays stable across multiple results and missing optional fields do not crash materialization", async () => {
265-  const outputDir = mkdtempSync(join(tmpdir(), "baa-conductor-artifacts-order-"));
266-
267-  try {
268-    const materialization = await materializeBaaExecutionArtifacts(
269-      [
270-        {
271-          blockIndex: 3,
272-          execution: createExecutionResult({
273-            data: {
274-              daemon: {
275-                leaseState: "leader"
276-              },
277-              runtime: {
278-                started: true
279-              }
280-            },
281-            dedupeKey: "dedupe-status",
282-            instructionId: "inst_status",
283-            message: null,
284-            requestId: null,
285-            route: {
286-              key: "local.status",
287-              method: "GET",
288-              path: "/v1/status"
289-            },
290-            tool: "status"
291-          })
292-        },
293-        {
294-          blockIndex: 1,
295-          execution: createExecutionResult({
296-            data: {
297-              endpoints: [
298-                {
299-                  method: "GET",
300-                  path: "/describe/control"
301-                }
302-              ],
303-              surface: "control"
304-            },
305-            dedupeKey: "dedupe-describe",
306-            instructionId: "inst_describe",
307-            route: {
308-              key: "local.describe.control",
309-              method: "GET",
310-              path: "/describe/control"
311-            },
312-            tool: "describe/control"
313-          })
314-        },
315-        {
316-          blockIndex: 2,
317-          execution: createExecutionResult({
318-            data: {
319-              ok: true,
320-              operation: "files/write",
321-              input: {
322-                content: "hello",
323-                createParents: false,
324-                cwd: "/tmp",
325-                encoding: "utf8",
326-                overwrite: true,
327-                path: "written.txt"
328-              },
329-              result: {
330-                absolutePath: "/tmp/written.txt",
331-                bytesWritten: 5,
332-                created: true,
333-                encoding: "utf8",
334-                modifiedAt: "2026-03-27T12:00:00.000Z"
335-              }
336-            },
337-            dedupeKey: "dedupe-write",
338-            details: null,
339-            instructionId: "inst_write",
340-            requestId: null,
341-            route: {
342-              key: "local.files.write",
343-              method: "POST",
344-              path: "/v1/files/write"
345-            },
346-            tool: "files/write"
347-          })
348-        }
349-      ],
350-      {
351-        artifactOnlyTextBytes: 512,
352-        inlineTextBytes: 32,
353-        outputDir,
354-        roundId: "r07",
355-        traceId: "trace_order"
356-      }
357-    );
358-    const manifestBundle = await buildBaaArtifactManifest(materialization);
359+test("renderBaaDeliveryMessageText truncates overlong output and appends marker", () => {
360+  const stdout = Array.from({
361+    length: DEFAULT_BAA_DELIVERY_LINE_LIMIT + 20
362+  }, (_, index) => `line-${index + 1}`).join("\n");
363+  const rendered = renderBaaDeliveryMessageText(
364+    {
365+      assistantMessageId: "msg-render-truncated",
366+      conversationId: "conv-render-truncated",
367+      platform: "claude",
368+      processResult: createProcessResult([
369+        createExecutionResult({
370+          data: {
371+            result: {
372+              stdout
373+            }
374+          },
375+          instructionId: "inst_render_truncated",
376+          route: {
377+            key: "local.exec",
378+            method: "POST",
379+            path: "/v1/exec"
380+          },
381+          tool: "exec"
382+        })
383+      ])
384+    }
385+  );
386 
387-    assert.deepEqual(
388-      materialization.results.map((entry) => entry.instructionId),
389-      ["inst_describe", "inst_write", "inst_status"]
390-    );
391-    assert.deepEqual(
392-      manifestBundle.manifest.results.map((entry) => entry.blockIndex),
393-      [1, 2, 3]
394-    );
395-    assert.equal(manifestBundle.manifest.resultCount, 3);
396-    assert.equal(manifestBundle.manifest.notes.length >= 2, true);
397-  } finally {
398-    rmSync(outputDir, {
399-      force: true,
400-      recursive: true
401-    });
402-  }
403+  assert.equal(rendered.messageLineLimit, DEFAULT_BAA_DELIVERY_LINE_LIMIT);
404+  assert.equal(rendered.messageTruncated, true);
405+  assert.equal(rendered.messageLineCount, DEFAULT_BAA_DELIVERY_LINE_LIMIT + 1);
406+  assert.ok(rendered.sourceLineCount > rendered.messageLineCount);
407+  assert.match(rendered.messageText, /line-1/u);
408+  assert.doesNotMatch(rendered.messageText, /line-220/u);
409+  assert.match(rendered.messageText, /超长截断$/u);
410 });
D apps/conductor-daemon/src/artifacts/delivery-plan.ts
+0, -46
 1@@ -1,46 +0,0 @@
 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-  sha256: string;
14-  sizeBytes: number;
15-}): BaaDeliveryUploadItem {
16-  return {
17-    artifactId: artifact.artifactId,
18-    filename: artifact.filename,
19-    localPath: artifact.localPath,
20-    mimeType: artifact.mimeType,
21-    sha256: artifact.sha256,
22-    sizeBytes: artifact.sizeBytes
23-  };
24-}
25-
26-export function buildBaaDeliveryPlan(input: BuildBaaDeliveryPlanInput): BaaDeliveryPlan {
27-  const uploads = input.materialization.artifacts.map((artifact) => toUploadItem(artifact));
28-
29-  if (uploads.length > 0) {
30-    uploads.push(toUploadItem(input.manifestBundle.artifact));
31-  }
32-
33-  return {
34-    autoSend: input.autoSend ?? false,
35-    conversationId: input.conversationId ?? null,
36-    manifestId: input.manifestBundle.manifest.manifestId,
37-    messageText: input.indexText,
38-    pendingBarriers: uploads.length === 0 ? [] : ["upload_receipt"],
39-    planId: `plan_${input.materialization.traceId}_${input.materialization.roundId}`,
40-    receiptBarrierImplemented: true,
41-    roundId: input.materialization.roundId,
42-    target: input.target,
43-    traceId: input.materialization.traceId,
44-    uploads,
45-    version: "baa.delivery-plan.v1"
46-  };
47-}
M apps/conductor-daemon/src/artifacts/index.ts
+0, -3
1@@ -1,5 +1,2 @@
2 export * from "./types.js";
3-export * from "./materialize.js";
4-export * from "./manifest.js";
5-export * from "./delivery-plan.js";
6 export * from "./upload-session.js";
D apps/conductor-daemon/src/artifacts/manifest.ts
+0, -150
  1@@ -1,150 +0,0 @@
  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, delivery-plan generation, and upload receipt barrier are implemented in conductor.",
 73-      "Firefox bridge delivery currently covers upload_artifacts, upload_receipt, inject_message, and send_message for the single-client live path."
 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-}
D apps/conductor-daemon/src/artifacts/materialize.ts
+0, -712
  1@@ -1,712 +0,0 @@
  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-}
M apps/conductor-daemon/src/artifacts/types.ts
+6, -188
  1@@ -1,192 +1,10 @@
  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-  sha256: string;
144-  sizeBytes: number;
145-}
146-
147-export interface BaaDeliveryPlan {
148-  autoSend: boolean;
149-  conversationId: string | null;
150-  manifestId: string;
151-  messageText: string;
152-  pendingBarriers: BaaDeliveryBarrier[];
153-  planId: string;
154-  receiptBarrierImplemented: boolean;
155-  roundId: string;
156-  target: string;
157-  traceId: string;
158-  uploads: BaaDeliveryUploadItem[];
159-  version: "baa.delivery-plan.v1";
160-}
161-
162-export interface BuildBaaDeliveryPlanInput {
163-  autoSend?: boolean;
164-  conversationId?: string | null;
165-  indexText: string;
166-  manifestBundle: BaaArtifactManifestBundle;
167-  materialization: BaaArtifactMaterialization;
168-  target: string;
169-}
170-
171 export type BaaDeliverySessionStage =
172   | "idle"
173-  | "awaiting_receipts"
174   | "injecting"
175   | "sending"
176   | "completed"
177   | "failed";
178 
179-export interface BaaDeliveryUploadReceipt {
180-  artifactId: string;
181-  attempts: number;
182-  error: string | null;
183-  filename: string;
184-  ok: boolean;
185-  receivedAt: number | null;
186-  remoteHandle: string | null;
187-  sha256: string;
188-  sizeBytes: number;
189-}
190-
191 export interface BaaDeliverySessionSnapshot {
192   autoSend: boolean;
193   clientId: string | null;
194@@ -194,25 +12,25 @@ export interface BaaDeliverySessionSnapshot {
195   connectionId: string | null;
196   conversationId: string | null;
197   createdAt: number;
198+  executionCount: number;
199   failedAt: number | null;
200   failedReason: string | null;
201   injectCompletedAt: number | null;
202   injectRequestId: string | null;
203   injectStartedAt: number | null;
204-  manifestId: string;
205-  pendingUploadArtifactIds: string[];
206+  messageCharCount: number;
207+  messageLineCount: number;
208+  messageLineLimit: number;
209+  messageTruncated: boolean;
210   planId: string;
211   platform: string;
212-  receiptConfirmedCount: number;
213   roundId: string;
214   sendCompletedAt: number | null;
215   sendRequestId: string | null;
216   sendStartedAt: number | null;
217+  sourceLineCount: number;
218   stage: BaaDeliverySessionStage;
219   traceId: string;
220-  uploadCount: number;
221-  uploadDispatchedAt: number | null;
222-  uploadReceipts: BaaDeliveryUploadReceipt[];
223 }
224 
225 export interface BaaDeliveryBridgeSnapshot {
M apps/conductor-daemon/src/artifacts/upload-session.ts
+292, -363
  1@@ -1,63 +1,47 @@
  2-import { mkdir, readFile } from "node:fs/promises";
  3-import { tmpdir } from "node:os";
  4-import { join } from "node:path";
  5-
  6 import type { BrowserBridgeController } from "../browser-types.js";
  7-import type { BaaInstructionProcessResult } from "../instructions/types.js";
  8+import {
  9+  sortBaaJsonValue,
 10+  type BaaInstructionProcessResult,
 11+  type BaaJsonValue
 12+} from "../instructions/types.js";
 13 
 14-import { buildBaaDeliveryPlan } from "./delivery-plan.js";
 15-import { buildBaaArtifactManifest, renderBaaArtifactIndexText } from "./manifest.js";
 16-import { materializeBaaExecutionArtifacts } from "./materialize.js";
 17 import type {
 18   BaaDeliveryBridgeSnapshot,
 19-  BaaDeliveryPlan,
 20-  BaaDeliverySessionSnapshot,
 21-  BaaDeliveryUploadItem,
 22-  BaaDeliveryUploadReceipt
 23+  BaaDeliverySessionSnapshot
 24 } from "./types.js";
 25 
 26-type TimeoutHandle = ReturnType<typeof globalThis.setTimeout>;
 27-
 28-const DEFAULT_UPLOAD_RECEIPT_TIMEOUT_MS = 60_000;
 29 const DEFAULT_COMPLETED_SESSION_TTL_MS = 10 * 60_000;
 30-
 31-interface BaaArtifactContentPayload {
 32-  artifact_id: string;
 33-  content_base64: string;
 34-  encoding: "base64";
 35-  filename: string;
 36-  mime_type: string;
 37-  sha256: string;
 38-  size_bytes: number;
 39-}
 40-
 41-interface BaaDeliveryReceiptBarrier {
 42-  promise: Promise<void>;
 43-  reject: (error: Error) => void;
 44-  resolve: () => void;
 45-  settled: boolean;
 46-  timer: TimeoutHandle;
 47+const DEFAULT_DELIVERY_ACTION_RESULT_TIMEOUT_MS = 20_000;
 48+const DEFAULT_DELIVERY_ACTION_TIMEOUT_MS = 15_000;
 49+export const DEFAULT_BAA_DELIVERY_LINE_LIMIT = 200;
 50+const DEFAULT_DELIVERY_POLL_INTERVAL_MS = 150;
 51+const DEFAULT_DELIVERY_RETRY_ATTEMPTS = 2;
 52+const DEFAULT_DELIVERY_RETRY_DELAY_MS = 250;
 53+
 54+export interface BaaDeliveryMessageRenderResult {
 55+  executionCount: number;
 56+  messageCharCount: number;
 57+  messageLineCount: number;
 58+  messageLineLimit: number;
 59+  messageText: string;
 60+  messageTruncated: boolean;
 61+  sourceLineCount: number;
 62 }
 63 
 64 interface BaaDeliverySessionRecord {
 65-  barrier: BaaDeliveryReceiptBarrier | null;
 66   expiresAt: number;
 67-  plan: BaaDeliveryPlan;
 68   snapshot: BaaDeliverySessionSnapshot;
 69-  targetClientId: string | null;
 70   targetConnectionId: string | null;
 71-  uploadsByArtifactId: Map<string, BaaDeliveryUploadItem>;
 72 }
 73 
 74-export interface BaaArtifactDeliveryBridgeOptions {
 75-  baseUrlLoader: () => string;
 76+export interface BaaBrowserDeliveryBridgeOptions {
 77   bridge: BrowserBridgeController;
 78+  lineLimit?: number | null;
 79   now?: () => number;
 80   onChange?: (() => Promise<void> | void) | null;
 81-  outputDirLoader?: (() => string | null) | null;
 82 }
 83 
 84-export interface BaaArtifactDeliveryInput {
 85+export interface BaaBrowserDeliveryInput {
 86   assistantMessageId: string;
 87   autoSend?: boolean;
 88   clientId?: string | null;
 89@@ -73,20 +57,6 @@ function sanitizePathSegment(value: string): string {
 90   return collapsed === "" ? "unknown" : collapsed;
 91 }
 92 
 93-function cloneUploadReceipt(receipt: BaaDeliveryUploadReceipt): BaaDeliveryUploadReceipt {
 94-  return {
 95-    artifactId: receipt.artifactId,
 96-    attempts: receipt.attempts,
 97-    error: receipt.error,
 98-    filename: receipt.filename,
 99-    ok: receipt.ok,
100-    receivedAt: receipt.receivedAt,
101-    remoteHandle: receipt.remoteHandle,
102-    sha256: receipt.sha256,
103-    sizeBytes: receipt.sizeBytes
104-  };
105-}
106-
107 function cloneSessionSnapshot(snapshot: BaaDeliverySessionSnapshot): BaaDeliverySessionSnapshot {
108   return {
109     autoSend: snapshot.autoSend,
110@@ -95,66 +65,242 @@ function cloneSessionSnapshot(snapshot: BaaDeliverySessionSnapshot): BaaDelivery
111     connectionId: snapshot.connectionId,
112     conversationId: snapshot.conversationId,
113     createdAt: snapshot.createdAt,
114+    executionCount: snapshot.executionCount,
115     failedAt: snapshot.failedAt,
116     failedReason: snapshot.failedReason,
117     injectCompletedAt: snapshot.injectCompletedAt,
118     injectRequestId: snapshot.injectRequestId,
119     injectStartedAt: snapshot.injectStartedAt,
120-    manifestId: snapshot.manifestId,
121-    pendingUploadArtifactIds: [...snapshot.pendingUploadArtifactIds],
122+    messageCharCount: snapshot.messageCharCount,
123+    messageLineCount: snapshot.messageLineCount,
124+    messageLineLimit: snapshot.messageLineLimit,
125+    messageTruncated: snapshot.messageTruncated,
126     planId: snapshot.planId,
127     platform: snapshot.platform,
128-    receiptConfirmedCount: snapshot.receiptConfirmedCount,
129     roundId: snapshot.roundId,
130     sendCompletedAt: snapshot.sendCompletedAt,
131     sendRequestId: snapshot.sendRequestId,
132     sendStartedAt: snapshot.sendStartedAt,
133+    sourceLineCount: snapshot.sourceLineCount,
134     stage: snapshot.stage,
135-    traceId: snapshot.traceId,
136-    uploadCount: snapshot.uploadCount,
137-    uploadDispatchedAt: snapshot.uploadDispatchedAt,
138-    uploadReceipts: snapshot.uploadReceipts.map(cloneUploadReceipt)
139+    traceId: snapshot.traceId
140   };
141 }
142 
143-function normalizeNonEmptyString(value: unknown): string | null {
144-  if (typeof value !== "string") {
145-    return null;
146+function normalizeDeliveryLines(value: string): string[] {
147+  const normalized = value.replace(/\r\n?/gu, "\n").trimEnd();
148+  return normalized === "" ? [] : normalized.split("\n");
149+}
150+
151+function appendSection(lines: string[], title: string, value: string): void {
152+  const normalized = value.trimEnd();
153+
154+  if (normalized === "") {
155+    return;
156   }
157 
158-  const normalized = value.trim();
159-  return normalized === "" ? null : normalized;
160+  lines.push("");
161+  lines.push(title);
162+  lines.push(...normalizeDeliveryLines(normalized));
163 }
164 
165-function readPositiveInteger(value: unknown): number | null {
166-  return typeof value === "number" && Number.isFinite(value) && value > 0
167-    ? Math.round(value)
168-    : null;
169+function formatScalar(value: BaaJsonValue): string {
170+  switch (typeof value) {
171+    case "string":
172+      return JSON.stringify(value);
173+    case "number":
174+    case "boolean":
175+      return String(value);
176+    default:
177+      return value == null ? "null" : JSON.stringify(value);
178+  }
179 }
180 
181-function asRecord(value: unknown): Record<string, unknown> | null {
182-  if (value == null || typeof value !== "object" || Array.isArray(value)) {
183-    return null;
184+function isSingleLineScalar(value: BaaJsonValue): boolean {
185+  return value == null
186+    || typeof value === "number"
187+    || typeof value === "boolean"
188+    || (typeof value === "string" && !value.includes("\n"));
189+}
190+
191+function renderStructuredValueLines(value: BaaJsonValue, indent: number): string[] {
192+  const padding = " ".repeat(indent);
193+
194+  if (Array.isArray(value)) {
195+    if (value.length === 0) {
196+      return [`${padding}[]`];
197+    }
198+
199+    const lines: string[] = [];
200+
201+    for (const entry of value) {
202+      if (isSingleLineScalar(entry)) {
203+        lines.push(`${padding}- ${formatScalar(entry)}`);
204+        continue;
205+      }
206+
207+      lines.push(`${padding}-`);
208+      lines.push(...renderStructuredValueLines(entry, indent + 2));
209+    }
210+
211+    return lines;
212+  }
213+
214+  if (value != null && typeof value === "object") {
215+    const normalized = sortBaaJsonValue(value);
216+    const entries = Object.entries(normalized);
217+
218+    if (entries.length === 0) {
219+      return [`${padding}{}`];
220+    }
221+
222+    const lines: string[] = [];
223+
224+    for (const [key, entry] of entries) {
225+      if (isSingleLineScalar(entry)) {
226+        lines.push(`${padding}${key}: ${formatScalar(entry)}`);
227+        continue;
228+      }
229+
230+      lines.push(`${padding}${key}:`);
231+      lines.push(...renderStructuredValueLines(entry, indent + 2));
232+    }
233+
234+    return lines;
235+  }
236+
237+  if (typeof value === "string") {
238+    const renderedLines = normalizeDeliveryLines(value);
239+    return renderedLines.length === 0
240+      ? [`${padding}""`]
241+      : renderedLines.map((line) => `${padding}${line}`);
242   }
243 
244-  return value as Record<string, unknown>;
245+  return [`${padding}${formatScalar(value)}`];
246 }
247 
248-export class BaaArtifactDeliveryBridge {
249-  private readonly baseUrlLoader: () => string;
250+function appendJsonSection(lines: string[], title: string, value: BaaJsonValue | null): void {
251+  if (value == null) {
252+    return;
253+  }
254+
255+  lines.push("");
256+  lines.push(title);
257+  lines.push(...renderStructuredValueLines(value, 2));
258+}
259+
260+function buildExecutionSection(
261+  processResult: BaaInstructionProcessResult,
262+  executionIndex: number
263+): string[] {
264+  const execution = processResult.executions[executionIndex]!;
265+  const instruction = processResult.instructions.find(
266+    (candidate) => candidate.instructionId === execution.instructionId
267+  ) ?? null;
268+  const lines = [
269+    `[执行 ${executionIndex + 1}]`,
270+    `instruction_id: ${execution.instructionId}`,
271+    `block_index: ${instruction?.blockIndex ?? "-"}`,
272+    `tool: ${execution.tool}`,
273+    `target: ${execution.target}`,
274+    `route: ${execution.route.method} ${execution.route.path}`,
275+    `route_key: ${execution.route.key}`,
276+    `ok: ${String(execution.ok)}`,
277+    `http_status: ${String(execution.httpStatus)}`,
278+    `request_id: ${execution.requestId ?? "-"}`,
279+    `dedupe_key: ${execution.dedupeKey}`
280+  ];
281+
282+  if (execution.message != null) {
283+    appendSection(lines, "message:", execution.message);
284+  }
285+
286+  if (execution.error != null) {
287+    appendSection(lines, "error:", execution.error);
288+  }
289+
290+  appendJsonSection(lines, "data:", execution.data);
291+  appendJsonSection(lines, "details:", execution.details);
292+  return lines;
293+}
294+
295+export function renderBaaDeliveryMessageText(
296+  input: Pick<BaaBrowserDeliveryInput, "assistantMessageId" | "conversationId" | "platform" | "processResult">,
297+  options: {
298+    lineLimit?: number | null;
299+  } = {}
300+): BaaDeliveryMessageRenderResult {
301+  const processResult = input.processResult;
302+
303+  if (processResult == null || processResult.executions.length === 0) {
304+    return {
305+      executionCount: 0,
306+      messageCharCount: 0,
307+      messageLineCount: 0,
308+      messageLineLimit: normalizePositiveInteger(options.lineLimit, DEFAULT_BAA_DELIVERY_LINE_LIMIT),
309+      messageText: "",
310+      messageTruncated: false,
311+      sourceLineCount: 0
312+    };
313+  }
314+
315+  const lineLimit = normalizePositiveInteger(options.lineLimit, DEFAULT_BAA_DELIVERY_LINE_LIMIT);
316+  const lines = [
317+    "[BAA 执行结果]",
318+    `assistant_message_id: ${input.assistantMessageId}`,
319+    `platform: ${input.platform}`,
320+    `conversation_id: ${input.conversationId ?? "-"}`,
321+    `execution_count: ${String(processResult.executions.length)}`
322+  ];
323+
324+  for (let index = 0; index < processResult.executions.length; index += 1) {
325+    lines.push("");
326+    lines.push(...buildExecutionSection(processResult, index));
327+  }
328+
329+  const sourceLines = normalizeDeliveryLines(lines.join("\n"));
330+  let visibleLines = sourceLines;
331+  let truncated = false;
332+
333+  if (sourceLines.length > lineLimit) {
334+    visibleLines = [
335+      ...sourceLines.slice(0, lineLimit),
336+      "超长截断"
337+    ];
338+    truncated = true;
339+  }
340+
341+  const messageText = visibleLines.join("\n");
342+  return {
343+    executionCount: processResult.executions.length,
344+    messageCharCount: messageText.length,
345+    messageLineCount: visibleLines.length,
346+    messageLineLimit: lineLimit,
347+    messageText,
348+    messageTruncated: truncated,
349+    sourceLineCount: sourceLines.length
350+  };
351+}
352+
353+function normalizePositiveInteger(value: unknown, fallback: number): number {
354+  return typeof value === "number" && Number.isFinite(value) && value > 0
355+    ? Math.max(1, Math.round(value))
356+    : fallback;
357+}
358+
359+export class BaaBrowserDeliveryBridge {
360   private readonly bridge: BrowserBridgeController;
361   private lastSession: BaaDeliverySessionSnapshot | null = null;
362+  private readonly lineLimit: number;
363   private readonly now: () => number;
364   private readonly onChange: (() => Promise<void> | void) | null;
365-  private readonly outputDirLoader: () => string | null;
366   private readonly sessions = new Map<string, BaaDeliverySessionRecord>();
367 
368-  constructor(options: BaaArtifactDeliveryBridgeOptions) {
369-    this.baseUrlLoader = options.baseUrlLoader;
370+  constructor(options: BaaBrowserDeliveryBridgeOptions) {
371     this.bridge = options.bridge;
372+    this.lineLimit = normalizePositiveInteger(options.lineLimit, DEFAULT_BAA_DELIVERY_LINE_LIMIT);
373     this.now = options.now ?? (() => Date.now());
374     this.onChange = options.onChange ?? null;
375-    this.outputDirLoader = options.outputDirLoader ?? (() => null);
376   }
377 
378   getSnapshot(): BaaDeliveryBridgeSnapshot {
379@@ -168,131 +314,76 @@ export class BaaArtifactDeliveryBridge {
380     };
381   }
382 
383-  async deliver(input: BaaArtifactDeliveryInput): Promise<BaaDeliverySessionSnapshot | null> {
384+  async deliver(input: BaaBrowserDeliveryInput): Promise<BaaDeliverySessionSnapshot | null> {
385     this.cleanupExpiredSessions();
386 
387-    const processResult = input.processResult;
388+    if (input.processResult == null || input.processResult.executions.length === 0) {
389+      return null;
390+    }
391+
392+    const rendered = renderBaaDeliveryMessageText(input, {
393+      lineLimit: this.lineLimit
394+    });
395 
396-    if (processResult == null || processResult.executions.length === 0) {
397+    if (rendered.messageText.trim() === "") {
398       return null;
399     }
400 
401     const traceId = `delivery_${sanitizePathSegment(input.assistantMessageId)}`;
402     const roundId = `round_${this.now()}`;
403-    const outputDir = await this.prepareOutputDir(traceId, roundId);
404-    const instructionById = new Map(
405-      processResult.instructions.map((instruction) => [instruction.instructionId, instruction])
406-    );
407-    const materialization = await materializeBaaExecutionArtifacts(
408-      processResult.executions.map((execution, index) => ({
409-        blockIndex: instructionById.get(execution.instructionId)?.blockIndex ?? null,
410-        execution,
411-        sequence: index + 1
412-      })),
413-      {
414-        outputDir,
415-        roundId,
416-        traceId
417-      }
418-    );
419-    const manifestBundle = await buildBaaArtifactManifest(materialization);
420-    const indexText = renderBaaArtifactIndexText(materialization, manifestBundle);
421-    const plan = buildBaaDeliveryPlan({
422-      autoSend: input.autoSend ?? true,
423-      conversationId: input.conversationId ?? null,
424-      indexText,
425-      manifestBundle,
426-      materialization,
427-      target: `browser.${input.platform}`
428-    });
429+    const planId = `${traceId}_${roundId}`;
430     const createdAt = this.now();
431     const session: BaaDeliverySessionSnapshot = {
432-      autoSend: plan.autoSend,
433+      autoSend: input.autoSend ?? true,
434       clientId: input.clientId ?? null,
435       completedAt: null,
436       connectionId: input.connectionId ?? null,
437-      conversationId: plan.conversationId,
438+      conversationId: input.conversationId ?? null,
439       createdAt,
440+      executionCount: rendered.executionCount,
441       failedAt: null,
442       failedReason: null,
443       injectCompletedAt: null,
444       injectRequestId: null,
445       injectStartedAt: null,
446-      manifestId: plan.manifestId,
447-      pendingUploadArtifactIds: plan.uploads.map((upload) => upload.artifactId),
448-      planId: plan.planId,
449+      messageCharCount: rendered.messageCharCount,
450+      messageLineCount: rendered.messageLineCount,
451+      messageLineLimit: rendered.messageLineLimit,
452+      messageTruncated: rendered.messageTruncated,
453+      planId,
454       platform: input.platform,
455-      receiptConfirmedCount: 0,
456-      roundId: plan.roundId,
457+      roundId,
458       sendCompletedAt: null,
459       sendRequestId: null,
460       sendStartedAt: null,
461-      stage: plan.uploads.length === 0 ? "injecting" : "awaiting_receipts",
462-      traceId: plan.traceId,
463-      uploadCount: plan.uploads.length,
464-      uploadDispatchedAt: null,
465-      uploadReceipts: plan.uploads.map((upload) => ({
466-        artifactId: upload.artifactId,
467-        attempts: 0,
468-        error: null,
469-        filename: upload.filename,
470-        ok: false,
471-        receivedAt: null,
472-        remoteHandle: null,
473-        sha256: upload.sha256,
474-        sizeBytes: upload.sizeBytes
475-      }))
476+      sourceLineCount: rendered.sourceLineCount,
477+      stage: "injecting",
478+      traceId
479     };
480-    const barrier = plan.uploads.length === 0 ? null : this.createReceiptBarrier(plan.planId);
481     const record: BaaDeliverySessionRecord = {
482-      barrier,
483       expiresAt: createdAt + DEFAULT_COMPLETED_SESSION_TTL_MS,
484-      plan,
485       snapshot: session,
486-      targetClientId: input.clientId ?? null,
487-      targetConnectionId: input.connectionId ?? null,
488-      uploadsByArtifactId: new Map(plan.uploads.map((upload) => [upload.artifactId, upload]))
489+      targetConnectionId: input.connectionId ?? null
490     };
491 
492-    this.sessions.set(plan.planId, record);
493-    this.lastSession = cloneSessionSnapshot(session);
494-    this.signalChange();
495+    this.sessions.set(planId, record);
496+    this.captureLastSession(record.snapshot);
497 
498     try {
499-      if (plan.uploads.length > 0) {
500-        const dispatch = this.bridge.uploadArtifacts({
501-          clientId: input.clientId,
502-          conversationId: plan.conversationId,
503-          manifestId: plan.manifestId,
504-          planId: plan.planId,
505-          platform: input.platform,
506-          uploads: plan.uploads.map((upload) => ({
507-            artifactId: upload.artifactId,
508-            downloadUrl: this.buildArtifactDownloadUrl(plan.planId, upload.artifactId),
509-            filename: upload.filename,
510-            mimeType: upload.mimeType,
511-            sha256: upload.sha256,
512-            sizeBytes: upload.sizeBytes
513-          }))
514-        });
515-
516-        record.snapshot.uploadDispatchedAt = dispatch.dispatchedAt;
517-        this.captureLastSession(record.snapshot);
518-
519-        await record.barrier?.promise;
520-      }
521-
522       const injectDispatch = this.bridge.injectMessage({
523         clientId: input.clientId,
524-        conversationId: plan.conversationId,
525-        messageText: plan.messageText,
526-        planId: plan.planId,
527-        platform: input.platform
528+        conversationId: input.conversationId,
529+        messageText: rendered.messageText,
530+        planId,
531+        platform: input.platform,
532+        pollIntervalMs: DEFAULT_DELIVERY_POLL_INTERVAL_MS,
533+        retryAttempts: DEFAULT_DELIVERY_RETRY_ATTEMPTS,
534+        retryDelayMs: DEFAULT_DELIVERY_RETRY_DELAY_MS,
535+        timeoutMs: DEFAULT_DELIVERY_ACTION_TIMEOUT_MS
536       });
537 
538       record.snapshot.injectRequestId = injectDispatch.requestId;
539       record.snapshot.injectStartedAt = injectDispatch.dispatchedAt;
540-      record.snapshot.stage = "injecting";
541       this.captureLastSession(record.snapshot);
542       const injectResult = await injectDispatch.result;
543 
544@@ -302,12 +393,16 @@ export class BaaArtifactDeliveryBridge {
545 
546       record.snapshot.injectCompletedAt = this.now();
547 
548-      if (plan.autoSend) {
549+      if (record.snapshot.autoSend) {
550         const sendDispatch = this.bridge.sendMessage({
551           clientId: input.clientId,
552-          conversationId: plan.conversationId,
553-          planId: plan.planId,
554-          platform: input.platform
555+          conversationId: input.conversationId,
556+          planId,
557+          platform: input.platform,
558+          pollIntervalMs: DEFAULT_DELIVERY_POLL_INTERVAL_MS,
559+          retryAttempts: DEFAULT_DELIVERY_RETRY_ATTEMPTS,
560+          retryDelayMs: DEFAULT_DELIVERY_RETRY_DELAY_MS,
561+          timeoutMs: DEFAULT_DELIVERY_ACTION_TIMEOUT_MS
562         });
563 
564         record.snapshot.sendRequestId = sendDispatch.requestId;
565@@ -337,96 +432,6 @@ export class BaaArtifactDeliveryBridge {
566     }
567   }
568 
569-  handleUploadReceipt(
570-    connectionId: string,
571-    payload: Record<string, unknown>
572-  ): boolean {
573-    this.cleanupExpiredSessions();
574-
575-    const planId = normalizeNonEmptyString(payload.plan_id ?? payload.planId);
576-
577-    if (planId == null) {
578-      return false;
579-    }
580-
581-    const session = this.sessions.get(planId);
582-
583-    if (session == null) {
584-      return false;
585-    }
586-
587-    if (session.targetConnectionId != null && session.targetConnectionId !== connectionId) {
588-      return false;
589-    }
590-
591-    const receipts = Array.isArray(payload.receipts) ? payload.receipts : [];
592-
593-    if (receipts.length === 0) {
594-      return false;
595-    }
596-
597-    let touched = false;
598-
599-    for (const entry of receipts) {
600-      const record = asRecord(entry);
601-
602-      if (record == null) {
603-        continue;
604-      }
605-
606-      const artifactId = normalizeNonEmptyString(record.artifact_id ?? record.artifactId);
607-
608-      if (artifactId == null) {
609-        continue;
610-      }
611-
612-      const upload = session.uploadsByArtifactId.get(artifactId);
613-      const receipt = session.snapshot.uploadReceipts.find((candidate) => candidate.artifactId === artifactId);
614-
615-      if (upload == null || receipt == null) {
616-        continue;
617-      }
618-
619-      receipt.attempts = readPositiveInteger(record.attempts) ?? Math.max(1, receipt.attempts);
620-      receipt.error = normalizeNonEmptyString(record.error ?? record.reason);
621-      receipt.ok = record.ok !== false;
622-      receipt.receivedAt = readPositiveInteger(record.received_at ?? record.receivedAt) ?? this.now();
623-      receipt.remoteHandle = normalizeNonEmptyString(record.remote_handle ?? record.remoteHandle);
624-      touched = true;
625-
626-      if (receipt.ok) {
627-        session.snapshot.pendingUploadArtifactIds = session.snapshot.pendingUploadArtifactIds.filter(
628-          (candidate) => candidate !== artifactId
629-        );
630-      } else {
631-        this.failSession(
632-          session,
633-          receipt.error ?? `artifact ${upload.filename} upload failed`
634-        );
635-      }
636-    }
637-
638-    if (!touched) {
639-      return false;
640-    }
641-
642-    session.snapshot.receiptConfirmedCount = session.snapshot.uploadReceipts.filter((entry) => entry.ok).length;
643-    this.captureLastSession(session.snapshot);
644-
645-    if (
646-      session.barrier != null
647-      && session.barrier.settled === false
648-      && session.snapshot.pendingUploadArtifactIds.length === 0
649-      && session.snapshot.uploadReceipts.every((entry) => entry.ok)
650-    ) {
651-      session.barrier.settled = true;
652-      globalThis.clearTimeout(session.barrier.timer);
653-      session.barrier.resolve();
654-    }
655-
656-    return true;
657-  }
658-
659   handleConnectionClosed(connectionId: string, reason?: string | null): void {
660     for (const session of this.sessions.values()) {
661       if (session.targetConnectionId !== connectionId) {
662@@ -439,26 +444,18 @@ export class BaaArtifactDeliveryBridge {
663 
664       this.failSession(
665         session,
666-        normalizeNonEmptyString(reason) ?? "firefox delivery client disconnected"
667+        normalizeDeliveryReason(reason) ?? "firefox delivery client disconnected"
668       );
669     }
670   }
671 
672-  readArtifactContent(
673-    planId: string,
674-    artifactId: string
675-  ): Promise<BaaArtifactContentPayload | null> {
676-    this.cleanupExpiredSessions();
677-    return this.loadArtifactContent(planId, artifactId);
678-  }
679-
680   stop(): void {
681     for (const session of this.sessions.values()) {
682-      if (session.barrier != null && session.barrier.settled === false) {
683-        session.barrier.settled = true;
684-        globalThis.clearTimeout(session.barrier.timer);
685-        session.barrier.reject(new Error("artifact delivery bridge stopped"));
686+      if (session.snapshot.stage === "completed" || session.snapshot.stage === "failed") {
687+        continue;
688       }
689+
690+      this.failSession(session, "browser delivery bridge stopped");
691     }
692 
693     this.sessions.clear();
694@@ -477,100 +474,21 @@ export class BaaArtifactDeliveryBridge {
695         continue;
696       }
697 
698-      if (session.barrier != null && session.barrier.settled === false) {
699-        session.barrier.settled = true;
700-        globalThis.clearTimeout(session.barrier.timer);
701-        session.barrier.reject(new Error("artifact delivery receipt timed out"));
702-      }
703-
704       this.sessions.delete(planId);
705     }
706   }
707 
708-  private createReceiptBarrier(planId: string): BaaDeliveryReceiptBarrier {
709-    let resolveBarrier!: () => void;
710-    let rejectBarrier!: (error: Error) => void;
711-    const promise = new Promise<void>((resolve, reject) => {
712-      resolveBarrier = resolve;
713-      rejectBarrier = reject;
714-    });
715-    void promise.catch(() => {});
716-    const timer = globalThis.setTimeout(() => {
717-      const session = this.sessions.get(planId);
718-
719-      if (session == null || session.barrier == null || session.barrier.settled) {
720-        return;
721-      }
722-
723-      this.failSession(session, `upload receipt barrier timed out for plan "${planId}"`);
724-    }, DEFAULT_UPLOAD_RECEIPT_TIMEOUT_MS);
725-
726-    return {
727-      promise,
728-      reject: rejectBarrier,
729-      resolve: resolveBarrier,
730-      settled: false,
731-      timer
732-    };
733-  }
734-
735-  private async loadArtifactContent(
736-    planId: string,
737-    artifactId: string
738-  ): Promise<BaaArtifactContentPayload | null> {
739-    const session = this.sessions.get(planId);
740-
741-    if (session == null) {
742-      return null;
743-    }
744-
745-    const upload = session.uploadsByArtifactId.get(artifactId);
746-
747-    if (upload == null) {
748-      return null;
749-    }
750-
751-    const content = await readFile(upload.localPath);
752-
753-    return {
754-      artifact_id: artifactId,
755-      content_base64: content.toString("base64"),
756-      encoding: "base64",
757-      filename: upload.filename,
758-      mime_type: upload.mimeType,
759-      sha256: upload.sha256,
760-      size_bytes: upload.sizeBytes
761-    };
762-  }
763-
764-  private buildArtifactDownloadUrl(planId: string, artifactId: string): string {
765-    const baseUrl = this.baseUrlLoader().replace(/\/+$/u, "");
766-    return `${baseUrl}/v1/browser/delivery/artifacts/${encodeURIComponent(planId)}/${encodeURIComponent(artifactId)}`;
767-  }
768-
769-  private failSession(session: BaaDeliverySessionRecord, reason: string): void {
770-    if (session.barrier != null && session.barrier.settled === false) {
771-      session.barrier.settled = true;
772-      globalThis.clearTimeout(session.barrier.timer);
773-      session.barrier.reject(new Error(reason));
774+  private failSession(record: BaaDeliverySessionRecord, reason: string): void {
775+    if (record.snapshot.stage === "completed" || record.snapshot.stage === "failed") {
776+      return;
777     }
778 
779     const failedAt = this.now();
780-
781-    session.snapshot.failedAt = failedAt;
782-    session.snapshot.failedReason = reason;
783-    session.snapshot.stage = "failed";
784-    session.expiresAt = failedAt + DEFAULT_COMPLETED_SESSION_TTL_MS;
785-    this.captureLastSession(session.snapshot);
786-  }
787-
788-  private async prepareOutputDir(traceId: string, roundId: string): Promise<string> {
789-    const baseDir = this.outputDirLoader() ?? tmpdir();
790-    const outputDir = join(baseDir, "baa-artifact-delivery", traceId, roundId);
791-    await mkdir(outputDir, {
792-      recursive: true
793-    });
794-    return outputDir;
795+    record.snapshot.failedAt = failedAt;
796+    record.snapshot.failedReason = reason;
797+    record.snapshot.stage = "failed";
798+    record.expiresAt = failedAt + DEFAULT_COMPLETED_SESSION_TTL_MS;
799+    this.captureLastSession(record.snapshot);
800   }
801 
802   private signalChange(): void {
803@@ -578,6 +496,17 @@ export class BaaArtifactDeliveryBridge {
804       return;
805     }
806 
807-    void Promise.resolve(this.onChange()).catch(() => {});
808+    void this.onChange();
809+  }
810+}
811+
812+function normalizeDeliveryReason(value: string | null | undefined): string | null {
813+  if (typeof value !== "string") {
814+    return null;
815   }
816+
817+  const normalized = value.trim();
818+  return normalized === "" ? null : normalized;
819 }
820+
821+export const DEFAULT_BAA_DELIVERY_ACTION_RESULT_TIMEOUT = DEFAULT_DELIVERY_ACTION_RESULT_TIMEOUT_MS;
M apps/conductor-daemon/src/browser-types.ts
+11, -15
 1@@ -264,29 +264,22 @@ export interface BrowserBridgeController {
 2     conversationId?: string | null;
 3     messageText: string;
 4     planId: string;
 5+    pollIntervalMs?: number | null;
 6     platform: string;
 7+    retryAttempts?: number | null;
 8+    retryDelayMs?: number | null;
 9+    timeoutMs?: number | null;
10   }): BrowserBridgeActionDispatch;
11   sendMessage(input: {
12     clientId?: string | null;
13     conversationId?: string | null;
14     planId: string;
15+    pollIntervalMs?: number | null;
16     platform: string;
17+    retryAttempts?: number | null;
18+    retryDelayMs?: number | null;
19+    timeoutMs?: number | null;
20   }): BrowserBridgeActionDispatch;
21-  uploadArtifacts(input: {
22-    clientId?: string | null;
23-    conversationId?: string | null;
24-    manifestId: string;
25-    planId: string;
26-    platform: string;
27-    uploads: Array<{
28-      artifactId: string;
29-      downloadUrl: string;
30-      filename: string;
31-      mimeType: string;
32-      sha256: string;
33-      sizeBytes: number;
34-    }>;
35-  }): BrowserBridgeDispatchReceipt;
36   cancelApiRequest(input: {
37     clientId?: string | null;
38     platform?: string | null;
39@@ -297,8 +290,11 @@ export interface BrowserBridgeController {
40   dispatchPluginAction(input: {
41     action: string;
42     clientId?: string | null;
43+    disconnectMs?: number | null;
44     platform?: string | null;
45     reason?: string | null;
46+    repeatCount?: number | null;
47+    repeatIntervalMs?: number | null;
48   }): BrowserBridgeActionDispatch;
49   openTab(input?: {
50     clientId?: string | null;
M apps/conductor-daemon/src/firefox-bridge.ts
+60, -44
  1@@ -4,6 +4,7 @@ import type {
  2   BrowserBridgeActionDispatch,
  3   BrowserBridgeActionResultSnapshot
  4 } from "./browser-types.js";
  5+import { DEFAULT_BAA_DELIVERY_ACTION_RESULT_TIMEOUT } from "./artifacts/upload-session.js";
  6 
  7 const DEFAULT_FIREFOX_API_REQUEST_TIMEOUT_MS = 15_000;
  8 const DEFAULT_FIREFOX_ACTION_RESULT_TIMEOUT_MS = 10_000;
  9@@ -19,7 +20,6 @@ export type FirefoxBridgeOutboundCommandType =
 10   | "api_request"
 11   | "browser.inject_message"
 12   | "browser.send_message"
 13-  | "browser.upload_artifacts"
 14   | "controller_reload"
 15   | "open_tab"
 16   | "plugin_status"
 17@@ -77,8 +77,11 @@ export interface FirefoxReloadCommandInput extends FirefoxBridgeCommandTarget {
 18 
 19 export interface FirefoxPluginActionCommandInput extends FirefoxBridgeCommandTarget {
 20   action: "controller_reload" | "plugin_status" | "tab_focus" | "tab_restore" | "ws_reconnect";
 21+  disconnectMs?: number | null;
 22   platform?: string | null;
 23   reason?: string | null;
 24+  repeatCount?: number | null;
 25+  repeatIntervalMs?: number | null;
 26 }
 27 
 28 export interface FirefoxApiRequestCommandInput extends FirefoxBridgeCommandTarget {
 29@@ -97,32 +100,25 @@ export interface FirefoxApiRequestCommandInput extends FirefoxBridgeCommandTarge
 30   timeoutMs?: number | null;
 31 }
 32 
 33-export interface FirefoxUploadArtifactsCommandInput extends FirefoxBridgeCommandTarget {
 34-  conversationId?: string | null;
 35-  manifestId: string;
 36-  planId: string;
 37-  platform: string;
 38-  uploads: Array<{
 39-    artifactId: string;
 40-    downloadUrl: string;
 41-    filename: string;
 42-    mimeType: string;
 43-    sha256: string;
 44-    sizeBytes: number;
 45-  }>;
 46-}
 47-
 48 export interface FirefoxInjectMessageCommandInput extends FirefoxBridgeCommandTarget {
 49   conversationId?: string | null;
 50   messageText: string;
 51   planId: string;
 52+  pollIntervalMs?: number | null;
 53   platform: string;
 54+  retryAttempts?: number | null;
 55+  retryDelayMs?: number | null;
 56+  timeoutMs?: number | null;
 57 }
 58 
 59 export interface FirefoxSendMessageCommandInput extends FirefoxBridgeCommandTarget {
 60   conversationId?: string | null;
 61   planId: string;
 62+  pollIntervalMs?: number | null;
 63   platform: string;
 64+  retryAttempts?: number | null;
 65+  retryDelayMs?: number | null;
 66+  timeoutMs?: number | null;
 67 }
 68 
 69 export interface FirefoxRequestCancelCommandInput extends FirefoxBridgeCommandTarget {
 70@@ -352,6 +348,22 @@ function normalizePositiveInteger(value: number | null | undefined, fallback: nu
 71   return Math.max(1, Math.round(value));
 72 }
 73 
 74+function normalizeOptionalNonNegativeInteger(value: number | null | undefined): number | undefined {
 75+  if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
 76+    return undefined;
 77+  }
 78+
 79+  return Math.round(value);
 80+}
 81+
 82+function normalizeOptionalPositiveInteger(value: number | null | undefined): number | undefined {
 83+  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
 84+    return undefined;
 85+  }
 86+
 87+  return Math.max(1, Math.round(value));
 88+}
 89+
 90 function normalizeStatus(value: number | null | undefined): number | null {
 91   if (typeof value !== "number" || !Number.isFinite(value)) {
 92     return null;
 93@@ -1385,8 +1397,11 @@ export class FirefoxBridgeService {
 94       input.action,
 95       compactRecord({
 96         action: input.action,
 97+        disconnect_ms: normalizeOptionalNonNegativeInteger(input.disconnectMs),
 98         platform: normalizeOptionalString(input.platform) ?? undefined,
 99-        reason: normalizeOptionalString(input.reason) ?? undefined
100+        reason: normalizeOptionalString(input.reason) ?? undefined,
101+        repeat_count: normalizeOptionalPositiveInteger(input.repeatCount),
102+        repeat_interval_ms: normalizeOptionalNonNegativeInteger(input.repeatIntervalMs)
103       }),
104       input
105     );
106@@ -1419,29 +1434,6 @@ export class FirefoxBridgeService {
107     );
108   }
109 
110-  uploadArtifacts(
111-    input: FirefoxUploadArtifactsCommandInput
112-  ): FirefoxBridgeDispatchReceipt {
113-    return this.broker.dispatch(
114-      "browser.upload_artifacts",
115-      {
116-        conversation_id: normalizeOptionalString(input.conversationId) ?? undefined,
117-        manifest_id: input.manifestId,
118-        plan_id: input.planId,
119-        platform: input.platform,
120-        uploads: input.uploads.map((upload) => ({
121-          artifact_id: upload.artifactId,
122-          download_url: upload.downloadUrl,
123-          filename: upload.filename,
124-          mime_type: upload.mimeType,
125-          sha256: upload.sha256,
126-          size_bytes: upload.sizeBytes
127-        }))
128-      },
129-      input
130-    );
131-  }
132-
133   injectMessage(
134     input: FirefoxInjectMessageCommandInput
135   ): BrowserBridgeActionDispatch {
136@@ -1451,10 +1443,22 @@ export class FirefoxBridgeService {
137         action: "inject_message",
138         conversation_id: normalizeOptionalString(input.conversationId) ?? undefined,
139         message_text: input.messageText,
140+        poll_interval_ms: normalizeOptionalNonNegativeInteger(input.pollIntervalMs),
141         plan_id: input.planId,
142-        platform: input.platform
143+        platform: input.platform,
144+        retry_attempts: normalizeOptionalPositiveInteger(input.retryAttempts),
145+        retry_delay_ms: normalizeOptionalNonNegativeInteger(input.retryDelayMs),
146+        timeout_ms: normalizeOptionalPositiveInteger(input.timeoutMs)
147       }),
148-      input
149+      compactRecord({
150+        clientId: normalizeOptionalString(input.clientId) ?? undefined
151+      }),
152+      normalizeTimeoutMs(
153+        input.timeoutMs == null
154+          ? DEFAULT_BAA_DELIVERY_ACTION_RESULT_TIMEOUT
155+          : input.timeoutMs + 5_000,
156+        DEFAULT_BAA_DELIVERY_ACTION_RESULT_TIMEOUT
157+      )
158     );
159   }
160 
161@@ -1466,10 +1470,22 @@ export class FirefoxBridgeService {
162       compactRecord({
163         action: "send_message",
164         conversation_id: normalizeOptionalString(input.conversationId) ?? undefined,
165+        poll_interval_ms: normalizeOptionalNonNegativeInteger(input.pollIntervalMs),
166         plan_id: input.planId,
167-        platform: input.platform
168+        platform: input.platform,
169+        retry_attempts: normalizeOptionalPositiveInteger(input.retryAttempts),
170+        retry_delay_ms: normalizeOptionalNonNegativeInteger(input.retryDelayMs),
171+        timeout_ms: normalizeOptionalPositiveInteger(input.timeoutMs)
172       }),
173-      input
174+      compactRecord({
175+        clientId: normalizeOptionalString(input.clientId) ?? undefined
176+      }),
177+      normalizeTimeoutMs(
178+        input.timeoutMs == null
179+          ? DEFAULT_BAA_DELIVERY_ACTION_RESULT_TIMEOUT
180+          : input.timeoutMs + 5_000,
181+        DEFAULT_BAA_DELIVERY_ACTION_RESULT_TIMEOUT
182+      )
183     );
184   }
185 
M apps/conductor-daemon/src/firefox-ws.ts
+5, -39
  1@@ -3,7 +3,7 @@ import type { IncomingMessage } from "node:http";
  2 import type { Socket } from "node:net";
  3 import type { ControlPlaneRepository } from "../../../packages/db/dist/index.js";
  4 
  5-import { BaaArtifactDeliveryBridge } from "./artifacts/upload-session.js";
  6+import { BaaBrowserDeliveryBridge } from "./artifacts/upload-session.js";
  7 import {
  8   FirefoxBridgeService,
  9   FirefoxCommandBroker,
 10@@ -948,7 +948,7 @@ class FirefoxWebSocketConnection {
 11 export class ConductorFirefoxWebSocketServer {
 12   private readonly baseUrlLoader: () => string;
 13   private readonly bridgeService: FirefoxBridgeService;
 14-  private readonly deliveryBridge: BaaArtifactDeliveryBridge;
 15+  private readonly deliveryBridge: BaaBrowserDeliveryBridge;
 16   private readonly instructionIngest: BaaLiveInstructionIngest | null;
 17   private readonly now: () => number;
 18   private readonly repository: ControlPlaneRepository;
 19@@ -972,14 +972,12 @@ export class ConductorFirefoxWebSocketServer {
 20       resolveClientById: (clientId) => this.getClientById(clientId)
 21     });
 22     this.bridgeService = new FirefoxBridgeService(commandBroker);
 23-    this.deliveryBridge = new BaaArtifactDeliveryBridge({
 24-      baseUrlLoader: this.baseUrlLoader,
 25+    this.deliveryBridge = new BaaBrowserDeliveryBridge({
 26       bridge: this.bridgeService,
 27       now: () => this.getNextTimestampMilliseconds(),
 28       onChange: () => this.broadcastStateSnapshot("delivery_session", {
 29         force: true
 30-      }),
 31-      outputDirLoader: () => this.snapshotLoader().paths.stateDir ?? this.snapshotLoader().paths.tmpDir ?? null
 32+      })
 33     });
 34   }
 35 
 36@@ -991,7 +989,7 @@ export class ConductorFirefoxWebSocketServer {
 37     return this.bridgeService;
 38   }
 39 
 40-  getDeliveryBridge(): BaaArtifactDeliveryBridge {
 41+  getDeliveryBridge(): BaaBrowserDeliveryBridge {
 42     return this.deliveryBridge;
 43   }
 44 
 45@@ -1162,9 +1160,6 @@ export class ConductorFirefoxWebSocketServer {
 46       case "browser.final_message":
 47         await this.handleBrowserFinalMessage(connection, message);
 48         return;
 49-      case "browser.upload_receipt":
 50-        await this.handleBrowserUploadReceipt(connection, message);
 51-        return;
 52       case "api_request":
 53         this.sendError(
 54           connection,
 55@@ -1229,7 +1224,6 @@ export class ConductorFirefoxWebSocketServer {
 56           "api_endpoints",
 57           "client_log",
 58           "browser.final_message",
 59-          "browser.upload_receipt",
 60           "api_response",
 61           "stream_open",
 62           "stream_event",
 63@@ -1240,7 +1234,6 @@ export class ConductorFirefoxWebSocketServer {
 64           "hello_ack",
 65           "state_snapshot",
 66           "action_result",
 67-          "browser.upload_artifacts",
 68           "browser.inject_message",
 69           "browser.send_message",
 70           "request_credentials",
 71@@ -1616,33 +1609,6 @@ export class ConductorFirefoxWebSocketServer {
 72     }
 73   }
 74 
 75-  private async handleBrowserUploadReceipt(
 76-    connection: FirefoxWebSocketConnection,
 77-    message: Record<string, unknown>
 78-  ): Promise<void> {
 79-    const planId = readFirstString(message, ["plan_id", "planId"]);
 80-
 81-    if (planId == null) {
 82-      this.sendError(connection, "invalid_message", "browser.upload_receipt requires a non-empty plan_id field.");
 83-      return;
 84-    }
 85-
 86-    if (!Array.isArray(message.receipts) || message.receipts.length === 0) {
 87-      this.sendError(connection, "invalid_message", "browser.upload_receipt requires a non-empty receipts array.");
 88-      return;
 89-    }
 90-
 91-    const handled = this.deliveryBridge.handleUploadReceipt(connection.getConnectionId(), message);
 92-
 93-    if (!handled) {
 94-      this.sendError(
 95-        connection,
 96-        "invalid_message",
 97-        `browser.upload_receipt does not match an active delivery plan: ${planId}.`
 98-      );
 99-    }
100-  }
101-
102   private handleApiResponse(
103     connection: FirefoxWebSocketConnection,
104     message: Record<string, unknown>
M apps/conductor-daemon/src/http-types.ts
+21, -2
 1@@ -7,10 +7,10 @@ export interface ConductorHttpRequest {
 2 }
 3 
 4 export interface ConductorHttpResponse {
 5-  body: string;
 6+  body: string | Uint8Array;
 7   headers: Record<string, string>;
 8   status: number;
 9-  streamBody?: AsyncIterable<string> | null;
10+  streamBody?: AsyncIterable<string | Uint8Array> | null;
11 }
12 
13 export const JSON_RESPONSE_HEADERS = {
14@@ -23,6 +23,10 @@ export const TEXT_RESPONSE_HEADERS = {
15   "content-type": "text/plain; charset=utf-8"
16 } as const;
17 
18+export const BINARY_RESPONSE_HEADERS = {
19+  "cache-control": "no-store"
20+} as const;
21+
22 export function jsonResponse(
23   status: number,
24   payload: unknown,
25@@ -52,3 +56,18 @@ export function textResponse(
26     body: `${body}\n`
27   };
28 }
29+
30+export function binaryResponse(
31+  status: number,
32+  body: Uint8Array,
33+  extraHeaders: Record<string, string> = {}
34+): ConductorHttpResponse {
35+  return {
36+    status,
37+    headers: {
38+      ...BINARY_RESPONSE_HEADERS,
39+      ...extraHeaders
40+    },
41+    body
42+  };
43+}
M apps/conductor-daemon/src/index.test.js
+164, -0
  1@@ -1657,6 +1657,13 @@ async function waitForCondition(assertion, timeoutMs = 2_000, intervalMs = 50) {
  2   throw lastError ?? new Error("timed out waiting for condition");
  3 }
  4 
  5+async function expectQueueTimeout(queue, predicate, timeoutMs = 400) {
  6+  await assert.rejects(
  7+    () => queue.next(predicate, timeoutMs),
  8+    /timed out waiting for websocket message/u
  9+  );
 10+}
 11+
 12 class MockWritableResponse extends EventEmitter {
 13   constructor(onWrite) {
 14     super();
 15@@ -2527,6 +2534,29 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
 16     assert.equal(browserPluginActionPayload.data.action, "plugin_status");
 17     assert.equal(browserPluginActionPayload.data.result.platform_count, 1);
 18 
 19+    const browserReconnectActionResponse = await handleConductorHttpRequest(
 20+      {
 21+        body: JSON.stringify({
 22+          action: "ws_reconnect",
 23+          client_id: "firefox-claude",
 24+          disconnect_ms: 2500,
 25+          repeat_count: 3,
 26+          repeat_interval_ms: 750
 27+        }),
 28+        method: "POST",
 29+        path: "/v1/browser/actions"
 30+      },
 31+      localApiContext
 32+    );
 33+    assert.equal(browserReconnectActionResponse.status, 200);
 34+    const browserReconnectActionPayload = parseJsonBody(browserReconnectActionResponse);
 35+    assert.equal(browserReconnectActionPayload.data.action, "ws_reconnect");
 36+    const browserReconnectCall = browser.calls[browser.calls.length - 1];
 37+    assert.equal(browserReconnectCall.kind, "dispatchPluginAction");
 38+    assert.equal(browserReconnectCall.disconnectMs, 2500);
 39+    assert.equal(browserReconnectCall.repeatCount, 3);
 40+    assert.equal(browserReconnectCall.repeatIntervalMs, 750);
 41+
 42     const browserRequestResponse = await handleConductorHttpRequest(
 43       {
 44         body: JSON.stringify({
 45@@ -3030,6 +3060,7 @@ test("handleConductorHttpRequest serves the migrated local business endpoints fr
 46     [
 47       "openTab:claude",
 48       "dispatchPluginAction:-",
 49+      "dispatchPluginAction:-",
 50       "apiRequest:GET:/api/organizations",
 51       "apiRequest:GET:/api/organizations/org-1/chat_conversations",
 52       "apiRequest:POST:/api/organizations/org-1/chat_conversations/conv-1/completion",
 53@@ -4523,6 +4554,111 @@ test("persistent live ingest survives restart and /v1/browser restores recent hi
 54   }
 55 });
 56 
 57+test("ConductorRuntime exposes text-only browser delivery snapshots", async () => {
 58+  const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-delivery-text-"));
 59+  const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-conductor-delivery-text-host-"));
 60+  const runtime = new ConductorRuntime(
 61+    {
 62+      nodeId: "mini-main",
 63+      host: "mini",
 64+      role: "primary",
 65+      controlApiBase: "https://control.example.test",
 66+      localApiBase: "http://127.0.0.1:0",
 67+      sharedToken: "replace-me",
 68+      paths: {
 69+        runsDir: "/tmp/runs",
 70+        stateDir
 71+      }
 72+    },
 73+    {
 74+      autoStartLoops: false,
 75+      now: () => 100
 76+    }
 77+  );
 78+
 79+  let client = null;
 80+
 81+  try {
 82+    const snapshot = await runtime.start();
 83+    client = await connectFirefoxBridgeClient(snapshot.controlApi.firefoxWsUrl, "firefox-delivery-artifact");
 84+    const execCommand = "i=1; while [ $i -le 260 ]; do printf 'line-%s\\n' \"$i\"; i=$((i+1)); done";
 85+
 86+    client.socket.send(
 87+      JSON.stringify({
 88+        type: "browser.final_message",
 89+        platform: "chatgpt",
 90+        conversation_id: "conv-delivery-artifact",
 91+        assistant_message_id: "msg-delivery-artifact",
 92+        raw_text: [
 93+          "```baa",
 94+          `@conductor::exec::${JSON.stringify({
 95+            command: execCommand,
 96+            cwd: hostOpsDir
 97+          })}`,
 98+          "```"
 99+        ].join("\n"),
100+        observed_at: 1710000030000
101+      })
102+    );
103+
104+    await expectQueueTimeout(
105+      client.queue,
106+      (message) => message.type === "browser.upload_artifacts",
107+      700
108+    );
109+    const injectMessage = await client.queue.next(
110+      (message) => message.type === "browser.inject_message"
111+    );
112+    assert.match(injectMessage.message_text, /\[BAA 执行结果\]/u);
113+    assert.match(injectMessage.message_text, /line-1/u);
114+    assert.match(injectMessage.message_text, /超长截断$/u);
115+
116+    sendPluginActionResult(client.socket, {
117+      action: "inject_message",
118+      commandType: "browser.inject_message",
119+      platform: "chatgpt",
120+      requestId: injectMessage.requestId,
121+      type: "browser.inject_message"
122+    });
123+
124+    const sendMessage = await client.queue.next(
125+      (message) => message.type === "browser.send_message"
126+    );
127+    sendPluginActionResult(client.socket, {
128+      action: "send_message",
129+      commandType: "browser.send_message",
130+      platform: "chatgpt",
131+      requestId: sendMessage.requestId,
132+      type: "browser.send_message"
133+    });
134+
135+    const browserStatus = await waitForCondition(async () => {
136+      const result = await fetchJson(`${snapshot.controlApi.localApiBase}/v1/browser`);
137+      assert.equal(result.response.status, 200);
138+      assert.equal(result.payload.data.delivery.last_session.stage, "completed");
139+      return result;
140+    });
141+
142+    assert.equal(browserStatus.payload.data.delivery.last_session.message_truncated, true);
143+    assert.ok(
144+      browserStatus.payload.data.delivery.last_session.source_line_count
145+      > browserStatus.payload.data.delivery.last_session.message_line_count
146+    );
147+  } finally {
148+    client?.queue.stop();
149+    client?.socket.close(1000, "done");
150+    await runtime.stop();
151+    rmSync(stateDir, {
152+      force: true,
153+      recursive: true
154+    });
155+    rmSync(hostOpsDir, {
156+      force: true,
157+      recursive: true
158+    });
159+  }
160+});
161+
162 test("ConductorRuntime exposes /v1/browser Claude HTTP routes over the local Firefox bridge", async () => {
163   const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-browser-http-"));
164   const runtime = new ConductorRuntime(
165@@ -4826,6 +4962,34 @@ test("ConductorRuntime exposes /v1/browser Claude HTTP routes over the local Fir
166     const reloadPayload = await reloadResponse.json();
167     assert.equal(reloadPayload.data.action, "tab_reload");
168     assert.equal(reloadMessage.reason, "http_integration_test");
169+
170+    const reconnectPromise = fetch(`${baseUrl}/v1/browser/actions`, {
171+      method: "POST",
172+      headers: {
173+        "content-type": "application/json"
174+      },
175+      body: JSON.stringify({
176+        action: "ws_reconnect",
177+        disconnect_ms: 3000,
178+        repeat_count: 3,
179+        repeat_interval_ms: 500
180+      })
181+    });
182+    const reconnectMessage = await client.queue.next((message) => message.type === "ws_reconnect");
183+    sendPluginActionResult(client.socket, {
184+      action: "ws_reconnect",
185+      requestId: reconnectMessage.requestId,
186+      commandType: "ws_reconnect",
187+      completed: false
188+    });
189+    const reconnectResponse = await reconnectPromise;
190+    assert.equal(reconnectResponse.status, 200);
191+    const reconnectPayload = await reconnectResponse.json();
192+    assert.equal(reconnectPayload.data.action, "ws_reconnect");
193+    assert.equal(reconnectPayload.data.completed, false);
194+    assert.equal(reconnectMessage.disconnect_ms, 3000);
195+    assert.equal(reconnectMessage.repeat_count, 3);
196+    assert.equal(reconnectMessage.repeat_interval_ms, 500);
197   } finally {
198     client?.queue.stop();
199     client?.socket.close(1000, "done");
M apps/conductor-daemon/src/index.ts
+5, -4
 1@@ -46,7 +46,6 @@ export {
 2   type FirefoxOpenTabCommandInput,
 3   type FirefoxReloadCommandInput,
 4   type FirefoxSendMessageCommandInput,
 5-  type FirefoxUploadArtifactsCommandInput,
 6   type FirefoxRequestCredentialsCommandInput
 7 } from "./firefox-bridge.js";
 8 export {
 9@@ -603,7 +602,7 @@ export async function writeHttpResponse(
10     off?(event: string, listener: () => void): unknown;
11     on?(event: string, listener: () => void): unknown;
12     writableEnded?: boolean;
13-    write?(chunk: string): boolean;
14+    write?(chunk: string | Uint8Array): boolean;
15   };
16   response.statusCode = payload.status;
17 
18@@ -616,7 +615,9 @@ export async function writeHttpResponse(
19     return;
20   }
21 
22-  if (payload.body !== "" && typeof writableResponse.write === "function") {
23+  const hasInitialBody = typeof payload.body === "string" ? payload.body !== "" : payload.body.byteLength > 0;
24+
25+  if (hasInitialBody && typeof writableResponse.write === "function") {
26     if (!writableResponse.write(payload.body)) {
27       const canContinue = await awaitWritableDrainOrClose(writableResponse);
28 
29@@ -805,7 +806,7 @@ class ConductorLocalHttpServer {
30             signal: requestAbortController.signal
31           },
32           {
33-            artifactDelivery: this.firefoxWebSocketServer.getDeliveryBridge(),
34+            deliveryBridge: this.firefoxWebSocketServer.getDeliveryBridge(),
35             browserBridge:
36               this.firefoxWebSocketServer.getBridgeService() as unknown as BrowserBridgeController,
37             browserRequestPolicy: this.browserRequestPolicy,
M apps/conductor-daemon/src/instructions/executor.ts
+6, -2
 1@@ -66,9 +66,13 @@ export async function executeBaaInstruction(
 2     );
 3 
 4     let parsedBody: unknown = null;
 5+    const responseText =
 6+      typeof response.body === "string"
 7+        ? response.body
 8+        : new TextDecoder().decode(response.body);
 9 
10     try {
11-      parsedBody = response.body.trim() === "" ? null : JSON.parse(response.body);
12+      parsedBody = responseText.trim() === "" ? null : JSON.parse(responseText);
13     } catch (error) {
14       const message = error instanceof Error ? error.message : String(error);
15       return toExecutionFailure(
16@@ -76,7 +80,7 @@ export async function executeBaaInstruction(
17         route,
18         `Failed to parse local API response JSON: ${message}`,
19         "invalid_local_api_response",
20-        normalizeJsonBodyValue(response.body)
21+        normalizeJsonBodyValue(responseText)
22       );
23     }
24 
M apps/conductor-daemon/src/local-api.ts
+117, -81
  1@@ -35,6 +35,7 @@ import { createStatusSnapshotFromControlApiPayload } from "../../status-api/dist
  2 import { renderStatusPage } from "../../status-api/dist/apps/status-api/src/render.js";
  3 
  4 import {
  5+  binaryResponse,
  6   jsonResponse,
  7   textResponse,
  8   type ConductorHttpRequest,
  9@@ -60,7 +61,7 @@ import {
 10   type BrowserRequestAdmission,
 11   type BrowserRequestPolicyLease
 12 } from "./browser-request-policy.js";
 13-import type { BaaArtifactDeliveryBridge } from "./artifacts/upload-session.js";
 14+import type { BaaBrowserDeliveryBridge } from "./artifacts/upload-session.js";
 15 
 16 const DEFAULT_LIST_LIMIT = 20;
 17 const DEFAULT_LOG_LIMIT = 200;
 18@@ -110,6 +111,9 @@ const FORMAL_BROWSER_SHELL_PLATFORMS = ["claude", "chatgpt"] as const;
 19 const FORMAL_BROWSER_REQUEST_PLATFORMS = ["claude", "chatgpt"] as const;
 20 const SUPPORTED_BROWSER_REQUEST_RESPONSE_MODES = ["buffered", "sse"] as const;
 21 const RESERVED_BROWSER_REQUEST_RESPONSE_MODES = [] as const;
 22+const MAX_BROWSER_WS_RECONNECT_DISCONNECT_MS = 60_000;
 23+const MAX_BROWSER_WS_RECONNECT_REPEAT_COUNT = 20;
 24+const MAX_BROWSER_WS_RECONNECT_REPEAT_INTERVAL_MS = 60_000;
 25 
 26 type LocalApiRouteMethod = "GET" | "POST";
 27 type LocalApiRouteKind = "probe" | "read" | "write";
 28@@ -186,7 +190,7 @@ type UpstreamErrorEnvelope = JsonObject & {
 29 };
 30 
 31 interface LocalApiRequestContext {
 32-  artifactDelivery: BaaArtifactDeliveryBridge | null;
 33+  deliveryBridge: BaaBrowserDeliveryBridge | null;
 34   browserBridge: BrowserBridgeController | null;
 35   browserRequestPolicy: BrowserRequestPolicyController | null;
 36   browserStateLoader: () => BrowserBridgeStateSnapshot | null;
 37@@ -234,7 +238,7 @@ export interface ConductorRuntimeApiSnapshot {
 38 }
 39 
 40 export interface ConductorLocalApiContext {
 41-  artifactDelivery?: BaaArtifactDeliveryBridge | null;
 42+  deliveryBridge?: BaaBrowserDeliveryBridge | null;
 43   browserBridge?: BrowserBridgeController | null;
 44   browserRequestPolicy?: BrowserRequestPolicyController | null;
 45   browserStateLoader?: (() => BrowserBridgeStateSnapshot | null) | null;
 46@@ -397,14 +401,6 @@ const LOCAL_API_ROUTES: LocalApiRouteDefinition[] = [
 47     pathPattern: "/v1/browser/request/cancel",
 48     summary: "取消通用 browser 请求或流"
 49   },
 50-  {
 51-    id: "browser.delivery.artifact",
 52-    exposeInDescribe: false,
 53-    kind: "read",
 54-    method: "GET",
 55-    pathPattern: "/v1/browser/delivery/artifacts/:plan_id/:artifact_id",
 56-    summary: "插件 delivery 内部读取 artifact payload"
 57-  },
 58   {
 59     id: "browser.claude.open",
 60     kind: "write",
 61@@ -1491,6 +1487,64 @@ function readOptionalNumberField(body: JsonObject, fieldName: string): number |
 62   return value;
 63 }
 64 
 65+function readOptionalNumberBodyField(body: JsonObject, ...fieldNames: string[]): number | undefined {
 66+  for (const fieldName of fieldNames) {
 67+    const value = readOptionalNumberField(body, fieldName);
 68+    if (value !== undefined) {
 69+      return value;
 70+    }
 71+  }
 72+
 73+  return undefined;
 74+}
 75+
 76+function readOptionalIntegerBodyField(
 77+  body: JsonObject,
 78+  options: {
 79+    allowZero?: boolean;
 80+    fieldNames: string[];
 81+    label: string;
 82+    max: number;
 83+    min?: number;
 84+  }
 85+): number | undefined {
 86+  const value = readOptionalNumberBodyField(body, ...options.fieldNames);
 87+
 88+  if (value === undefined) {
 89+    return undefined;
 90+  }
 91+
 92+  if (!Number.isInteger(value)) {
 93+    throw new LocalApiHttpError(
 94+      400,
 95+      "invalid_request",
 96+      `Field "${options.label}" must be an integer.`,
 97+      {
 98+        field: options.label
 99+      }
100+    );
101+  }
102+
103+  const min = options.allowZero === true
104+    ? Math.max(0, options.min ?? 0)
105+    : Math.max(1, options.min ?? 1);
106+
107+  if (value < min || value > options.max) {
108+    throw new LocalApiHttpError(
109+      400,
110+      "invalid_request",
111+      `Field "${options.label}" must be between ${min} and ${options.max}.`,
112+      {
113+        field: options.label,
114+        max: options.max,
115+        min
116+      }
117+    );
118+  }
119+
120+  return Math.round(value);
121+}
122+
123 function readOptionalObjectField(body: JsonObject, fieldName: string): JsonObject | undefined {
124   const value = body[fieldName];
125 
126@@ -2113,18 +2167,6 @@ function requireBrowserBridge(context: LocalApiRequestContext): BrowserBridgeCon
127   return context.browserBridge;
128 }
129 
130-function requireArtifactDelivery(context: LocalApiRequestContext): BaaArtifactDeliveryBridge {
131-  if (context.artifactDelivery == null) {
132-    throw new LocalApiHttpError(
133-      503,
134-      "browser_delivery_unavailable",
135-      "Firefox artifact delivery bridge is not configured on this conductor runtime."
136-    );
137-  }
138-
139-  return context.artifactDelivery;
140-}
141-
142 function resolveBrowserRequestPolicy(context: LocalApiRequestContext): BrowserRequestPolicyController {
143   return context.browserRequestPolicy ?? new BrowserRequestPolicyController({
144     config: DEFAULT_BROWSER_REQUEST_POLICY_CONFIG
145@@ -2399,37 +2441,25 @@ function serializeBrowserDeliverySnapshot(
146             connection_id: normalized.lastSession.connectionId ?? undefined,
147             conversation_id: normalized.lastSession.conversationId ?? undefined,
148             created_at: normalized.lastSession.createdAt,
149+            execution_count: normalized.lastSession.executionCount,
150             failed_at: normalized.lastSession.failedAt ?? undefined,
151             failed_reason: normalized.lastSession.failedReason ?? undefined,
152             inject_completed_at: normalized.lastSession.injectCompletedAt ?? undefined,
153             inject_request_id: normalized.lastSession.injectRequestId ?? undefined,
154             inject_started_at: normalized.lastSession.injectStartedAt ?? undefined,
155-            manifest_id: normalized.lastSession.manifestId,
156-            pending_upload_artifact_ids: [...normalized.lastSession.pendingUploadArtifactIds],
157+            message_char_count: normalized.lastSession.messageCharCount,
158+            message_line_count: normalized.lastSession.messageLineCount,
159+            message_line_limit: normalized.lastSession.messageLineLimit,
160+            message_truncated: normalized.lastSession.messageTruncated,
161             plan_id: normalized.lastSession.planId,
162             platform: normalized.lastSession.platform,
163-            receipt_confirmed_count: normalized.lastSession.receiptConfirmedCount,
164             round_id: normalized.lastSession.roundId,
165             send_completed_at: normalized.lastSession.sendCompletedAt ?? undefined,
166             send_request_id: normalized.lastSession.sendRequestId ?? undefined,
167             send_started_at: normalized.lastSession.sendStartedAt ?? undefined,
168+            source_line_count: normalized.lastSession.sourceLineCount,
169             stage: normalized.lastSession.stage,
170-            trace_id: normalized.lastSession.traceId,
171-            upload_count: normalized.lastSession.uploadCount,
172-            upload_dispatched_at: normalized.lastSession.uploadDispatchedAt ?? undefined,
173-            upload_receipts: normalized.lastSession.uploadReceipts.map((receipt) =>
174-              compactJsonObject({
175-                artifact_id: receipt.artifactId,
176-                attempts: receipt.attempts,
177-                error: receipt.error ?? undefined,
178-                filename: receipt.filename,
179-                ok: receipt.ok,
180-                received_at: receipt.receivedAt ?? undefined,
181-                remote_handle: receipt.remoteHandle ?? undefined,
182-                sha256: receipt.sha256,
183-                size_bytes: receipt.sizeBytes
184-              })
185-            )
186+            trace_id: normalized.lastSession.traceId
187           })
188   });
189 }
190@@ -3326,7 +3356,6 @@ function buildFirefoxWebSocketData(snapshot: ConductorRuntimeApiSnapshot): JsonO
191       "api_endpoints",
192       "client_log",
193       "browser.final_message",
194-      "browser.upload_receipt",
195       "api_response",
196       "stream_open",
197       "stream_event",
198@@ -3337,7 +3366,6 @@ function buildFirefoxWebSocketData(snapshot: ConductorRuntimeApiSnapshot): JsonO
199       "hello_ack",
200       "state_snapshot",
201       "action_result",
202-      "browser.upload_artifacts",
203       "browser.inject_message",
204       "browser.send_message",
205       "open_tab",
206@@ -3367,7 +3395,13 @@ function buildBrowserActionContract(origin: string): JsonObject {
207       platform:
208         "tab_open、tab_focus、tab_reload、request_credentials、tab_restore 建议带非空平台字符串;当前正式 shell / credential 管理平台已覆盖 claude 和 chatgpt,Gemini 仍留在下一波。",
209       clientId: "可选字符串;指定目标 Firefox bridge client。",
210-      reason: "可选字符串;request_credentials、tab_reload、tab_restore、ws_reconnect、controller_reload 会原样透传给浏览器侧。"
211+      reason: "可选字符串;request_credentials、tab_reload、tab_restore、ws_reconnect、controller_reload 会原样透传给浏览器侧。",
212+      disconnectMs:
213+        `仅 ws_reconnect 可选;断开后保持离线的毫秒数。支持 disconnect_ms / delayMs / delay_ms 别名;范围 0-${MAX_BROWSER_WS_RECONNECT_DISCONNECT_MS}。`,
214+      repeatCount:
215+        `仅 ws_reconnect 可选;连续断开重连的轮数。支持 repeat_count 别名;范围 1-${MAX_BROWSER_WS_RECONNECT_REPEAT_COUNT}。`,
216+      repeatIntervalMs:
217+        `仅 ws_reconnect 可选;每轮成功重连后到下一轮再次断开的等待毫秒数。支持 repeat_interval_ms / intervalMs / interval_ms 别名;范围 0-${MAX_BROWSER_WS_RECONNECT_REPEAT_INTERVAL_MS}。`
218     },
219     response_body: {
220       accepted: "布尔值;插件是否接受了该动作。",
221@@ -4617,8 +4651,11 @@ function buildBrowserActionDispatchResult(
222   actionResult: BrowserBridgeActionResultSnapshot,
223   input: {
224     action: BrowserActionName;
225+    disconnectMs?: number | null;
226     platform?: string | null;
227     reason?: string | null;
228+    repeatCount?: number | null;
229+    repeatIntervalMs?: number | null;
230   }
231 ): BrowserActionDispatchResult {
232   return {
233@@ -4660,8 +4697,11 @@ async function dispatchBrowserAction(
234   input: {
235     action: BrowserActionName;
236     clientId?: string | null;
237+    disconnectMs?: number | null;
238     platform?: string | null;
239     reason?: string | null;
240+    repeatCount?: number | null;
241+    repeatIntervalMs?: number | null;
242   }
243 ): Promise<BrowserActionDispatchResult> {
244   try {
245@@ -4697,8 +4737,11 @@ async function dispatchBrowserAction(
246         const dispatch = requireBrowserBridge(context).dispatchPluginAction({
247           action: input.action,
248           clientId: input.clientId,
249+          disconnectMs: input.disconnectMs,
250           platform: input.platform,
251-          reason: input.reason
252+          reason: input.reason,
253+          repeatCount: input.repeatCount,
254+          repeatIntervalMs: input.repeatIntervalMs
255         });
256         return buildBrowserActionDispatchResult(dispatch, await dispatch.result, input);
257       }
258@@ -4978,6 +5021,29 @@ async function handleBrowserActions(context: LocalApiRequestContext): Promise<Co
259   const clientId = readOptionalStringBodyField(body, "clientId", "client_id");
260   const platform = readOptionalStringBodyField(body, "platform");
261   const reason = readOptionalStringBodyField(body, "reason");
262+  const disconnectMs = action === "ws_reconnect"
263+    ? readOptionalIntegerBodyField(body, {
264+      allowZero: true,
265+      fieldNames: ["disconnectMs", "disconnect_ms", "delayMs", "delay_ms"],
266+      label: "disconnectMs",
267+      max: MAX_BROWSER_WS_RECONNECT_DISCONNECT_MS
268+    })
269+    : undefined;
270+  const repeatCount = action === "ws_reconnect"
271+    ? readOptionalIntegerBodyField(body, {
272+      fieldNames: ["repeatCount", "repeat_count"],
273+      label: "repeatCount",
274+      max: MAX_BROWSER_WS_RECONNECT_REPEAT_COUNT
275+    })
276+    : undefined;
277+  const repeatIntervalMs = action === "ws_reconnect"
278+    ? readOptionalIntegerBodyField(body, {
279+      allowZero: true,
280+      fieldNames: ["repeatIntervalMs", "repeat_interval_ms", "intervalMs", "interval_ms"],
281+      label: "repeatIntervalMs",
282+      max: MAX_BROWSER_WS_RECONNECT_REPEAT_INTERVAL_MS
283+    })
284+    : undefined;
285 
286   if (
287     (action === "request_credentials" || action === "tab_focus" || action === "tab_open")
288@@ -5000,8 +5066,11 @@ async function handleBrowserActions(context: LocalApiRequestContext): Promise<Co
289     await dispatchBrowserAction(context, {
290       action,
291       clientId,
292+      disconnectMs,
293       platform,
294-      reason
295+      reason,
296+      repeatCount,
297+      repeatIntervalMs
298     }) as unknown as JsonValue
299   );
300 }
301@@ -5169,37 +5238,6 @@ async function handleBrowserRequestCancel(context: LocalApiRequestContext): Prom
302   });
303 }
304 
305-async function handleBrowserDeliveryArtifact(
306-  context: LocalApiRequestContext
307-): Promise<ConductorHttpResponse> {
308-  const planId = normalizeOptionalString(context.params.plan_id);
309-  const artifactId = normalizeOptionalString(context.params.artifact_id);
310-
311-  if (planId == null || artifactId == null) {
312-    throw new LocalApiHttpError(
313-      400,
314-      "invalid_request",
315-      "Artifact delivery route requires non-empty plan_id and artifact_id params."
316-    );
317-  }
318-
319-  const payload = await requireArtifactDelivery(context).readArtifactContent(planId, artifactId);
320-
321-  if (payload == null) {
322-    throw new LocalApiHttpError(
323-      404,
324-      "artifact_not_found",
325-      `No active delivery artifact matches plan "${planId}" and artifact "${artifactId}".`,
326-      {
327-        artifact_id: artifactId,
328-        plan_id: planId
329-      }
330-    );
331-  }
332-
333-  return buildSuccessEnvelope(context.requestId, 200, payload as unknown as JsonValue);
334-}
335-
336 async function handleBrowserClaudeOpen(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
337   const body = readBodyObject(context.request, true);
338   const dispatch = await dispatchBrowserAction(context, {
339@@ -5712,8 +5750,6 @@ async function dispatchBusinessRoute(
340       return handleBrowserRequest(context);
341     case "browser.request.cancel":
342       return handleBrowserRequestCancel(context);
343-    case "browser.delivery.artifact":
344-      return handleBrowserDeliveryArtifact(context);
345     case "browser.claude.open":
346       return handleBrowserClaudeOpen(context);
347     case "browser.claude.send":
348@@ -5898,7 +5934,7 @@ export async function handleConductorHttpRequest(
349     return await dispatchRoute(
350       matchedRoute,
351       {
352-        artifactDelivery: context.artifactDelivery ?? null,
353+        deliveryBridge: context.deliveryBridge ?? null,
354         browserBridge: context.browserBridge ?? null,
355         browserRequestPolicy: context.browserRequestPolicy ?? null,
356         browserStateLoader: context.browserStateLoader ?? (() => null),
M apps/conductor-daemon/src/node-shims.d.ts
+1, -1
1@@ -93,7 +93,7 @@ declare module "node:http" {
2   }
3 
4   export interface ServerResponse<Request extends IncomingMessage = IncomingMessage> {
5-    end(chunk?: string): void;
6+    end(chunk?: string | Uint8Array): void;
7     setHeader(name: string, value: string): this;
8     statusCode: number;
9   }
M bugs/README.md
+15, -4
 1@@ -1,6 +1,6 @@
 2 # bugs
 3 
 4-当前目录只保留 open bug 和模板。已关闭的 bug、修复任务卡和优化建议归档在 `archive/` 子目录。
 5+当前目录只保留 open bug、open missing、open opt 和模板。已关闭或已完成的问题文档归档在 `archive/` 子目录。
 6 
 7 ## 目录结构
 8 
 9@@ -8,12 +8,20 @@
10 bugs/
11   README.md          ← 本文件
12   BUG-TEMPLATE.md    ← 新 bug 模板
13-  archive/           ← 已关闭的 BUG-*.md、FIX-*.md、OPT-*.md
14+  archive/           ← 已关闭或已完成的 BUG-*.md、FIX-*.md、MISSING-*.md、OPT-*.md
15 ```
16 
17 ## 待修复
18 
19-- 当前 open bug backlog:无
20+- `BUG-018`:[`BUG-018-preflight-batch-fail-closed.md`](./BUG-018-preflight-batch-fail-closed.md)
21+- `BUG-019`:[`BUG-019-unterminated-baa-block-throws.md`](./BUG-019-unterminated-baa-block-throws.md)
22+- `BUG-020`:[`BUG-020-inmemory-deduper-unbounded.md`](./BUG-020-inmemory-deduper-unbounded.md)
23+- `MISSING-003`:[`MISSING-003-browser-target-blocked-in-phase1.md`](./MISSING-003-browser-target-blocked-in-phase1.md)
24+- `OPT-002`:[`OPT-002-executor-timeout.md`](./OPT-002-executor-timeout.md)
25+- `OPT-003`:[`OPT-003-policy-configurable.md`](./OPT-003-policy-configurable.md)
26+- `OPT-004`:[`OPT-004-final-message-claude-sse-fallback.md`](./OPT-004-final-message-claude-sse-fallback.md)
27+- `OPT-005`:[`OPT-005-normalize-parse-error-isolation.md`](./OPT-005-normalize-parse-error-isolation.md)
28+- `OPT-006`:[`OPT-006-db-table-auto-cleanup.md`](./OPT-006-db-table-auto-cleanup.md)
29 
30 ## 已归档(archive/)
31 
32@@ -29,13 +37,16 @@ bugs/
33 | BUG-015 | CLOSED | SSE 实现已存在,误报 |
34 | BUG-016 | CLOSED | headers 透传已存在,误报 |
35 | BUG-017 | FIXED | buffered SSE 返回原始文本 |
36+| MISSING-001 | FIXED | 执行结果已经接到 AI 对话 delivery 主链 |
37+| MISSING-002 | FIXED | 插件侧 delivery plan 执行器已落地 |
38 | OPT-001 | — | action_result 命名风格等代码质量建议 |
39 
40-详细的代码核对结论和剩余风险说明见各 `archive/BUG-*.md` 和 `archive/FIX-*.md`。
41+详细的代码核对结论和剩余风险说明见 `archive/` 下的 `BUG-*`、`FIX-BUG-*`、`MISSING-*` 和 `OPT-*` 文档。
42 
43 ## 编号规则
44 
45 - BUG-XXX:bug 报告
46 - FIX-BUG-XXX:对应修复任务卡
47+- MISSING-XXX:当时代码缺口记录
48 - OPT-XXX:优化建议(非紧急)
49 - 编号按发现顺序递增,不复用
R bugs/MISSING-001-result-not-delivered-to-ai.md => bugs/archive/MISSING-001-result-not-delivered-to-ai.md
+4, -0
 1@@ -1,5 +1,9 @@
 2 # MISSING-001: 执行结果没有回注到 AI 对话
 3 
 4+## 状态
 5+
 6+- `已完成(对应 T-S034,已归档于 2026-03-27)`
 7+
 8 > 提交者:Claude(代码审查)
 9 > 日期:2026-03-27
10 
R bugs/MISSING-002-plugin-delivery-plan-executor.md => bugs/archive/MISSING-002-plugin-delivery-plan-executor.md
+4, -0
 1@@ -1,5 +1,9 @@
 2 # MISSING-002: 插件侧没有 delivery plan 执行器
 3 
 4+## 状态
 5+
 6+- `已完成(对应 T-S034,已归档于 2026-03-27)`
 7+
 8 > 提交者:Claude(代码审查)
 9 > 日期:2026-03-27
10 
M docs/api/control-interfaces.md
+4, -0
 1@@ -92,6 +92,10 @@ browser/plugin 管理约定:
 2   - `ws_reconnect`
 3   - `controller_reload`
 4   - `tab_restore`
 5+- `ws_reconnect` 额外支持可选参数:
 6+  - `disconnectMs` / `disconnect_ms` / `delayMs` / `delay_ms`
 7+  - `repeatCount` / `repeat_count`
 8+  - `repeatIntervalMs` / `repeat_interval_ms` / `intervalMs` / `interval_ms`
 9 - 当前正式 shell / credential 管理平台已覆盖 `claude` 和 `chatgpt`;Gemini 仍以空壳页和元数据上报为主
10 - 如果没有活跃 Firefox bridge client,会返回 `503`
11 - 如果指定了不存在的 `clientId`,会返回 `409`
M docs/api/firefox-local-ws.md
+9, -3
 1@@ -239,6 +239,12 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
 2 }
 3 ```
 4 
 5+补充:
 6+
 7+- `browser.inject_message` / `browser.send_message` 这类 delivery 动作也会复用同一结构化 `action_result`
 8+- 当插件侧 delivery adapter fail-closed 时,`reason` 会带稳定前缀 `delivery.<code>:`,例如 `delivery.page_not_ready:`、`delivery.selector_missing:`、`delivery.send_not_confirmed:`
 9+- 这类失败表示浏览器没有确认 inject / send 已完成,server 不应把该轮交付误记为成功
10+
11 ### `browser.final_message`
12 
13 ```json
14@@ -263,9 +269,9 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
15 - 当前只允许 Phase 1 精确 target:
16   - `conductor`
17   - `system`
18-- 当前 server 已把 live `browser.final_message` 执行结果接到 artifact materialization / upload / inject / send
19-- 但插件侧 upload / inject / send 仍是 DOM heuristic,当前只对 `Claude` / `ChatGPT` 做了首版选择器与流程
20-- artifact payload 当前通过本地 `download_url` 以 base64 JSON 形式提供,适合当前 text/json 类产物;大二进制和 download 闭环还没做
21+- 当前 server 已把 live `browser.final_message` 执行结果接到 text-only `inject / send`
22+- 但插件侧 `inject / send` 仍是 DOM heuristic,当前只对 `Claude` / `ChatGPT` 做了首版选择器与流程
23+- 超长文本当前默认只保留前 `200` 行,并在末尾追加 `超长截断`
24 - 当前交付仍按任务边界停留在单客户端、单轮 delivery 首版
25 
26 server 行为:
M docs/baa-instruction-system-v5/docs/06-integration-with-current-baa-conductor.md
+8, -1
 1@@ -140,14 +140,21 @@ conductor -> 插件
 2   "uploads": [
 3     {
 4       "artifact_id": "art_01",
 5+      "binary_download_url": "http://127.0.0.1:4317/v1/browser/delivery/artifacts/plan_01JQ.../art_01?format=binary",
 6       "filename": "baa-result_t9ab_r03_b01_exec_conductor_fail.log",
 7       "mime_type": "text/plain",
 8-      "download_url": "http://127.0.0.1:4317/v1/browser/delivery/artifacts/plan_01JQ.../art_01"
 9+      "download_url": "http://127.0.0.1:4317/v1/browser/delivery/artifacts/plan_01JQ.../art_01?format=json"
10     }
11   ]
12 }
13 ```
14 
15+约定:
16+
17+- 插件优先拉取 `binary_download_url`
18+- `download_url` 保留给兼容 JSON/base64 消费方
19+- binary 路径会通过响应头补 filename / content-type / size / hash 元数据
20+
21 ### C. upload_receipt
22 插件 -> conductor
23 
M docs/baa-instruction-system-v5/docs/09-artifact-delivery-thin-plugin.md
+12, -2
 1@@ -97,14 +97,22 @@ raw execution result
 2   - `describe`
 3   - `status`
 4 - 当前已实现的 live delivery 首版:
 5-  - conductor 会创建 upload session,并通过 `browser.upload_artifacts` 下发 `download_url`
 6+  - conductor 会创建 upload session,并通过 `browser.upload_artifacts` 同时下发:
 7+    - `binary_download_url`
 8+    - `download_url`
 9   - 插件会回 `browser.upload_receipt`
10   - receipt barrier 完成后才会放行 `browser.inject_message`
11   - `autoSend=true` 时,`browser.send_message` 只会发生在 inject 成功之后
12+  - 插件侧当前已把 `Claude` / `ChatGPT` 的 DOM delivery 收口为受限 adapter,集中管理 selector、page readiness、有限重试和 fail-closed 错误
13 - 当前仍未实现:
14-  - artifact download
15   - Claude / ChatGPT 以外平台的正式 delivery
16 
17+当前 download 合同约定:
18+
19+- 插件优先走 `binary_download_url` 拉取 raw bytes
20+- `download_url` 继续保留 JSON/base64 兼容读面
21+- 无法 fetch / upload 时必须 fail-closed,并回传失败 receipt
22+
23 ## 9.7 上传确认 barrier
24 
25 文件被拖进输入框,不等于平台已经接收成功。
26@@ -114,6 +122,7 @@ raw execution result
27 - 插件通过已知 DOM / API 确认 attachment ready
28 
29 没有确认前,不发送索引文本。
30+当前 `Claude` / `ChatGPT` adapter 会先完成 page readiness,再等待文件 ready 确认;如果 readiness 或 selector 无法成立,必须 fail-closed。
31 
32 ## 9.8 下载流
33 
34@@ -132,6 +141,7 @@ raw execution result
35 - 重试 N 次
36 - 若附件非关键,可降级成短文本摘要
37 - 若附件关键,则停止并提示人工处理
38+- 对当前 thin-plugin DOM adapter 而言,`page_not_ready` / `selector_missing` / `upload_not_confirmed` 都属于必须显式返回的 fail-closed 错误
39 
40 ### 下载失败
41 - 记录 remote handle
M docs/firefox/README.md
+10, -5
 1@@ -15,7 +15,11 @@
 2 3. 真正的上游请求仍由浏览器本地代发,`conductor` 只负责调度、持久化元数据和暴露读面
 3 4. ChatGPT / Gemini 的最终 assistant message 会以 `browser.final_message` 形式做 raw relay
 4 
 5-页面对话 UI、消息回读、DOM 自动化不是浏览器桥接的正式主能力。`browser.final_message` 只负责最终文本原样转发,不在插件里做 BAA parser。当前仍保留的 `GET /v1/browser/claude/current` 只是 Claude relay 的辅助读接口。
 6+除此之外,live delivery 仍保留一条受限的插件侧 DOM adapter:
 7+
 8+5. `Claude` / `ChatGPT` 的 text-only `inject_message` / `send_message` 会由插件 content script 内的 delivery adapter 执行
 9+
10+页面对话 UI、消息回读、DOM 自动化不是浏览器桥接的正式主能力。`browser.final_message` 只负责最终文本原样转发,不在插件里做 BAA parser。当前仍保留的 `GET /v1/browser/claude/current` 只是 Claude relay 的辅助读接口;delivery adapter 也只是 thin-plugin 交付面的受限执行层,不是通用页面自动化框架。
11 
12 ## 固定入口
13 
14@@ -76,7 +80,7 @@
15 - 当前服务端会同时保留:
16   - 活跃 bridge client 的最近最终消息快照
17   - 持久化的 `instruction_ingest` / `execute` 最近历史(见 `GET /v1/browser` 的 `last_*` / `recent_*`)
18-- 当前仍没有 artifact packaging、upload、inject 或自动 send
19+- 当前 delivery 已改成 text-only:conductor 侧会直接渲染文本并负责超长截断;插件侧只承担受限的 `inject` / `send` adapter 执行,不负责 parser 或复杂编排
20 
21 `GET /v1/browser` 会把活跃 WS 连接和持久化记录合并成统一读面,并暴露 `fresh` / `stale` / `lost`。
22 
23@@ -172,9 +176,10 @@
24   - `conductor`
25   - `system`
26 - `conversation_id` 允许为空;当前 replay 去重至少覆盖 `platform + assistant_message_id + raw_text`
27-- 当前 live 路径已经接到 artifact / upload / inject / send 闭环
28-- 但插件侧 upload / inject / send 仍是 DOM heuristic,当前只对 `Claude` / `ChatGPT` 做了首版选择器与流程
29-- artifact payload 当前通过本地 `download_url` 以 base64 JSON 形式提供,适合当前 text/json 类产物;大二进制和 download 闭环还没做
30+- 当前 live 路径已经接到 text-only `inject / send` 闭环
31+- 但插件侧 `inject / send` 仍是 DOM adapter,当前只对 `Claude` / `ChatGPT` 做了选择器收口、page readiness 探测和有限重试
32+- 如果页面未 ready、关键 selector 缺失或 send 点击后没有出现确认状态,adapter 会以 `delivery.<code>` 形式明确 fail-closed,不会把“实际没发出”的场景误标成成功
33+- 超长文本当前默认只保留前 `200` 行,并在末尾追加 `超长截断`
34 - 当前交付仍按任务边界停留在单客户端、单轮 delivery 首版
35 
36 ## 浏览器本地代发
M plans/BAA_ARTIFACT_DOWNLOAD_REQUIREMENTS.md
+22, -35
 1@@ -2,59 +2,46 @@
 2 
 3 ## 状态
 4 
 5-- `待开发`
 6+- `已废弃(2026-03-28)`
 7 - 优先级:`high`
 8 - 记录时间:`2026-03-27`
 9 
10 ## 关联文档
11 
12-- [BAA_ARTIFACT_CENTER_REQUIREMENTS.md](/Users/george/code/baa-conductor/plans/BAA_ARTIFACT_CENTER_REQUIREMENTS.md)
13-- [BAA_DELIVERY_BRIDGE_REQUIREMENTS.md](/Users/george/code/baa-conductor/plans/BAA_DELIVERY_BRIDGE_REQUIREMENTS.md)
14+- [BAA_ARTIFACT_CENTER_REQUIREMENTS.md](/Users/george/code/baa-conductor/plans/archive/BAA_ARTIFACT_CENTER_REQUIREMENTS.md)
15+- [BAA_DELIVERY_BRIDGE_REQUIREMENTS.md](/Users/george/code/baa-conductor/plans/archive/BAA_DELIVERY_BRIDGE_REQUIREMENTS.md)
16 - [09-artifact-delivery-thin-plugin.md](/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/docs/09-artifact-delivery-thin-plugin.md)
17 - [firefox-local-ws.md](/Users/george/code/baa-conductor/docs/api/firefox-local-ws.md)
18 
19 ## 背景
20 
21-`T-S034` 的首版 delivery 目前通过本地 `download_url` 下发 artifact payload,但仍有明显边界:
22+这份需求已不再作为当前主线。
23 
24-- payload 当前以 base64 JSON 形式提供
25-- 更适合 text/json 类产物
26-- 大二进制和真正的 download 闭环还没做
27+`2026-03-28` 主线已明确转向:
28 
29-如果继续沿用这条首版路径,artifact delivery 会被 payload 体积、内容类型和平台文件上传能力一起卡住。
30+- 不再支持 artifact upload / download
31+- 不再支持 binary-safe delivery 合同
32+- 不再下发 result index / 摘要作为默认交付面
33+- 改成 text-only `inject / send`
34+- 超长文本默认按前 `200` 行截断,并在末尾追加 `超长截断`
35 
36 ## 核心结论
37 
38-- server 侧需要把 artifact payload 合同从“便于 text/json 注入”推进到“binary-safe download”
39-- 插件侧需要把 artifact fetch / upload 适配到 `Blob` / `File` 这类更通用的交付形式
40-- 兼容性上应保留现有 text/json 产物,不做破坏性切换
41-- 当前仍不扩到多节点 artifact store 或多客户端协同
42+- 旧的 binary delivery 方案已放弃
43+- 当前 delivery 的正式方向是纯文本直注入
44+- 如果未来重新评估附件交付,需要另起新需求,不复用这份文档
45 
46 ## 范围
47 
48-- 本地 artifact download 合同升级
49-- content-type / size / filename / hash 等基础元数据
50-- binary-safe artifact fetch / upload
51-- text/json 向后兼容
52-- 自动化测试与文档回写
53+- 当前仅保留废弃说明,方便回溯历史决策
54 
55-## 当前明确不要求
56+## 废弃说明
57 
58-- 不要求这张卡里扩到跨节点 artifact 分发
59-- 不要求这张卡里扩到多客户端、多轮 delivery
60-- 不要求这张卡里扩到 `Gemini`
61-- 不要求这张卡里重做 task/run 编排
62+- `apps/conductor-daemon/src/artifacts/manifest.ts`
63+- `apps/conductor-daemon/src/artifacts/delivery-plan.ts`
64+- `apps/conductor-daemon/src/artifacts/materialize.ts`
65+- `GET /v1/browser/delivery/artifacts/:plan_id/:artifact_id`
66+- `browser.upload_artifacts`
67+- `browser.upload_receipt`
68 
69-## 验收条件
70-
71-- artifact payload 不再默认退化成 base64 JSON 唯一路径
72-- text/json 现有交付不回退
73-- 至少一种 binary-safe upload 路径能通过自动化验证
74-- 无法上传的场景会明确 fail-closed,不会静默成功
75-- 文档已同步到 `plans/`、`tasks/`、`docs/firefox/` 和必要的 `docs/api/`
76-
77-## 当前预期残余边界
78-
79-- 首版仍是单节点、本地 artifact store
80-- 首版仍停留在单客户端、单轮 delivery
81-- 平台页面上传能力仍取决于具体 adapter,不代表所有页面都天然支持大文件
82+以上路径已从当前代码主线移除。
M plans/BAA_PLUGIN_DELIVERY_HARDENING_REQUIREMENTS.md
+20, -12
 1@@ -2,29 +2,26 @@
 2 
 3 ## 状态
 4 
 5-- `待开发`
 6+- `已完成(T-S035,2026-03-28)`
 7 - 优先级:`high`
 8 - 记录时间:`2026-03-27`
 9 
10 ## 关联文档
11 
12-- [BAA_DELIVERY_BRIDGE_REQUIREMENTS.md](/Users/george/code/baa-conductor/plans/BAA_DELIVERY_BRIDGE_REQUIREMENTS.md)
13-- [BAA_ARTIFACT_CENTER_REQUIREMENTS.md](/Users/george/code/baa-conductor/plans/BAA_ARTIFACT_CENTER_REQUIREMENTS.md)
14-- [09-artifact-delivery-thin-plugin.md](/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/docs/09-artifact-delivery-thin-plugin.md)
15+- [BAA_DELIVERY_BRIDGE_REQUIREMENTS.md](/Users/george/code/baa-conductor/plans/archive/BAA_DELIVERY_BRIDGE_REQUIREMENTS.md)
16+- [BAA_ARTIFACT_CENTER_REQUIREMENTS.md](/Users/george/code/baa-conductor/plans/archive/BAA_ARTIFACT_CENTER_REQUIREMENTS.md)
17 - [README.md](/Users/george/code/baa-conductor/docs/firefox/README.md)
18 
19 ## 背景
20 
21-`T-S034` 已经把 live 执行结果接到:
22+当前 live delivery 已经收口到:
23 
24-- `browser.upload_artifacts`
25-- `browser.upload_receipt`
26 - `browser.inject_message`
27 - `browser.send_message`
28 
29 但当前插件侧交付仍保留 3 个直接风险:
30 
31-- upload / inject / send 仍主要依赖 DOM heuristic
32+- inject / send 仍主要依赖 DOM heuristic
33 - 首版只对 `Claude` / `ChatGPT` 做了选择器与流程
34 - 失败恢复、页面 readiness 和平台差异当前还不够收口
35 
36@@ -46,20 +43,20 @@
37 ## 范围
38 
39 - `Claude` / `ChatGPT` 平台 delivery adapter 收口
40-- upload / inject / send readiness 探测与超时
41+- text-only `inject / send` readiness 探测与超时
42 - 结构化失败原因、平台状态和调试字段
43 - browser smoke 与文档回写
44 
45 ## 当前明确不要求
46 
47 - 不要求在本需求里扩到 `Gemini`
48-- 不要求在本需求里实现 binary download / artifact download 闭环
49+- 不要求在本需求里恢复 upload / download / binary delivery
50 - 不要求在本需求里扩到多客户端、多轮 delivery
51 - 不要求在本需求里改 task/run 编排或跨节点分发
52 
53 ## 验收条件
54 
55-- `Claude` / `ChatGPT` 的 upload / inject / send 逻辑有明确 adapter 边界,不再散落为隐式 heuristic
56+- `Claude` / `ChatGPT` 的 text-only `inject / send` 逻辑有明确 adapter 边界,不再散落为隐式 heuristic
57 - 页面未 ready、选择器失效、发送失败时会返回明确错误,不会静默成功
58 - browser smoke 能覆盖至少一条成功路径和一条 fail-closed 路径
59 - 文档已同步到 `plans/`、`tasks/`、`docs/firefox/` 和必要的 `docs/api/`
60@@ -68,5 +65,16 @@
61 
62 - 插件侧最终仍会依赖 DOM / 页面结构,只是把风险收口成可维护的 adapter 层
63 - 首版仍只覆盖 `Claude` / `ChatGPT`
64-- 后续 binary artifact / download 闭环留给下一张卡
65 - 当前仍只服务于单客户端、单轮 delivery
66+
67+## 完成回写(2026-03-27)
68+
69+- 已完成:
70+  - Firefox 插件 content script 已新增独立 `delivery-adapters.js`,把 `Claude` / `ChatGPT` 的 text-only `inject / send` 统一收口到 adapter 边界
71+  - adapter 已补 page readiness、selector 解析、发送确认和有限重试;失败时返回稳定 `delivery.<code>` 错误并 fail-closed
72+  - 插件侧不再接受 `upload_artifact` 命令
73+  - browser smoke 已覆盖 adapter 成功路径、page-not-ready 和 send-not-confirmed 失败路径
74+- 当前残余边界:
75+  - 仍然依赖 DOM / 页面结构,只是风险已集中在 adapter 模块
76+  - 仍未扩到 `Gemini`
77+  - 当前仍只服务于单客户端、单轮 delivery
M plans/STATUS_SUMMARY.md
+21, -33
  1@@ -9,30 +9,24 @@
  2 - 浏览器控制主链路收口基线:`main@07895cd`
  3 - 最近功能代码提交:`main@25be868`(启动时自动恢复受管 Firefox shell tabs)
  4 - `2026-03-27` 当前本地代码已额外完成 `BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 修复,以及 `T-S029`、`T-S030`、`T-S031`、`T-S032`、`T-S033`、`T-S034` 落地
  5-- 任务文档已统一收口到 `tasks/`
  6+- 活跃任务文档保留在 `tasks/` 根目录,已完成任务文档已归档到 [`../tasks/archive/README.md`](../tasks/archive/README.md)
  7 - 当前活动任务见 `tasks/TASK_OVERVIEW.md`
  8-- `T-S001` 到 `T-S034` 已经完成,`T-BUG-011`、`T-BUG-012`、`T-BUG-014` 也已完成
  9+- 已完成需求文档已归档到 [`./archive/README.md`](./archive/README.md)
 10 
 11 ## 当前状态分类
 12 
 13-- `已完成`:`T-S001` 到 `T-S034`,以及 `T-BUG-011`、`T-BUG-012`、`T-BUG-014`
 14+- `已完成`:见 [`../tasks/archive/README.md`](../tasks/archive/README.md) 和 [`./archive/README.md`](./archive/README.md)
 15 - `当前 TODO`:
 16-  - `T-S026` 真实 Firefox 手工 smoke 与验收记录
 17-- `待处理缺陷`:当前无 open bug backlog(见 `bugs/README.md`)
 18+  - 下一张主线任务卡待新建
 19+- `待处理缺陷`:见 [`../bugs/README.md`](../bugs/README.md)
 20 - `低优先级 TODO`:`4318/status-api` 兼容层删旧与解耦
 21 
 22-当前新的主需求文档:
 23+当前活跃需求文档:
 24 
 25 - [`./BAA_PLUGIN_DELIVERY_HARDENING_REQUIREMENTS.md`](./BAA_PLUGIN_DELIVERY_HARDENING_REQUIREMENTS.md)
 26 - [`./BAA_ARTIFACT_DOWNLOAD_REQUIREMENTS.md`](./BAA_ARTIFACT_DOWNLOAD_REQUIREMENTS.md)
 27-- [`./BAA_EXECUTION_PERSISTENCE_REQUIREMENTS.md`](./BAA_EXECUTION_PERSISTENCE_REQUIREMENTS.md)
 28-- [`./BAA_DELIVERY_BRIDGE_REQUIREMENTS.md`](./BAA_DELIVERY_BRIDGE_REQUIREMENTS.md)
 29-- [`./BAA_FINAL_MESSAGE_INGEST_REQUIREMENTS.md`](./BAA_FINAL_MESSAGE_INGEST_REQUIREMENTS.md)
 30-- [`./BAA_ARTIFACT_CENTER_REQUIREMENTS.md`](./BAA_ARTIFACT_CENTER_REQUIREMENTS.md)
 31-- [`./BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md`](./BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md)
 32-- [`./BAA_INSTRUCTION_CENTER_REQUIREMENTS.md`](./BAA_INSTRUCTION_CENTER_REQUIREMENTS.md)
 33-- [`./BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md`](./BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md)
 34-- [`./FIREFOX_BRIDGE_CONTROL_REQUIREMENTS.md`](./FIREFOX_BRIDGE_CONTROL_REQUIREMENTS.md)
 35+- [`./BAA_INSTRUCTION_SYSTEM.md`](./BAA_INSTRUCTION_SYSTEM.md)
 36+- 已完成需求归档见 [`./archive/README.md`](./archive/README.md)
 37 
 38 ## 当前状态
 39 
 40@@ -84,16 +78,16 @@
 41 10. `2026-03-27` 跟进任务:`T-S027` 已完成,browser request policy 已补 stale `inFlight` 自愈清扫、lease 活跃时间追踪和 `GET /v1/browser` 读面诊断字段
 42 11. `2026-03-27` 跟进任务:`T-S028` 已完成,`platform=chatgpt` 的 `/v1/browser/request` 现已正式支持 path-based buffered / SSE / cancel,并已补到 automated smoke 与文档
 43 12. `2026-03-27` 跟进任务:`T-S031` 已完成,`browser.final_message` 已接入 live instruction ingest,并把最近一次 ingest / execute 摘要暴露到 Firefox WS 与 `/v1/browser`
 44-13. `2026-03-27` 跟进任务:`T-S032` 已完成,`conductor-daemon` 已补 service-side artifact center core:artifact materialize、manifest / index text 和 delivery plan
 45+13. `2026-03-27` 跟进任务:`T-S032` 已完成,`conductor-daemon` 已补 service-side artifact center 首版实现
 46 14. `2026-03-27` 跟进任务:`T-S033` 已完成,live message dedupe、instruction dedupe 和 bounded execution journal 已落到本地 control-plane sqlite,并在重启后恢复
 47-15. `2026-03-27` 跟进任务:`T-S034` 已完成,live 执行结果已接到 artifact upload、receipt barrier、inject / send 主链,并补了 fail-closed 自动化 smoke
 48+15. `2026-03-27` 跟进任务:`T-S034` 已完成,live 执行结果已接到首版 delivery 主链,并补了 fail-closed 自动化 smoke
 49+16. `2026-03-27` 跟进任务:`T-S026` 已完成真实 Firefox 手工 smoke,覆盖 `plugin_status`、`tab_open`、`tab_restore`、`ws_reconnect` 和“扩展重载后自动恢复”验收;额外完成了 `disconnect_ms=3000`、`repeat_count=3`、`repeat_interval_ms=500` 的多轮 WS 重连稳定性验证
 50+17. `2026-03-28` delivery 主链已转为 text-only:artifact materialize / manifest / upload/download 路径已移除,`browser.final_message` 的执行结果会直接渲染成纯文本,通过 `browser.inject_message` / `browser.send_message` 交付;超长默认按前 `200` 行截断并追加 `超长截断`
 51 
 52 当前策略:
 53 
 54-- 当前最高优先级剩余任务按顺序是:
 55-  - `T-S026`:真实 Firefox 手工 smoke 与验收记录
 56-  - `T-S035`:加固插件侧 delivery adapter 与页面交付流程
 57-  - `T-S036`:补 artifact download 合同与 binary delivery 能力
 58+- 当前最高优先级剩余任务:
 59+  - 新任务待建立;上一轮 delivery 相关代码已完成并已归档
 60 - 当前 bug backlog 单独留在 `bugs/` 目录,不把它和主线需求任务混写
 61 - 当前不把大文件拆分当作主线 blocker
 62 - 以下重构工作顺延到下一轮专门重构任务:
 63@@ -105,8 +99,8 @@
 64 
 65 ## 当前缺陷 backlog
 66 
 67-- 当前 open bug backlog:无
 68-- 当前没有 bug fix 正在主线开发中;当前下一波主线任务顺序是 `T-S026`
 69+- 当前 open bug backlog:见 [`../bugs/README.md`](../bugs/README.md)
 70+- 当前没有 bug fix 正在主线开发中;下一波主线任务待新建
 71 
 72 ## 低优先级 TODO
 73 
 74@@ -142,7 +136,7 @@
 75 - `T-S022`:Firefox 插件侧已完成空壳页 runtime、`desired/actual` 状态模型和插件管理类 payload 准备
 76 - `T-S023`:通用 browser request / cancel / SSE 链路、`stream_*` 事件模型和首版浏览器风控策略已经接入 `conductor` 与 Firefox bridge
 77 - `T-S024`:README、API / Firefox / runtime 文档、browser smoke 和任务状态视图已同步到正式主线口径
 78-- `T-S025`:`shell_runtime`、结构化 `action_result` 和控制读面已接入主线;唯一残余风险是当前机器缺少 `Firefox.app`,因此未完成真实 Firefox 手工 smoke
 79+- `T-S025`:`shell_runtime`、结构化 `action_result` 和控制读面已接入主线;后续遗留的真实 Firefox 验收已由 `T-S026` 在 `2026-03-27` 收口
 80 - `2026-03-27` 跟进修复:Firefox 插件现在会在管理页启动、浏览器重开或扩展重载后自动执行一次后台 `tab_restore`,并会把用户手工打开的 Claude `new`、ChatGPT 根页和 Gemini `app` 收进受管理 shell 集合
 81 - 根级 `pnpm smoke` 已进主线,覆盖 runtime public-api compatibility、legacy absence、codexd e2e 和 browser-control e2e smoke
 82 
 83@@ -175,18 +169,12 @@
 84 - `status-api` 的终局已经先收口到“保留为 opt-in 兼容层”;真正删除它之前,还要先清 `4318` 调用方并拆掉当前构建时复用
 85 - 风控状态当前仍是进程内内存态;`conductor` 重启后,限流、退避和熔断计数会重置
 86 - 正式 browser HTTP relay 现已正式验收 Claude 与 ChatGPT;Gemini 当前新增的是最终消息 raw relay,不是 `/v1/browser/request` 正式支持面
 87-- 当前 open bug backlog 已清空
 88-- 当前主线下一波任务是:
 89-  - `T-S026`:真实 Firefox 手工 smoke 与验收记录
 90-- 当前 BAA 下一波主线任务是:
 91-  - `T-S035`:加固插件侧 delivery adapter 与页面交付流程
 92-  - `T-S036`:补 artifact download 合同与 binary delivery 能力
 93-- `T-S029`、`T-S030`、`T-S031`、`T-S032`、`T-S033`、`T-S034` 已完成,当前 BAA 已具备 ChatGPT / Gemini 最终消息 raw relay、`browser.final_message` 最近快照保留、conductor 侧 instruction center Phase 1 最小闭环与 live ingest、dedupe / journal 本地持久化,以及 artifact / manifest / delivery plan / upload receipt barrier / inject / send 主链
 94+- 当前 open bug backlog:见 [`../bugs/README.md`](../bugs/README.md)
 95+- `T-S029`、`T-S030`、`T-S031`、`T-S032`、`T-S033`、`T-S034` 已完成,且 `2026-03-28` 已把 delivery 主链继续收口到 text-only `inject / send`
 96 - 当前 BAA 仍保留这些边界:
 97   - ChatGPT 当前主要依赖 conversation SSE 结构;如果页面 payload 形态变化,需要同步修改提取器
 98   - Gemini 最终文本提取当前基于 `StreamGenerate` / `batchexecute` 风格 payload 的启发式解析,稳定性弱于 ChatGPT,因此保留 synthetic `assistant_message_id` 兜底
 99-  - 插件侧 upload / inject / send 仍是 DOM heuristic,当前只对 `Claude` / `ChatGPT` 做了首版选择器与流程
100-  - artifact payload 当前通过本地 `download_url` 以 base64 JSON 形式提供,适合当前 text/json 类产物;大二进制和 download 闭环还没做
101+  - 插件侧 `inject / send` 仍是 DOM heuristic,当前只对 `Claude` / `ChatGPT` 做了首版选择器与流程
102   - 当前交付仍按任务边界停留在单客户端、单轮 delivery 首版
103   - live message dedupe 和 instruction dedupe 当前已做成单节点本地持久化,但不做跨节点共享
104   - execution journal 当前只保留最近窗口,不扩成无限历史审计
105@@ -194,5 +182,5 @@
106 - `BUG-012` 这轮修复已补上 stale `inFlight` 自愈清扫;当前残余边界是“健康但长时间完全静默”的超长 buffered 请求,理论上仍可能被 `5min` idle 阈值误判
107 - ChatGPT 当前仍依赖浏览器里真实捕获到的有效登录态 / header,且没有 Claude 风格 prompt shortcut;这是当前正式支持面的已知边界
108 - `BUG-014` 的自动化验证目前覆盖的是 conductor 侧语义透传,不是 Firefox 扩展真实运行环境里的 reconnect 生命周期;真实“重连完成”仍依赖后续 `hello` / 状态同步
109-- 当前机器未发现 `Firefox.app`,因此尚未执行“手动关 tab -> tab_restore -> WS 重连后状态回报恢复”的真实 Firefox 手工 smoke;这是当前最高优先级环境型残余风险
110+- 真实 Firefox 手工 smoke 已在 `2026-03-27` 完成;浏览器管理面当前不再把“真机 tab / reconnect 生命周期未验收”列为残余风险
111 - 当前多个源码文件已超过常规体量,但拆分重构已明确延后到浏览器桥接主线收口之后再做
R plans/BAA_ARTIFACT_CENTER_REQUIREMENTS.md => plans/archive/BAA_ARTIFACT_CENTER_REQUIREMENTS.md
+1, -1
1@@ -9,7 +9,7 @@
2 ## 关联文档
3 
4 - [BAA_INSTRUCTION_SYSTEM.md](/Users/george/code/baa-conductor/plans/BAA_INSTRUCTION_SYSTEM.md)
5-- [BAA_INSTRUCTION_CENTER_REQUIREMENTS.md](/Users/george/code/baa-conductor/plans/BAA_INSTRUCTION_CENTER_REQUIREMENTS.md)
6+- [BAA_INSTRUCTION_CENTER_REQUIREMENTS.md](/Users/george/code/baa-conductor/plans/archive/BAA_INSTRUCTION_CENTER_REQUIREMENTS.md)
7 - [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)
8 - [07-rollout-plan.md](/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/docs/07-rollout-plan.md)
9 
R plans/BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md => plans/archive/BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md
+0, -0
R plans/BAA_DELIVERY_BRIDGE_REQUIREMENTS.md => plans/archive/BAA_DELIVERY_BRIDGE_REQUIREMENTS.md
+1, -1
1@@ -8,7 +8,7 @@
2 
3 ## 关联文档
4 
5-- [BAA_ARTIFACT_CENTER_REQUIREMENTS.md](/Users/george/code/baa-conductor/plans/BAA_ARTIFACT_CENTER_REQUIREMENTS.md)
6+- [BAA_ARTIFACT_CENTER_REQUIREMENTS.md](/Users/george/code/baa-conductor/plans/archive/BAA_ARTIFACT_CENTER_REQUIREMENTS.md)
7 - [BAA_INSTRUCTION_SYSTEM.md](/Users/george/code/baa-conductor/plans/BAA_INSTRUCTION_SYSTEM.md)
8 - [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)
9 - [09-artifact-delivery-thin-plugin.md](/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/docs/09-artifact-delivery-thin-plugin.md)
R plans/BAA_EXECUTION_PERSISTENCE_REQUIREMENTS.md => plans/archive/BAA_EXECUTION_PERSISTENCE_REQUIREMENTS.md
+2, -2
 1@@ -8,8 +8,8 @@
 2 
 3 ## 关联文档
 4 
 5-- [BAA_FINAL_MESSAGE_INGEST_REQUIREMENTS.md](/Users/george/code/baa-conductor/plans/BAA_FINAL_MESSAGE_INGEST_REQUIREMENTS.md)
 6-- [BAA_INSTRUCTION_CENTER_REQUIREMENTS.md](/Users/george/code/baa-conductor/plans/BAA_INSTRUCTION_CENTER_REQUIREMENTS.md)
 7+- [BAA_FINAL_MESSAGE_INGEST_REQUIREMENTS.md](/Users/george/code/baa-conductor/plans/archive/BAA_FINAL_MESSAGE_INGEST_REQUIREMENTS.md)
 8+- [BAA_INSTRUCTION_CENTER_REQUIREMENTS.md](/Users/george/code/baa-conductor/plans/archive/BAA_INSTRUCTION_CENTER_REQUIREMENTS.md)
 9 - [BAA_INSTRUCTION_SYSTEM.md](/Users/george/code/baa-conductor/plans/BAA_INSTRUCTION_SYSTEM.md)
10 - [04-execution-loop-state-machine.md](/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/docs/04-execution-loop-state-machine.md)
11 
R plans/BAA_FINAL_MESSAGE_INGEST_REQUIREMENTS.md => plans/archive/BAA_FINAL_MESSAGE_INGEST_REQUIREMENTS.md
+2, -2
 1@@ -9,8 +9,8 @@
 2 ## 关联文档
 3 
 4 - [BAA_INSTRUCTION_SYSTEM.md](/Users/george/code/baa-conductor/plans/BAA_INSTRUCTION_SYSTEM.md)
 5-- [BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md](/Users/george/code/baa-conductor/plans/BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md)
 6-- [BAA_INSTRUCTION_CENTER_REQUIREMENTS.md](/Users/george/code/baa-conductor/plans/BAA_INSTRUCTION_CENTER_REQUIREMENTS.md)
 7+- [BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md](/Users/george/code/baa-conductor/plans/archive/BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md)
 8+- [BAA_INSTRUCTION_CENTER_REQUIREMENTS.md](/Users/george/code/baa-conductor/plans/archive/BAA_INSTRUCTION_CENTER_REQUIREMENTS.md)
 9 - [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)
10 - [07-rollout-plan.md](/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/docs/07-rollout-plan.md)
11 
R plans/BAA_INSTRUCTION_CENTER_REQUIREMENTS.md => plans/archive/BAA_INSTRUCTION_CENTER_REQUIREMENTS.md
+1, -1
1@@ -9,7 +9,7 @@
2 ## 关联文档
3 
4 - [BAA_INSTRUCTION_SYSTEM.md](/Users/george/code/baa-conductor/plans/BAA_INSTRUCTION_SYSTEM.md)
5-- [BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md](/Users/george/code/baa-conductor/plans/BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md)
6+- [BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md](/Users/george/code/baa-conductor/plans/archive/BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md)
7 - [README.md](/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/README.md)
8 - [02-protocol-spec.md](/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/docs/02-protocol-spec.md)
9 - [04-execution-loop-state-machine.md](/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/docs/04-execution-loop-state-machine.md)
R plans/BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md => plans/archive/BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md
+1, -1
1@@ -8,7 +8,7 @@
2 
3 ## 关联文档
4 
5-- [FIREFOX_BRIDGE_CONTROL_REQUIREMENTS.md](/Users/george/code/baa-conductor/plans/FIREFOX_BRIDGE_CONTROL_REQUIREMENTS.md)
6+- [FIREFOX_BRIDGE_CONTROL_REQUIREMENTS.md](/Users/george/code/baa-conductor/plans/archive/FIREFOX_BRIDGE_CONTROL_REQUIREMENTS.md)
7 - [DISCUSS-FIREFOX-BRIDGE-CONTROL.md](/Users/george/code/baa-conductor/plans/discuss/DISCUSS-FIREFOX-BRIDGE-CONTROL.md)
8 
9 ## 背景
R plans/FIREFOX_BRIDGE_CONTROL_REQUIREMENTS.md => plans/archive/FIREFOX_BRIDGE_CONTROL_REQUIREMENTS.md
+1, -1
1@@ -8,7 +8,7 @@
2 
3 ## 关联文档
4 
5-- [BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md](/Users/george/code/baa-conductor/plans/BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md)
6+- [BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md](/Users/george/code/baa-conductor/plans/archive/BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md)
7 - [DISCUSS-FIREFOX-BRIDGE-CONTROL.md](/Users/george/code/baa-conductor/plans/discuss/DISCUSS-FIREFOX-BRIDGE-CONTROL.md)
8 
9 ## 背景
A plans/archive/README.md
+24, -0
 1@@ -0,0 +1,24 @@
 2+# 需求归档
 3+
 4+本目录只保留 `已完成` 的需求文档。根目录 `plans/` 现在只保留当前活跃需求、总状态摘要和讨论文档。
 5+
 6+- 归档时间:`2026-03-27`
 7+- 归档状态:本目录中的需求文档均视为 `已完成`
 8+
 9+## 已归档需求
10+
11+- [`BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md`](./BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md):已完成,主线对应 `T-S017` 到 `T-S024`
12+- [`FIREFOX_BRIDGE_CONTROL_REQUIREMENTS.md`](./FIREFOX_BRIDGE_CONTROL_REQUIREMENTS.md):已完成,主线对应 `T-S021` 到 `T-S024`
13+- [`BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md`](./BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md):已完成,对应 `T-S029`
14+- [`BAA_INSTRUCTION_CENTER_REQUIREMENTS.md`](./BAA_INSTRUCTION_CENTER_REQUIREMENTS.md):已完成,对应 `T-S030`
15+- [`BAA_FINAL_MESSAGE_INGEST_REQUIREMENTS.md`](./BAA_FINAL_MESSAGE_INGEST_REQUIREMENTS.md):已完成,对应 `T-S031`
16+- [`BAA_ARTIFACT_CENTER_REQUIREMENTS.md`](./BAA_ARTIFACT_CENTER_REQUIREMENTS.md):已完成,对应 `T-S032`
17+- [`BAA_EXECUTION_PERSISTENCE_REQUIREMENTS.md`](./BAA_EXECUTION_PERSISTENCE_REQUIREMENTS.md):已完成,对应 `T-S033`
18+- [`BAA_DELIVERY_BRIDGE_REQUIREMENTS.md`](./BAA_DELIVERY_BRIDGE_REQUIREMENTS.md):已完成,对应 `T-S034`
19+
20+## 当前仍在根目录的需求文档
21+
22+- [`../BAA_PLUGIN_DELIVERY_HARDENING_REQUIREMENTS.md`](../BAA_PLUGIN_DELIVERY_HARDENING_REQUIREMENTS.md)
23+- [`../BAA_ARTIFACT_DOWNLOAD_REQUIREMENTS.md`](../BAA_ARTIFACT_DOWNLOAD_REQUIREMENTS.md)
24+- [`../BAA_INSTRUCTION_SYSTEM.md`](../BAA_INSTRUCTION_SYSTEM.md)
25+- [`../STATUS_SUMMARY.md`](../STATUS_SUMMARY.md)
M plugins/baa-firefox/README.md
+25, -1
 1@@ -7,7 +7,13 @@ Firefox 插件的正式能力已经收口到四件事:
 2 - 在浏览器本地持有原始凭证并代发 API 请求
 3 - 在 ChatGPT / Gemini 上观察最终 assistant message,并通过 `browser.final_message` 做 raw relay
 4 
 5-页面对话 UI、会话回读和 DOM 自动化不再是正式能力。`browser.final_message` 只负责最终文本转发,不负责 BAA parser。
 6+另有一条受限的 thin-plugin delivery 附属能力:
 7+
 8+- 仅对 `Claude` / `ChatGPT` 提供 DOM adapter 方式的 text-only `inject_message` / `send_message`
 9+- adapter 会把平台选择器、page readiness、重试和 fail-closed 错误收口到统一模块
10+- 这不是通用浏览器自动化框架,也不扩到 `Gemini`
11+
12+页面对话 UI、会话回读和 DOM 自动化仍然不是正式主能力。`browser.final_message` 只负责最终文本转发,不负责 BAA parser;delivery adapter 只服务 text-only delivery 的最小执行面。
13 
14 ## 当前默认连接
15 
16@@ -92,6 +98,24 @@ browser.runtime.sendMessage({
17 });
18 ```
19 
20+`ws_reconnect` 支持额外测试参数:
21+
22+- `disconnectMs` / `disconnect_ms` / `delayMs` / `delay_ms`
23+- `repeatCount` / `repeat_count`
24+- `repeatIntervalMs` / `repeat_interval_ms` / `intervalMs` / `interval_ms`
25+
26+例如:
27+
28+```js
29+browser.runtime.sendMessage({
30+  type: "plugin_runtime_action",
31+  action: "ws_reconnect",
32+  disconnectMs: 3000,
33+  repeatCount: 3,
34+  repeatIntervalMs: 500
35+});
36+```
37+
38 ## 上报的元数据
39 
40 插件会通过本地 WS 发送:
M plugins/baa-firefox/content-script.js
+12, -354
  1@@ -5,52 +5,9 @@ function sendBridgeMessage(type, data) {
  2   }).catch(() => {});
  3 }
  4 
  5-const DELIVERY_POLL_INTERVAL_MS = 200;
  6-const DELIVERY_TIMEOUT_MS = 30_000;
  7-const ATTACHMENT_PROGRESS_SELECTORS = [
  8-  "[role='progressbar']",
  9-  "[aria-busy='true']",
 10-  "progress",
 11-  "[class*='upload'][class*='progress']",
 12-  "[class*='spinner']"
 13-];
 14-const PLATFORM_DELIVERY_CONFIG = {
 15-  claude: {
 16-    composerSelectors: [
 17-      "div[contenteditable='true'][data-testid*='composer']",
 18-      "div[contenteditable='true'][aria-label*='message' i]",
 19-      "div[contenteditable='true'][role='textbox']",
 20-      "div.ProseMirror[contenteditable='true']",
 21-      "textarea"
 22-    ],
 23-    fileInputSelectors: [
 24-      "input[type='file']:not([disabled])"
 25-    ],
 26-    sendButtonSelectors: [
 27-      "button[data-testid*='send' i]",
 28-      "button[aria-label*='send' i]",
 29-      "form button[type='submit']"
 30-    ]
 31-  },
 32-  chatgpt: {
 33-    composerSelectors: [
 34-      "#prompt-textarea",
 35-      "textarea[data-testid='prompt-textarea']",
 36-      "div[contenteditable='true'][data-testid='prompt-textarea']",
 37-      "div[contenteditable='true'][role='textbox']",
 38-      "textarea"
 39-    ],
 40-    fileInputSelectors: [
 41-      "input[type='file']:not([disabled])"
 42-    ],
 43-    sendButtonSelectors: [
 44-      "button[data-testid='send-button']",
 45-      "button[aria-label*='send prompt' i]",
 46-      "button[aria-label*='send message' i]",
 47-      "form button[type='submit']"
 48-    ]
 49-  }
 50-};
 51+const deliveryRuntime = typeof globalThis.BAADeliveryAdapters?.createDeliveryRuntime === "function"
 52+  ? globalThis.BAADeliveryAdapters.createDeliveryRuntime()
 53+  : null;
 54 
 55 function trimToNull(value) {
 56   if (typeof value !== "string") {
 57@@ -61,320 +18,21 @@ function trimToNull(value) {
 58   return normalized === "" ? null : normalized;
 59 }
 60 
 61-function sleep(ms) {
 62-  return new Promise((resolve) => setTimeout(resolve, Math.max(0, Number(ms) || 0)));
 63-}
 64-
 65-function getDeliveryConfig(platform) {
 66-  return platform ? PLATFORM_DELIVERY_CONFIG[platform] || null : null;
 67-}
 68-
 69-function isElementVisible(element, options = {}) {
 70-  if (!(element instanceof Element)) {
 71-    return false;
 72-  }
 73-
 74-  if (options.allowHidden === true) {
 75-    return true;
 76-  }
 77-
 78-  const style = globalThis.getComputedStyle(element);
 79-  if (!style) {
 80-    return true;
 81-  }
 82-
 83-  if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity || "1") === 0) {
 84-    return false;
 85-  }
 86-
 87-  const rect = element.getBoundingClientRect();
 88-  return rect.width > 0 && rect.height > 0;
 89-}
 90-
 91-function queryFirst(selectors, options = {}) {
 92-  for (const selector of selectors) {
 93-    const matches = document.querySelectorAll(selector);
 94-
 95-    for (const element of matches) {
 96-      if (isElementVisible(element, options)) {
 97-        return element;
 98-      }
 99-    }
100-  }
101-
102-  return null;
103-}
104-
105-function normalizeText(text) {
106-  return String(text || "")
107-    .replace(/\s+/gu, " ")
108-    .trim()
109-    .toLowerCase();
110-}
111-
112-function elementDisabled(element) {
113-  return element instanceof HTMLButtonElement || element instanceof HTMLInputElement
114-    ? element.disabled
115-    : element.getAttribute("aria-disabled") === "true";
116-}
117-
118-function countAttachmentProgressIndicators() {
119-  return ATTACHMENT_PROGRESS_SELECTORS.reduce(
120-    (count, selector) => count + document.querySelectorAll(selector).length,
121-    0
122-  );
123-}
124-
125-function readComposerText(element) {
126-  if (element instanceof HTMLTextAreaElement || element instanceof HTMLInputElement) {
127-    return element.value || "";
128-  }
129-
130-  return element.textContent || "";
131-}
132-
133-function dispatchInputEvents(element) {
134-  element.dispatchEvent(new InputEvent("input", {
135-    bubbles: true,
136-    cancelable: true,
137-    data: readComposerText(element),
138-    inputType: "insertText"
139-  }));
140-  element.dispatchEvent(new Event("change", {
141-    bubbles: true
142-  }));
143-}
144-
145-function setNativeValue(element, value) {
146-  if (element instanceof HTMLTextAreaElement) {
147-    const descriptor = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value");
148-    descriptor?.set?.call(element, value);
149-    return;
150-  }
151-
152-  if (element instanceof HTMLInputElement) {
153-    const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value");
154-    descriptor?.set?.call(element, value);
155-  }
156-}
157-
158-function setComposerText(element, text) {
159-  if (element instanceof HTMLTextAreaElement || element instanceof HTMLInputElement) {
160-    element.focus();
161-    setNativeValue(element, text);
162-    dispatchInputEvents(element);
163-    return;
164-  }
165-
166-  if (!(element instanceof HTMLElement) || element.isContentEditable !== true) {
167-    throw new Error("page composer is not editable");
168-  }
169-
170-  element.focus();
171-
172-  if (typeof document.execCommand === "function") {
173-    try {
174-      document.execCommand("selectAll", false);
175-      document.execCommand("insertText", false, text);
176-      dispatchInputEvents(element);
177-      return;
178-    } catch (_) {}
179-  }
180-
181-  element.textContent = text;
182-  dispatchInputEvents(element);
183-}
184-
185-function setInputFiles(input, files) {
186-  const transfer = new DataTransfer();
187-
188-  for (const file of files) {
189-    transfer.items.add(file);
190-  }
191-
192-  const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "files");
193-  descriptor?.set?.call(input, transfer.files);
194-  input.dispatchEvent(new Event("input", {
195-    bubbles: true
196-  }));
197-  input.dispatchEvent(new Event("change", {
198-    bubbles: true
199-  }));
200-}
201-
202-async function waitFor(predicate, timeoutMs = DELIVERY_TIMEOUT_MS, intervalMs = DELIVERY_POLL_INTERVAL_MS) {
203-  const startedAt = Date.now();
204-  let lastError = null;
205-
206-  while (Date.now() - startedAt <= timeoutMs) {
207-    try {
208-      const result = await predicate();
209-
210-      if (result) {
211-        return result;
212-      }
213-    } catch (error) {
214-      lastError = error;
215-    }
216-
217-    await sleep(intervalMs);
218-  }
219-
220-  throw lastError || new Error("delivery command timed out");
221-}
222-
223-function findComposer(platform) {
224-  const config = getDeliveryConfig(platform);
225-  return config ? queryFirst(config.composerSelectors) : null;
226-}
227-
228-function findFileInput(platform) {
229-  const config = getDeliveryConfig(platform);
230-  return config ? queryFirst(config.fileInputSelectors, {
231-    allowHidden: true
232-  }) : null;
233-}
234-
235-function findSendButton(platform) {
236-  const config = getDeliveryConfig(platform);
237-
238-  if (!config) {
239-    return null;
240-  }
241-
242-  const candidate = queryFirst(config.sendButtonSelectors);
243-
244-  if (candidate && !elementDisabled(candidate)) {
245-    return candidate;
246-  }
247-
248-  return null;
249-}
250-
251-function attachmentLooksReady(filename, input, baselineProgress) {
252-  const normalizedFilename = normalizeText(filename);
253-  const pageText = normalizeText(document.body?.innerText || "");
254-
255-  if (normalizedFilename && pageText.includes(normalizedFilename)) {
256-    return filename;
257-  }
258-
259-  if (
260-    input instanceof HTMLInputElement
261-    && input.files?.length === 1
262-    && normalizeText(input.files[0]?.name || "") === normalizedFilename
263-    && countAttachmentProgressIndicators() <= baselineProgress
264-  ) {
265-    return filename;
266-  }
267-
268-  return null;
269-}
270-
271-async function handleUploadArtifact(command) {
272-  const platform = trimToNull(command?.platform);
273-  const filename = trimToNull(command?.filename);
274-  const mimeType = trimToNull(command?.mimeType) || "application/octet-stream";
275-
276-  if (!platform || !getDeliveryConfig(platform)) {
277-    throw new Error(`unsupported delivery platform: ${platform || "-"}`);
278-  }
279-
280-  if (!filename) {
281-    throw new Error("artifact filename is required");
282-  }
283-
284-  if (!(command?.bytes instanceof ArrayBuffer)) {
285-    throw new Error("artifact bytes must be an ArrayBuffer");
286-  }
287-
288-  const input = await waitFor(() => findFileInput(platform));
289-  const baselineProgress = countAttachmentProgressIndicators();
290-  const file = new File([command.bytes], filename, {
291-    lastModified: Date.now(),
292-    type: mimeType
293-  });
294-
295-  setInputFiles(input, [file]);
296-
297-  const remoteHandle = await waitFor(
298-    () => attachmentLooksReady(filename, input, baselineProgress),
299-    Number(command?.timeoutMs) || DELIVERY_TIMEOUT_MS
300-  );
301-
302-  return {
303-    ok: true,
304-    remoteHandle
305-  };
306-}
307-
308-async function handleInjectMessage(command) {
309-  const platform = trimToNull(command?.platform);
310-  const text = trimToNull(command?.text);
311-
312-  if (!platform || !getDeliveryConfig(platform)) {
313-    throw new Error(`unsupported delivery platform: ${platform || "-"}`);
314-  }
315-
316-  if (!text) {
317-    throw new Error("message text is required");
318-  }
319-
320-  const composer = await waitFor(() => findComposer(platform));
321-  setComposerText(composer, text);
322-  await waitFor(() => normalizeText(readComposerText(composer)).includes(normalizeText(text)), 5_000);
323-
324-  return {
325-    ok: true
326-  };
327-}
328-
329-async function handleSendMessage(command) {
330-  const platform = trimToNull(command?.platform);
331-
332-  if (!platform || !getDeliveryConfig(platform)) {
333-    throw new Error(`unsupported delivery platform: ${platform || "-"}`);
334-  }
335-
336-  const button = await waitFor(() => findSendButton(platform));
337-  button.click();
338-  await sleep(150);
339-
340-  return {
341-    ok: true
342-  };
343-}
344-
345 async function handleDeliveryCommand(data = {}) {
346-  const command = trimToNull(data?.command);
347-
348-  if (!command) {
349+  if (!deliveryRuntime) {
350     return {
351       ok: false,
352-      reason: "delivery command is required"
353+      code: "runtime_unavailable",
354+      command: trimToNull(data?.command),
355+      details: {
356+        url: location.href
357+      },
358+      platform: trimToNull(data?.platform),
359+      reason: "delivery.runtime_unavailable: delivery adapter runtime is not loaded"
360     };
361   }
362 
363-  try {
364-    switch (command) {
365-      case "upload_artifact":
366-        return await handleUploadArtifact(data);
367-      case "inject_message":
368-        return await handleInjectMessage(data);
369-      case "send_message":
370-        return await handleSendMessage(data);
371-      default:
372-        return {
373-          ok: false,
374-          reason: `unsupported delivery command: ${command}`
375-        };
376-    }
377-  } catch (error) {
378-    return {
379-      ok: false,
380-      reason: error instanceof Error ? error.message : String(error)
381-    };
382-  }
383+  return await deliveryRuntime.handleCommand(data);
384 }
385 
386 sendBridgeMessage("baa_page_bridge_ready", {
M plugins/baa-firefox/controller.css
+1, -1
1@@ -109,7 +109,7 @@ button:hover {
2 
3 .grid {
4   display: grid;
5-  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
6+  grid-template-columns: repeat(3, minmax(0, 1fr));
7   gap: 14px;
8   margin-bottom: 18px;
9 }
M plugins/baa-firefox/controller.html
+6, -6
 1@@ -35,6 +35,12 @@
 2         <p id="control-meta" class="meta">等待同步</p>
 3       </article>
 4 
 5+      <article class="card">
 6+        <p class="label">代理端点</p>
 7+        <p id="endpoint-count" class="value off">0</p>
 8+        <p id="endpoint-meta" class="meta">等待端点发现</p>
 9+      </article>
10+
11       <article class="card">
12         <p class="label">空壳页</p>
13         <p id="tracked-count" class="value off">0</p>
14@@ -52,12 +58,6 @@
15         <p id="credential-count" class="value off">0</p>
16         <p id="credential-meta" class="meta">等待登录态快照</p>
17       </article>
18-
19-      <article class="card">
20-        <p class="label">代理端点</p>
21-        <p id="endpoint-count" class="value off">0</p>
22-        <p id="endpoint-meta" class="meta">等待端点发现</p>
23-      </article>
24     </section>
25 
26     <section class="panel">
M plugins/baa-firefox/controller.js
+203, -160
  1@@ -44,10 +44,15 @@ const TRACKED_TAB_REFRESH_DELAY = 150;
  2 const SHELL_RUNTIME_HEALTHCHECK_INTERVAL = 30_000;
  3 const CONTROL_STATUS_BODY_LIMIT = 12_000;
  4 const WS_RECONNECT_DELAY = 3_000;
  5+const MANUAL_WS_RECONNECT_DEFAULT_DISCONNECT_MS = 80;
  6+const MANUAL_WS_RECONNECT_MAX_DISCONNECT_MS = 60_000;
  7+const MANUAL_WS_RECONNECT_MAX_REPEAT_COUNT = 20;
  8+const MANUAL_WS_RECONNECT_MAX_REPEAT_INTERVAL_MS = 60_000;
  9+const MANUAL_WS_RECONNECT_OPEN_TIMEOUT = 10_000;
 10+const MANUAL_WS_RECONNECT_POLL_INTERVAL = 100;
 11+const MANUAL_WS_RECONNECT_START_DELAY_MS = 80;
 12 const PROXY_REQUEST_TIMEOUT = 180_000;
 13 const DELIVERY_COMMAND_TIMEOUT = 30_000;
 14-const DELIVERY_UPLOAD_MAX_ATTEMPTS = 2;
 15-const DELIVERY_FETCH_RETRY_DELAY = 500;
 16 const CLAUDE_MESSAGE_LIMIT = 20;
 17 const CLAUDE_TOOL_PLACEHOLDER_RE = /```\n?This block is not supported on your current device yet\.?\n?```/g;
 18 const CLAUDE_THINKING_START_RE = /^(The user|Let me|I need to|I should|I'll|George|User |Looking at|This is a|OK[,.]|Alright|Hmm|Now |Here|So |Wait|Actually|My |Their |His |Her |We |用户|让我|我需要|我来|我想|好的|那|先|接下来)/;
 19@@ -179,6 +184,7 @@ const state = {
 20   ws: null,
 21   wsConnected: false,
 22   reconnectTimer: null,
 23+  manualWsReconnectSequence: 0,
 24   controlRefreshTimer: null,
 25   controlRefreshInFlight: null,
 26   lastControlFailureLogAt: 0,
 27@@ -2199,6 +2205,8 @@ function validateCredentialSnapshot(platform, headers, requestUrl = "") {
 28 
 29 function describeCredentialReason(reason) {
 30   switch (reason) {
 31+    case "ok":
 32+      return "可用";
 33     case "missing-headers":
 34       return "无快照";
 35     case "chatgpt-anon":
 36@@ -2527,6 +2535,175 @@ function sleep(ms) {
 37   return new Promise((resolve) => setTimeout(resolve, ms));
 38 }
 39 
 40+function readReconnectActionNumber(source, paths) {
 41+  const candidate = getFirstDefinedValue(source, paths);
 42+  if (candidate === undefined) return null;
 43+  if (Number.isFinite(candidate)) return Number(candidate);
 44+  if (typeof candidate === "string" && candidate.trim()) {
 45+    const parsed = Number(candidate);
 46+    return Number.isFinite(parsed) ? parsed : Number.NaN;
 47+  }
 48+  return Number.NaN;
 49+}
 50+
 51+function normalizeReconnectActionInteger(value, options = {}) {
 52+  const {
 53+    allowZero = false,
 54+    defaultValue = null,
 55+    label = "value",
 56+    max = Number.MAX_SAFE_INTEGER,
 57+    min = allowZero ? 0 : 1
 58+  } = options;
 59+
 60+  if (value == null) {
 61+    return defaultValue;
 62+  }
 63+
 64+  if (!Number.isFinite(value) || Math.round(value) !== value) {
 65+    throw new Error(`${label} 必须是整数`);
 66+  }
 67+
 68+  const normalized = Math.round(value);
 69+  const lowerBound = allowZero ? Math.max(0, min) : Math.max(1, min);
 70+  if (normalized < lowerBound || normalized > max) {
 71+    throw new Error(`${label} 必须在 ${lowerBound} 到 ${max} 之间`);
 72+  }
 73+
 74+  return normalized;
 75+}
 76+
 77+function resolveWsReconnectActionOptions(source = {}) {
 78+  const disconnectMs = normalizeReconnectActionInteger(
 79+    readReconnectActionNumber(source, ["disconnectMs", "disconnect_ms", "delayMs", "delay_ms"]),
 80+    {
 81+      allowZero: true,
 82+      defaultValue: MANUAL_WS_RECONNECT_DEFAULT_DISCONNECT_MS,
 83+      label: "disconnectMs",
 84+      max: MANUAL_WS_RECONNECT_MAX_DISCONNECT_MS
 85+    }
 86+  );
 87+  const repeatCount = normalizeReconnectActionInteger(
 88+    readReconnectActionNumber(source, ["repeatCount", "repeat_count"]),
 89+    {
 90+      defaultValue: 1,
 91+      label: "repeatCount",
 92+      max: MANUAL_WS_RECONNECT_MAX_REPEAT_COUNT
 93+    }
 94+  );
 95+  const repeatIntervalMs = normalizeReconnectActionInteger(
 96+    readReconnectActionNumber(source, ["repeatIntervalMs", "repeat_interval_ms", "intervalMs", "interval_ms"]),
 97+    {
 98+      allowZero: true,
 99+      defaultValue: 0,
100+      label: "repeatIntervalMs",
101+      max: MANUAL_WS_RECONNECT_MAX_REPEAT_INTERVAL_MS
102+    }
103+  );
104+
105+  return {
106+    disconnectMs,
107+    repeatCount,
108+    repeatIntervalMs
109+  };
110+}
111+
112+function describeWsReconnectActionPlan(options) {
113+  return `disconnect_ms=${options.disconnectMs} repeat_count=${options.repeatCount} repeat_interval_ms=${options.repeatIntervalMs}`;
114+}
115+
116+function extractWsReconnectActionOverrides(source = {}) {
117+  return {
118+    disconnectMs: getFirstDefinedValue(source, ["disconnectMs", "disconnect_ms", "delayMs", "delay_ms"]),
119+    repeatCount: getFirstDefinedValue(source, ["repeatCount", "repeat_count"]),
120+    repeatIntervalMs: getFirstDefinedValue(source, ["repeatIntervalMs", "repeat_interval_ms", "intervalMs", "interval_ms"])
121+  };
122+}
123+
124+async function waitForWsConnected(sequenceId, timeoutMs = MANUAL_WS_RECONNECT_OPEN_TIMEOUT) {
125+  const deadline = Date.now() + timeoutMs;
126+
127+  while (Date.now() < deadline) {
128+    if (state.manualWsReconnectSequence !== sequenceId) {
129+      return false;
130+    }
131+
132+    if (state.wsConnected && normalizeWsConnection(state.wsState?.connection) === "connected") {
133+      return true;
134+    }
135+
136+    await sleep(MANUAL_WS_RECONNECT_POLL_INTERVAL);
137+  }
138+
139+  return state.manualWsReconnectSequence === sequenceId
140+    && state.wsConnected
141+    && normalizeWsConnection(state.wsState?.connection) === "connected";
142+}
143+
144+async function runWsReconnectActionSequence(sequenceId, options) {
145+  for (let cycle = 1; cycle <= options.repeatCount; cycle += 1) {
146+    if (state.manualWsReconnectSequence !== sequenceId) {
147+      return;
148+    }
149+
150+    closeWsConnection();
151+    const now = Date.now();
152+    setWsState({
153+      ...cloneWsState(state.wsState),
154+      connection: options.disconnectMs > 0 ? "retrying" : "connecting",
155+      nextRetryAt: options.disconnectMs > 0 ? now + options.disconnectMs : 0,
156+      retryCount: cycle,
157+      lastError: null
158+    });
159+    addLog("info", `本地 WS 重连测试 ${cycle}/${options.repeatCount}:${describeWsReconnectActionPlan(options)}`, false);
160+
161+    if (options.disconnectMs > 0) {
162+      await sleep(options.disconnectMs);
163+    }
164+
165+    if (state.manualWsReconnectSequence !== sequenceId) {
166+      return;
167+    }
168+
169+    connectWs({ silentWhenDisabled: true });
170+
171+    if (cycle >= options.repeatCount) {
172+      return;
173+    }
174+
175+    const connected = await waitForWsConnected(sequenceId);
176+    if (state.manualWsReconnectSequence !== sequenceId) {
177+      return;
178+    }
179+
180+    if (!connected) {
181+      addLog("warn", `本地 WS 重连测试在第 ${cycle} 轮后未恢复连接,停止后续循环`, false);
182+      return;
183+    }
184+
185+    if (options.repeatIntervalMs > 0) {
186+      await sleep(options.repeatIntervalMs);
187+    }
188+  }
189+}
190+
191+function scheduleWsReconnectActionSequence(options) {
192+  state.manualWsReconnectSequence += 1;
193+  const sequenceId = state.manualWsReconnectSequence;
194+
195+  setTimeout(() => {
196+    if (state.manualWsReconnectSequence !== sequenceId) {
197+      return;
198+    }
199+
200+    void runWsReconnectActionSequence(sequenceId, options).catch((error) => {
201+      const message = error instanceof Error ? error.message : String(error);
202+      addLog("error", `本地 WS 重连测试失败:${message}`, false);
203+    });
204+  }, MANUAL_WS_RECONNECT_START_DELAY_MS);
205+
206+  return sequenceId;
207+}
208+
209 async function persistState() {
210   await browser.storage.local.set({
211     [CONTROLLER_STORAGE_KEYS.clientId]: state.clientId,
212@@ -3328,6 +3505,7 @@ function extractPluginManagementMessage(message) {
213     return {
214       action: directAction,
215       commandType: messageType,
216+      ...(directAction === "ws_reconnect" ? extractWsReconnectActionOverrides(message) : {}),
217       platform,
218       requestId,
219       source: "ws_direct"
220@@ -3395,15 +3573,14 @@ async function runPluginManagementAction(action, options = {}) {
221     case "plugin_status":
222       break;
223     case "ws_reconnect":
224-      addLog("info", "正在重连本地 WS", false);
225-      setTimeout(() => {
226-        closeWsConnection();
227-        connectWs({ silentWhenDisabled: true });
228-      }, 80);
229+      const reconnectOptions = resolveWsReconnectActionOptions(options);
230+      addLog("info", `正在重连本地 WS:${describeWsReconnectActionPlan(reconnectOptions)}`, false);
231+      scheduleWsReconnectActionSequence(reconnectOptions);
232       return {
233         action: methodName,
234         platform: trimToNull(options.platform),
235         deferred: true,
236+        reason: `scheduled ${describeWsReconnectActionPlan(reconnectOptions)}`,
237         results,
238         scheduled: true,
239         snapshot: buildPluginStatusPayload()
240@@ -3953,14 +4130,6 @@ function connectWs(options = {}) {
241       lastError: null
242     });
243 
244-    if (message.type === "browser.upload_artifacts") {
245-      handleUploadArtifactsCommand(message).catch((error) => {
246-        const messageText = error instanceof Error ? error.message : String(error);
247-        addLog("error", `artifact 上传失败:${messageText}`, false);
248-      });
249-      return;
250-    }
251-
252     if (message.type === "browser.inject_message" || message.type === "browser.send_message") {
253       const command = message.type === "browser.inject_message" ? "inject_message" : "send_message";
254 
255@@ -3996,6 +4165,9 @@ function connectWs(options = {}) {
256     const pluginAction = extractPluginManagementMessage(message);
257     if (pluginAction) {
258       runPluginManagementAction(pluginAction.action, {
259+        ...(pluginAction.action === "ws_reconnect"
260+          ? extractWsReconnectActionOverrides(pluginAction)
261+          : {}),
262         platform: pluginAction.platform,
263         source: pluginAction.source,
264         reason: trimToNull(message.reason) || "ws_plugin_action"
265@@ -4964,17 +5136,6 @@ async function postProxyRequestToTab(tabId, data) {
266   throw lastError || new Error("无法连接内容脚本");
267 }
268 
269-function decodeBase64ToArrayBuffer(value) {
270-  const decoded = globalThis.atob(String(value || ""));
271-  const bytes = new Uint8Array(decoded.length);
272-
273-  for (let index = 0; index < decoded.length; index += 1) {
274-    bytes[index] = decoded.charCodeAt(index);
275-  }
276-
277-  return bytes.buffer;
278-}
279-
280 async function sendDeliveryCommandToTab(tabId, data) {
281   let lastError = null;
282 
283@@ -4996,39 +5157,6 @@ async function sendDeliveryCommandToTab(tabId, data) {
284   throw lastError || new Error("无法连接内容脚本");
285 }
286 
287-async function fetchDeliveryArtifactPayload(upload) {
288-  const downloadUrl = trimToNull(upload?.download_url || upload?.downloadUrl);
289-
290-  if (!downloadUrl) {
291-    throw new Error("artifact download_url 缺失");
292-  }
293-
294-  const response = await fetch(downloadUrl, {
295-    headers: {
296-      accept: "application/json"
297-    }
298-  });
299-
300-  if (!response.ok) {
301-    throw new Error(`artifact payload 拉取失败 (${response.status})`);
302-  }
303-
304-  const payload = await response.json();
305-  const data = payload?.ok === true ? payload.data : null;
306-  const base64 = trimToNull(data?.content_base64 || data?.contentBase64);
307-
308-  if (!data || !base64) {
309-    throw new Error("artifact payload 响应缺少 content_base64");
310-  }
311-
312-  return {
313-    artifactId: trimToNull(data.artifact_id || data.artifactId || upload?.artifact_id || upload?.artifactId),
314-    bytes: decodeBase64ToArrayBuffer(base64),
315-    filename: trimToNull(data.filename || upload?.filename) || "artifact.bin",
316-    mimeType: trimToNull(data.mime_type || data.mimeType || upload?.mime_type || upload?.mimeType) || "application/octet-stream"
317-  };
318-}
319-
320 async function resolveDeliveryTab(platform) {
321   if (!platform || !["claude", "chatgpt"].includes(platform)) {
322     throw new Error(`当前 delivery 仅覆盖 claude/chatgpt,收到:${platform || "-"}`);
323@@ -5057,106 +5185,6 @@ function buildDeliveryShellRuntime(platform) {
324   }
325 }
326 
327-function sendUploadReceipt(planId, platform, receipt) {
328-  return wsSend({
329-    type: "browser.upload_receipt",
330-    plan_id: planId,
331-    platform,
332-    receipts: [
333-      {
334-        artifact_id: receipt.artifactId,
335-        attempts: receipt.attempts,
336-        error: receipt.error || null,
337-        ok: receipt.ok === true,
338-        received_at: Date.now(),
339-        remote_handle: receipt.remoteHandle || null
340-      }
341-    ]
342-  });
343-}
344-
345-async function performArtifactUpload(planId, platform, tabId, upload) {
346-  let lastError = null;
347-
348-  for (let attempt = 1; attempt <= DELIVERY_UPLOAD_MAX_ATTEMPTS; attempt += 1) {
349-    try {
350-      const artifact = await fetchDeliveryArtifactPayload(upload);
351-      const result = await sendDeliveryCommandToTab(tabId, {
352-        artifactId: artifact.artifactId,
353-        bytes: artifact.bytes,
354-        command: "upload_artifact",
355-        filename: artifact.filename,
356-        mimeType: artifact.mimeType,
357-        platform,
358-        planId,
359-        timeoutMs: DELIVERY_COMMAND_TIMEOUT
360-      });
361-
362-      if (result?.ok === true) {
363-        return {
364-          artifactId: artifact.artifactId,
365-          attempts: attempt,
366-          error: null,
367-          ok: true,
368-          remoteHandle: trimToNull(result.remoteHandle) || artifact.filename
369-        };
370-      }
371-
372-      lastError = trimToNull(result?.reason) || `artifact ${artifact.filename} upload failed`;
373-    } catch (error) {
374-      lastError = error instanceof Error ? error.message : String(error);
375-    }
376-
377-    if (attempt < DELIVERY_UPLOAD_MAX_ATTEMPTS) {
378-      await sleep(DELIVERY_FETCH_RETRY_DELAY);
379-    }
380-  }
381-
382-  return {
383-    artifactId: trimToNull(upload?.artifact_id || upload?.artifactId),
384-    attempts: DELIVERY_UPLOAD_MAX_ATTEMPTS,
385-    error: lastError || "artifact upload failed",
386-    ok: false,
387-    remoteHandle: null
388-  };
389-}
390-
391-async function handleUploadArtifactsCommand(message) {
392-  const platform = trimToNull(message?.platform);
393-  const planId = trimToNull(message?.plan_id || message?.planId);
394-  const uploads = Array.isArray(message?.uploads) ? message.uploads : [];
395-
396-  if (!platform) {
397-    throw new Error("browser.upload_artifacts 缺少 platform");
398-  }
399-
400-  if (!planId) {
401-    throw new Error("browser.upload_artifacts 缺少 plan_id");
402-  }
403-
404-  if (uploads.length === 0) {
405-    throw new Error("browser.upload_artifacts 缺少 uploads");
406-  }
407-
408-  addLog("info", `开始上传 ${uploads.length} 个 ${platformLabel(platform)} artifact`, false);
409-  const tab = await resolveDeliveryTab(platform);
410-
411-  for (const upload of uploads) {
412-    const receipt = await performArtifactUpload(planId, platform, tab.id, upload);
413-    const accepted = sendUploadReceipt(planId, platform, receipt);
414-
415-    if (!accepted) {
416-      throw new Error("上传回执发送失败:本地 WS 未连接");
417-    }
418-
419-    if (receipt.ok !== true) {
420-      throw new Error(receipt.error || "artifact upload failed");
421-    }
422-  }
423-
424-  addLog("info", `${platformLabel(platform)} artifact 上传已确认`, false);
425-}
426-
427 async function runDeliveryAction(message, command) {
428   const platform = trimToNull(message?.platform);
429   const planId = trimToNull(message?.plan_id || message?.planId);
430@@ -5174,9 +5202,21 @@ async function runDeliveryAction(message, command) {
431     command,
432     platform,
433     planId,
434+    pollIntervalMs: Number.isFinite(Number(message?.poll_interval_ms || message?.pollIntervalMs))
435+      ? Number(message?.poll_interval_ms || message?.pollIntervalMs)
436+      : undefined,
437+    retryAttempts: Number.isFinite(Number(message?.retry_attempts || message?.retryAttempts))
438+      ? Number(message?.retry_attempts || message?.retryAttempts)
439+      : undefined,
440+    retryDelayMs: Number.isFinite(Number(message?.retry_delay_ms || message?.retryDelayMs))
441+      ? Number(message?.retry_delay_ms || message?.retryDelayMs)
442+      : undefined,
443     text: command === "inject_message"
444       ? trimToNull(message?.message_text || message?.messageText)
445-      : null
446+      : null,
447+    timeoutMs: Number.isFinite(Number(message?.timeout_ms || message?.timeoutMs))
448+      ? Number(message?.timeout_ms || message?.timeoutMs)
449+      : DELIVERY_COMMAND_TIMEOUT
450   };
451   const result = await sendDeliveryCommandToTab(tab.id, payload);
452 
453@@ -5551,6 +5591,9 @@ function registerRuntimeListeners() {
454       case "plugin_runtime_action":
455       case "plugin_action":
456         return runPluginManagementAction(message.action, {
457+          ...(normalizePluginManagementAction(message.action) === "ws_reconnect"
458+            ? extractWsReconnectActionOverrides(message)
459+            : {}),
460           platform: message.platform,
461           source: message.source || "runtime_message",
462           reason: message.reason || "runtime_plugin_action"
A plugins/baa-firefox/delivery-adapters.js
+764, -0
  1@@ -0,0 +1,764 @@
  2+(function initBaaDeliveryAdapters(globalScope) {
  3+  const DEFAULT_TIMEOUT_MS = 30_000;
  4+  const DEFAULT_POLL_INTERVAL_MS = 150;
  5+  const DEFAULT_RETRY_ATTEMPTS = 2;
  6+  const DEFAULT_RETRY_DELAY_MS = 250;
  7+  const PLATFORM_ADAPTERS = {
  8+    claude: {
  9+      label: "Claude",
 10+      pageHosts: ["claude.ai"],
 11+      readinessSelectors: [
 12+        "main",
 13+        "form",
 14+        "[data-testid*='composer' i]",
 15+        "div.ProseMirror[contenteditable='true']",
 16+        "[role='textbox']"
 17+      ],
 18+      composerSelectors: [
 19+        "div[contenteditable='true'][data-testid*='composer']",
 20+        "div[contenteditable='true'][aria-label*='message' i]",
 21+        "div[contenteditable='true'][role='textbox']",
 22+        "div.ProseMirror[contenteditable='true']",
 23+        "textarea"
 24+      ],
 25+      sendButtonSelectors: [
 26+        "button[data-testid*='send' i]",
 27+        "button[aria-label*='send' i]",
 28+        "form button[type='submit']"
 29+      ],
 30+      sendingSelectors: [
 31+        "button[data-testid*='stop' i]",
 32+        "button[aria-label*='stop' i]",
 33+        "button[aria-label*='cancel' i]"
 34+      ]
 35+    },
 36+    chatgpt: {
 37+      label: "ChatGPT",
 38+      pageHosts: ["chatgpt.com", "chat.openai.com"],
 39+      readinessSelectors: [
 40+        "main",
 41+        "form",
 42+        "#prompt-textarea",
 43+        "[data-testid='prompt-textarea']",
 44+        "[role='textbox']"
 45+      ],
 46+      composerSelectors: [
 47+        "#prompt-textarea",
 48+        "textarea[data-testid='prompt-textarea']",
 49+        "div[contenteditable='true'][data-testid='prompt-textarea']",
 50+        "div[contenteditable='true'][role='textbox']",
 51+        "textarea"
 52+      ],
 53+      sendButtonSelectors: [
 54+        "button[data-testid='send-button']",
 55+        "button[aria-label*='send prompt' i]",
 56+        "button[aria-label*='send message' i]",
 57+        "form button[type='submit']"
 58+      ],
 59+      sendingSelectors: [
 60+        "button[data-testid='stop-button']",
 61+        "button[aria-label*='stop generating' i]",
 62+        "button[aria-label*='stop' i]"
 63+      ]
 64+    }
 65+  };
 66+
 67+  class DeliveryError extends Error {
 68+    constructor(code, message, details = {}) {
 69+      super(message);
 70+      this.name = "DeliveryError";
 71+      this.code = trimToNull(code) || "command_failed";
 72+      this.details = isRecord(details) ? details : {};
 73+    }
 74+  }
 75+
 76+  function isRecord(value) {
 77+    return value !== null && typeof value === "object" && !Array.isArray(value);
 78+  }
 79+
 80+  function trimToNull(value) {
 81+    if (typeof value !== "string") {
 82+      return null;
 83+    }
 84+
 85+    const normalized = value.trim();
 86+    return normalized === "" ? null : normalized;
 87+  }
 88+
 89+  function toPositiveInteger(value, fallback) {
 90+    const numeric = Number(value);
 91+    if (!Number.isFinite(numeric) || numeric <= 0) {
 92+      return fallback;
 93+    }
 94+
 95+    return Math.max(1, Math.floor(numeric));
 96+  }
 97+
 98+  function normalizeText(value) {
 99+    return String(value || "")
100+      .replace(/\s+/gu, " ")
101+      .trim()
102+      .toLowerCase();
103+  }
104+
105+  function normalizeError(error, fallbackMessage, details = {}) {
106+    if (error instanceof DeliveryError) {
107+      return error;
108+    }
109+
110+    const message = error instanceof Error ? error.message : trimToNull(String(error)) || fallbackMessage;
111+    return new DeliveryError("command_failed", message, details);
112+  }
113+
114+  function formatFailureReason(error) {
115+    const normalized = normalizeDeliveryError(error, {});
116+    return `delivery.${normalized.code}: ${normalized.message}`;
117+  }
118+
119+  function normalizeDeliveryError(error, details = {}) {
120+    const normalized = normalizeError(error, "delivery command failed", details);
121+
122+    if (!normalized.details.attempt && details.attempt) {
123+      normalized.details.attempt = details.attempt;
124+    }
125+    if (!normalized.details.attempts && details.attempts) {
126+      normalized.details.attempts = details.attempts;
127+    }
128+
129+    return normalized;
130+  }
131+
132+  function isElementLike(value) {
133+    return value != null && typeof value === "object" && typeof value.getBoundingClientRect === "function";
134+  }
135+
136+  function isButtonLike(value) {
137+    return isElementLike(value) && String(value.tagName || "").toUpperCase() === "BUTTON";
138+  }
139+
140+  function isInputLike(value) {
141+    const tagName = String(value?.tagName || "").toUpperCase();
142+    return isElementLike(value) && (tagName === "INPUT" || tagName === "TEXTAREA");
143+  }
144+
145+  function isContentEditableLike(value) {
146+    return isElementLike(value) && value.isContentEditable === true;
147+  }
148+
149+  function createBrowserEnv(overrides = {}) {
150+    const documentRef = overrides.document || globalScope.document || null;
151+    const locationRef = overrides.location || globalScope.location || null;
152+
153+    return {
154+      createChangeEvent: overrides.createChangeEvent || (() => new globalScope.Event("change", {
155+        bubbles: true
156+      })),
157+      createInputEvent: overrides.createInputEvent || ((data) =>
158+        new globalScope.InputEvent("input", {
159+          bubbles: true,
160+          cancelable: true,
161+          data,
162+          inputType: "insertText"
163+        })
164+      ),
165+      document: documentRef,
166+      getComputedStyle: overrides.getComputedStyle || ((element) =>
167+        typeof globalScope.getComputedStyle === "function" ? globalScope.getComputedStyle(element) : null
168+      ),
169+      getLocationHref: overrides.getLocationHref || (() => locationRef?.href || ""),
170+      now: overrides.now || (() => Date.now()),
171+      sleep: overrides.sleep || ((ms) =>
172+        new Promise((resolve) => globalScope.setTimeout(resolve, Math.max(0, Number(ms) || 0)))
173+      )
174+    };
175+  }
176+
177+  function getPlatformAdapter(platform) {
178+    const normalized = trimToNull(platform);
179+
180+    if (!normalized) {
181+      return null;
182+    }
183+
184+    const adapter = PLATFORM_ADAPTERS[normalized];
185+    return adapter ? {
186+      ...adapter,
187+      platform: normalized
188+    } : null;
189+  }
190+
191+  function listPlatformAdapters() {
192+    return Object.keys(PLATFORM_ADAPTERS).sort().map((platform) => getPlatformAdapter(platform));
193+  }
194+
195+  function elementDisabled(element) {
196+    if (!isElementLike(element)) {
197+      return true;
198+    }
199+
200+    if (typeof element.disabled === "boolean") {
201+      return element.disabled;
202+    }
203+
204+    return element.getAttribute?.("aria-disabled") === "true";
205+  }
206+
207+  function isElementVisible(env, element, options = {}) {
208+    if (!isElementLike(element)) {
209+      return false;
210+    }
211+
212+    if (options.allowHidden === true) {
213+      return true;
214+    }
215+
216+    const style = env.getComputedStyle(element);
217+    if (style) {
218+      if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity || "1") === 0) {
219+        return false;
220+      }
221+    }
222+
223+    const rect = element.getBoundingClientRect();
224+    return rect.width > 0 && rect.height > 0;
225+  }
226+
227+  function queryAll(env, selector) {
228+    if (!env.document || typeof selector !== "string" || !selector) {
229+      return [];
230+    }
231+
232+    try {
233+      const matches = env.document.querySelectorAll(selector);
234+      return Array.isArray(matches) ? matches : Array.from(matches || []);
235+    } catch (_) {
236+      return [];
237+    }
238+  }
239+
240+  function queryFirst(env, selectors, options = {}) {
241+    for (const selector of selectors) {
242+      for (const element of queryAll(env, selector)) {
243+        if (isElementVisible(env, element, options)) {
244+          return element;
245+        }
246+      }
247+    }
248+
249+    return null;
250+  }
251+
252+  function readComposerText(element) {
253+    if (isInputLike(element)) {
254+      return typeof element.value === "string" ? element.value : "";
255+    }
256+
257+    if (isContentEditableLike(element)) {
258+      return typeof element.textContent === "string" ? element.textContent : "";
259+    }
260+
261+    return "";
262+  }
263+
264+  function dispatchInputEvents(env, element) {
265+    if (!isElementLike(element) || typeof element.dispatchEvent !== "function") {
266+      return;
267+    }
268+
269+    element.dispatchEvent(env.createInputEvent(readComposerText(element)));
270+    element.dispatchEvent(env.createChangeEvent());
271+  }
272+
273+  function setNativeValue(element, value) {
274+    if (!isInputLike(element)) {
275+      return;
276+    }
277+
278+    const prototype = String(element.tagName || "").toUpperCase() === "TEXTAREA"
279+      ? globalScope.HTMLTextAreaElement?.prototype
280+      : globalScope.HTMLInputElement?.prototype;
281+    const descriptor = prototype ? Object.getOwnPropertyDescriptor(prototype, "value") : null;
282+
283+    if (descriptor?.set) {
284+      descriptor.set.call(element, value);
285+      return;
286+    }
287+
288+    element.value = value;
289+  }
290+
291+  function setComposerText(env, element, text) {
292+    if (isInputLike(element)) {
293+      element.focus?.();
294+      setNativeValue(element, text);
295+      dispatchInputEvents(env, element);
296+      return;
297+    }
298+
299+    if (!isContentEditableLike(element)) {
300+      throw new Error("page composer is not editable");
301+    }
302+
303+    element.focus?.();
304+    try {
305+      if (typeof env.document?.execCommand === "function") {
306+        env.document.execCommand("selectAll", false);
307+        env.document.execCommand("insertText", false, text);
308+      } else {
309+        element.textContent = text;
310+      }
311+    } catch (_) {
312+      element.textContent = text;
313+    }
314+    dispatchInputEvents(env, element);
315+  }
316+
317+  async function waitForValue(env, resolveValue, options = {}) {
318+    const timeoutMs = toPositiveInteger(options.timeoutMs, DEFAULT_TIMEOUT_MS);
319+    const intervalMs = toPositiveInteger(options.intervalMs, DEFAULT_POLL_INTERVAL_MS);
320+    const startedAt = env.now();
321+    let lastError = null;
322+
323+    while (env.now() - startedAt <= timeoutMs) {
324+      try {
325+        const value = await resolveValue();
326+
327+        if (value) {
328+          return value;
329+        }
330+      } catch (error) {
331+        lastError = error;
332+      }
333+
334+      if (env.now() - startedAt >= timeoutMs) {
335+        break;
336+      }
337+
338+      await env.sleep(intervalMs);
339+    }
340+
341+    if (typeof options.buildError === "function") {
342+      throw options.buildError(lastError);
343+    }
344+
345+    throw normalizeDeliveryError(lastError, {
346+      timeout_ms: timeoutMs
347+    });
348+  }
349+
350+  function matchesExpectedHost(expectedHosts, currentHref) {
351+    const href = trimToNull(currentHref);
352+    if (!href || expectedHosts.length === 0) {
353+      return true;
354+    }
355+
356+    try {
357+      const parsed = new URL(href, "https://delivery.invalid/");
358+      return expectedHosts.includes(parsed.hostname);
359+    } catch (_) {
360+      return true;
361+    }
362+  }
363+
364+  function buildAttemptContext(env, adapter, command, metadata = {}) {
365+    return {
366+      command,
367+      platform: adapter.platform,
368+      ready_state: trimToNull(env.document?.readyState) || null,
369+      url: trimToNull(env.getLocationHref()) || null,
370+      ...metadata
371+    };
372+  }
373+
374+  function resolveAttemptSettings(command) {
375+    const totalTimeoutMs = toPositiveInteger(command?.timeoutMs, DEFAULT_TIMEOUT_MS);
376+    const attempts = Math.min(3, Math.max(1, toPositiveInteger(command?.retryAttempts, DEFAULT_RETRY_ATTEMPTS)));
377+    const intervalMs = Math.max(10, toPositiveInteger(command?.pollIntervalMs, DEFAULT_POLL_INTERVAL_MS));
378+    const attemptBudgetMs = Math.max(150, Math.floor(totalTimeoutMs / attempts));
379+
380+    return {
381+      attemptBudgetMs,
382+      attempts,
383+      confirmTimeoutMs: Math.max(50, Math.min(6_000, Math.floor(attemptBudgetMs * 0.3))),
384+      intervalMs,
385+      pageReadyTimeoutMs: Math.max(50, Math.min(12_000, Math.floor(attemptBudgetMs * 0.35))),
386+      retryDelayMs: Math.max(25, toPositiveInteger(command?.retryDelayMs, DEFAULT_RETRY_DELAY_MS)),
387+      selectorTimeoutMs: Math.max(50, Math.min(10_000, Math.floor(attemptBudgetMs * 0.35)))
388+    };
389+  }
390+
391+  function findComposer(env, adapter) {
392+    return queryFirst(env, adapter.composerSelectors);
393+  }
394+
395+  function findSendButton(env, adapter, options = {}) {
396+    const candidate = queryFirst(env, adapter.sendButtonSelectors, {
397+      allowHidden: options.allowHidden === true
398+    });
399+
400+    if (candidate == null) {
401+      return null;
402+    }
403+
404+    if (options.includeDisabled === true || !elementDisabled(candidate)) {
405+      return candidate;
406+    }
407+
408+    return null;
409+  }
410+
411+  function findSendingIndicator(env, adapter) {
412+    return queryFirst(env, adapter.sendingSelectors, {
413+      allowHidden: true
414+    });
415+  }
416+
417+  async function ensurePageReady(env, adapter, command, settings, attempt, attempts) {
418+    const expectedHosts = Array.isArray(adapter.pageHosts) ? adapter.pageHosts : [];
419+    if (!matchesExpectedHost(expectedHosts, env.getLocationHref())) {
420+      throw new DeliveryError(
421+        "page_context_mismatch",
422+        `${adapter.label} page host does not match delivery target`,
423+        buildAttemptContext(env, adapter, command, {
424+          attempt,
425+          attempts,
426+          expected_hosts: expectedHosts
427+        })
428+      );
429+    }
430+
431+    return await waitForValue(
432+      env,
433+      () => {
434+        if (!env.document?.body) {
435+          return null;
436+        }
437+
438+        const readyState = String(env.document.readyState || "").toLowerCase();
439+        if (readyState === "loading") {
440+          return null;
441+        }
442+
443+        const marker = queryFirst(env, adapter.readinessSelectors, {
444+          allowHidden: true
445+        });
446+        if (!marker) {
447+          return null;
448+        }
449+
450+        return {
451+          readyState
452+        };
453+      },
454+      {
455+        buildError: () => new DeliveryError(
456+          "page_not_ready",
457+          `${adapter.label} page is not ready for ${command}`,
458+          buildAttemptContext(env, adapter, command, {
459+            attempt,
460+            attempts,
461+            readiness_selectors: adapter.readinessSelectors
462+          })
463+        ),
464+        intervalMs: settings.intervalMs,
465+        timeoutMs: settings.pageReadyTimeoutMs
466+      }
467+    );
468+  }
469+
470+  async function resolveSelector(env, adapter, command, selectorKind, selectors, settings, attempt, attempts, options = {}) {
471+    return await waitForValue(
472+      env,
473+      () => queryFirst(env, selectors, options),
474+      {
475+        buildError: () => new DeliveryError(
476+          "selector_missing",
477+          `${adapter.label} ${selectorKind} selector was not found`,
478+          buildAttemptContext(env, adapter, command, {
479+            attempt,
480+            attempts,
481+            selector_kind: selectorKind,
482+            selectors
483+          })
484+        ),
485+        intervalMs: settings.intervalMs,
486+        timeoutMs: settings.selectorTimeoutMs
487+      }
488+    );
489+  }
490+
491+  async function confirmInjection(env, adapter, command, text, settings, attempt, attempts) {
492+    const expectedText = normalizeText(text);
493+
494+    return await waitForValue(
495+      env,
496+      () => {
497+        const composer = findComposer(env, adapter);
498+        if (!composer) {
499+          return null;
500+        }
501+
502+        const currentText = normalizeText(readComposerText(composer));
503+        if (!currentText || !currentText.includes(expectedText)) {
504+          return null;
505+        }
506+
507+        return {
508+          confirmation: "composer_text_match"
509+        };
510+      },
511+      {
512+        buildError: () => new DeliveryError(
513+          "inject_not_confirmed",
514+          `${adapter.label} composer did not retain injected text`,
515+          buildAttemptContext(env, adapter, command, {
516+            attempt,
517+            attempts,
518+            text_length: text.length
519+          })
520+        ),
521+        intervalMs: settings.intervalMs,
522+        timeoutMs: settings.confirmTimeoutMs
523+      }
524+    );
525+  }
526+
527+  async function confirmSend(env, adapter, command, beforeText, settings, attempt, attempts) {
528+    return await waitForValue(
529+      env,
530+      () => {
531+        if (findSendingIndicator(env, adapter)) {
532+          return {
533+            confirmation: "sending_indicator"
534+          };
535+        }
536+
537+        const button = findSendButton(env, adapter, {
538+          allowHidden: true,
539+          includeDisabled: true
540+        });
541+        if (button && elementDisabled(button)) {
542+          return {
543+            confirmation: "send_button_disabled"
544+          };
545+        }
546+
547+        const composer = findComposer(env, adapter);
548+        if (composer) {
549+          const afterText = normalizeText(readComposerText(composer));
550+          if (beforeText && afterText !== beforeText) {
551+            return {
552+              confirmation: afterText ? "composer_changed" : "composer_cleared"
553+            };
554+          }
555+        }
556+
557+        return null;
558+      },
559+      {
560+        buildError: () => new DeliveryError(
561+          "send_not_confirmed",
562+          `${adapter.label} send click did not produce a confirmed state transition`,
563+          buildAttemptContext(env, adapter, command, {
564+            attempt,
565+            attempts
566+          })
567+        ),
568+        intervalMs: settings.intervalMs,
569+        timeoutMs: settings.confirmTimeoutMs
570+      }
571+    );
572+  }
573+
574+  async function executeInject(env, adapter, command, settings, attempt, attempts) {
575+    const text = trimToNull(command?.text);
576+
577+    if (!text) {
578+      throw new DeliveryError("invalid_payload", "message text is required");
579+    }
580+
581+    await ensurePageReady(env, adapter, "inject_message", settings, attempt, attempts);
582+    const composer = await resolveSelector(
583+      env,
584+      adapter,
585+      "inject_message",
586+      "composer",
587+      adapter.composerSelectors,
588+      settings,
589+      attempt,
590+      attempts
591+    );
592+    setComposerText(env, composer, text);
593+    const confirmation = await confirmInjection(env, adapter, "inject_message", text, settings, attempt, attempts);
594+
595+    return {
596+      ok: true,
597+      details: {
598+        attempt,
599+        attempts,
600+        confirmed_by: confirmation.confirmation
601+      }
602+    };
603+  }
604+
605+  async function executeSend(env, adapter, command, settings, attempt, attempts) {
606+    await ensurePageReady(env, adapter, "send_message", settings, attempt, attempts);
607+    const composer = await resolveSelector(
608+      env,
609+      adapter,
610+      "send_message",
611+      "composer",
612+      adapter.composerSelectors,
613+      settings,
614+      attempt,
615+      attempts
616+    );
617+    const button = await resolveSelector(
618+      env,
619+      adapter,
620+      "send_message",
621+      "send_button",
622+      adapter.sendButtonSelectors,
623+      settings,
624+      attempt,
625+      attempts
626+    );
627+
628+    if (elementDisabled(button)) {
629+      throw new DeliveryError(
630+        "send_unavailable",
631+        `${adapter.label} send button is disabled before click`,
632+        buildAttemptContext(env, adapter, "send_message", {
633+          attempt,
634+          attempts
635+        })
636+      );
637+    }
638+
639+    const beforeText = normalizeText(readComposerText(composer));
640+    button.click?.();
641+    const confirmation = await confirmSend(env, adapter, "send_message", beforeText, settings, attempt, attempts);
642+
643+    return {
644+      ok: true,
645+      details: {
646+        attempt,
647+        attempts,
648+        confirmed_by: confirmation.confirmation
649+      }
650+    };
651+  }
652+
653+  async function executeCommandAttempt(env, adapter, command, settings, attempt, attempts) {
654+    switch (command.command) {
655+      case "inject_message":
656+        return await executeInject(env, adapter, command, settings, attempt, attempts);
657+      case "send_message":
658+        return await executeSend(env, adapter, command, settings, attempt, attempts);
659+      default:
660+        throw new DeliveryError("invalid_command", `unsupported delivery command: ${command.command}`);
661+    }
662+  }
663+
664+  function shouldRetry(error) {
665+    const code = trimToNull(error?.code) || "";
666+    return !["invalid_command", "invalid_payload", "page_context_mismatch"].includes(code);
667+  }
668+
669+  function buildSuccessResult(adapter, commandName, result) {
670+    return {
671+      ok: true,
672+      platform: adapter.platform,
673+      command: commandName,
674+      remoteHandle: trimToNull(result?.remoteHandle) || null,
675+      details: {
676+        adapter: adapter.label,
677+        ...(isRecord(result?.details) ? result.details : {})
678+      }
679+    };
680+  }
681+
682+  function buildFailureResult(adapter, commandName, error) {
683+    const normalized = normalizeDeliveryError(error, {
684+      command: commandName,
685+      platform: adapter?.platform || null
686+    });
687+
688+    return {
689+      ok: false,
690+      platform: adapter?.platform || null,
691+      command: commandName,
692+      code: normalized.code,
693+      reason: formatFailureReason(normalized),
694+      details: normalized.details
695+    };
696+  }
697+
698+  function createDeliveryRuntime(options = {}) {
699+    const env = createBrowserEnv(options.env || {});
700+
701+    return {
702+      async handleCommand(command = {}) {
703+        const commandName = trimToNull(command?.command);
704+        if (!commandName) {
705+          return {
706+            ok: false,
707+            command: null,
708+            platform: trimToNull(command?.platform),
709+            code: "invalid_command",
710+            reason: "delivery.invalid_command: delivery command is required",
711+            details: {}
712+          };
713+        }
714+
715+        const adapter = getPlatformAdapter(command?.platform);
716+        if (!adapter) {
717+          return {
718+            ok: false,
719+            command: commandName,
720+            platform: trimToNull(command?.platform),
721+            code: "unsupported_platform",
722+            reason: `delivery.unsupported_platform: unsupported delivery platform: ${trimToNull(command?.platform) || "-"}`,
723+            details: {}
724+          };
725+        }
726+
727+        const settings = resolveAttemptSettings(command);
728+        let lastError = null;
729+
730+        for (let attempt = 1; attempt <= settings.attempts; attempt += 1) {
731+          try {
732+            const result = await executeCommandAttempt(env, adapter, command, settings, attempt, settings.attempts);
733+            return buildSuccessResult(adapter, commandName, result);
734+          } catch (error) {
735+            lastError = normalizeDeliveryError(error, {
736+              attempt,
737+              attempts: settings.attempts,
738+              command: commandName,
739+              platform: adapter.platform
740+            });
741+
742+            if (attempt < settings.attempts && shouldRetry(lastError)) {
743+              await env.sleep(settings.retryDelayMs);
744+              continue;
745+            }
746+          }
747+        }
748+
749+        return buildFailureResult(adapter, commandName, lastError);
750+      }
751+    };
752+  }
753+
754+  const api = {
755+    createDeliveryRuntime,
756+    getPlatformAdapter,
757+    listPlatformAdapters
758+  };
759+
760+  if (typeof module !== "undefined" && module.exports) {
761+    module.exports = api;
762+  }
763+
764+  globalScope.BAADeliveryAdapters = api;
765+})(typeof globalThis !== "undefined" ? globalThis : this);
M plugins/baa-firefox/manifest.json
+1, -0
1@@ -46,6 +46,7 @@
2         "https://gemini.google.com/*"
3       ],
4       "js": [
5+        "delivery-adapters.js",
6         "content-script.js"
7       ],
8       "run_at": "document_start"
D tasks/T-S036.md
+0, -150
  1@@ -1,150 +0,0 @@
  2-# Task T-S036:补 artifact download 合同与 binary delivery 能力
  3-
  4-## 直接给对话的提示词
  5-
  6-读 `/Users/george/code/baa-conductor/tasks/T-S036.md` 任务文档,完成开发任务。
  7-
  8-如需补背景,再读:
  9-
 10-- `/Users/george/code/baa-conductor/plans/BAA_ARTIFACT_DOWNLOAD_REQUIREMENTS.md`
 11-- `/Users/george/code/baa-conductor/plans/BAA_ARTIFACT_CENTER_REQUIREMENTS.md`
 12-- `/Users/george/code/baa-conductor/plans/BAA_DELIVERY_BRIDGE_REQUIREMENTS.md`
 13-- `/Users/george/code/baa-conductor/docs/api/firefox-local-ws.md`
 14-
 15-## 当前基线
 16-
 17-- 仓库:`/Users/george/code/baa-conductor`
 18-- 分支:`main`
 19-- 提交:`309eab2`
 20-- 开工要求:如需新分支,从当前 `main` 新切
 21-
 22-## 建议分支名
 23-
 24-- `feat/baa-artifact-download-binary-delivery`
 25-
 26-## 目标
 27-
 28-把当前仅适合 text/json 的 artifact payload 下发方式升级成 binary-safe download / upload 合同,为更大 payload 和二进制交付打基础。
 29-
 30-## 背景
 31-
 32-`T-S034` 当前的 artifact payload 交付仍有明显边界:
 33-
 34-- 通过本地 `download_url` 下发
 35-- 当前是 base64 JSON 形式
 36-- 更适合 text/json 类产物
 37-- 大二进制和 download 闭环还没做
 38-
 39-如果后续要稳定支持附件、较大结果和更真实的文件交付,这条链需要先升级。
 40-
 41-## 涉及仓库
 42-
 43-- `/Users/george/code/baa-conductor`
 44-
 45-## 范围
 46-
 47-- artifact download 合同升级
 48-- binary-safe payload fetch / upload
 49-- text/json 向后兼容
 50-- 自动化测试与文档回写
 51-
 52-## 路径约束
 53-
 54-- 首版仍只做单节点、本地 artifact store
 55-- 首版仍停留在单客户端、单轮 delivery
 56-- 不要在这张卡里扩到多节点 artifact 分发
 57-- 不要在这张卡里扩到 `Gemini`
 58-
 59-## 推荐实现边界
 60-
 61-建议新增或扩展:
 62-
 63-- `apps/conductor-daemon/src/artifacts/`
 64-- `apps/conductor-daemon/src/local-api.ts`
 65-- `plugins/baa-firefox/` 的 artifact fetch / upload 处理
 66-
 67-建议放到:
 68-
 69-- `/Users/george/code/baa-conductor/apps/conductor-daemon/src/artifacts/`
 70-- `/Users/george/code/baa-conductor/plugins/baa-firefox/`
 71-- `/Users/george/code/baa-conductor/tests/browser/`
 72-
 73-## 允许修改的目录
 74-
 75-- `/Users/george/code/baa-conductor/apps/conductor-daemon/src/`
 76-- `/Users/george/code/baa-conductor/plugins/baa-firefox/`
 77-- `/Users/george/code/baa-conductor/tests/browser/`
 78-- `/Users/george/code/baa-conductor/docs/firefox/`
 79-- `/Users/george/code/baa-conductor/docs/api/`
 80-- `/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/`
 81-- `/Users/george/code/baa-conductor/tasks/`
 82-- `/Users/george/code/baa-conductor/plans/`
 83-
 84-## 尽量不要修改
 85-
 86-- `/Users/george/code/baa-conductor/packages/db/`
 87-- `/Users/george/code/baa-conductor/apps/status-api/`
 88-
 89-## 必须完成
 90-
 91-### 1. 升级 artifact download 合同
 92-
 93-- payload 不再默认只能走 base64 JSON
 94-- 至少要有 binary-safe 的 download / fetch 路径
 95-
 96-### 2. 保持现有 text/json 兼容
 97-
 98-- 当前 text/json 交付不能回退
 99-- 原有 receipt barrier 和 fail-closed 语义不能被破坏
100-
101-### 3. 补 binary delivery 验证
102-
103-- 至少覆盖一条 binary-safe 路径
104-- 无法上传的场景要明确失败,不能静默成功
105-
106-## 需要特别注意
107-
108-- 不要把这张卡扩成多节点 artifact store
109-- 不要把这张卡扩成多客户端、多轮 delivery 编排
110-- 如果 `T-S035` 仍在推进,避免并行改坏同一批插件 delivery 文件;必要时先 rebase 或串行落地
111-
112-## 验收标准
113-
114-- artifact payload 不再默认退化成 base64 JSON 唯一路径
115-- text/json 现有交付保持兼容
116-- 至少一条 binary-safe 路径通过自动化验证
117-- `git diff --check` 通过
118-
119-## 评测要求
120-
121-### 1. 正向评测
122-
123-- text/json artifact 继续可交付
124-- 至少一种 binary artifact 能被 fetch 并进入上传路径
125-
126-### 2. 反向评测
127-
128-- 大 payload 不能因为默认 JSON 包装而直接把链路打爆
129-- 无法上传的 binary 场景不能误判成成功发送
130-
131-### 3. 边界评测
132-
133-- 缺少 filename / content-type 等可选字段时不能让整条 delivery 崩掉
134-- 大小接近阈值时,策略选择和错误行为要稳定
135-
136-## 推荐验证命令
137-
138-- `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build`
139-- `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js`
140-- `node --test /Users/george/code/baa-conductor/tests/browser/browser-control-e2e-smoke.test.mjs`
141-- `git -C /Users/george/code/baa-conductor diff --check`
142-
143-## 交付要求
144-
145-完成后请说明:
146-
147-- 修改了哪些文件
148-- artifact download / binary delivery 合同是怎么收口的
149-- text/json 兼容是怎么保留的
150-- 跑了哪些测试
151-- 还有哪些剩余风险
M tasks/TASK_OVERVIEW.md
+33, -90
  1@@ -8,80 +8,31 @@
  2 - canonical public host: `https://conductor.makefile.so`
  3 - `control-api.makefile.so`、Cloudflare Worker、D1 只剩迁移期 legacy 兼容残留和依赖盘点用途
  4 - `baa-hand` / `baa-shell` 只保留为接口语义参考,不再作为主系统维护
  5-- 当前任务卡都放在本目录
  6+- 当前活跃任务卡保留在本目录;已完成任务卡已归档到 [`./archive/README.md`](./archive/README.md)
  7 - 浏览器控制主链路收口基线:`main@07895cd`
  8 - 最近功能代码提交:`main@25be868`(启动时自动恢复受管 Firefox shell tabs)
  9 - `2026-03-27` 当前本地代码已额外完成 `BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 修复,以及 `T-S029`、`T-S030`、`T-S031`、`T-S032`、`T-S033`、`T-S034` 落地
 10 
 11 ## 状态分类
 12 
 13-- `已完成`:`T-S001` 到 `T-S034`,以及 `T-BUG-011`、`T-BUG-012`、`T-BUG-014`
 14+- `已完成`:见 [`./archive/README.md`](./archive/README.md)
 15 - `当前 TODO`:
 16-  - `T-S026` 真实 Firefox 手工 smoke 与验收记录
 17-- `待处理缺陷`:当前无 open bug backlog(见 `../bugs/README.md`)
 18+  - 下一张主线任务卡待新建
 19+- `待处理缺陷`:见 [`../bugs/README.md`](../bugs/README.md)
 20 - `低优先级 TODO`:`4318/status-api` 兼容层删旧与解耦
 21 
 22-当前新的主需求文档:
 23+当前活跃需求文档:
 24 
 25 - [`../plans/BAA_PLUGIN_DELIVERY_HARDENING_REQUIREMENTS.md`](../plans/BAA_PLUGIN_DELIVERY_HARDENING_REQUIREMENTS.md)
 26 - [`../plans/BAA_ARTIFACT_DOWNLOAD_REQUIREMENTS.md`](../plans/BAA_ARTIFACT_DOWNLOAD_REQUIREMENTS.md)
 27-- [`../plans/BAA_EXECUTION_PERSISTENCE_REQUIREMENTS.md`](../plans/BAA_EXECUTION_PERSISTENCE_REQUIREMENTS.md)
 28-- [`../plans/BAA_DELIVERY_BRIDGE_REQUIREMENTS.md`](../plans/BAA_DELIVERY_BRIDGE_REQUIREMENTS.md)
 29-- [`../plans/BAA_FINAL_MESSAGE_INGEST_REQUIREMENTS.md`](../plans/BAA_FINAL_MESSAGE_INGEST_REQUIREMENTS.md)
 30-- [`../plans/BAA_ARTIFACT_CENTER_REQUIREMENTS.md`](../plans/BAA_ARTIFACT_CENTER_REQUIREMENTS.md)
 31-- [`../plans/BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md`](../plans/BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md)
 32-- [`../plans/BAA_INSTRUCTION_CENTER_REQUIREMENTS.md`](../plans/BAA_INSTRUCTION_CENTER_REQUIREMENTS.md)
 33-- [`../plans/BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md`](../plans/BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md)
 34-- [`../plans/FIREFOX_BRIDGE_CONTROL_REQUIREMENTS.md`](../plans/FIREFOX_BRIDGE_CONTROL_REQUIREMENTS.md)
 35-
 36-## 最近完成任务
 37-
 38-当前已完成的主线任务:
 39-
 40-1. [`T-S001.md`](./T-S001.md):修复 codexd turn 完成状态
 41-2. [`T-S002.md`](./T-S002.md):清理 `control-api-worker` 残留与坏测试
 42-3. [`T-S003.md`](./T-S003.md):切换 `status-api` 到 `conductor` 主接口
 43-4. [`T-S004.md`](./T-S004.md):修复 `conductor-daemon` 测试 listener 泄漏
 44-5. [`T-S005.md`](./T-S005.md):收口 `BAA_CONTROL_API_BASE` 兼容入口
 45-6. [`T-S006.md`](./T-S006.md):清理历史 `control-api` 命名残留
 46-7. [`T-S007.md`](./T-S007.md):补 `ConductorRuntime.stop()` 关闭路径专项测试
 47-8. [`T-S008.md`](./T-S008.md):补 codexd child / transport 断流诊断与 reopen 规则
 48-9. [`T-S009.md`](./T-S009.md):把 conductor upstream/public API base 从 legacy 名字里解耦
 49-10. [`T-S010.md`](./T-S010.md):补仓库根验证入口
 50-11. [`T-S011.md`](./T-S011.md):把 `status-api` 测试接入根验证入口
 51-12. [`T-S012.md`](./T-S012.md):清理 repo 中最后一批 legacy 模板与残留 importer
 52-13. [`T-S013.md`](./T-S013.md):给 `worker-runner` 补包级测试并接入根验证入口
 53-14. [`T-S014.md`](./T-S014.md):把 `status-api` 从默认 runtime 服务集合里降为显式 opt-in
 54-15. [`T-S015.md`](./T-S015.md):给 `mini` 单节点补统一 on-node verify wrapper
 55-16. [`T-S016.md`](./T-S016.md):收口 `status-api` 终局并给 `conductor` 提供兼容状态视图
 56-17. [`T-S017.md`](./T-S017.md):定义浏览器登录态持久化模型与仓储
 57-18. [`T-S018.md`](./T-S018.md):把 Firefox 插件收口到空壳标签页并上报账号/指纹/端点
 58-19. [`T-S019.md`](./T-S019.md):让 conductor 持久化浏览器登录态并提供查询面
 59-20. [`T-S020.md`](./T-S020.md):回写浏览器桥接文档、补持久化 smoke 并同步状态视图
 60-21. [`T-S021.md`](./T-S021.md):收口 `conductor` describe 与通用 browser HTTP 合同
 61-22. [`T-S022.md`](./T-S022.md):实现 Firefox 空壳页 runtime 与插件管理动作
 62-23. [`T-S023.md`](./T-S023.md):打通通用 browser request / cancel / SSE 链路与 `conductor` 风控策略
 63-24. [`T-S024.md`](./T-S024.md):回写正式文档、补 browser smoke 并同步主线状态
 64-25. [`T-S025.md`](./T-S025.md):收口插件管理闭环与真实 Firefox 验收
 65-最近完成的缺陷任务:
 66-
 67-26. [`T-BUG-011.md`](./T-BUG-011.md):修复 `writeHttpResponse()` 在背压断连下的永久挂起,并补专项测试
 68-27. [`T-BUG-012.md`](./T-BUG-012.md):修复 `browser-request-policy` waiter 永久挂起,并补专项测试
 69-28. [`T-BUG-014.md`](./T-BUG-014.md):修正 `ws_reconnect` 的 `completed` 语义,并补 smoke 断言
 70-29. [`T-S027.md`](./T-S027.md):补 `browser-request-policy` stale `inFlight` 自愈清扫
 71-30. [`T-S028.md`](./T-S028.md):收口 ChatGPT browser relay 到正式合同
 72-31. [`T-S029.md`](./T-S029.md):补 ChatGPT / Gemini 最终消息观察与 `browser.final_message` raw relay
 73-32. [`T-S030.md`](./T-S030.md):收口 BAA 指令解析中心 Phase 1 与最小执行闭环
 74-33. [`T-S031.md`](./T-S031.md):把 live `browser.final_message` 接到 BAA instruction center
 75-34. [`T-S032.md`](./T-S032.md):补 BAA artifact center 与 delivery plan Phase 2 服务端核心
 76-35. [`T-S033.md`](./T-S033.md):补 BAA dedupe 与 execution journal 持久化
 77-36. [`T-S034.md`](./T-S034.md):打通 BAA delivery bridge、upload receipt barrier 与 inject/send
 78-
 79-## 已准备的后续任务
 80-
 81-- [`T-S026.md`](./T-S026.md):真实 Firefox 手工 smoke 与验收记录
 82-- [`T-S035.md`](./T-S035.md):加固插件侧 delivery adapter 与页面交付流程
 83-- [`T-S036.md`](./T-S036.md):补 artifact download 合同与 binary delivery 能力
 84+- [`../plans/BAA_INSTRUCTION_SYSTEM.md`](../plans/BAA_INSTRUCTION_SYSTEM.md)
 85+- 已完成需求归档见 [`../plans/archive/README.md`](../plans/archive/README.md)
 86+
 87+## 已归档完成项
 88+
 89+- 已完成任务卡与修复卡:[`./archive/README.md`](./archive/README.md)
 90+- 已完成需求文档:[`../plans/archive/README.md`](../plans/archive/README.md)
 91+- 已关闭 / 已完成缺陷:[`../bugs/archive/`](../bugs/archive/)
 92 
 93 当前主线已经额外收口:
 94 
 95@@ -95,30 +46,24 @@
 96 
 97 ## 当前活动任务
 98 
 99-- 当前高优先级剩余任务按顺序是:
100-  - [`T-S026.md`](./T-S026.md):真实 Firefox 手工 smoke 与验收记录
101-  - [`T-S035.md`](./T-S035.md):加固插件侧 delivery adapter 与页面交付流程
102-  - [`T-S036.md`](./T-S036.md):补 artifact download 合同与 binary delivery 能力
103-- 当前没有正在执行中的缺陷修复卡;如需继续推进工程改动,优先从残余风险或后续增强项开新卡
104+- `2026-03-28` 代码已完成一次 delivery 架构转向:
105+  - 主链改成 text-only `inject / send`
106+  - upload / download / artifact route 已移除
107+  - 默认超长策略改成前 `200` 行 + `超长截断`
108+- 当前没有正在执行中的缺陷修复卡;如需继续推进工程改动,优先新开卡处理剩余风险
109 
110 ## 当前主线收口情况
111 
112 当前浏览器桥接主线第二阶段已经完成:
113 
114-1. [`T-S017.md`](./T-S017.md):已完成,提供浏览器登录态持久化模型与仓储
115-2. [`T-S018.md`](./T-S018.md):已完成,Firefox 插件已收口到空壳标签页并开始上报账号/指纹/端点
116-3. [`T-S019.md`](./T-S019.md):已完成,`conductor` 已接上仓储、读接口和状态老化逻辑
117-4. [`T-S020.md`](./T-S020.md):已完成,文档、browser smoke 和任务状态视图已同步到正式模型
118-5. [`T-S021.md`](./T-S021.md):已完成,收口 `conductor` describe 与通用 browser HTTP 合同
119-6. [`T-S022.md`](./T-S022.md):已完成,实现 Firefox 空壳页 runtime 与插件管理动作
120-7. [`T-S023.md`](./T-S023.md):已完成,通用 browser request / cancel / SSE 链路和首版风控策略已接入主线
121-8. [`T-S024.md`](./T-S024.md):已完成,README / docs / smoke / 状态视图已经同步到正式口径
122-9. [`T-S025.md`](./T-S025.md):已完成,`shell_runtime`、结构化 `action_result` 和控制读面已接入;唯一剩余风险是当前机器缺少 `Firefox.app`,未完成真实手工 smoke
123+1. `T-S017` 到 `T-S026`:已完成并已归档到 [`./archive/README.md`](./archive/README.md)
124+2. `2026-03-27` 跟进修复:Firefox 插件管理页启动、浏览器重开或扩展重载后,会自动恢复之前明确启用过、但当前缺失的 shell tab
125+3. `2026-03-27` 跟进修复:如果用户手工打开 Claude `new`、ChatGPT 根页或 Gemini `app`,插件会把它们纳入受管理 shell 集合
126+4. `2026-03-27` 跟进任务:`T-S027`、`T-S028` 已完成并已归档
127+5. `2026-03-27` 跟进任务:`T-S026` 已完成真实 Firefox 手工 smoke,覆盖 `plugin_status`、`tab_open`、`tab_restore`、`ws_reconnect` 和“扩展重载后自动恢复”验收
128 
129 最近代码跟进:
130 
131-- `2026-03-27`:Firefox 插件管理页启动、浏览器重开或扩展重载后,会自动恢复之前明确启用过、但当前缺失的 shell tab
132-- `2026-03-27`:如果用户手工打开 Claude `new`、ChatGPT 根页或 Gemini `app` 这类平台根页,插件会把它们纳入受管理 shell 集合
133 - `2026-03-27`:`verify-mini.sh` 的 wrapper 调用已收口为数组参数组装,避免空参数场景下的命令拼接问题
134 - `2026-03-27`:`BUG-011` 已修复,`writeHttpResponse()` 的 body / stream 背压断连不再永久挂起
135 - `2026-03-27`:`BUG-012` 已修复,browser request policy waiter 现在会超时退出并返回明确错误
136@@ -141,23 +86,20 @@
137 
138 - 风控状态当前仍是进程内内存态;`conductor` 重启后,限流、退避和熔断计数会重置
139 - 正式 browser HTTP relay 现已正式验收 Claude 与 ChatGPT;Gemini 当前新增的是最终消息 raw relay,不是 `/v1/browser/request` 正式支持面
140-- 当前 open bug backlog 已清空
141-- 当前主线剩余任务是:
142-  - [`T-S026.md`](./T-S026.md):补真实 Firefox 手工 smoke 与验收记录
143-- 当前 BAA 下一波主线任务是:
144-  - [`T-S035.md`](./T-S035.md):加固插件侧 delivery adapter 与页面交付流程
145-  - [`T-S036.md`](./T-S036.md):补 artifact download 合同与 binary delivery 能力
146+- 当前 open bug / missing backlog 见 [`../bugs/README.md`](../bugs/README.md)
147+- 当前这轮主线代码已完成并归档:
148+  - [`archive/T-S035.md`](./archive/T-S035.md):加固插件侧 text-only delivery adapter 与页面交付流程
149+  - [`archive/T-S036.md`](./archive/T-S036.md):收口 text-only delivery 主链与超长截断策略
150 - `T-S029`、`T-S030`、`T-S031`、`T-S032`、`T-S033`、`T-S034` 已完成,当前 BAA 已具备:
151   - ChatGPT / Gemini 最终消息 raw relay 与 `browser.final_message` 快照保留
152   - conductor 侧 instruction center Phase 1 最小闭环与 live ingest
153   - conductor 侧 dedupe 与 execution journal 本地持久化,以及 `/v1/browser` 最近摘要恢复
154-  - conductor 侧 artifact / manifest / index text / delivery plan 服务端核心
155-  - delivery bridge、upload receipt barrier、inject / send 的 live 闭环
156+  - conductor 侧 live ingest / execution journal / browser delivery 主链
157+  - text-only delivery bridge、inject / send 的 live 闭环
158 - 当前保留的 BAA 边界是:
159   - ChatGPT 当前主要依赖 conversation SSE 结构;页面 payload 形态变化后需要同步调整提取器
160   - Gemini 最终文本提取当前基于 `StreamGenerate` / `batchexecute` 风格 payload 的启发式解析,稳定性弱于 ChatGPT,因此保留 synthetic `assistant_message_id` 兜底
161-  - 插件侧 upload / inject / send 仍是 DOM heuristic,当前只对 `Claude` / `ChatGPT` 做了首版选择器与流程
162-  - artifact payload 当前通过本地 `download_url` 以 base64 JSON 形式提供,适合当前 text/json 类产物;大二进制和 download 闭环还没做
163+  - 插件侧 `inject / send` 仍是 DOM heuristic,当前只对 `Claude` / `ChatGPT` 做了首版选择器与流程
164   - 当前交付仍按任务边界停留在单客户端、单轮 delivery 首版
165   - live message dedupe 和 instruction dedupe 当前已做成单节点本地持久化,但不做跨节点共享
166   - execution journal 当前只保留最近窗口,不扩成无限历史审计
167@@ -166,7 +108,7 @@
168 - ChatGPT 当前仍依赖浏览器里真实捕获到的有效登录态 / header,且没有 Claude 风格 prompt shortcut;这是当前正式支持面的已知边界,不是 regression
169 - `BUG-014` 的自动化验证目前覆盖的是 conductor 侧语义透传,不是 Firefox 扩展真实运行环境里的 reconnect 生命周期;真实“重连完成”仍依赖后续 `hello` / 状态同步
170 - runtime smoke 仍依赖仓库根已有 `state/`、`runs/`、`worktrees/`、`logs/launchd/`、`logs/codexd/`、`tmp/` 等本地运行目录;这是现有脚本前提,不是本轮功能回归
171-- 当前机器未发现 `Firefox.app`,因此尚未执行“手动关 tab -> `tab_restore` -> WS 重连后状态回报恢复”的真实 Firefox 手工 smoke;这是当前最高优先级残余风险
172+- 真实 Firefox 手工 smoke 已完成;delivery 主链改造也已完成,下一步需新建任务卡继续推进剩余风险
173 
174 ## 低优先级 TODO
175 
176@@ -178,7 +120,7 @@
177 ## 任务文档约定
178 
179 - 任务卡统一使用纯 Markdown 结构,不再使用 frontmatter
180-- 每张任务卡都要写清当前基线、建议分支名、允许修改目录和验收命令
181+- 每张任务卡都要写清当前基线、必须创建的新分支名、允许修改目录和验收命令
182 - 新任务优先参考 [`task-doc-template.md`](./task-doc-template.md)
183 
184 ## 现在该读什么
185@@ -196,6 +138,7 @@
186 - 所有新任务默认以 `100.71.210.78:4317` 和 `conductor.makefile.so` 为 canonical 接口面
187 - `control-api.makefile.so` 只允许作为删旧前的 legacy 兼容背景或残留依赖盘点说明出现
188 - 当前任务编号继续使用 `T-S***`
189+- 新任务必须先从当前 `main` 新建任务分支再开发;功能任务用 `feat/` 前缀,缺陷任务用 `bug/` 前缀
190 - 能并行的任务优先拆开,并明确写清允许修改的目录
191 - 新任务文档结构参考 [`task-doc-template.md`](./task-doc-template.md)
192 - 不再恢复旧 wave 文档;历史内容继续靠 tag `ha-failover-archive-2026-03-22` 回溯
A tasks/archive/README.md
+18, -0
 1@@ -0,0 +1,18 @@
 2+# 任务归档
 3+
 4+本目录只保留 `已完成` 的任务卡与缺陷修复卡。根目录 `tasks/` 现在只保留当前活跃任务、总览和模板。
 5+
 6+- 归档时间:`2026-03-28`
 7+- 归档状态:本目录中的任务卡均视为 `已完成`
 8+
 9+## 已归档任务卡
10+
11+- 基础收口:[`T-S001.md`](./T-S001.md)、[`T-S002.md`](./T-S002.md)、[`T-S003.md`](./T-S003.md)、[`T-S004.md`](./T-S004.md)、[`T-S005.md`](./T-S005.md)、[`T-S006.md`](./T-S006.md)、[`T-S007.md`](./T-S007.md)、[`T-S008.md`](./T-S008.md)、[`T-S009.md`](./T-S009.md)、[`T-S010.md`](./T-S010.md)、[`T-S011.md`](./T-S011.md)、[`T-S012.md`](./T-S012.md)、[`T-S013.md`](./T-S013.md)、[`T-S014.md`](./T-S014.md)、[`T-S015.md`](./T-S015.md)、[`T-S016.md`](./T-S016.md)
12+- 浏览器桥接主线:[`T-S017.md`](./T-S017.md)、[`T-S018.md`](./T-S018.md)、[`T-S019.md`](./T-S019.md)、[`T-S020.md`](./T-S020.md)、[`T-S021.md`](./T-S021.md)、[`T-S022.md`](./T-S022.md)、[`T-S023.md`](./T-S023.md)、[`T-S024.md`](./T-S024.md)、[`T-S025.md`](./T-S025.md)、[`T-S026.md`](./T-S026.md)、[`T-S027.md`](./T-S027.md)、[`T-S028.md`](./T-S028.md)
13+- BAA 主线:[`T-S029.md`](./T-S029.md)、[`T-S030.md`](./T-S030.md)、[`T-S031.md`](./T-S031.md)、[`T-S032.md`](./T-S032.md)、[`T-S033.md`](./T-S033.md)、[`T-S034.md`](./T-S034.md)、[`T-S035.md`](./T-S035.md)、[`T-S036.md`](./T-S036.md)
14+- 缺陷修复:[`T-BUG-011.md`](./T-BUG-011.md)、[`T-BUG-012.md`](./T-BUG-012.md)、[`T-BUG-014.md`](./T-BUG-014.md)
15+
16+## 当前仍在根目录的任务卡
17+
18+- [`../TASK_OVERVIEW.md`](../TASK_OVERVIEW.md):当前任务总览
19+- [`../task-doc-template.md`](../task-doc-template.md):新任务模板
R tasks/T-BUG-011.md => tasks/archive/T-BUG-011.md
+1, -1
1@@ -2,7 +2,7 @@
2 
3 ## 直接给对话的提示词
4 
5-读 `/Users/george/code/baa-conductor/tasks/T-BUG-011.md` 任务文档,完成开发任务。
6+读 `/Users/george/code/baa-conductor/tasks/archive/T-BUG-011.md` 任务文档,完成开发任务。
7 
8 如需补背景,再读:
9 
R tasks/T-BUG-012.md => tasks/archive/T-BUG-012.md
+1, -1
1@@ -2,7 +2,7 @@
2 
3 ## 直接给对话的提示词
4 
5-读 `/Users/george/code/baa-conductor/tasks/T-BUG-012.md` 任务文档,完成开发任务。
6+读 `/Users/george/code/baa-conductor/tasks/archive/T-BUG-012.md` 任务文档,完成开发任务。
7 
8 如需补背景,再读:
9 
R tasks/T-BUG-014.md => tasks/archive/T-BUG-014.md
+1, -1
1@@ -2,7 +2,7 @@
2 
3 ## 直接给对话的提示词
4 
5-读 `/Users/george/code/baa-conductor/tasks/T-BUG-014.md` 任务文档,完成开发任务。
6+读 `/Users/george/code/baa-conductor/tasks/archive/T-BUG-014.md` 任务文档,完成开发任务。
7 
8 如需补背景,再读:
9 
R tasks/T-S001.md => tasks/archive/T-S001.md
+1, -1
1@@ -2,7 +2,7 @@
2 
3 ## 直接给对话的提示词
4 
5-读 `/Users/george/code/baa-conductor/tasks/T-S001.md` 任务文档,完成开发任务。
6+读 `/Users/george/code/baa-conductor/tasks/archive/T-S001.md` 任务文档,完成开发任务。
7 
8 如需补背景,再读:
9 
R tasks/T-S002.md => tasks/archive/T-S002.md
+1, -1
1@@ -4,7 +4,7 @@
2 
3 ## 直接给对话的提示词
4 
5-读 `/Users/george/code/baa-conductor/tasks/T-S002.md` 任务文档,完成开发任务。
6+读 `/Users/george/code/baa-conductor/tasks/archive/T-S002.md` 任务文档,完成开发任务。
7 
8 如需补背景,再读:
9 
R tasks/T-S003.md => tasks/archive/T-S003.md
+1, -1
1@@ -4,7 +4,7 @@
2 
3 ## 直接给对话的提示词
4 
5-读 `/Users/george/code/baa-conductor/tasks/T-S003.md` 任务文档,完成开发任务。
6+读 `/Users/george/code/baa-conductor/tasks/archive/T-S003.md` 任务文档,完成开发任务。
7 
8 如需补背景,再读:
9 
R tasks/T-S004.md => tasks/archive/T-S004.md
+2, -2
 1@@ -8,7 +8,7 @@ updated_at: 2026-03-25 00:21:37 +0800
 2 
 3 ## 直接给对话的提示词
 4 
 5-读 `/Users/george/code/baa-conductor/tasks/T-S004.md` 任务文档,完成开发任务。
 6+读 `/Users/george/code/baa-conductor/tasks/archive/T-S004.md` 任务文档,完成开发任务。
 7 
 8 如需补背景,再读:
 9 
10@@ -132,7 +132,7 @@ updated_at: 2026-03-25 00:21:37 +0800
11 
12 - `/Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js`
13 - `/Users/george/code/baa-conductor/bugs/BUG-009-conductor-daemon-index-test-leaks-local-listener.md`
14-- `/Users/george/code/baa-conductor/tasks/T-S004.md`
15+- `/Users/george/code/baa-conductor/tasks/archive/T-S004.md`
16 
17 ## commands_run
18 
R tasks/T-S005.md => tasks/archive/T-S005.md
+1, -1
1@@ -4,7 +4,7 @@
2 
3 ## 直接给对话的提示词
4 
5-读 `/Users/george/code/baa-conductor/tasks/T-S005.md` 任务文档,完成开发任务。
6+读 `/Users/george/code/baa-conductor/tasks/archive/T-S005.md` 任务文档,完成开发任务。
7 
8 如需补背景,再读:
9 
R tasks/T-S006.md => tasks/archive/T-S006.md
+1, -1
1@@ -2,7 +2,7 @@
2 
3 ## 直接给对话的提示词
4 
5-读 `/Users/george/code/baa-conductor/tasks/T-S006.md` 任务文档,完成开发任务。
6+读 `/Users/george/code/baa-conductor/tasks/archive/T-S006.md` 任务文档,完成开发任务。
7 
8 如需补背景,再读:
9 
R tasks/T-S007.md => tasks/archive/T-S007.md
+1, -1
1@@ -2,7 +2,7 @@
2 
3 ## 直接给对话的提示词
4 
5-读 `/Users/george/code/baa-conductor/tasks/T-S007.md` 任务文档,完成开发任务。
6+读 `/Users/george/code/baa-conductor/tasks/archive/T-S007.md` 任务文档,完成开发任务。
7 
8 如需补背景,再读:
9 
R tasks/T-S008.md => tasks/archive/T-S008.md
+1, -1
1@@ -2,7 +2,7 @@
2 
3 ## 直接给对话的提示词
4 
5-读 `/Users/george/code/baa-conductor/tasks/T-S008.md` 任务文档,完成开发任务。
6+读 `/Users/george/code/baa-conductor/tasks/archive/T-S008.md` 任务文档,完成开发任务。
7 
8 如需补背景,再读:
9 
R tasks/T-S009.md => tasks/archive/T-S009.md
+1, -1
1@@ -2,7 +2,7 @@
2 
3 ## 直接给对话的提示词
4 
5-读 `/Users/george/code/baa-conductor/tasks/T-S009.md` 任务文档,完成开发任务。
6+读 `/Users/george/code/baa-conductor/tasks/archive/T-S009.md` 任务文档,完成开发任务。
7 
8 如需补背景,再读:
9 
R tasks/T-S010.md => tasks/archive/T-S010.md
+1, -1
1@@ -2,7 +2,7 @@
2 
3 ## 直接给对话的提示词
4 
5-读 `/Users/george/code/baa-conductor/tasks/T-S010.md` 任务文档,完成开发任务。
6+读 `/Users/george/code/baa-conductor/tasks/archive/T-S010.md` 任务文档,完成开发任务。
7 
8 如需补背景,再读:
9 
R tasks/T-S011.md => tasks/archive/T-S011.md
+1, -1
1@@ -2,7 +2,7 @@
2 
3 ## 直接给对话的提示词
4 
5-读 `/Users/george/code/baa-conductor/tasks/T-S011.md` 任务文档,完成开发任务。
6+读 `/Users/george/code/baa-conductor/tasks/archive/T-S011.md` 任务文档,完成开发任务。
7 
8 如需补背景,再读:
9 
R tasks/T-S012.md => tasks/archive/T-S012.md
+1, -1
1@@ -2,7 +2,7 @@
2 
3 ## 直接给对话的提示词
4 
5-读 `/Users/george/code/baa-conductor/tasks/T-S012.md` 任务文档,完成开发任务。
6+读 `/Users/george/code/baa-conductor/tasks/archive/T-S012.md` 任务文档,完成开发任务。
7 
8 如需补背景,再读:
9 
R tasks/T-S013.md => tasks/archive/T-S013.md
+1, -1
1@@ -2,7 +2,7 @@
2 
3 ## 直接给对话的提示词
4 
5-读 `/Users/george/code/baa-conductor/tasks/T-S013.md` 任务文档,完成开发任务。
6+读 `/Users/george/code/baa-conductor/tasks/archive/T-S013.md` 任务文档,完成开发任务。
7 
8 如需补背景,再读:
9 
R tasks/T-S014.md => tasks/archive/T-S014.md
+1, -1
1@@ -2,7 +2,7 @@
2 
3 ## 直接给对话的提示词
4 
5-读 `/Users/george/code/baa-conductor/tasks/T-S014.md` 任务文档,完成开发任务。
6+读 `/Users/george/code/baa-conductor/tasks/archive/T-S014.md` 任务文档,完成开发任务。
7 
8 如需补背景,再读:
9 
R tasks/T-S015.md => tasks/archive/T-S015.md
+1, -1
1@@ -2,7 +2,7 @@
2 
3 ## 直接给对话的提示词
4 
5-读 `/Users/george/code/baa-conductor/tasks/T-S015.md` 任务文档,完成开发任务。
6+读 `/Users/george/code/baa-conductor/tasks/archive/T-S015.md` 任务文档,完成开发任务。
7 
8 如需补背景,再读:
9 
R tasks/T-S016.md => tasks/archive/T-S016.md
+1, -1
1@@ -2,7 +2,7 @@
2 
3 ## 直接给对话的提示词
4 
5-读 `/Users/george/code/baa-conductor/tasks/T-S016.md` 任务文档,完成开发任务。
6+读 `/Users/george/code/baa-conductor/tasks/archive/T-S016.md` 任务文档,完成开发任务。
7 
8 如需补背景,再读:
9 
R tasks/T-S017.md => tasks/archive/T-S017.md
+2, -2
 1@@ -2,11 +2,11 @@
 2 
 3 ## 直接给对话的提示词
 4 
 5-读 `/Users/george/code/baa-conductor/tasks/T-S017.md` 任务文档,完成开发任务。
 6+读 `/Users/george/code/baa-conductor/tasks/archive/T-S017.md` 任务文档,完成开发任务。
 7 
 8 如需补背景,再读:
 9 
10-- `/Users/george/code/baa-conductor/plans/BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md`
11+- `/Users/george/code/baa-conductor/plans/archive/BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md`
12 - `/Users/george/code/baa-conductor/packages/db/src/index.ts`
13 - `/Users/george/code/baa-conductor/ops/sql/schema.sql`
14 - `/Users/george/code/baa-conductor/ops/sql/migrations/0001_init.sql`
R tasks/T-S018.md => tasks/archive/T-S018.md
+2, -2
 1@@ -2,11 +2,11 @@
 2 
 3 ## 直接给对话的提示词
 4 
 5-读 `/Users/george/code/baa-conductor/tasks/T-S018.md` 任务文档,完成开发任务。
 6+读 `/Users/george/code/baa-conductor/tasks/archive/T-S018.md` 任务文档,完成开发任务。
 7 
 8 如需补背景,再读:
 9 
10-- `/Users/george/code/baa-conductor/plans/BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md`
11+- `/Users/george/code/baa-conductor/plans/archive/BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md`
12 - `/Users/george/code/baa-conductor/plugins/baa-firefox/README.md`
13 - `/Users/george/code/baa-conductor/plugins/baa-firefox/manifest.json`
14 - `/Users/george/code/baa-conductor/plugins/baa-firefox/background.js`
R tasks/T-S019.md => tasks/archive/T-S019.md
+4, -4
 1@@ -2,13 +2,13 @@
 2 
 3 ## 直接给对话的提示词
 4 
 5-读 `/Users/george/code/baa-conductor/tasks/T-S019.md` 任务文档,完成开发任务。
 6+读 `/Users/george/code/baa-conductor/tasks/archive/T-S019.md` 任务文档,完成开发任务。
 7 
 8 如需补背景,再读:
 9 
10-- `/Users/george/code/baa-conductor/plans/BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md`
11-- `/Users/george/code/baa-conductor/tasks/T-S017.md`
12-- `/Users/george/code/baa-conductor/tasks/T-S018.md`
13+- `/Users/george/code/baa-conductor/plans/archive/BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md`
14+- `/Users/george/code/baa-conductor/tasks/archive/T-S017.md`
15+- `/Users/george/code/baa-conductor/tasks/archive/T-S018.md`
16 - `/Users/george/code/baa-conductor/apps/conductor-daemon/src/browser-types.ts`
17 - `/Users/george/code/baa-conductor/apps/conductor-daemon/src/firefox-ws.ts`
18 - `/Users/george/code/baa-conductor/apps/conductor-daemon/src/local-api.ts`
R tasks/T-S020.md => tasks/archive/T-S020.md
+5, -5
 1@@ -2,14 +2,14 @@
 2 
 3 ## 直接给对话的提示词
 4 
 5-读 `/Users/george/code/baa-conductor/tasks/T-S020.md` 任务文档,完成开发任务。
 6+读 `/Users/george/code/baa-conductor/tasks/archive/T-S020.md` 任务文档,完成开发任务。
 7 
 8 如需补背景,再读:
 9 
10-- `/Users/george/code/baa-conductor/plans/BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md`
11-- `/Users/george/code/baa-conductor/tasks/T-S017.md`
12-- `/Users/george/code/baa-conductor/tasks/T-S018.md`
13-- `/Users/george/code/baa-conductor/tasks/T-S019.md`
14+- `/Users/george/code/baa-conductor/plans/archive/BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md`
15+- `/Users/george/code/baa-conductor/tasks/archive/T-S017.md`
16+- `/Users/george/code/baa-conductor/tasks/archive/T-S018.md`
17+- `/Users/george/code/baa-conductor/tasks/archive/T-S019.md`
18 - `/Users/george/code/baa-conductor/README.md`
19 - `/Users/george/code/baa-conductor/docs/api/README.md`
20 - `/Users/george/code/baa-conductor/docs/firefox/README.md`
R tasks/T-S021.md => tasks/archive/T-S021.md
+5, -5
 1@@ -2,12 +2,12 @@
 2 
 3 ## 直接给对话的提示词
 4 
 5-读 `/Users/george/code/baa-conductor/tasks/T-S021.md` 任务文档,完成开发任务。
 6+读 `/Users/george/code/baa-conductor/tasks/archive/T-S021.md` 任务文档,完成开发任务。
 7 
 8 如需补背景,再读:
 9 
10-- `/Users/george/code/baa-conductor/plans/FIREFOX_BRIDGE_CONTROL_REQUIREMENTS.md`
11-- `/Users/george/code/baa-conductor/plans/BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md`
12+- `/Users/george/code/baa-conductor/plans/archive/FIREFOX_BRIDGE_CONTROL_REQUIREMENTS.md`
13+- `/Users/george/code/baa-conductor/plans/archive/BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md`
14 - `/Users/george/code/baa-conductor/plans/discuss/DISCUSS-FIREFOX-BRIDGE-CONTROL.md`
15 - `/Users/george/code/baa-conductor/docs/api/README.md`
16 - `/Users/george/code/baa-conductor/docs/api/business-interfaces.md`
17@@ -159,8 +159,8 @@
18 
19 - 本任务已完成,`conductor` 的浏览器能力发现继续收口到 `business` / `control` 两层 describe。
20 - 当前剩余集成工作已经转入:
21-  - `/Users/george/code/baa-conductor/tasks/T-S023.md`
22-  - `/Users/george/code/baa-conductor/tasks/T-S024.md`
23+  - `/Users/george/code/baa-conductor/tasks/archive/T-S023.md`
24+  - `/Users/george/code/baa-conductor/tasks/archive/T-S024.md`
25 
26 ## 当前残余风险
27 
R tasks/T-S022.md => tasks/archive/T-S022.md
+5, -5
 1@@ -2,12 +2,12 @@
 2 
 3 ## 直接给对话的提示词
 4 
 5-读 `/Users/george/code/baa-conductor/tasks/T-S022.md` 任务文档,完成开发任务。
 6+读 `/Users/george/code/baa-conductor/tasks/archive/T-S022.md` 任务文档,完成开发任务。
 7 
 8 如需补背景,再读:
 9 
10-- `/Users/george/code/baa-conductor/plans/FIREFOX_BRIDGE_CONTROL_REQUIREMENTS.md`
11-- `/Users/george/code/baa-conductor/plans/BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md`
12+- `/Users/george/code/baa-conductor/plans/archive/FIREFOX_BRIDGE_CONTROL_REQUIREMENTS.md`
13+- `/Users/george/code/baa-conductor/plans/archive/BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md`
14 - `/Users/george/code/baa-conductor/plans/discuss/DISCUSS-FIREFOX-BRIDGE-CONTROL.md`
15 - `/Users/george/code/baa-conductor/plugins/baa-firefox/README.md`
16 - `/Users/george/code/baa-conductor/plugins/baa-firefox/controller.js`
17@@ -151,8 +151,8 @@
18 
19 - 本任务已完成,Firefox 插件侧已经把空壳页 runtime、`desired/actual` 和管理类 payload 准备好。
20 - 当前剩余集成工作已经转入:
21-  - `/Users/george/code/baa-conductor/tasks/T-S023.md`
22-  - `/Users/george/code/baa-conductor/tasks/T-S024.md`
23+  - `/Users/george/code/baa-conductor/tasks/archive/T-S023.md`
24+  - `/Users/george/code/baa-conductor/tasks/archive/T-S024.md`
25 
26 ## 当前残余风险
27 
R tasks/T-S023.md => tasks/archive/T-S023.md
+5, -5
 1@@ -2,15 +2,15 @@
 2 
 3 ## 直接给对话的提示词
 4 
 5-读 `/Users/george/code/baa-conductor/tasks/T-S023.md` 任务文档,完成开发任务。
 6+读 `/Users/george/code/baa-conductor/tasks/archive/T-S023.md` 任务文档,完成开发任务。
 7 
 8 如需补背景,再读:
 9 
10-- `/Users/george/code/baa-conductor/plans/FIREFOX_BRIDGE_CONTROL_REQUIREMENTS.md`
11-- `/Users/george/code/baa-conductor/plans/BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md`
12+- `/Users/george/code/baa-conductor/plans/archive/FIREFOX_BRIDGE_CONTROL_REQUIREMENTS.md`
13+- `/Users/george/code/baa-conductor/plans/archive/BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md`
14 - `/Users/george/code/baa-conductor/plans/discuss/DISCUSS-FIREFOX-BRIDGE-CONTROL.md`
15-- `/Users/george/code/baa-conductor/tasks/T-S021.md`
16-- `/Users/george/code/baa-conductor/tasks/T-S022.md`
17+- `/Users/george/code/baa-conductor/tasks/archive/T-S021.md`
18+- `/Users/george/code/baa-conductor/tasks/archive/T-S022.md`
19 - `/Users/george/code/baa-conductor/tests/browser/browser-control-e2e-smoke.test.mjs`
20 
21 ## 当前基线
R tasks/T-S024.md => tasks/archive/T-S024.md
+6, -6
 1@@ -2,15 +2,15 @@
 2 
 3 ## 直接给对话的提示词
 4 
 5-读 `/Users/george/code/baa-conductor/tasks/T-S024.md` 任务文档,完成开发任务。
 6+读 `/Users/george/code/baa-conductor/tasks/archive/T-S024.md` 任务文档,完成开发任务。
 7 
 8 如需补背景,再读:
 9 
10-- `/Users/george/code/baa-conductor/plans/FIREFOX_BRIDGE_CONTROL_REQUIREMENTS.md`
11-- `/Users/george/code/baa-conductor/plans/BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md`
12-- `/Users/george/code/baa-conductor/tasks/T-S021.md`
13-- `/Users/george/code/baa-conductor/tasks/T-S022.md`
14-- `/Users/george/code/baa-conductor/tasks/T-S023.md`
15+- `/Users/george/code/baa-conductor/plans/archive/FIREFOX_BRIDGE_CONTROL_REQUIREMENTS.md`
16+- `/Users/george/code/baa-conductor/plans/archive/BROWSER_BRIDGE_PERSISTENCE_REQUIREMENTS.md`
17+- `/Users/george/code/baa-conductor/tasks/archive/T-S021.md`
18+- `/Users/george/code/baa-conductor/tasks/archive/T-S022.md`
19+- `/Users/george/code/baa-conductor/tasks/archive/T-S023.md`
20 - `/Users/george/code/baa-conductor/README.md`
21 - `/Users/george/code/baa-conductor/docs/api/README.md`
22 - `/Users/george/code/baa-conductor/docs/firefox/README.md`
R tasks/T-S025.md => tasks/archive/T-S025.md
+17, -15
 1@@ -2,14 +2,14 @@
 2 
 3 ## 直接给对话的提示词
 4 
 5-读 `/Users/george/code/baa-conductor/tasks/T-S025.md` 任务文档,完成开发任务。
 6+读 `/Users/george/code/baa-conductor/tasks/archive/T-S025.md` 任务文档,完成开发任务。
 7 
 8 如需补背景,再读:
 9 
10-- `/Users/george/code/baa-conductor/plans/FIREFOX_BRIDGE_CONTROL_REQUIREMENTS.md`
11-- `/Users/george/code/baa-conductor/tasks/T-S022.md`
12-- `/Users/george/code/baa-conductor/tasks/T-S023.md`
13-- `/Users/george/code/baa-conductor/tasks/T-S024.md`
14+- `/Users/george/code/baa-conductor/plans/archive/FIREFOX_BRIDGE_CONTROL_REQUIREMENTS.md`
15+- `/Users/george/code/baa-conductor/tasks/archive/T-S022.md`
16+- `/Users/george/code/baa-conductor/tasks/archive/T-S023.md`
17+- `/Users/george/code/baa-conductor/tasks/archive/T-S024.md`
18 - `/Users/george/code/baa-conductor/docs/api/control-interfaces.md`
19 - `/Users/george/code/baa-conductor/docs/api/firefox-local-ws.md`
20 - `/Users/george/code/baa-conductor/tests/browser/browser-control-e2e-smoke.test.mjs`
21@@ -26,9 +26,10 @@
22 - `已完成(2026-03-26)`
23 - `2026-03-26`:代码闭环已接通;`shell_runtime`、结构化 `action_result`、`/v1/browser` 与 `/describe/control` 已同步落地。
24 - `2026-03-26`:自动化验证已通过,包括 `apps/conductor-daemon/src/index.test.js` 和 `tests/browser/browser-control-e2e-smoke.test.mjs`。
25-- `2026-03-26`:真实 Firefox 手工 smoke 在当前机器上受阻;未发现 `Firefox.app`(已检查 `/Applications` 与 `~/Applications`),因此无法在本环境完成“真实 Firefox 手工验收”。
26+- `2026-03-26`:当日的真实 Firefox 手工 smoke 在当前机器上受阻;未发现 `Firefox.app`(已检查 `/Applications` 与 `~/Applications`),因此当日无法在本环境完成“真实 Firefox 手工验收”。
27 - `2026-03-27`:后续代码跟进已补上“插件管理页启动 / 浏览器重开 / 扩展重载后自动恢复 desired shell tabs”,并支持把平台根页收进受管理 shell 集合。
28 - `2026-03-27`:后续缺陷修复已补上 `ws_reconnect` 的 deferred 结果语义;当前 `action_result.completed` 不再在真正重连前提前为 `true`。
29+- `2026-03-27`:后续拆卡 `T-S026` 已在真实 `Firefox.app` 上完成手工 smoke;`plugin_status`、`tab_open`、`tab_restore`、`ws_reconnect` 和“扩展重载后自动恢复”均已通过。
30 
31 ## 建议分支名
32 
33@@ -175,12 +176,11 @@
34 
35 - `2026-03-27`:提交 `25be868` 已补上启动时的受管 shell tab 自动恢复逻辑,收口了“插件重启后 desired 仍在、actual 丢失但没有自动调和”的空窗。
36 - `2026-03-27`:同一轮跟进里,`verify-mini.sh` 的 wrapper 调用也改成数组参数拼装,降低空参数场景下的脚本调用风险。
37-- `2026-03-27`:当前没有新增手工 smoke 结果;环境阻塞仍然是本机缺少可启动的 `Firefox.app`。
38-- `2026-03-27`:后续缺陷任务已修复 `BUG-014`,并补上 `ws_reconnect.completed === false` 的 smoke 断言;剩余风险不再是 completed 语义,而是真实 Firefox reconnect 生命周期仍依赖手工验收。
39-- `2026-03-27`:真实 Firefox 手工 smoke 的执行流程和记录模板已拆到 `/Users/george/code/baa-conductor/tasks/T-S026.md`,后续真机验收直接按该任务卡执行并回写结果。
40+- `2026-03-27`:后续缺陷任务已修复 `BUG-014`,并补上 `ws_reconnect.completed === false` 的 smoke 断言;同日真机手工验收也已完成,剩余主线风险已转到 `T-S035` / `T-S036`。
41+- `2026-03-27`:真实 Firefox 手工 smoke 的执行流程和记录模板已拆到 `/Users/george/code/baa-conductor/tasks/archive/T-S026.md`;同日已在真机完成验收并回写结果。
42 - `2026-03-27`:其余两项工程化后续任务也已拆卡:
43-  - `/Users/george/code/baa-conductor/tasks/T-S027.md`:补 `browser-request-policy` stale `inFlight` 自愈清扫
44-  - `/Users/george/code/baa-conductor/tasks/T-S028.md`:把 ChatGPT browser relay 收口到正式合同
45+  - `/Users/george/code/baa-conductor/tasks/archive/T-S027.md`:补 `browser-request-policy` stale `inFlight` 自愈清扫
46+  - `/Users/george/code/baa-conductor/tasks/archive/T-S028.md`:把 ChatGPT browser relay 收口到正式合同
47 
48 ## 自动化验证
49 
50@@ -190,15 +190,17 @@
51 
52 ## 真实 Firefox 手工 Smoke 记录
53 
54-- 日期:`2026-03-26`
55+- 日期:`2026-03-26`、`2026-03-27`
56 - 目标步骤:
57   - 手动关闭平台 tab
58   - `tab_restore`
59   - WS 重连
60   - 状态回报恢复
61-- 结果:当前环境阻塞,未执行
62-- 阻塞原因:本机未发现可启动的 `Firefox.app`(已检查 `/Applications`、`~/Applications` 和 Spotlight `org.mozilla.firefox` bundle id),因此无法在这台机器上完成真实 Firefox 手工 smoke。
63-- 截至 `2026-03-27`:该状态未变化;本轮只补了启动恢复代码和文档同步,没有新增真实 Firefox 手工验收结果。
64+- `2026-03-26` 结果:阻塞,未执行
65+- `2026-03-26` 阻塞原因:当日机器未发现可启动的 `Firefox.app`(已检查 `/Applications`、`~/Applications` 和 Spotlight `org.mozilla.firefox` bundle id)。
66+- `2026-03-27` 结果:通过
67+- `2026-03-27` 记录来源:后续拆卡 `/Users/george/code/baa-conductor/tasks/archive/T-S026.md`
68+- `2026-03-27` 结论:真实 Firefox 手工 smoke 已覆盖 `plugin_status`、`tab_open`、`tab_restore`、`ws_reconnect`、扩展重载自动恢复;其中 `ws_reconnect` 还额外完成了 `disconnect_ms=3000`、`repeat_count=3`、`repeat_interval_ms=500` 的 3 轮稳定性验证。
69 
70 ## 交付要求
71 
R tasks/T-S026.md => tasks/archive/T-S026.md
+35, -37
  1@@ -2,11 +2,11 @@
  2 
  3 ## 直接给对话的提示词
  4 
  5-读 `/Users/george/code/baa-conductor/tasks/T-S026.md` 任务文档,完成开发任务。
  6+读 `/Users/george/code/baa-conductor/tasks/archive/T-S026.md` 任务文档,完成开发任务。
  7 
  8 如需补背景,再读:
  9 
 10-- `/Users/george/code/baa-conductor/tasks/T-S025.md`
 11+- `/Users/george/code/baa-conductor/tasks/archive/T-S025.md`
 12 - `/Users/george/code/baa-conductor/docs/api/firefox-local-ws.md`
 13 - `/Users/george/code/baa-conductor/docs/api/control-interfaces.md`
 14 - `/Users/george/code/baa-conductor/plugins/baa-firefox/README.md`
 15@@ -19,6 +19,13 @@
 16 - 提交:`a2b1055`
 17 - 开工要求:不要从其他任务分支切出;如需新分支,从当前 `main` 新切
 18 
 19+## 当前状态
 20+
 21+- `已完成(2026-03-27)`
 22+- `2026-03-27`:已在真实 `Firefox.app` 环境完成 `plugin_status`、`tab_open`、`tab_restore`、`ws_reconnect` 和“扩展重载后自动恢复”手工 smoke。
 23+- `2026-03-27`:场景 D 额外补做了 `disconnect_ms=3000`、`repeat_count=3`、`repeat_interval_ms=500` 的多轮 WS 重连稳定性验证;3 轮断开重连后,`plugin_status` 恢复到 `actual_count=3`、`desired_count=3`、`drift_count=0`。
 24+- `2026-03-27`:本轮未发现需要新开卡的 Firefox 手工验收缺陷;当前主线剩余任务已切换到 `T-S035` 和 `T-S036`。
 25+
 26 ## 建议分支名
 27 
 28 - `docs/firefox-manual-smoke`
 29@@ -129,7 +136,7 @@
 30   - 验证时间
 31   - 本地 API 地址
 32 - 把最终结论回写到:
 33-  - `/Users/george/code/baa-conductor/tasks/T-S025.md`
 34+  - `/Users/george/code/baa-conductor/tasks/archive/T-S025.md`
 35   - `/Users/george/code/baa-conductor/plans/STATUS_SUMMARY.md`
 36   - `/Users/george/code/baa-conductor/PROGRESS/2026-03-27-current-code-progress.md`
 37 
 38@@ -165,51 +172,42 @@
 39 - `curl -s -X POST "${LOCAL_API_BASE}/v1/browser/actions" -H "content-type: application/json" -d '{"action":"tab_restore","platform":"claude"}' | jq`
 40 - `curl -s -X POST "${LOCAL_API_BASE}/v1/browser/actions" -H "content-type: application/json" -d '{"action":"ws_reconnect"}' | jq`
 41 
 42-## 手工验收记录模板
 43-
 44-建议按下面格式回写:
 45+## 真实 Firefox 手工 Smoke 记录(2026-03-27)
 46 
 47-```md
 48-## 真实 Firefox 手工 Smoke 记录(YYYY-MM-DD)
 49-
 50-- 机器:
 51-- macOS:
 52-- Firefox:
 53-- 扩展加载方式:
 54-- LOCAL_API_BASE:
 55+- 机器:`mini`
 56+- macOS:`15.1 (24B2083)`
 57+- Firefox:`148.0.2`
 58+- 扩展加载方式:`about:debugging#/runtime/this-firefox` 临时扩展
 59+- LOCAL_API_BASE:`http://100.71.210.78:4317`
 60+- 验证时间:`2026-03-27 23:47:54 CST`
 61 
 62 ### 场景 A:plugin_status
 63-- 结果:
 64-- 观察:
 65+
 66+- 结果:`通过`
 67+- 观察:`plugin_status` 返回 `accepted=true`、`completed=true`、`failed=false`;`/v1/browser` 能看到活跃 `client_id=firefox-73q0ro`、最新 `last_action_result` 和 `shell_runtime`。
 68 
 69 ### 场景 B:tab_open + 元数据
 70-- 结果:
 71-- 观察:
 72+
 73+- 结果:`通过`
 74+- 观察:Claude `tab_open` 返回 `accepted=true`、`completed=true`;当前 Claude shell tab 维持在 `tab_id=18`,`/v1/browser?platform=claude` 可见账号、指纹、endpoint 元数据和 `aligned=true` 的 `shell_runtime`。
 75 
 76 ### 场景 C:手动关 tab -> tab_restore
 77-- 结果:
 78-- 观察:
 79+
 80+- 结果:`通过`
 81+- 观察:手动关闭 Claude shell tab 后,`plugin_status` 正确反映 `actual_count=2`、`desired_count=3`、`drift_count=1`;执行 `tab_restore` 后,新 tab 被恢复,随后 `plugin_status` 回到 `actual_count=3`、`drift_count=0`。
 82 
 83 ### 场景 D:ws_reconnect
 84-- 结果:
 85-- 观察:
 86 
 87-### 场景 E:浏览器重开 / 扩展重载自动恢复
 88-- 结果:
 89-- 观察:
 90+- 结果:`通过`
 91+- 观察:默认 `ws_reconnect` 返回 `completed=false`,随后通过新的 `hello` / 状态同步完成重连;额外用 `disconnect_ms=3000`、`repeat_count=3`、`repeat_interval_ms=500` 做了 3 轮重连稳定性验证,`connection_id` 依次从 `f8101883-1f4a-4bfa-9992-c5bd1497f5c5` 切换到 `45991b62-14a7-427f-96e9-9be21718fae1`、`afde2a15-0e3b-426b-adcb-af8065eadc30`、`c2b44e84-5d73-4944-88e9-36b6f1eeb914`,结束后 `plugin_status` 恢复到 `actual_count=3`、`desired_count=3`、`drift_count=0`。
 92 
 93-### 总结
 94-- 是否通过:
 95-- 新发现问题:
 96-- 剩余风险:
 97-```
 98+### 场景 E:浏览器重开 / 扩展重载自动恢复
 99 
100-## 交付要求
101+- 结果:`通过`
102+- 观察:手动关闭 Claude shell tab 后重载扩展,`connection_id` 换新为 `4dbff2dc-dd81-4560-ba4d-e811162fd462`;扩展启动时先上报了一次 `missing_actual` 的 `request_credentials` 快照,随后自动恢复 Claude shell tab,最终 `plugin_status` 显示 Claude `tab_id=18`、`actual.exists=true`、`healthy=true`、`drift.aligned=true`。
103 
104-完成后请说明:
105+### 总结
106 
107-- 实际验证了哪些场景
108-- 每个场景的结果是什么
109-- 回写了哪些文档
110-- 是否发现新 bug
111-- 还有哪些剩余风险
112+- 是否通过:`通过`
113+- 新发现问题:`无`
114+- 剩余风险:浏览器管理面真机验收已经完成;当前剩余主线风险转移到 `T-S035` / `T-S036`,即插件侧 delivery adapter 稳定性、binary-safe artifact delivery,以及既有 ChatGPT / Gemini 提取与 DOM heuristic 边界。
R tasks/T-S027.md => tasks/archive/T-S027.md
+1, -1
1@@ -2,7 +2,7 @@
2 
3 ## 直接给对话的提示词
4 
5-读 `/Users/george/code/baa-conductor/tasks/T-S027.md` 任务文档,完成开发任务。
6+读 `/Users/george/code/baa-conductor/tasks/archive/T-S027.md` 任务文档,完成开发任务。
7 
8 如需补背景,再读:
9 
R tasks/T-S028.md => tasks/archive/T-S028.md
+3, -3
 1@@ -2,12 +2,12 @@
 2 
 3 ## 直接给对话的提示词
 4 
 5-读 `/Users/george/code/baa-conductor/tasks/T-S028.md` 任务文档,完成开发任务。
 6+读 `/Users/george/code/baa-conductor/tasks/archive/T-S028.md` 任务文档,完成开发任务。
 7 
 8 如需补背景,再读:
 9 
10-- `/Users/george/code/baa-conductor/tasks/T-S023.md`
11-- `/Users/george/code/baa-conductor/tasks/T-S025.md`
12+- `/Users/george/code/baa-conductor/tasks/archive/T-S023.md`
13+- `/Users/george/code/baa-conductor/tasks/archive/T-S025.md`
14 - `/Users/george/code/baa-conductor/docs/api/README.md`
15 - `/Users/george/code/baa-conductor/apps/conductor-daemon/src/local-api.ts`
16 - `/Users/george/code/baa-conductor/plugins/baa-firefox/controller.js`
R tasks/T-S029.md => tasks/archive/T-S029.md
+2, -2
 1@@ -2,11 +2,11 @@
 2 
 3 ## 直接给对话的提示词
 4 
 5-读 `/Users/george/code/baa-conductor/tasks/T-S029.md` 任务文档,完成开发任务。
 6+读 `/Users/george/code/baa-conductor/tasks/archive/T-S029.md` 任务文档,完成开发任务。
 7 
 8 如需补背景,再读:
 9 
10-- `/Users/george/code/baa-conductor/plans/BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md`
11+- `/Users/george/code/baa-conductor/plans/archive/BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md`
12 - `/Users/george/code/baa-conductor/plans/BAA_INSTRUCTION_SYSTEM.md`
13 - `/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/docs/06-integration-with-current-baa-conductor.md`
14 - `/Users/george/code/baa-conductor/plugins/baa-firefox/controller.js`
R tasks/T-S030.md => tasks/archive/T-S030.md
+2, -2
 1@@ -2,11 +2,11 @@
 2 
 3 ## 直接给对话的提示词
 4 
 5-读 `/Users/george/code/baa-conductor/tasks/T-S030.md` 任务文档,完成开发任务。
 6+读 `/Users/george/code/baa-conductor/tasks/archive/T-S030.md` 任务文档,完成开发任务。
 7 
 8 如需补背景,再读:
 9 
10-- `/Users/george/code/baa-conductor/plans/BAA_INSTRUCTION_CENTER_REQUIREMENTS.md`
11+- `/Users/george/code/baa-conductor/plans/archive/BAA_INSTRUCTION_CENTER_REQUIREMENTS.md`
12 - `/Users/george/code/baa-conductor/plans/BAA_INSTRUCTION_SYSTEM.md`
13 - `/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/docs/02-protocol-spec.md`
14 - `/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/docs/04-execution-loop-state-machine.md`
R tasks/T-S031.md => tasks/archive/T-S031.md
+4, -4
 1@@ -2,13 +2,13 @@
 2 
 3 ## 直接给对话的提示词
 4 
 5-读 `/Users/george/code/baa-conductor/tasks/T-S031.md` 任务文档,完成开发任务。
 6+读 `/Users/george/code/baa-conductor/tasks/archive/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/plans/archive/BAA_FINAL_MESSAGE_INGEST_REQUIREMENTS.md`
14+- `/Users/george/code/baa-conductor/plans/archive/BAA_BROWSER_FINAL_MESSAGE_RELAY_REQUIREMENTS.md`
15+- `/Users/george/code/baa-conductor/plans/archive/BAA_INSTRUCTION_CENTER_REQUIREMENTS.md`
16 - `/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/docs/06-integration-with-current-baa-conductor.md`
17 
18 ## 当前基线
R tasks/T-S032.md => tasks/archive/T-S032.md
+2, -2
 1@@ -2,11 +2,11 @@
 2 
 3 ## 直接给对话的提示词
 4 
 5-读 `/Users/george/code/baa-conductor/tasks/T-S032.md` 任务文档,完成开发任务。
 6+读 `/Users/george/code/baa-conductor/tasks/archive/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/archive/BAA_ARTIFACT_CENTER_REQUIREMENTS.md`
12 - `/Users/george/code/baa-conductor/plans/BAA_INSTRUCTION_SYSTEM.md`
13 - `/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/docs/06-integration-with-current-baa-conductor.md`
14 - `/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/docs/07-rollout-plan.md`
R tasks/T-S033.md => tasks/archive/T-S033.md
+4, -4
 1@@ -2,13 +2,13 @@
 2 
 3 ## 直接给对话的提示词
 4 
 5-读 `/Users/george/code/baa-conductor/tasks/T-S033.md` 任务文档,完成开发任务。
 6+读 `/Users/george/code/baa-conductor/tasks/archive/T-S033.md` 任务文档,完成开发任务。
 7 
 8 如需补背景,再读:
 9 
10-- `/Users/george/code/baa-conductor/plans/BAA_EXECUTION_PERSISTENCE_REQUIREMENTS.md`
11-- `/Users/george/code/baa-conductor/plans/BAA_FINAL_MESSAGE_INGEST_REQUIREMENTS.md`
12-- `/Users/george/code/baa-conductor/plans/BAA_INSTRUCTION_CENTER_REQUIREMENTS.md`
13+- `/Users/george/code/baa-conductor/plans/archive/BAA_EXECUTION_PERSISTENCE_REQUIREMENTS.md`
14+- `/Users/george/code/baa-conductor/plans/archive/BAA_FINAL_MESSAGE_INGEST_REQUIREMENTS.md`
15+- `/Users/george/code/baa-conductor/plans/archive/BAA_INSTRUCTION_CENTER_REQUIREMENTS.md`
16 - `/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/docs/04-execution-loop-state-machine.md`
17 
18 ## 当前基线
R tasks/T-S034.md => tasks/archive/T-S034.md
+3, -3
 1@@ -2,12 +2,12 @@
 2 
 3 ## 直接给对话的提示词
 4 
 5-读 `/Users/george/code/baa-conductor/tasks/T-S034.md` 任务文档,完成开发任务。
 6+读 `/Users/george/code/baa-conductor/tasks/archive/T-S034.md` 任务文档,完成开发任务。
 7 
 8 如需补背景,再读:
 9 
10-- `/Users/george/code/baa-conductor/plans/BAA_DELIVERY_BRIDGE_REQUIREMENTS.md`
11-- `/Users/george/code/baa-conductor/plans/BAA_ARTIFACT_CENTER_REQUIREMENTS.md`
12+- `/Users/george/code/baa-conductor/plans/archive/BAA_DELIVERY_BRIDGE_REQUIREMENTS.md`
13+- `/Users/george/code/baa-conductor/plans/archive/BAA_ARTIFACT_CENTER_REQUIREMENTS.md`
14 - `/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/docs/06-integration-with-current-baa-conductor.md`
15 - `/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/docs/09-artifact-delivery-thin-plugin.md`
16 
R tasks/T-S035.md => tasks/archive/T-S035.md
+30, -18
  1@@ -1,4 +1,4 @@
  2-# Task T-S035:加固插件侧 delivery adapter 与页面交付流程
  3+# Task T-S035:加固插件侧 text-only delivery adapter 与页面交付流程
  4 
  5 ## 直接给对话的提示词
  6 
  7@@ -7,30 +7,29 @@
  8 如需补背景,再读:
  9 
 10 - `/Users/george/code/baa-conductor/plans/BAA_PLUGIN_DELIVERY_HARDENING_REQUIREMENTS.md`
 11-- `/Users/george/code/baa-conductor/plans/BAA_DELIVERY_BRIDGE_REQUIREMENTS.md`
 12-- `/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/docs/09-artifact-delivery-thin-plugin.md`
 13+- `/Users/george/code/baa-conductor/plans/archive/BAA_DELIVERY_BRIDGE_REQUIREMENTS.md`
 14 - `/Users/george/code/baa-conductor/docs/firefox/README.md`
 15 
 16 ## 当前基线
 17 
 18 - 仓库:`/Users/george/code/baa-conductor`
 19-- 分支:`main`
 20+- 分支基线:`main`
 21 - 提交:`309eab2`
 22-- 开工要求:如需新分支,从当前 `main` 新切
 23+- 开工要求:必须先从当前 `main` 新建任务分支,再开始开发;禁止直接在 `main` 上修改。本任务是功能任务,分支名必须以 `feat/` 开头。
 24 
 25-## 建议分支名
 26+## 必须创建的新分支名
 27 
 28 - `feat/baa-plugin-delivery-hardening`
 29 
 30 ## 目标
 31 
 32-把当前首版 `Claude` / `ChatGPT` 的 upload / inject / send 收口成更稳定的插件侧 delivery adapter,降低页面结构变化带来的脆弱性。
 33+把当前首版 `Claude` / `ChatGPT` 的 text-only `inject / send` 收口成更稳定的插件侧 delivery adapter,降低页面结构变化带来的脆弱性。
 34 
 35 ## 背景
 36 
 37-`T-S034` 已经把 live delivery 主链接通,但插件侧当前仍保留明显边界:
 38+当前 live delivery 已经切到 text-only 主链,但插件侧当前仍保留明显边界:
 39 
 40-- upload / inject / send 仍是 DOM heuristic
 41+- inject / send 仍是 DOM heuristic
 42 - 只有 `Claude` / `ChatGPT` 做了首版选择器和流程
 43 - 页面 readiness、失败恢复和结构化错误还不够稳定
 44 
 45@@ -43,7 +42,7 @@
 46 ## 范围
 47 
 48 - `Claude` / `ChatGPT` delivery adapter 收口
 49-- upload / inject / send readiness 探测
 50+- text-only `inject / send` readiness 探测
 51 - 结构化失败原因与 fail-closed 路径
 52 - browser smoke 与文档回写
 53 
 54@@ -51,8 +50,8 @@
 55 
 56 - 只加固 `Claude` / `ChatGPT`
 57 - 不要在这张卡里扩到 `Gemini`
 58-- 不要在这张卡里做 binary download / artifact download 升级
 59-- 保持 thin-plugin,不把 artifact 打包、parser 或复杂编排塞回插件
 60+- 不要在这张卡里恢复 upload / download / binary delivery
 61+- 保持 thin-plugin,不把 parser 或复杂编排塞回插件
 62 
 63 ## 推荐实现边界
 64 
 65@@ -86,7 +85,7 @@
 66 
 67 ### 1. 收口平台 adapter
 68 
 69-- `Claude` / `ChatGPT` 的 upload / inject / send 流程要有清晰 adapter 边界
 70+- `Claude` / `ChatGPT` 的 text-only `inject / send` 流程要有清晰 adapter 边界
 71 - 平台选择器、页面探测和前置条件不能继续散落成隐式逻辑
 72 
 73 ### 2. 补 readiness 与 fail-closed
 74@@ -98,16 +97,17 @@
 75 
 76 - 至少覆盖一条成功路径和一条失败路径
 77 - 文档要明确当前仍是 DOM adapter,不是通用页面自动化框架
 78+- 文档要明确当前 delivery 不再支持 upload / download
 79 
 80 ## 需要特别注意
 81 
 82-- 不要把这张卡扩成 artifact binary/download 任务
 83+- 不要把这张卡扩成 artifact / binary / download 任务
 84 - 不要顺手扩到 `Gemini`
 85-- 如果后续还有 `T-S036`,这张卡优先收口 adapter 边界和失败恢复,不抢它的 payload/下载合同
 86+- 如果后续还有 `T-S036`,这张卡优先收口 adapter 边界和失败恢复,不把服务端 delivery 逻辑塞回插件
 87 
 88 ## 验收标准
 89 
 90-- `Claude` / `ChatGPT` upload / inject / send 的 adapter 边界清晰
 91+- `Claude` / `ChatGPT` text-only `inject / send` 的 adapter 边界清晰
 92 - 页面未 ready 或关键 selector 失效时会明确 fail-closed
 93 - browser smoke 覆盖成功与失败路径
 94 - `git diff --check` 通过
 95@@ -116,8 +116,8 @@
 96 
 97 ### 1. 正向评测
 98 
 99-- `Claude` 路径能稳定完成 upload / inject / send
100-- `ChatGPT` 路径能稳定完成 upload / inject / send
101+- `Claude` 路径能稳定完成 `inject / send`
102+- `ChatGPT` 路径能稳定完成 `inject / send`
103 
104 ### 2. 反向评测
105 
106@@ -145,3 +145,15 @@
107 - 哪些页面失败场景现在会明确 fail-closed
108 - 跑了哪些测试
109 - 还有哪些剩余风险
110+
111+## 完成回写(2026-03-28)
112+
113+- 已完成:
114+  - `plugins/baa-firefox/delivery-adapters.js` 已接入 `manifest.json` / `content-script.js`,把 `Claude` / `ChatGPT` 的 text-only `inject_message` / `send_message` 收口到统一 adapter
115+  - adapter 现在会先做 page readiness,再做关键 selector 解析和结果确认;send 点击后若没有确认状态变化,会明确 fail-closed
116+  - 插件侧已彻底删除 `upload_artifact` 路径,不再接受 upload/download 型 delivery 命令
117+  - browser smoke 已补成功路径,以及 `page_not_ready`、`send_not_confirmed` 失败路径
118+- 当前剩余风险:
119+  - 仍依赖平台 DOM 结构,后续页面改版仍可能要求更新 adapter selector
120+  - 当前只覆盖 `Claude` / `ChatGPT`
121+  - 当前仍是单客户端、单轮 delivery
A tasks/archive/T-S036.md
+162, -0
  1@@ -0,0 +1,162 @@
  2+# Task T-S036:收口 text-only delivery 主链与超长截断策略
  3+
  4+## 直接给对话的提示词
  5+
  6+读 `/Users/george/code/baa-conductor/tasks/T-S036.md` 任务文档,完成开发任务。
  7+
  8+如需补背景,再读:
  9+
 10+- `/Users/george/code/baa-conductor/plans/BAA_ARTIFACT_DOWNLOAD_REQUIREMENTS.md`
 11+- `/Users/george/code/baa-conductor/plans/archive/BAA_ARTIFACT_CENTER_REQUIREMENTS.md`
 12+- `/Users/george/code/baa-conductor/plans/archive/BAA_DELIVERY_BRIDGE_REQUIREMENTS.md`
 13+- `/Users/george/code/baa-conductor/docs/api/firefox-local-ws.md`
 14+
 15+## 当前基线
 16+
 17+- 仓库:`/Users/george/code/baa-conductor`
 18+- 分支基线:`main`
 19+- 提交:`309eab2`
 20+- 开工要求:必须先从当前 `main` 新建任务分支,再开始开发;禁止直接在 `main` 上修改。本任务是功能任务,分支名必须以 `feat/` 开头。
 21+
 22+## 必须创建的新分支名
 23+
 24+- `feat/baa-text-only-delivery`
 25+
 26+## 目标
 27+
 28+把当前 delivery 主链改成纯文本直注入:不再支持 artifact upload / download,不再下发摘要索引,超长文本只保留前若干行并在末尾追加 `超长截断`。
 29+
 30+## 背景
 31+
 32+之前的 artifact payload 交付链有明显问题:
 33+
 34+- upload / download 路径复杂,真机调试成本高
 35+- 摘要 / index 文本不适合继续作为默认交付面
 36+- 插件页面最终还是更适合直接接收文本
 37+
 38+因此主线改成:直接渲染完整文本,超长按行截断,不再做文件交付。
 39+
 40+## 涉及仓库
 41+
 42+- `/Users/george/code/baa-conductor`
 43+
 44+## 范围
 45+
 46+- server 侧 text-only delivery 渲染
 47+- 插件 / WS / local API 删除 upload/download 协议
 48+- 超长文本按行截断并追加 `超长截断`
 49+- 自动化测试与文档回写
 50+
 51+## 路径约束
 52+
 53+- 首版仍停留在单客户端、单轮 delivery
 54+- 不要在这张卡里扩到多节点分发
 55+- 不要在这张卡里扩到 `Gemini`
 56+
 57+## 推荐实现边界
 58+
 59+建议新增或扩展:
 60+
 61+- `apps/conductor-daemon/src/artifacts/`
 62+- `apps/conductor-daemon/src/local-api.ts`
 63+- `plugins/baa-firefox/` 的 text-only delivery 处理
 64+
 65+建议放到:
 66+
 67+- `/Users/george/code/baa-conductor/apps/conductor-daemon/src/artifacts/`
 68+- `/Users/george/code/baa-conductor/plugins/baa-firefox/`
 69+- `/Users/george/code/baa-conductor/tests/browser/`
 70+
 71+## 允许修改的目录
 72+
 73+- `/Users/george/code/baa-conductor/apps/conductor-daemon/src/`
 74+- `/Users/george/code/baa-conductor/plugins/baa-firefox/`
 75+- `/Users/george/code/baa-conductor/tests/browser/`
 76+- `/Users/george/code/baa-conductor/docs/firefox/`
 77+- `/Users/george/code/baa-conductor/docs/api/`
 78+- `/Users/george/code/baa-conductor/docs/baa-instruction-system-v5/`
 79+- `/Users/george/code/baa-conductor/tasks/`
 80+- `/Users/george/code/baa-conductor/plans/`
 81+
 82+## 尽量不要修改
 83+
 84+- `/Users/george/code/baa-conductor/packages/db/`
 85+- `/Users/george/code/baa-conductor/apps/status-api/`
 86+
 87+## 必须完成
 88+
 89+### 1. 收口 text-only delivery
 90+
 91+- 服务端不再生成 upload/download 合同
 92+- delivery 直接走 `browser.inject_message` / `browser.send_message`
 93+- 不再下发摘要 / result index
 94+
 95+### 2. 补超长截断策略
 96+
 97+- 大文本默认按行截断
 98+- 当前实现只保留前若干行,并在末尾追加 `超长截断`
 99+- 不做多消息分段,不做文件回退
100+
101+### 3. 补自动化验证
102+
103+- 至少覆盖一条 text-only 成功路径
104+- 至少覆盖一条 inject fail-closed 路径
105+
106+## 需要特别注意
107+
108+- 不要把这张卡扩成多节点分发
109+- 不要把这张卡扩成多客户端、多轮 delivery 编排
110+- 如果 `T-S035` 仍在推进,优先保证 text-only `inject / send` 口径一致
111+
112+## 验收标准
113+
114+- upload / download 路径已从主链移除
115+- text-only delivery 会直接注入完整文本
116+- 超长文本会稳定截断并追加 `超长截断`
117+- `git diff --check` 通过
118+
119+## 评测要求
120+
121+### 1. 正向评测
122+
123+- text-only delivery 能直接注入并发送
124+- 超长输出会稳定触发截断
125+
126+### 2. 反向评测
127+
128+- inject 失败不能误判成成功
129+- send 失败不能误判成成功
130+
131+### 3. 边界评测
132+
133+- 行数接近阈值时,截断行为要稳定
134+- 页面轻微抖动时,adapter 错误结果仍要稳定
135+
136+## 推荐验证命令
137+
138+- `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build`
139+- `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js`
140+- `node --test /Users/george/code/baa-conductor/tests/browser/browser-control-e2e-smoke.test.mjs`
141+- `git -C /Users/george/code/baa-conductor diff --check`
142+
143+## 交付要求
144+
145+完成后请说明:
146+
147+- 修改了哪些文件
148+- text-only delivery 是怎么收口的
149+- 超长截断策略是怎么实现的
150+- 跑了哪些测试
151+- 还有哪些剩余风险
152+
153+## 完成回写(2026-03-28)
154+
155+- 已完成:
156+  - `conductor-daemon` 已删除 artifact materialize / manifest / delivery-plan / upload/download 路径
157+  - Firefox bridge 和插件侧已不再接受 `browser.upload_artifacts` / `browser.upload_receipt`
158+  - live delivery 现在直接渲染纯文本并走 `browser.inject_message` / `browser.send_message`
159+  - 超长文本会按默认 `200` 行截断,并在末尾追加 `超长截断`
160+  - `apps/conductor-daemon/src/index.test.js` 与 `tests/browser/browser-control-e2e-smoke.test.mjs` 已覆盖 text-only 成功路径、截断路径和 inject fail-closed
161+- 当前剩余风险:
162+  - 当前仍是单客户端、单轮 delivery
163+  - 极长单行文本没有再做二次分段,当前主要依赖行截断策略
M tasks/task-doc-template.md
+7, -5
 1@@ -20,13 +20,15 @@
 2 
 3 如果是“按某个任务文档开始开发”,推荐提示词:
 4 
 5-- `读 /Users/george/code/baa-conductor/tasks/T-S001.md 任务文档,完成开发任务。`
 6+- `读 /Users/george/code/baa-conductor/tasks/<当前任务文档>.md 任务文档,完成开发任务。`
 7 
 8 ## 编写规则
 9 
10 - 路径一律写绝对路径
11 - 任务文档统一放在 `/Users/george/code/baa-conductor/tasks/`
12 - 当前基线要写明 `main` 和具体提交
13+- 开工要求必须明确写“先从当前 `main` 新建任务分支,再开始开发;禁止直接在 `main` 上改”
14+- 功能任务分支名必须以 `feat/` 开头;缺陷任务分支名必须以 `bug/` 开头
15 - 能并行的任务优先拆开,并明确写清 `允许修改的目录`
16 - 不要把背景、实现边界、验收标准混成一段
17 - 验收标准必须能直接检查,不要只写“功能正常”
18@@ -51,13 +53,13 @@
19 ## 当前基线
20 
21 - 仓库:`/Users/george/code/baa-conductor`
22-- 分支:`main`
23+- 分支基线:`main`
24 - 提交:`<当前 main 提交>`
25-- 开工要求:不要从其他任务分支切出;如需新分支,从当前 `main` 新切
26+- 开工要求:必须先从当前 `main` 新建任务分支,再开始开发;禁止直接在 `main` 上修改。功能任务分支名必须以 `feat/` 开头,缺陷任务分支名必须以 `bug/` 开头。
27 
28-## 建议分支名
29+## 必须创建的新分支名
30 
31-- `<type>/<short-branch-name>`
32+- `<feat/short-branch-name 或 bug/short-branch-name>`
33 
34 ## 目标
35 
M tests/browser/browser-control-e2e-smoke.test.mjs
+278, -80
  1@@ -8,6 +8,10 @@ import test from "node:test";
  2 import { ConductorRuntime } from "../../apps/conductor-daemon/dist/index.js";
  3 
  4 const require = createRequire(import.meta.url);
  5+const {
  6+  createDeliveryRuntime,
  7+  getPlatformAdapter,
  8+} = require("../../plugins/baa-firefox/delivery-adapters.js");
  9 const {
 10   createRelayState,
 11   observeSse,
 12@@ -298,6 +302,179 @@ function buildShellRuntime(platform, overrides = {}) {
 13   };
 14 }
 15 
 16+function registerSelectors(map, selectors, element) {
 17+  for (const selector of selectors) {
 18+    map.set(selector, [element]);
 19+  }
 20+}
 21+
 22+function createMockElement(options = {}) {
 23+  const attributes = new Map(
 24+    Object.entries(options.attributes || {}).map(([key, value]) => [key.toLowerCase(), String(value)])
 25+  );
 26+
 27+  return {
 28+    disabled: options.disabled === true,
 29+    dispatchedEvents: [],
 30+    files: options.files || [],
 31+    focusCalls: 0,
 32+    getAttribute(name) {
 33+      return attributes.get(String(name || "").toLowerCase()) ?? null;
 34+    },
 35+    getBoundingClientRect() {
 36+      return options.visible === false
 37+        ? {
 38+            width: 0,
 39+            height: 0
 40+          }
 41+        : {
 42+            width: 120,
 43+            height: 32
 44+          };
 45+    },
 46+    isContentEditable: options.isContentEditable === true,
 47+    tagName: String(options.tagName || "DIV").toUpperCase(),
 48+    textContent: options.textContent || "",
 49+    type: options.type || "",
 50+    value: options.value || "",
 51+    focus() {
 52+      this.focusCalls += 1;
 53+    },
 54+    click() {
 55+      if (typeof options.onClick === "function") {
 56+        options.onClick(this);
 57+      }
 58+    },
 59+    dispatchEvent(event) {
 60+      this.dispatchedEvents.push(event);
 61+      return true;
 62+    }
 63+  };
 64+}
 65+
 66+function createDeliveryHarness(options = {}) {
 67+  const platform = options.platform || "chatgpt";
 68+  const adapter = getPlatformAdapter(platform);
 69+  if (!adapter) {
 70+    throw new Error(`unsupported harness platform: ${platform}`);
 71+  }
 72+
 73+  const selectorMap = new Map();
 74+  const state = {
 75+    bodyText: options.bodyText || "Shell ready",
 76+    now: 0,
 77+    readyState: options.pageReady === false ? "loading" : "complete",
 78+    url: options.url
 79+      || (platform === "claude" ? "https://claude.ai/#baa-shell" : "https://chatgpt.com/#baa-shell")
 80+  };
 81+  const body = {
 82+    get innerText() {
 83+      return state.bodyText;
 84+    },
 85+    set innerText(value) {
 86+      state.bodyText = String(value || "");
 87+    }
 88+  };
 89+  const document = {
 90+    body: options.pageReady === false ? null : body,
 91+    execCommand() {
 92+      return true;
 93+    },
 94+    querySelectorAll(selector) {
 95+      return selectorMap.get(selector) || [];
 96+    },
 97+    readyState: state.readyState
 98+  };
 99+  const main = createMockElement({
100+    tagName: "main"
101+  });
102+  const composer = createMockElement({
103+    tagName: "textarea",
104+    value: options.initialComposerText || ""
105+  });
106+  const sendButton = createMockElement({
107+    tagName: "button",
108+    onClick: () => {
109+      state.sendClicked = true;
110+
111+      if (options.confirmSend === false) {
112+        return;
113+      }
114+
115+      sendButton.disabled = true;
116+      composer.value = "";
117+    }
118+  });
119+
120+  if (options.pageReady !== false) {
121+    registerSelectors(selectorMap, adapter.readinessSelectors, main);
122+  }
123+  if (options.includeComposer !== false) {
124+    registerSelectors(selectorMap, adapter.composerSelectors, composer);
125+  }
126+  if (options.includeSendButton !== false) {
127+    registerSelectors(selectorMap, adapter.sendButtonSelectors, sendButton);
128+  }
129+
130+  const runtime = createDeliveryRuntime({
131+    env: {
132+      assignFiles(input, files) {
133+        input.files = files;
134+
135+        if (options.confirmUpload === false) {
136+          return;
137+        }
138+
139+        state.bodyText = `${state.bodyText}\n${files[0]?.name || ""}`.trim();
140+      },
141+      createChangeEvent() {
142+        return {
143+          type: "change"
144+        };
145+      },
146+      createFile(bytes, filename, mimeType) {
147+        return {
148+          bytes,
149+          name: filename,
150+          type: mimeType
151+        };
152+      },
153+      createInputEvent(data) {
154+        return {
155+          data,
156+          type: "input"
157+        };
158+      },
159+      document,
160+      getComputedStyle() {
161+        return {
162+          display: "block",
163+          opacity: "1",
164+          visibility: "visible"
165+        };
166+      },
167+      getLocationHref() {
168+        return state.url;
169+      },
170+      now() {
171+        return state.now;
172+      },
173+      async sleep(ms) {
174+        state.now += Number(ms) || 0;
175+      }
176+    }
177+  });
178+
179+  return {
180+    adapter,
181+    composer,
182+    document,
183+    runtime,
184+    sendButton,
185+    state
186+  };
187+}
188+
189 function sendPluginActionResult(socket, input) {
190   const shellRuntime = input.shell_runtime ?? (input.platform ? [buildShellRuntime(input.platform)] : []);
191   const results =
192@@ -1251,7 +1428,7 @@ test("browser control e2e smoke covers metadata read surface plus Claude and Cha
193   }
194 });
195 
196-test("browser delivery bridge waits for all upload receipts before inject and send", async () => {
197+test("browser delivery bridge injects text-only results and truncates overlong output", async () => {
198   const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-delivery-smoke-"));
199   const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-browser-delivery-host-"));
200   const runtime = new ConductorRuntime(
201@@ -1279,6 +1456,7 @@ test("browser delivery bridge waits for all upload receipts before inject and se
202     const snapshot = await runtime.start();
203     const baseUrl = snapshot.controlApi.localApiBase;
204     client = await connectFirefoxBridgeClient(snapshot.controlApi.firefoxWsUrl, "firefox-delivery-smoke");
205+    const execCommand = "i=1; while [ $i -le 260 ]; do printf 'line-%s\\n' \"$i\"; i=$((i+1)); done";
206 
207     client.socket.send(
208       JSON.stringify({
209@@ -1288,72 +1466,30 @@ test("browser delivery bridge waits for all upload receipts before inject and se
210         assistant_message_id: "msg-delivery-smoke",
211         raw_text: [
212           "```baa",
213-          `@conductor::exec::{"command":"printf 'artifact-one\\n'","cwd":${JSON.stringify(hostOpsDir)}}`,
214-          "```",
215-          "",
216-          "```baa",
217-          `@conductor::exec::{"command":"printf 'artifact-two\\n'","cwd":${JSON.stringify(hostOpsDir)}}`,
218+          `@conductor::exec::${JSON.stringify({
219+            command: execCommand,
220+            cwd: hostOpsDir
221+          })}`,
222           "```"
223         ].join("\n"),
224         observed_at: 1710000010000
225       })
226     );
227 
228-    const uploadMessage = await client.queue.next(
229-      (message) => message.type === "browser.upload_artifacts"
230-    );
231-    assert.equal(uploadMessage.platform, "chatgpt");
232-    assert.equal(uploadMessage.uploads.length, 3);
233-
234-    const firstArtifact = await fetchJson(uploadMessage.uploads[0].download_url);
235-    assert.equal(firstArtifact.response.status, 200);
236-    assert.equal(firstArtifact.payload.data.artifact_id, uploadMessage.uploads[0].artifact_id);
237-    assert.equal(firstArtifact.payload.data.filename, uploadMessage.uploads[0].filename);
238-    assert.equal(firstArtifact.payload.data.encoding, "base64");
239-
240     await expectQueueTimeout(
241       client.queue,
242-      (message) => message.type === "browser.inject_message"
243-    );
244-
245-    client.socket.send(
246-      JSON.stringify({
247-        type: "browser.upload_receipt",
248-        plan_id: uploadMessage.plan_id,
249-        receipts: [
250-          {
251-            artifact_id: uploadMessage.uploads[0].artifact_id,
252-            attempts: 1,
253-            ok: true,
254-            remote_handle: "remote-file-1"
255-          }
256-        ]
257-      })
258-    );
259-
260-    await expectQueueTimeout(
261-      client.queue,
262-      (message) => message.type === "browser.inject_message"
263-    );
264-
265-    client.socket.send(
266-      JSON.stringify({
267-        type: "browser.upload_receipt",
268-        plan_id: uploadMessage.plan_id,
269-        receipts: uploadMessage.uploads.slice(1).map((upload, index) => ({
270-          artifact_id: upload.artifact_id,
271-          attempts: 1,
272-          ok: true,
273-          remote_handle: `remote-file-${index + 2}`
274-        }))
275-      })
276+      (message) => message.type === "browser.upload_artifacts",
277+      700
278     );
279 
280     const injectMessage = await client.queue.next(
281       (message) => message.type === "browser.inject_message"
282     );
283     assert.equal(injectMessage.platform, "chatgpt");
284-    assert.match(injectMessage.message_text, /\[BAA Result Index\]/u);
285+    assert.match(injectMessage.message_text, /\[BAA 执行结果\]/u);
286+    assert.match(injectMessage.message_text, /line-1/u);
287+    assert.doesNotMatch(injectMessage.message_text, /line-260/u);
288+    assert.match(injectMessage.message_text, /超长截断$/u);
289 
290     await expectQueueTimeout(
291       client.queue,
292@@ -1387,10 +1523,12 @@ test("browser delivery bridge waits for all upload receipts before inject and se
293       return result;
294     });
295 
296-    assert.equal(browserStatus.payload.data.delivery.last_session.receipt_confirmed_count, 3);
297-    assert.deepEqual(browserStatus.payload.data.delivery.last_session.pending_upload_artifact_ids, []);
298-    assert.equal(browserStatus.payload.data.delivery.last_session.upload_receipts.length, 3);
299     assert.equal(browserStatus.payload.data.delivery.last_session.platform, "chatgpt");
300+    assert.equal(browserStatus.payload.data.delivery.last_session.message_truncated, true);
301+    assert.ok(
302+      browserStatus.payload.data.delivery.last_session.source_line_count
303+      > browserStatus.payload.data.delivery.last_session.message_line_count
304+    );
305   } finally {
306     client?.queue.stop();
307     client?.socket.close(1000, "done");
308@@ -1406,7 +1544,7 @@ test("browser delivery bridge waits for all upload receipts before inject and se
309   }
310 });
311 
312-test("browser delivery bridge fails closed when upload receipts report failure", async () => {
313+test("browser delivery bridge fails closed when inject_message reports failure", async () => {
314   const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-delivery-fail-"));
315   const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-browser-delivery-fail-host-"));
316   const runtime = new ConductorRuntime(
317@@ -1450,31 +1588,25 @@ test("browser delivery bridge fails closed when upload receipts report failure",
318       })
319     );
320 
321-    const uploadMessage = await client.queue.next(
322-      (message) => message.type === "browser.upload_artifacts"
323-    );
324-    assert.equal(uploadMessage.uploads.length, 2);
325-
326-    client.socket.send(
327-      JSON.stringify({
328-        type: "browser.upload_receipt",
329-        plan_id: uploadMessage.plan_id,
330-        receipts: [
331-          {
332-            artifact_id: uploadMessage.uploads[0].artifact_id,
333-            attempts: 2,
334-            error: "upload_failed",
335-            ok: false
336-          }
337-        ]
338-      })
339-    );
340-
341     await expectQueueTimeout(
342       client.queue,
343-      (message) => message.type === "browser.inject_message",
344+      (message) => message.type === "browser.upload_artifacts",
345       700
346     );
347+
348+    const injectMessage = await client.queue.next(
349+      (message) => message.type === "browser.inject_message"
350+    );
351+    sendPluginActionResult(client.socket, {
352+      action: "inject_message",
353+      commandType: "browser.inject_message",
354+      failed: true,
355+      platform: "chatgpt",
356+      reason: "inject_failed",
357+      requestId: injectMessage.requestId,
358+      type: "browser.inject_message"
359+    });
360+
361     await expectQueueTimeout(
362       client.queue,
363       (message) => message.type === "browser.send_message",
364@@ -1488,8 +1620,8 @@ test("browser delivery bridge fails closed when upload receipts report failure",
365       return result;
366     });
367 
368-    assert.match(browserStatus.payload.data.delivery.last_session.failed_reason, /upload_failed/u);
369-    assert.equal(browserStatus.payload.data.delivery.last_session.inject_started_at, undefined);
370+    assert.match(browserStatus.payload.data.delivery.last_session.failed_reason, /inject_failed/u);
371+    assert.ok(browserStatus.payload.data.delivery.last_session.inject_started_at);
372     assert.equal(browserStatus.payload.data.delivery.last_session.send_started_at, undefined);
373   } finally {
374     client?.queue.stop();
375@@ -1506,6 +1638,72 @@ test("browser delivery bridge fails closed when upload receipts report failure",
376   }
377 });
378 
379+test("delivery adapters complete ChatGPT inject/send with explicit confirmation", async () => {
380+  const harness = createDeliveryHarness({
381+    platform: "chatgpt"
382+  });
383+
384+  const injectResult = await harness.runtime.handleCommand({
385+    command: "inject_message",
386+    platform: "chatgpt",
387+    retryAttempts: 1,
388+    text: "hello from delivery smoke",
389+    timeoutMs: 120
390+  });
391+  assert.equal(injectResult.ok, true);
392+  assert.equal(injectResult.details.confirmed_by, "composer_text_match");
393+  assert.equal(harness.composer.value, "hello from delivery smoke");
394+
395+  const sendResult = await harness.runtime.handleCommand({
396+    command: "send_message",
397+    platform: "chatgpt",
398+    retryAttempts: 1,
399+    timeoutMs: 120
400+  });
401+  assert.equal(sendResult.ok, true);
402+  assert.equal(sendResult.details.confirmed_by, "send_button_disabled");
403+  assert.equal(harness.sendButton.disabled, true);
404+  assert.equal(harness.composer.value, "");
405+});
406+
407+test("delivery adapters fail closed when page is not ready", async () => {
408+  const harness = createDeliveryHarness({
409+    pageReady: false,
410+    platform: "claude"
411+  });
412+
413+  const result = await harness.runtime.handleCommand({
414+    command: "inject_message",
415+    platform: "claude",
416+    retryAttempts: 1,
417+    text: "should not send",
418+    timeoutMs: 120
419+  });
420+
421+  assert.equal(result.ok, false);
422+  assert.equal(result.code, "page_not_ready");
423+  assert.match(result.reason, /delivery\.page_not_ready/u);
424+});
425+
426+test("delivery adapters fail closed when send click is not confirmed", async () => {
427+  const harness = createDeliveryHarness({
428+    confirmSend: false,
429+    platform: "chatgpt"
430+  });
431+  harness.composer.value = "still queued";
432+
433+  const result = await harness.runtime.handleCommand({
434+    command: "send_message",
435+    platform: "chatgpt",
436+    retryAttempts: 1,
437+    timeoutMs: 120
438+  });
439+
440+  assert.equal(result.ok, false);
441+  assert.equal(result.code, "send_not_confirmed");
442+  assert.match(result.reason, /delivery\.send_not_confirmed/u);
443+});
444+
445 test("browser control e2e smoke accepts browser.final_message and keeps recent relay snapshots", async () => {
446   const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-final-message-smoke-"));
447   const runtime = new ConductorRuntime(