@sema-agent/client-core 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/adapt.d.ts +17 -3
  2. package/dist/adapt.js +555 -36
  3. package/dist/clientSlice.d.ts +169 -0
  4. package/dist/clientSlice.js +62 -0
  5. package/dist/diff/patch.d.ts +29 -0
  6. package/dist/diff/patch.js +45 -0
  7. package/dist/engineSessionParam.d.ts +7 -0
  8. package/dist/engineSessionParam.js +35 -0
  9. package/dist/engineWireSdk.d.ts +25 -1
  10. package/dist/engineWireSdk.js +30 -4
  11. package/dist/finalVerifyWire.d.ts +74 -0
  12. package/dist/finalVerifyWire.js +63 -0
  13. package/dist/headlessPermissionModeWire.d.ts +55 -0
  14. package/dist/headlessPermissionModeWire.js +111 -0
  15. package/dist/headlessReconnectWire.d.ts +96 -0
  16. package/dist/headlessReconnectWire.js +141 -0
  17. package/dist/host.d.ts +90 -0
  18. package/dist/host.js +84 -0
  19. package/dist/index.d.ts +36 -1
  20. package/dist/index.js +48 -3
  21. package/dist/interactiveToolsWire.d.ts +48 -0
  22. package/dist/interactiveToolsWire.js +86 -0
  23. package/dist/limitsWire.d.ts +89 -0
  24. package/dist/limitsWire.js +225 -0
  25. package/dist/notifications.d.ts +7 -0
  26. package/dist/notifications.js +32 -0
  27. package/dist/request/taskRequest.d.ts +135 -0
  28. package/dist/request/taskRequest.js +176 -0
  29. package/dist/sandboxWire.d.ts +75 -0
  30. package/dist/sandboxWire.js +138 -0
  31. package/dist/scenarioWire.d.ts +62 -0
  32. package/dist/scenarioWire.js +115 -0
  33. package/dist/scratchpadWireCaps.d.ts +16 -0
  34. package/dist/scratchpadWireCaps.js +65 -0
  35. package/dist/seam.d.ts +114 -11
  36. package/dist/seam.js +31 -0
  37. package/dist/toolResult.d.ts +118 -0
  38. package/dist/toolResult.js +774 -0
  39. package/package.json +4 -2
package/dist/adapt.js CHANGED
@@ -1,8 +1,16 @@
1
1
  import { deriveTranscriptId } from './seam.js';
2
- import { normalizeTaskNotification, renderTaskNotificationXml, taskNotificationDedupKey } from './notifications.js';
2
+ import { dropQueuedNotificationsForRun, isEngineWorkflowNotified, isWorkflowCompletionCardEnqueued, listNotifiedRuns, listWorkflowCompletionCardsEnqueued, markEngineWorkflowNotified, normalizeTaskNotification, noteWorkflowCompletionCardEnqueued, renderTaskNotificationXml, taskNotificationDedupKey, } from './notifications.js';
3
3
  import { mapBrainStatusToRetry } from './retryStatus.js';
4
4
  import { steeringInjectedToAttachments } from './steering.js';
5
5
  import { projectDiagnosticsFrame } from './diagnostics.js';
6
+ // ── B4:A 层搬入后适配器**自持**的四个本包台账(写者与读者同包 = R4 单实例纪律;
7
+ // 这四件都没有 UI 订阅面,做成 chrome 臂只会凭空长出一条「宿主不实现就静默失效」的义务)。
8
+ // ── B5:B/D/E 层(工具结果卡)。纯投影 + 三个本包台账副作用,零宿主依赖。
9
+ import { degradedToolResultBody, detectEngineBgShellReceipt, reportFindingsToolUseResult, structuredToToolUseResult, todoWriteToolUseResult, wireOutputToBody, } from './toolResult.js';
10
+ import { recordEngineToolLabel } from './engineToolLabelStore.js';
11
+ import { registerSubagentAlias } from './subagentContentStore.js';
12
+ import { isWorkflowAgentTaskId, recordWorkflowAgentTaskId } from './workflow.js';
13
+ import { clearEnginePanelTaskResident, markEnginePanelTaskResident } from './engineAgentPanelStore.js';
6
14
  /** 主车道证明——本批实现的臂全在 leader lane(子代/workflow lane 的 tick 面留 #52b)。 */
7
15
  const MAIN = { lane: 'main' };
8
16
  /**
@@ -62,6 +70,32 @@ export function estimateCjkTokens(s) {
62
70
  return Math.round(t);
63
71
  }
64
72
  /** 引擎 subagent 卡的短标签(cli shortTaskLabel 逐字)。 */
