- 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
+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。
+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`
+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 });
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-}
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";
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-}
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-}
+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 {
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;
+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;
+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
+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>
+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+}
+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");
+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,
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
+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),
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 }
+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
+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`
+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 行为:
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
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
+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 ## 浏览器本地代发
+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+以上路径已从当前代码主线移除。
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
+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 ## 背景
+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)
+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 发送:
+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", {
+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 }
+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">
+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"
+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);
+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"
+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-- 还有哪些剩余风险
+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` 回溯
+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
+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+ - 极长单行文本没有再做二次分段,当前主要依赖行截断策略
+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
+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(