73
+ /**
74
+ * T8 —— 压平 §E1 wire `output`(**非均匀**:`string` | `(TextContent|ImageContent)[]`,events.d.ts:78)
75
+ * 成一条纯文本(cli `flattenWireOutput` 逐字)。text 块贡献 `.text`,image 块贡献中性占位 `[image]`
76
+ * (转录卡是文本粒度),裸字符串直通。
77
+ * 🔴 载荷 UNTRUSTED + 仅供观测(events.d.ts:80-85):渲染可以,**绝不回喂模型**;`truncated` 的更不行。
78
+ * B4 把它从 B 层(`wireOutputToBody`)提到独立导出:A 层的 `settlePanelTasks(report)` 也要用它
79
+ * (关卡时把子代最终报告串到面板行 end 事件上),两处必须同一份实现。
80
+ */
81
+ export function flattenWireOutput(output) {
82
+ if (typeof output === 'string')
83
+ return output;
84
+ if (Array.isArray(output)) {
85
+ const parts = [];
86
+ for (const block of output) {
87
+ if (block && typeof block === 'object') {
88
+ const b = block;
89
+ if (b.type === 'text' && typeof b.text === 'string')
90
+ parts.push(b.text);
91
+ else if (b.type === 'image')
92
+ parts.push('[image]');
93
+ }
94
+ }
95
+ return parts.join('');
96
+ }
97
+ return '';
98
+ }
65
99
  export function shortTaskLabel(task) {
66
100
  const firstLine = (task.split('\n').find(l => l.trim()) ?? task).trim();
67
101
  const words = firstLine.split(/\s+/).slice(0, 6).join(' ');
@@ -82,6 +116,13 @@ export function sanitizeToolUseBlock(block) {
82
116
  const rawInput = out.input;
83
117
  const eventId = typeof out.eventId === 'string' ? out.eventId : undefined;
84
118
  const parentToolCallId = typeof out.parentToolCallId === 'string' ? out.parentToolCallId : undefined;
119
+ // 🔴 B4 补漏(0.5.0 真缺口,差分守卫此前无 fixture 携 label ⇒ 漏得静默):core 的**展示名**
120
+ // `tool_start.label` 同样是侧信道,必须在渲染前摘掉 —— tool_use block 会进壳 transcript,
121
+ // /compact 时原样直发 provider,多一个键 = 400 invalid_request(cli 头注记的「件1 事故同款」)。
122
+ // 摘下来的值由调用方交给 engineToolLabelStore(纯函数不做副作用),unknown-tool 卡按
123
+ // toolCallId 查。今天唯一的真命中族 = MCP 远端工具(name=mcp__srv__tool / label=srv:tool)。
124
+ const label = typeof out.label === 'string' ? out.label : undefined;
125
+ delete out.label;
85
126
  delete out.eventId;
86
127
  delete out.parentToolCallId;
87
128
  if (out.input && typeof out.input === 'object') {
@@ -114,7 +155,13 @@ export function sanitizeToolUseBlock(block) {
114
155
  delete inp.subagent_type;
115
156
  }
116
157
  }
117
- return { block: out, rawInput, ...(eventId !== undefined ? { eventId } : {}), ...(parentToolCallId !== undefined ? { parentToolCallId } : {}) };
158
+ return {
159
+ block: out,
160
+ rawInput,
161
+ ...(eventId !== undefined ? { eventId } : {}),
162
+ ...(parentToolCallId !== undefined ? { parentToolCallId } : {}),
163
+ ...(label !== undefined ? { label } : {}),
164
+ };
118
165
  }
119
166
  /** seam 的 reserved decision 哨兵(cli decisionOf 逐字)。 */
120
167
  function decisionOf(block) {
@@ -137,8 +184,10 @@ export const ADAPTER_COVERAGE = {
137
184
  'diagnostics',
138
185
  'steering_injected',
139
186
  'workspace_changed',
187
+ 'prompt_suggestions',
140
188
  'retry_status',
141
- 'tool_end_result(仅 response-id 复位 + 开卡台账出栈,不铸 tool_result)',
189
+ 'task_progress',
190
+ 'tool_end_result(label 补位 + 关卡 settle + response-id 复位 + 开卡台账出栈;不铸 tool_result)',
142
191
  ],
143
192
  /** 已落码臂产出的 transcript 消息类目(差分守卫的比对域)。 */
144
193
  transcriptKinds: [
@@ -157,15 +206,26 @@ export const ADAPTER_COVERAGE = {
157
206
  'T59 abort 半场(ctx.signal;中断后不产终答)',
158
207
  'T60 无 result 收尾腿(泄尾增量 → 清 liveness → 尾思考 → 尾文本段)',
159
208
  ],
209
+ /** B4(0.6.0)新落码的臂/半场 —— A 层收官批,同样别再在 todo 里留同名条目。 */
210
+ b4: [
211
+ 'A 层第 14 臂 task_progress:workflow lane 三级门 + 判据④ belt 回植 + C1 alias + bindTaskCard(显式父/FIFO/含糊即 unbound)+ inline stats + T50 双形读 + SubagentStart + #6 resident 台账 + 面板 tick',
212
+ 'settlePanelTasks 三处 sweep 全接线(tool_end 显式关卡带报告 / result 防御 sweep / turn 末 + abort sweep)',
213
+ 'P0-2 tool label 侧信道(sanitizeToolUseBlock 摘 label + engineToolLabelStore 登记;0.5.0 真缺口,label 会随 tool_use block 落转录 → /compact 直发 provider 400)',
214
+ 'task_notification 的两件宿主义务收回库内自持:dropQueuedNotificationsForRun + clearEnginePanelTaskResident;stop hook 的 if-started 门由库解',
215
+ 'T8 flattenWireOutput 提为独立导出(A 层 settle 报告与 B 层卡体同一份)',
216
+ ],
217
+ /** B5(0.7.0)新落码的臂/半场 —— B/D/E 层收批,同样别再在 todo 里留同名条目。 */
218
+ b5: [
219
+ 'B 层 structuredToToolUseResult 14 case(ask-user-question/bash/edit/create/update/text/grep/glob/notebook-edit/cron-create/cron-delete/cron-list/agent/task-output)+ workflow-run 登记副作用',
220
+ 'D 层 wireOutputToBody 3 臂(bash / taskoutput / 泛化);structured 白名单在场时 T11+T14 正则退位,缺席保回落',
221
+ 'E 层 4 臂:#117a bg Bash 回执(判定在库→chrome bgshell_register)· TodoWrite oldTodos 富卡 · ReportFindings 按名认领 · Remember→memory_saved 系统消息',
222
+ 'tool_end_result → user tool_result 正常路径(卡本体);result 与 turn 末两处开卡兜底关闭(abort 不兜底,cli 同)',
223
+ 'task/task-list structured → chrome task_ledger_sync{source:"structured"}(判定归一在库、落盘留宿主)',
224
+ 'T7 诚实缺席(§8-4:不搬 mock 合成器,改 degraded 标记)· T20 diff hunks 进包(src/diff/patch.ts)',
225
+ ],
160
226
  /** 如实留白——写不动/依赖未搬运资产的臂,#52b 起分批接。 */
161
227
  todo: [
162
- 'tool_end_result user tool_result 正常路径(需搬 wireOutputToBody/structuredToToolUseResult/mock 合成器 + TodoWrite/ReportFindings 富卡片,约 600 行;workflow 轮询信封的人话投影已在 workflow.ts 就位,等这条接线)',
163
- 'tool_end_result 的两个侧产物:Remember → memory_saved 系统消息、task/task-list structured → 面板台账全量同步',
164
- 'result/turn 末的开卡兜底关闭(同上,依赖合成器)',
165
- 'task_progress → 面板行绑定(bindTaskCard FIFO/显式父、inline stats、alias、resident 台账、SubagentStart hook、workflow lane 三级门)',
166
- 'settlePanelTasks 三处 sweep(依赖上面的行台账)',
167
- '#117a bg Bash 回执探测(detectEngineBgShellReceipt 文案锚定探测器)',
168
- 'abort 半场里依赖行台账的两件:settlePanelTasks 兜底 sweep + 开卡合成器兜底关卡(见上面两条)',
228
+ 'T13 **D 层** 半场(taskoutput block content)未随 structured 退位:退位会改 block content 形状(cli 那里是裸对象 = 件1 隐患),属行为面改动,无真实需求驱动 ⇒ 见 DIVERGENCE-8',
169
229
  'macrotask 让渡(cli 在提交前让出宏任务给 Ink;宿主渲染策略,不进库)',
170
230
  ],
171
231
  };
@@ -182,6 +242,29 @@ export const ADAPTER_DIVERGENCES = [
182
242
  '文件探针写 `${Date.now()} text_flush len=N`,adapt 是 `ctx.probe(\'stream\', …)` 注入口写 ' +
183
243
  '`${ctx.now()} text_flush len=N`。行格式与触发点(每次文本 flush 一次)逐字相同,' +
184
244
  '差在时钟源与落地方式(宿主决定落文件/上报/丢弃)。',
245
+ 'DIVERGENCE-5(B4):A 层行台账的三个探针同 DIVERGENCE-4 处理 —— cli 的 `SEMA_BIND_PROBE` / ' +
246
+ '`SEMA_TICK_PROBE` / `SEMA_HOOK_PROBE` 三个文件探针,在本包是 `ctx.probe(\'bind\'|\'tick\'|\'hook\', line)`。' +
247
+ '行载荷逐字同形(tick 是同键序 JSON),只把 `Date.now()` 换成 `ctx.now()`。',
248
+ 'DIVERGENCE-6(B4):`subagent_lifecycle` 的**去重与 if-started 门在库里解**,cli 在 module 级 Set 上解。' +
249
+ '可观测后果相同(同一 taskId 的 Start 恒一次;没 Start 过的 taskId 收不到通知臂的 Stop),' +
250
+ '差在「谁持台账」:cli=进程级、本包=适配器实例级(= 一个 session)。多 session 宿主(web 一页两会话)' +
251
+ '上本包语义**更正确**——cli 的 module 形会让两个会话互吞对方的 Start。' +
252
+ '⚠️ B5 更正范围:同批把 `notifiedRuns`/`cardEnqueuedRuns` **折回 module 台账**(它们与队列 port/' +
253
+ 'idle watcher/outstanding 药丸配对,per-session 会造成前提不共享);本条只剩 ' +
254
+ '`firedSubagentStartHookTaskIds` 与 `renderedNotifications` 两项仍是实例级。',
255
+ 'DIVERGENCE-7(B5,§8-4 裁定的**有意**分歧):`tool_end.output === undefined` 时 —— cli 用 mock ' +
256
+ 'toolIO 注册表**反造**结果体(卡片显示「看起来像真的、但不是这次执行产物」的内容);本包**诚实缺席**:' +
257
+ '`content: \'\'` + 消息上打 `_sema_degraded: \'wire_carried_no_output\'`,显示决策留端。' +
258
+ '依据=宪法「诚实优先于产出」+ 多端复制会各自编不同的假话。差分守卫按「哨兵成对替换 + 独立 oracle」' +
259
+ '比对(标记集必须恰等于「有 tool_end 但没带 output 的开卡集」,少标/多标两向都红)。',
260
+ 'DIVERGENCE-8(B5):**structured 在场时正则反解退位**(T11/T14;[1840]§一 白名单 + 设计稿 R9②)。' +
261
+ '同一帧上 cli 从模型面框架文本反解 {stdout,stderr,exitCode},本包直接读 `structured.bash.*`。' +
262
+ '框架**自洽**时两者逐字节相等(差分守卫 6 条 bash 双路 fixture 实证);不自洽时本包跟 structured ' +
263
+ '(框架里的尾换行等是渲染副产物)。T13/T24 的 **B 层**半场同款惰性退位(structured 带齐 ' +
264
+ 'status+content 就根本不解模型面文本);T13 的 **D 层** block-content 半场本批**未**退位 —— ' +
265
+ '那一步会改 block content 形状(cli 在这里是裸对象,件1 隐患),无真实需求驱动,如实留白。',
266
+ 'DIVERGENCE-9(B5):`tool_result` 消息的 uuid —— cli 在缺 `eventId` 时铸随机 v4,本包派生 ' +
267
+ '`wtc_<toolUseId>`(DIVERGENCE-2 同族:同流重放 ⇒ 同 id 序列,[1653])。守卫按别名结构比,不比字面。',
185
268
  ];
186
269
  const TASK_NOTIFICATION_TAG = 'task-notification';
187
270
  const TASK_ID_TAG = 'task-id';
@@ -192,16 +275,27 @@ const STATUS_TAG = 'status';
192
275
  */
193
276
  class WireToCcAdapterImpl {
194
277
  renderedNotifications = new Set();
195
- notifiedRuns = new Set();
196
- cardEnqueuedRuns = new Set();
278
+ // 🔴 B5 ①:`notifiedRuns` / `cardEnqueuedRuns` **不在实例上** —— 它们的配对半场(清账的
279
+ // dropQueuedNotificationsForRun、记账的两个 enqueue、idle watcher)全在 notifications.ts 的
280
+ // module 台账上,放实例 = 前提不共享 = 双卡/零显示/模型二收(0.6.0 真缺口,[1857])。
281
+ // 适配器一律经 notifications.ts 的四个口读写,别在这里再长一份影子副本。
197
282
  /** W1 三级判据素材:本壳见过的 Workflow 工具 tool_use id(module 级台账的实例化)。 */
198
283
  seenWorkflowToolCallIds = new Set();
284
+ /**
285
+ * B4 —— SubagentStart 一生只许一次(frame-lane-matrix #4)。cli 是 **module 级** Set:跨 turn 存活的
286
+ * bg 子代在每个新 generator 的首 tick 都会走到 fire 点(`livePanelTasks` 是 per-turn 的),没有它
287
+ * 就每 turn 重 fire 一次。本包放**实例级** = 一个 session 一份,语义等价且不污染进程(多 session
288
+ * 宿主如 web 一个页面开两个会话,cli 的 module 形反而会互相吞)。
289
+ * 同时它也是 `task_notification` 臂发 stop 的门(cli:`firedSubagentStartHookTaskIds.has(taskId)`)。
290
+ */
291
+ firedSubagentStartHookTaskIds = new Set();
199
292
  exportLedger() {
200
293
  return {
201
294
  version: 1,
202
295
  renderedNotifications: [...this.renderedNotifications],
203
- notifiedRuns: [...this.notifiedRuns],
204
- cardEnqueuedRuns: [...this.cardEnqueuedRuns],
296
+ // B5 ①:这两项是 module 台账的快照(不是实例私有),跨实例一致。
297
+ notifiedRuns: listNotifiedRuns(),
298
+ cardEnqueuedRuns: listWorkflowCompletionCardsEnqueued(),
205
299
  };
206
300
  }
207
301
  importLedger(state) {
@@ -216,10 +310,21 @@ class WireToCcAdapterImpl {
216
310
  }
217
311
  }
218
312
  };
313
+ /** B5 ①:module 台账那两项经真源写口回植(has 判重 = 与 take 同样只数新增)。 */
314
+ const takeModule = (v, has, add) => {
315
+ if (!Array.isArray(v))
316
+ return;
317
+ for (const k of v) {
318
+ if (typeof k === 'string' && k.length > 0 && !has(k)) {
319
+ add(k);
320
+ n++;
321
+ }
322
+ }
323
+ };
219
324
  const s = state;
220
325
  take(s.renderedNotifications, this.renderedNotifications);
221
- take(s.notifiedRuns, this.notifiedRuns);
222
- take(s.cardEnqueuedRuns, this.cardEnqueuedRuns);
326
+ takeModule(s.notifiedRuns, isEngineWorkflowNotified, markEngineWorkflowNotified);
327
+ takeModule(s.cardEnqueuedRuns, isWorkflowCompletionCardEnqueued, noteWorkflowCompletionCardEnqueued);
223
328
  return n;
224
329
  }
225
330
  /**
@@ -268,7 +373,9 @@ class WireToCcAdapterImpl {
268
373
  const external = /\btype="external"/.test(attrs);
269
374
  this.renderedNotifications.add(`${external ? 'external:' : ''}${taskId}:${status}`);
270
375
  if (!external && (status === 'completed' || status === 'failed' || status === 'killed')) {
271
- this.notifiedRuns.add(taskId);
376
+ // cli seedEngineTaskNotificationDedupFromTranscript 同位:回植走 module 台账
377
+ // (outstanding 药丸 / idle watcher 收摊那半场读的就是它)。
378
+ markEngineWorkflowNotified(taskId);
272
379
  }
273
380
  seeded++;
274
381
  }
@@ -277,9 +384,15 @@ class WireToCcAdapterImpl {
277
384
  return seeded;
278
385
  }
279
386
  noteCardEnqueued(runId) {
280
- this.cardEnqueuedRuns.add(runId);
387
+ // B5 ①:写 module 台账(与 dropQueuedNotificationsForRun 的清账半场共享前提)
388
+ // 壳 B4 的队列端口回钩仍可调它 —— 三条入队链本来就写同一个集合,回钩因此变成幂等 no-op。
389
+ noteWorkflowCompletionCardEnqueued(runId);
281
390
  }
282
391
  async *adapt(frames, ctx) {
392
+ // 下面几个 `function*` 表达式(非箭头 —— generator 不能写成箭头)要访问实例台账,
393
+ // 它们的 `this` 不是适配器,所以在这里固定一个引用。
394
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
395
+ const self = this;
283
396
  const turnStartAt = ctx.now();
284
397
  // ── id 派生 ────────────────────────────────────────────────────────────────────────────────
285
398
  // [1653]:稳定键优先序 = 帧 id > seq > toolCallId,全缺才 ctx.uuid()。现役 wire 的稳定键落在
@@ -318,12 +431,213 @@ class WireToCcAdapterImpl {
318
431
  let segmentAnchor = null;
319
432
  let thinkingAnchor = null;
320
433
  const pending = new Map();
434
+ // ── B4 / MF-10:面板行台账(cli generator 局部变量逐个对位)────────────────────────────────
435
+ // 引擎侧子代的 tick 只带 rollup 数字({toolUses,totalTokens}) = CC `AgentProgress` 同形,
436
+ // 所以忠实的 CC-187 面是 footer 的 CoordinatorTaskPanel(CC 自己对进程外 agent 的先例:
437
+ // 面板行 + task-notification,从不合成 inline 消息轨)。下面这组把「tick → 哪张委派卡」的
438
+ // 归属、行的活性(live/inert/unbound)、以及 settle 的三处 sweep 全部管起来。
439
+ /** taskId → 绑定的 open tool_use id。 */
440
+ const taskCardBinding = new Map();
441
+ /** 已被某 taskId 认领的 tool_use id(一张卡只认一个子代)。 */
442
+ const boundToolCalls = new Set();
443
+ /** 本 turn 发布过面板行的全部 taskId(绑定与否都算)。 */
444
+ const livePanelTasks = new Set();
445
+ /** 已落终态的行(settle 幂等)。 */
446
+ const endedPanelTasks = new Set();
447
+ /**
448
+ * #6 —— 绑定时那张卡**正开着**的 taskId(= "live" 绑定)。只有这些行属本 turn 生命周期、
449
+ * 可被 turn 末防御 sweep 结掉。inert(显式父指向已关/没见过的卡 —— 跨 turn bg 子代的典型形)
450
+ * 与 unbound 行是 **session 常驻**:终态只认 task_notification / bg_notification。
451
+ */
452
+ const liveBoundPanelTasks = new Set();
453
+ /** taskId → 子代类型(Stop hook 查它;cli 同为 turn 局部)。 */
454
+ const subagentTypeById = new Map();
321
455
  const chrome = (event) => ({ plane: 'chrome', event });
322
456
  const transcript = (message) => ({
323
457
  plane: 'transcript',
324
458
  message: message,
325
459
  });
326
460
  const mintResponseId = (frame) => (assistantResponseId ??= `msg_sema_${idOf(frame, 'resp')}`);
461
+ // ── B4 / MF-10:行绑定 + 三处 sweep(cli bindTaskCard / unboundSubagentCandidates /
462
+ // settlePanelTasks 逐条对位)───────────────────────────────────────────────────────────
463
+ /** 行的车道证明:绑到卡的 = subagent lane(带父卡 id),未绑的 = 主 lane(taskId 键的独立行)。 */
464
+ const laneOfTask = (taskId) => {
465
+ const card = taskCardBinding.get(taskId);
466
+ return card !== undefined ? { lane: 'subagent', parentToolCallId: card } : MAIN;
467
+ };
468
+ const bindTaskCard = (taskId, parentToolCallId) => {
469
+ const existing = taskCardBinding.get(taskId);
470
+ if (existing !== undefined)
471
+ return existing;
472
+ let target;
473
+ if (parentToolCallId !== undefined) {
474
+ // P1② 精确绑定 —— wire 点名了委派卡。信但不无条件信([1617]①,frame-lane-matrix #1):
475
+ // 被点名的卡**开着**且**不是** subagent 形工具(Task/Agent/Fork)时拒绝绑定 —— 否则
476
+ // workflow/Bash 卡一关就会把这条行 settle 掉、还会收走它的 inline stats(错归因)。
477
+ // 行保持 unbound(taskId 键的 rollup 仍是真的)。已关/没见过的 id 仍**惰性绑定**(inert):
478
+ // 终态由通知落,严格优于错绑另一张开着的卡。
479
+ const named = pending.get(parentToolCallId);
480
+ if (named !== undefined && !SUBAGENT_TOOL_NAMES.has(named.name))
481
+ return undefined;
482
+ target = parentToolCallId;
483
+ }
484
+ else {
485
+ for (const [id, p] of pending) {
486
+ if (!boundToolCalls.has(id) && SUBAGENT_TOOL_NAMES.has(p.name)) {
487
+ target = id;
488
+ break;
489
+ }
490
+ }
491
+ }
492
+ if (target === undefined)
493
+ return undefined;
494
+ taskCardBinding.set(taskId, target);
495
+ boundToolCalls.add(target);
496
+ // #6 live vs inert:只有绑到**此刻开着**的卡才把行系进本 turn 的生命周期
497
+ // (FIFO 目标恒开着;显式目标可能已关 = inert)。
498
+ if (pending.has(target))
499
+ liveBoundPanelTasks.add(taskId);
500
+ // cli 的 `SEMA_BIND_PROBE` 文件探针 → 注入口(载荷逐字同形,见 ADAPTER_DIVERGENCES-5)。
501
+ ctx.probe?.('bind', `bind ${taskId} -> ${target} via=${parentToolCallId !== undefined ? 'explicit' : 'fifo'}`);
502
+ return target;
503
+ };
504
+ /** 对抗复审修(w0zwpa251 #1):≥2 张未绑的 subagent 形卡开着且 wire 没点名 ⇒ 任何猜都是错归因。 */
505
+ const unboundSubagentCandidates = () => {
506
+ let n = 0;
507
+ for (const [id, p] of pending) {
508
+ if (!boundToolCalls.has(id) && SUBAGENT_TOOL_NAMES.has(p.name))
509
+ n++;
510
+ }
511
+ return n;
512
+ };
513
+ /** SubagentStart(cli fireSubagentStartHook 的臂化):类型恒记,fire 一生一次。 */
514
+ const fireSubagentStart = function* (taskId, agentType) {
515
+ subagentTypeById.set(taskId, agentType); // Stop 的类型查找恒记(fire 与否无关)
516
+ if (self.firedSubagentStartHookTaskIds.has(taskId))
517
+ return;
518
+ self.firedSubagentStartHookTaskIds.add(taskId);
519
+ ctx.probe?.('hook', `start ${taskId} type=${agentType}`);
520
+ yield chrome({ kind: 'subagent_lifecycle', laneProof: laneOfTask(taskId), phase: 'start', taskId, agentType });
521
+ };
522
+ /**
523
+ * 结掉一张关闭中的卡所绑的面板行(`cardId`),或 turn 末/abort 结掉**本 turn 活绑**的全部行
524
+ * (`cardId===null` 的防御 sweep —— 子代从不发终态 tick)。
525
+ * 拍1 查看态:显式关卡臂携委派卡的 output(= 子代最终报告)到 end 事件上;sweep 无报告。
526
+ */
527
+ const settlePanelTasks = function* (cardId, isError, report) {
528
+ // inline 群组行孪生(clay dogfood 2026-07-03):冻结该卡的 tick 终值,免得"N agents finished"
529
+ // 行把真实工具数/token 重置回 0。null-cardId 的 sweep 结掉每一条活着的 inline 条目。
530
+ if (cardId !== null) {
531
+ yield chrome({ kind: 'inline_task_stats', laneProof: { lane: 'subagent', parentToolCallId: cardId }, op: 'settle', cardId, isError });
532
+ }
533
+ else {
534
+ yield chrome({ kind: 'inline_task_stats', laneProof: MAIN, op: 'settle_all' });
535
+ }
536
+ for (const taskId of livePanelTasks) {
537
+ if (cardId !== null && taskCardBinding.get(taskId) !== cardId)
538
+ continue;
539
+ // #6 —— 防御 sweep 只结**本 turn 活绑**的行(流被截断的形)。session 常驻行
540
+ // (inert 绑定 / unbound = 跨 turn bg 子代)绝不在这里冻成假 completed:它的终态真相
541
+ // 由 task_notification / bg_notification 落(通知-settle 边)。
542
+ if (cardId === null && !liveBoundPanelTasks.has(taskId))
543
+ continue;
544
+ if (endedPanelTasks.has(taskId))
545
+ continue;
546
+ endedPanelTasks.add(taskId);
547
+ const lane = laneOfTask(taskId);
548
+ const agentType = subagentTypeById.get(taskId);
549
+ yield chrome({
550
+ kind: 'subagent_lifecycle',
551
+ laneProof: lane,
552
+ phase: 'stop',
553
+ taskId,
554
+ ...(agentType !== undefined ? { agentType } : {}),
555
+ });
556
+ yield chrome({
557
+ kind: 'panel_task',
558
+ laneProof: lane,
559
+ event: {
560
+ kind: 'end',
561
+ taskId,
562
+ isError,
563
+ ...(cardId !== null && report !== undefined ? { report } : {}),
564
+ },
565
+ });
566
+ }
567
+ };
568
+ // ══ B5 —— B/D/E 层:开卡 → `tool_result` 转录消息 ═══════════════════════════════════════
569
+ /**
570
+ * TodoWrite 划删线 diff 的 `oldTodos`。
571
+ * 🔴 **turn 级**(cli 同:`let lastLiveTodos` 声明在 generator 体内,不是 module 级 ——
572
+ * cli 的注释写着「module-level, per-process」但代码不是,以**代码**为准,差分守卫认代码)。
573
+ */
574
+ let lastLiveTodos = [];
575
+ /**
576
+ * 一张开卡的收口(cli `buildToolResult` + 它内联的 #117a 桥)。产出顺序与 cli 的
577
+ * 「副作用先于 yield」一致:先 chrome(bg shell 建行),再 transcript(tool_result 卡)。
578
+ */
579
+ const buildToolResult = function* (p, end) {
580
+ const isError = end?.isError ?? false;
581
+ // D 层 / T7:wire 带正文 ⇒ 真身投影;没带 ⇒ **诚实缺席**(§8-4,不再 mock 反造)。
582
+ const body = end && end.output !== undefined
583
+ ? wireOutputToBody(p.name, end.output, isError, end.truncated === true, flattenWireOutput, end.structured)
584
+ : degradedToolResultBody(isError);
585
+ // ── E 层 ③:#117a 模型发起的后台 Bash 回执 → ctrl+B 面板行(判定在库、执行留宿主)。
586
+ if (p.name === 'Bash' && !isError && end?.output !== undefined) {
587
+ const reg = detectEngineBgShellReceipt(p.rawInput, flattenWireOutput(end.output));
588
+ if (reg) {
589
+ yield chrome({ kind: 'bgshell_register', laneProof: MAIN, taskId: reg.taskId, registration: reg });
590
+ }
591
+ }
592
+ // B 层:structured → 逐工具 typed toolUseResult(卡的 outputSchema safeParse 过才渲富卡)。
593
+ let rich = end?.structured !== undefined
594
+ ? structuredToToolUseResult(end.structured, end.output !== undefined ? flattenWireOutput(end.output) : undefined)
595
+ : null;
596
+ // E 层 ④/⑤:两条按**工具名**的补位(引擎不发 type 判别位,B 层 switch 认领不了)。
597
+ if (rich === null && p.name === 'TodoWrite') {
598
+ const todo = todoWriteToolUseResult(p.rawInput, lastLiveTodos, isError);
599
+ if (todo !== null) {
600
+ rich = { toolUseResult: todo.toolUseResult, isError: todo.isError };
601
+ lastLiveTodos = todo.newTodos;
602
+ }
603
+ }
604
+ if (rich === null && !isError && p.name === 'ReportFindings') {
605
+ rich = reportFindingsToolUseResult(end?.structured, p.rawInput);
606
+ }
607
+ const eventId = end?.eventId ?? p.eventId;
608
+ const parentToolCallId = end?.parentToolCallId ?? p.parentToolCallId;
609
+ yield transcript({
610
+ type: 'user',
611
+ message: {
612
+ role: 'user',
613
+ content: [
614
+ {
615
+ type: 'tool_result',
616
+ tool_use_id: p.id,
617
+ content: body.content,
618
+ is_error: rich?.isError === true || body.isError,
619
+ },
620
+ ],
621
+ },
622
+ // DIVERGENCE-2:cli 在缺 eventId 时铸随机 v4,本包一律**稳定键派生**
623
+ // (`wtc_<toolUseId>` —— 一张卡一个结果,天然唯一;同流重放 ⇒ 同 id 序列,[1653])。
624
+ uuid: eventId ?? deriveTranscriptId({ toolCallId: p.id }, ctx),
625
+ session_id: ctx.sessionId,
626
+ ...(parentToolCallId !== undefined ? { parent_tool_use_id: parentToolCallId } : {}),
627
+ // CC 结果卡读 sidecar 上的 typed 输出(不是 block content)。
628
+ toolUseResult: rich !== null ? rich.toolUseResult : (body.toolUseResult ?? body.content),
629
+ // T7:wire 没带正文的诚实标记 —— 在场 = 这张卡没有真实执行结果(UI 显示决策留端)。
630
+ ...(body.degraded !== undefined ? { _sema_degraded: body.degraded } : {}),
631
+ });
632
+ };
633
+ /** 关掉所有还开着的卡(流被截断 / 无 result 收尾;cli 两处同形,abort 路径**不做**)。 */
634
+ const sweepOpenCards = function* () {
635
+ for (const [id, p] of pending) {
636
+ pending.delete(id);
637
+ assistantResponseId = null; // S1
638
+ yield* buildToolResult(p);
639
+ }
640
+ };
327
641
  /** 提交累积的思考块(cli takeThinking:committed 形 + 关活体块 + 收活动行)。 */
328
642
  function* takeThinking() {
329
643
  if (thinking.length === 0)
@@ -481,6 +795,9 @@ class WireToCcAdapterImpl {
481
795
  const renderBlocks = blocks.map((b, i) => (i === toolUseIdx ? san.block : b));
482
796
  const toolUseId = String(san.block.id);
483
797
  const toolName = typeof san.block.name === 'string' ? san.block.name : 'unknown';
798
+ // P0-2 侧信道:core 展示名进本包台账(渲染块上已被 sanitize 摘掉)。cli 同位、同顺序
799
+ // (在任何 yield 之前),对 reject/cancel/error 三条早退路径同样生效。
800
+ recordEngineToolLabel(toolUseId, san.label);
484
801
  // 引擎模式下壳工具 call() 从不执行 —— ctrl+t 面板台账只能靠这条侧信道(宿主义务)。
485
802
  if (TASK_TOOL_NAMES.has(toolName)) {
486
803
  yield chrome({
@@ -588,15 +905,184 @@ class WireToCcAdapterImpl {
588
905
  break;
589
906
  }
590
907
  case 'tool_end_result': {
591
- // 本批只做**卡台账出栈 + response-id 复位**(S1:一个结果关闭该 response,后续 tool_use
592
- // 必须换新 id 才不会被 CC 分组塌成一张卡)。tool_result 消息本体见 ADAPTER_COVERAGE.todo。
908
+ // B4 起本臂做三件:① P0-2 label 补位(只有 tool_end 带 label 的场景;台账自身幂等不覆盖)
909
+ // MF-10 **关卡 settle**(子代从不发终态 tick,父卡的 tool_end 就是完成信号;卡的 output
910
+ // 文本 = 子代最终报告,串到行 end 事件上)③ 卡台账出栈 + response-id 复位(S1)。
911
+ // 🆕 B5:④ **B/D/E 层** —— 铸 `tool_result` 卡本体 + 三个侧产物
912
+ // (Remember→memory_saved / task 族 structured 台账 / #117a bg Bash 桥)。
593
913
  const callId = typeof m.toolCallId === 'string' ? m.toolCallId : undefined;
594
- if (callId !== undefined && pending.has(callId)) {
595
- pending.delete(callId);
596
- assistantResponseId = null;
914
+ recordEngineToolLabel(callId, m.label);
915
+ if (callId !== undefined) {
916
+ yield* settlePanelTasks(callId, m.isError === true, m.output !== undefined ? flattenWireOutput(m.output) : undefined);
917
+ }
918
+ const p = callId !== undefined ? pending.get(callId) : undefined;
919
+ // 无对应开卡的 tool_end(乱序 / 重复)直接丢(cli 同)。
920
+ if (p === undefined || callId === undefined)
921
+ break;
922
+ pending.delete(callId);
923
+ // S1:结果关闭该 model response —— **yield 之前**复位(生成器会在 yield 上挂起)。
924
+ assistantResponseId = null;
925
+ const structured = m.structured;
926
+ yield* buildToolResult(p, {
927
+ output: m.output,
928
+ structured,
929
+ truncated: m.truncated === true,
930
+ isError: m.isError === true,
931
+ eventId: typeof m.eventId === 'string' ? m.eventId : undefined,
932
+ parentToolCallId: typeof m.parentToolCallId === 'string' ? m.parentToolCallId : undefined,
933
+ });
934
+ const s = structured !== undefined && structured !== null && typeof structured === 'object'
935
+ ? structured
936
+ : undefined;
937
+ // ── E 层 ①:CC198 gap #2 —— 引擎 Remember 工具 → CC198 automemory「Saved N memories」卡
938
+ // (system{subtype:'memory_saved'})。引擎不发写入路径,note 本身就是那一条 writtenPaths。
939
+ // 上面的 tool_result 照旧走泛化兜底卡(双覆盖);structuredToToolUseResult 的白名单不动
940
+ // (Remember 从不进那个 switch)。core 1.217 形 {type:'memory-saved', ok, note, …}。
941
+ if (p.name === 'Remember' && m.isError !== true) {
942
+ if (s && s.ok === true && typeof s.note === 'string' && s.note.length > 0) {
943
+ yield transcript({
944
+ type: 'system',
945
+ subtype: 'memory_saved',
946
+ writtenPaths: [s.note],
947
+ // cli 用 `new Date().toISOString()` + randomUUID();本包一律走 ctx(可重放)。
948
+ // 见 DIVERGENCE-7。
949
+ timestamp: new Date(ctx.now()).toISOString(),
950
+ uuid: idOf(m, 'memory-saved'),
951
+ isMeta: false,
952
+ });
953
+ }
954
+ }
955
+ // ── E 层 ②:core 1.217 task 族 structured —— 权威全量态精确同步 ctrl+t 面板(引擎真实 id)。
956
+ // 落盘是宿主职责(T44 拆缝:判定+归一进库、落盘留宿主)。
957
+ if (s && (s.type === 'task' || s.type === 'task-list')) {
958
+ yield chrome({
959
+ kind: 'task_ledger_sync',
960
+ laneProof: MAIN,
961
+ source: 'structured',
962
+ structuredType: s.type,
963
+ structured: s,
964
+ });
597
965
  }
598
966
  break;
599
967
  }
968
+ case 'task_progress': {
969
+ // ── B4 A 层最后一臂(MF-10)—— 引擎侧子代的累计 usage tick。
970
+ // 只带 rollup 数字 = CC `AgentProgress` 同形 ⇒ 忠实的 CC-187 面是 footer 面板行
971
+ // (CC 自己对进程外 agent 的先例:面板行 + task-notification,从不合成 inline 消息轨)。
972
+ // **不产任何 transcript 消息**:全是 chrome。
973
+ const taskId = typeof m.taskId === 'string' ? m.taskId : undefined;
974
+ if (taskId === undefined)
975
+ break;
976
+ const explicitParent = typeof m.parentToolCallId === 'string' ? m.parentToolCallId : undefined;
977
+ // ── W1 段2([1617]②,frame-lane-matrix #2/#8)workflow lane 门:workflow 子代的 tick 属
978
+ // fleet lane 自描述行(footer 只渲 workflow 聚合行),**绝不**进前台 subagent lane
979
+ // (不登 alias、不 bind、不 livePanelTasks、不发 panel tick —— 修前每个 workflow agent
980
+ // 都在 footer 铸一条幽灵 local_agent 行,还能骑错 run_workflow 卡的 inline stats)。
981
+ // 判据三级:① 帧带 workflowRunId(一手证据)② wa+16hex 形 id(core BCE 合成 id)
982
+ // ③ 显式父指向本会话见过的 Workflow 工具卡(实例台账,覆盖 uuid 形子代 id + 跨 turn 迟到 tick)。
983
+ const workflowRunId = typeof m.workflowRunId === 'string' ? m.workflowRunId : undefined;
984
+ if (workflowRunId !== undefined ||
985
+ isWorkflowAgentTaskId(taskId) ||
986
+ (explicitParent !== undefined && this.seenWorkflowToolCallIds.has(explicitParent))) {
987
+ // 判据④ 回植([1641] workflow 启动闪 subagent 行案):本门是「这条 taskId 属 workflow」的
988
+ // **权威判定**。同一 taskId 在 fleet 面还有一条 run 发布器双生行(`${runId} ${taskId}`)
989
+ // 且那行不带 workflowRunId —— 登进 belt 台账,fleet 行按尾段查它,双生行就再进不了
990
+ // footer task 树。🔴 写者在本包、读者(isKnownWorkflowAgentTaskId)也在本包 = R4 纪律,
991
+ // 做成 chrome 臂反而会让「宿主没接线 ⇒ Set 恒空 ⇒ 判据④ 静默失效」复活。
992
+ recordWorkflowAgentTaskId(taskId);
993
+ ctx.probe?.('tick', JSON.stringify({
994
+ t: ctx.now(),
995
+ taskId,
996
+ lane: 'workflow',
997
+ via: workflowRunId !== undefined
998
+ ? 'workflowRunId'
999
+ : isWorkflowAgentTaskId(taskId)
1000
+ ? 'wa-id'
1001
+ : 'workflow-card-parent',
1002
+ }));
1003
+ break;
1004
+ }
1005
+ // C1 alias:content 帧只带 parentToolCallId(EventIdentity 上没有 taskId),这条 tick
1006
+ // **两者都带** —— 是 store 学到「停放的内容属哪条引擎行」的唯一位置(查看态按引擎 task id 查)。
1007
+ if (explicitParent !== undefined)
1008
+ registerSubagentAlias(explicitParent, taskId);
1009
+ // 复审修 #1:含糊回落(无显式父 + >1 张未绑候选)⇒ 保持 unbound。
1010
+ const cardId = explicitParent === undefined && !taskCardBinding.has(taskId) && unboundSubagentCandidates() > 1
1011
+ ? undefined
1012
+ : bindTaskCard(taskId, explicitParent);
1013
+ const usage = (m.usage ?? {});
1014
+ ctx.probe?.('tick', JSON.stringify({
1015
+ t: ctx.now(),
1016
+ taskId,
1017
+ lane: 'foreground',
1018
+ parent: explicitParent ?? null,
1019
+ cardId: cardId ?? null,
1020
+ currentAction: m.currentAction ?? null,
1021
+ toolUses: usage.toolUses ?? null,
1022
+ }));
1023
+ // core 1.262.0 [558]B / service 1.154.0 [579] —— 子代的活体"我在干什么"一行
1024
+ // ("Bash npm test"),service 已 redact。PROGRESS 语义(喂状态/摘要显示槽),不是诊断。
1025
+ // 老引擎缺席 ⇒ 两个消费面维持今天的显示。
1026
+ const currentAction = typeof m.currentAction === 'string' && m.currentAction.length > 0 ? m.currentAction : undefined;
1027
+ // inline 群组行孪生:**绑定了**的 tick 同时喂转录里那条行的统计(按委派卡 id 键)。
1028
+ if (cardId !== undefined) {
1029
+ yield chrome({
1030
+ kind: 'inline_task_stats',
1031
+ laneProof: { lane: 'subagent', parentToolCallId: cardId },
1032
+ op: 'tick',
1033
+ cardId,
1034
+ stats: {
1035
+ toolUses: typeof usage.toolUses === 'number' ? usage.toolUses : 0,
1036
+ totalTokens: typeof usage.totalTokens === 'number' ? usage.totalTokens : 0,
1037
+ durationMs: typeof usage.durationMs === 'number' ? usage.durationMs : 0,
1038
+ ...(currentAction !== undefined ? { currentAction } : {}),
1039
+ },
1040
+ });
1041
+ }
1042
+ const card = cardId !== undefined ? pending.get(cardId) : undefined;
1043
+ // T50 双形读:`rawInput` 是**改名前**捕获的,真 wire 上引擎子代的 input 是
1044
+ // {agent, task, taskName} —— 两形都读(CC 名优先)。
1045
+ const input = (card?.rawInput ?? {});
1046
+ const description = typeof input.description === 'string'
1047
+ ? input.description
1048
+ : typeof input.taskName === 'string' && input.taskName.trim().length > 0
1049
+ ? input.taskName
1050
+ : typeof input.task === 'string'
1051
+ ? shortTaskLabel(input.task)
1052
+ : undefined;
1053
+ const prompt = typeof input.prompt === 'string'
1054
+ ? input.prompt
1055
+ : typeof input.task === 'string'
1056
+ ? input.task
1057
+ : undefined;
1058
+ if (!livePanelTasks.has(taskId)) {
1059
+ // 这条引擎子代在壳侧**可观测的起点** = 本 turn 的首 tick。
1060
+ const agentType = typeof m.name === 'string' && m.name.length > 0 ? m.name : 'subagent';
1061
+ yield* fireSubagentStart(taskId, agentType);
1062
+ }
1063
+ // #6 session 常驻台账:live-bound(卡本 turn 开着)= 本 turn 生命周期,turn 末可 sweep;
1064
+ // inert/unbound(跨 turn bg 子代形)= 常驻,两处 sweep 都放行,终态只认通知帧。
1065
+ if (liveBoundPanelTasks.has(taskId))
1066
+ clearEnginePanelTaskResident(taskId);
1067
+ else
1068
+ markEnginePanelTaskResident(taskId);
1069
+ livePanelTasks.add(taskId);
1070
+ yield chrome({
1071
+ kind: 'panel_task',
1072
+ laneProof: laneOfTask(taskId),
1073
+ event: {
1074
+ kind: 'tick',
1075
+ taskId,
1076
+ ...(typeof m.name === 'string' && m.name.length > 0 ? { name: m.name } : {}),
1077
+ ...(description !== undefined ? { description } : {}),
1078
+ ...(prompt !== undefined ? { prompt } : {}),
1079
+ ...(currentAction !== undefined ? { currentAction } : {}),
1080
+ toolUses: typeof usage.toolUses === 'number' ? usage.toolUses : 0,
1081
+ totalTokens: typeof usage.totalTokens === 'number' ? usage.totalTokens : 0,
1082
+ },
1083
+ });
1084
+ break;
1085
+ }
600
1086
  case 'stream_event': {
601
1087
  const ev = m.event;
602
1088
  if (ev && ev.type === 'text_delta' && typeof ev.delta === 'string') {
@@ -638,6 +1124,11 @@ class WireToCcAdapterImpl {
638
1124
  }
639
1125
  case 'result': {
640
1126
  yield chrome({ kind: 'retry_status', laneProof: MAIN, status: null });
1127
+ // MF-10 防御 sweep(sweep 点 1/3):结掉那些卡从没关过的行(流被截断)。
1128
+ // cli 同位:setRetryStatus(null) 之后、开卡兜底关闭之前。
1129
+ yield* settlePanelTasks(null, false);
1130
+ // B5:开卡兜底关闭(cli 同位 —— 在终答文本**之前**,卡才不会挂着空白)。
1131
+ yield* sweepOpenCards();
641
1132
  yield* takeThinking();
642
1133
  if (!endEmitted) {
643
1134
  const u = m.usage;
@@ -731,29 +1222,48 @@ class WireToCcAdapterImpl {
731
1222
  status: rawStatus,
732
1223
  ...(typeof n.task_type === 'string' ? { taskType: n.task_type } : {}),
733
1224
  });
1225
+ // B1 —— 这帧 = 引擎 server-side steer-inject 的回声,模型已在引擎侧收到一遍;把还躺在
1226
+ // 队列里的同 run Path B 条目(workflow_complete/probe/bg feeder)在 turn 收口 drain 前
1227
+ // 丢弃,免得 idle auto-submit 再喂模型一遍(跨进程双投的客户端半场)。
1228
+ // 🔴 B4 起由**适配器自持**(B2 已把队列端口搬进本包):此前挂在 `notification_terminal`
1229
+ // 的宿主义务①,是「说得通但没人做」的典型 —— 宿主没接线就静默双投。
1230
+ dropQueuedNotificationsForRun(taskId);
734
1231
  if (n.task_type !== 'background_bash') {
1232
+ // #6 通知-settle 边(推送半场):session 常驻的 bg 子代行不再被 turn sweep 假结,
1233
+ // 真终态在此落行(failed/killed ⇒ isError 真归因)。
1234
+ clearEnginePanelTaskResident(taskId);
735
1235
  yield chrome({
736
1236
  kind: 'panel_task',
737
1237
  laneProof: MAIN,
738
1238
  event: { kind: 'end', taskId, isError: rawStatus !== 'completed' },
739
1239
  });
740
- yield chrome({
741
- kind: 'subagent_lifecycle',
742
- laneProof: MAIN,
743
- phase: 'stop',
744
- taskId,
745
- guard: 'if-started',
746
- });
1240
+ if (!endedPanelTasks.has(taskId)) {
1241
+ endedPanelTasks.add(taskId);
1242
+ // SubagentStop 只对**真 fire 过 Start** 的子代(workflow runId / 外来 id 不配对乱 fire)。
1243
+ // cli 同门 `firedSubagentStartHookTaskIds.has(taskId)` —— B4 前本臂无条件发 stop,
1244
+ // 宿主若照 guard 注释自建台账才不会乱 fire;台账在库里就该库来判。
1245
+ if (this.firedSubagentStartHookTaskIds.has(taskId)) {
1246
+ yield chrome({
1247
+ kind: 'subagent_lifecycle',
1248
+ laneProof: MAIN,
1249
+ phase: 'stop',
1250
+ taskId,
1251
+ guard: 'if-started',
1252
+ });
1253
+ }
1254
+ }
747
1255
  }
748
1256
  }
749
1257
  // 双通道反向去重:probe 合成的完成卡已入队 ⇒ 后到的推送帧只做上面的记账,不渲第二条。
750
- if (n.task_type !== 'external' && this.cardEnqueuedRuns.has(taskId))
1258
+ // 🔴 B5 ①:读的是 **module 台账**(cli `isWorkflowCompletionCardEnqueued` 逐字)——
1259
+ // 记账方(两个 enqueue + idle watcher)与清账方(上面的 drop)都在那儿。
1260
+ if (n.task_type !== 'external' && isWorkflowCompletionCardEnqueued(taskId))
751
1261
  break;
752
1262
  // 跨通道记账(cli markEngineWorkflowNotified 同位、同条件:非 external,不看终态):
753
1263
  // 这帧到达 = 引擎已 server-side steer-inject 过,补发通道(workflow_complete)不得再注入。
754
1264
  // external 豁免([769] 对抗复审收紧:external 从不经补发通道,mark 只会缴械 probe 兜底)。
755
1265
  if (n.task_type !== 'external')
756
- this.notifiedRuns.add(taskId);
1266
+ markEngineWorkflowNotified(taskId);
757
1267
  yield transcript({
758
1268
  type: 'user',
759
1269
  message: { role: 'user', content: [{ type: 'text', text: renderTaskNotificationXml(fields) }] },
@@ -767,10 +1277,12 @@ class WireToCcAdapterImpl {
767
1277
  if (runId === undefined)
768
1278
  break;
769
1279
  // 跨通道去重(cli enqueueEngineWorkflowNotification 首行):该 run 已由推送帧记账过
770
- // (引擎 server-side 注入过)⇒ Path B 绝不再喂模型一遍。适配器自持,不摊给宿主。
771
- if (this.notifiedRuns.has(runId))
1280
+ // (引擎 server-side 注入过)⇒ Path B 绝不再喂模型一遍。
1281
+ // 🔴 B5 ①:门与记账都在 module 台账 —— cli 里这一整段就是 enqueueEngineWorkflowNotification
1282
+ // 的前三行,probe/idle watcher 合成的那两条链写的也是它。
1283
+ if (isEngineWorkflowNotified(runId))
772
1284
  break;
773
- this.notifiedRuns.add(runId);
1285
+ markEngineWorkflowNotified(runId);
774
1286
  const wfStatus = m.status === 'failed' ? 'failed' : 'completed';
775
1287
  const wfSummary = typeof m.summary === 'string' ? m.summary : '';
776
1288
  yield chrome({
@@ -782,7 +1294,7 @@ class WireToCcAdapterImpl {
782
1294
  // 模型面正文照铸(宿主直接入队即可,不必各自拼 XML;与 core 的 tag/段序同形)
783
1295
  message: renderTaskNotificationXml({ taskId: runId, status: wfStatus, summary: wfSummary }),
784
1296
  });
785
- this.cardEnqueuedRuns.add(runId);
1297
+ noteWorkflowCompletionCardEnqueued(runId);
786
1298
  break;
787
1299
  }
788
1300
  case 'diagnostics': {
@@ -886,6 +1398,9 @@ class WireToCcAdapterImpl {
886
1398
  if (ctx.signal?.aborted) {
887
1399
  yield chrome({ kind: 'retry_status', laneProof: MAIN, status: null });
888
1400
  yield chrome({ kind: 'thinking_activity', laneProof: MAIN, active: false });
1401
+ // 复审修 #2a:被打断的 turn 也**必须**结掉自己的面板行,否则 Esc 之后 footer 药丸永远
1402
+ // 显示 "1 local agent"(sweep 点 2/3)。
1403
+ yield* settlePanelTasks(null, false);
889
1404
  ctx.log?.('debug', 'turn aborted (Esc) — no terminal answer emitted');
890
1405
  return;
891
1406
  }
@@ -893,6 +1408,10 @@ class WireToCcAdapterImpl {
893
1408
  // 泄尾增量 → 清 liveness → 提交尾思考 → 提交尾文本段。
894
1409
  yield* drainLive();
895
1410
  yield chrome({ kind: 'retry_status', laneProof: MAIN, status: null });
1411
+ // 复审修 #2a 孪生:没有 `result` 臂的收尾路径同样 sweep(sweep 点 3/3)。
1412
+ yield* settlePanelTasks(null, false);
1413
+ // B5:无 `result` 收尾腿同样关掉还开着的卡(cli 同位;abort 路径**不做**,见上)。
1414
+ yield* sweepOpenCards();
896
1415
  yield* takeThinking();
897
1416
  yield* takeAnswerSegment();
898
1417
  ctx.log?.('debug', `turn done · emittedAssistantText=${emittedAssistantText} answerLen=${answer.length}`);