@sema-agent/client-core 0.3.0 → 0.5.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 (69) hide show
  1. package/README.md +57 -4
  2. package/dist/adapt.d.ts +30 -3
  3. package/dist/adapt.js +490 -354
  4. package/dist/adapter/downstream/eventToSdkMessage.d.ts +81 -0
  5. package/dist/adapter/downstream/eventToSdkMessage.js +330 -0
  6. package/dist/adapter/downstream/terminalToSdkResult.d.ts +71 -0
  7. package/dist/adapter/downstream/terminalToSdkResult.js +370 -0
  8. package/dist/adapter/downstream/turnUsageToModelUsage.d.ts +26 -0
  9. package/dist/adapter/downstream/turnUsageToModelUsage.js +12 -0
  10. package/dist/adapter/runStream.d.ts +38 -0
  11. package/dist/adapter/runStream.js +177 -0
  12. package/dist/adapter/types.d.ts +73 -0
  13. package/dist/adapter/types.js +23 -0
  14. package/dist/agentsWireCaps.d.ts +112 -0
  15. package/dist/agentsWireCaps.js +319 -0
  16. package/dist/attachmentsWireCaps.d.ts +47 -0
  17. package/dist/attachmentsWireCaps.js +46 -0
  18. package/dist/classifierVerdictWire.d.ts +45 -0
  19. package/dist/classifierVerdictWire.js +76 -0
  20. package/dist/clientContextWireCaps.d.ts +46 -0
  21. package/dist/clientContextWireCaps.js +48 -0
  22. package/dist/cloudConfigWireCaps.d.ts +110 -0
  23. package/dist/cloudConfigWireCaps.js +228 -0
  24. package/dist/controlRouter.d.ts +191 -0
  25. package/dist/controlRouter.js +244 -0
  26. package/dist/effortWire.d.ts +34 -0
  27. package/dist/effortWire.js +38 -0
  28. package/dist/engineWireSdk.d.ts +49 -0
  29. package/dist/engineWireSdk.js +61 -0
  30. package/dist/forkWireCaps.d.ts +25 -0
  31. package/dist/forkWireCaps.js +42 -0
  32. package/dist/hostEnv.d.ts +16 -0
  33. package/dist/hostEnv.js +17 -0
  34. package/dist/imagesWireCaps.d.ts +28 -0
  35. package/dist/imagesWireCaps.js +49 -0
  36. package/dist/index.d.ts +52 -0
  37. package/dist/index.js +62 -0
  38. package/dist/liveModelCatalog.d.ts +62 -0
  39. package/dist/liveModelCatalog.js +79 -0
  40. package/dist/mcpWireCaps.d.ts +41 -0
  41. package/dist/mcpWireCaps.js +51 -0
  42. package/dist/modelBudgetRule.d.ts +44 -0
  43. package/dist/modelBudgetRule.js +73 -0
  44. package/dist/modelWireCaps.d.ts +17 -0
  45. package/dist/modelWireCaps.js +16 -0
  46. package/dist/notifications.d.ts +150 -4
  47. package/dist/notifications.js +472 -0
  48. package/dist/permissionWireCaps.d.ts +37 -0
  49. package/dist/permissionWireCaps.js +46 -0
  50. package/dist/promptProfileWireCaps.d.ts +17 -0
  51. package/dist/promptProfileWireCaps.js +20 -0
  52. package/dist/retainBackgroundWireCaps.d.ts +50 -0
  53. package/dist/retainBackgroundWireCaps.js +58 -0
  54. package/dist/rewindWireCaps.d.ts +36 -0
  55. package/dist/rewindWireCaps.js +41 -0
  56. package/dist/seam.d.ts +97 -2
  57. package/dist/selfOrchestrationWireCaps.d.ts +40 -0
  58. package/dist/selfOrchestrationWireCaps.js +48 -0
  59. package/dist/sessionModelLatch.d.ts +35 -0
  60. package/dist/sessionModelLatch.js +63 -0
  61. package/dist/skillsWireCaps.d.ts +47 -0
  62. package/dist/skillsWireCaps.js +41 -0
  63. package/dist/sseIdleTriage.d.ts +94 -0
  64. package/dist/sseIdleTriage.js +142 -0
  65. package/dist/ultracodeWireCaps.d.ts +90 -0
  66. package/dist/ultracodeWireCaps.js +159 -0
  67. package/dist/webSearchWireCaps.d.ts +54 -0
  68. package/dist/webSearchWireCaps.js +76 -0
  69. package/package.json +2 -2
package/dist/adapt.js CHANGED
@@ -5,8 +5,33 @@ import { steeringInjectedToAttachments } from './steering.js';
5
5
  import { projectDiagnosticsFrame } from './diagnostics.js';
6
6
  /** 主车道证明——本批实现的臂全在 leader lane(子代/workflow lane 的 tick 面留 #52b)。 */
7
7
  const MAIN = { lane: 'main' };
8
- /** cli COALESCER 节流(207 口径;187 时代是 16ms)。宿主无关,时钟从 ctx.now() 取。 */
8
+ /**
9
+ * 活体增量攒批节拍的**默认值**(时钟从 ctx.now() 取;宿主可用 `ctx.coalesceIntervalMs` 覆盖)。
10
+ *
11
+ * 取值依据(两个都是实测,别把其中一个当"唯一真相"):
12
+ * · **100ms** = 我们对标的 CC 2.1.207 CLI 口径(187 时代是 16ms,207 提到 100ms 降重绘频率,
13
+ * 长回答滚动更稳)—— 壳是 207 fork,所以库的默认值取它,壳消费本包时行为零变化;
14
+ * · **16ms** = CC **桌面端**宿主 `makeStreamDeltaCoalescer` 的单帧攒批节拍(设计稿 R1 实测)。
15
+ * ⇒ 结论:**节拍是宿主渲染策略,不是 wire 语义** —— CC 自己两个端就是两个值。web/桌面宿主
16
+ * 想要 16ms 就传 `coalesceIntervalMs: 16`,合并**语义**(思考先于文本、段边界重开块、
17
+ * committed 恒随后)在库里,一份。
18
+ *
19
+ * 🔴 顺带钉一句反面事实(R1 证伪,别再写进任何注释/文档):**"UI 从不见裸 token" 是错的** ——
20
+ * CC 攒批后 flush 的载荷仍是 `stream_event` 增量 delta,CC 的 UI 照样消费增量;
21
+ * 完整 assistant 消息是 SDK 另发的一路。我们这里的 `stream_delta` chrome 臂同构。
22
+ */
9
23
  export const FLUSH_INTERVAL_MS = 100;
24
+ /**
25
+ * T40 IDLE-FLUSH 竞速窗(cli upstreamBridge `IDLE_FLUSH_MS` 逐字同值)。
26
+ *
27
+ * 病:模型转入长工具参数生成(大 Write 的 input_json 不经本桥)时 SSE 增量停摆,而 thinking/
28
+ * answerSegment 里**已经生成**的内容还押着陪等(pty 确证:11s 整屏 hash 恒定)。
29
+ * 修:增量停 ≥1.5s 且缓冲非空 ⇒ 先 drainLive()(活体预览语义不变),再提交思考+文本段
30
+ * (段序铁律不破:本来就该在下一张工具卡前 flush,这里只是提前)。
31
+ * 🔴 这是**过渡修**,正解在 core [817](wire 侧给事件);且**需要宿主定时器**:
32
+ * `ctx.setTimer` 缺席 ⇒ 整条竞速不启用(纯等下一帧),与 0.4.0 逐字同行为。
33
+ */
34
+ export const IDLE_FLUSH_MS = 1500;
10
35
  /** cli SUBAGENT_TOOL_NAMES / WORKFLOW_TOOL_NAMES 同源(判别资产,别各自重写)。 */
11
36
  export const SUBAGENT_TOOL_NAMES = new Set(['Task', 'Agent', 'Fork']);
12
37
  export const WORKFLOW_TOOL_NAMES = new Set(['Workflow', 'RunWorkflow', 'run_workflow']);
@@ -124,6 +149,14 @@ export const ADAPTER_COVERAGE = {
124
149
  'user_task_notification',
125
150
  'system_passthrough',
126
151
  ],
152
+ /** B3(0.5.0)新落码的臂/半场 —— 从 todo 移过来的,别再在 todo 里留同名条目。 */
153
+ b3: [
154
+ 'T38 三清第三清 + T57 第五处断闸:prompt_suggestions(turn 开场作废 + 子流断闸)',
155
+ 'T40 IDLE-FLUSH 1.5s 竞速(需 ctx.setTimer;缺席=不启用)',
156
+ 'T58 手动 iterator 的 finally cleanup(early break/throw 都要关上游生成器)',
157
+ 'T59 abort 半场(ctx.signal;中断后不产终答)',
158
+ 'T60 无 result 收尾腿(泄尾增量 → 清 liveness → 尾思考 → 尾文本段)',
159
+ ],
127
160
  /** 如实留白——写不动/依赖未搬运资产的臂,#52b 起分批接。 */
128
161
  todo: [
129
162
  'tool_end_result → user tool_result 正常路径(需搬 wireOutputToBody/structuredToToolUseResult/mock 合成器 + TodoWrite/ReportFindings 富卡片,约 600 行;workflow 轮询信封的人话投影已在 workflow.ts 就位,等这条接线)',
@@ -132,8 +165,7 @@ export const ADAPTER_COVERAGE = {
132
165
  'task_progress → 面板行绑定(bindTaskCard FIFO/显式父、inline stats、alias、resident 台账、SubagentStart hook、workflow lane 三级门)',
133
166
  'settlePanelTasks 三处 sweep(依赖上面的行台账)',
134
167
  '#117a bg Bash 回执探测(detectEngineBgShellReceipt 文案锚定探测器)',
135
- 'abort/Esc 半场(AdapterContext signal 位——见 SEAM-GAP-4)',
136
- 'IDLE_FLUSH 1.5s 竞速(件1 大 Write 静默窗;需 ctx 侧定时器能力)',
168
+ 'abort 半场里依赖行台账的两件:settlePanelTasks 兜底 sweep + 开卡合成器兜底关卡(见上面两条)',
137
169
  'macrotask 让渡(cli 在提交前让出宏任务给 Ink;宿主渲染策略,不进库)',
138
170
  ],
139
171
  };
@@ -143,8 +175,13 @@ export const ADAPTER_DIVERGENCES = [
143
175
  '现役 wire 不产 user 帧(eventToSdkMessage 无该出口),故对真流是惰性的;保留是为了 desktop 重放存量转录。',
144
176
  'DIVERGENCE-2:transcript uuid 取值形——cli 铸随机 v4 / 原样透传帧 uuid,adapt 一律走 ' +
145
177
  'deriveTranscriptId(稳定键前缀形 `wid_*`)。差分守卫按「别名结构」比对(同一侧内的复用/区分关系),不比字面值。',
146
- 'DIVERGENCE-3:合并节流的**分片边界**不比对——cli 用 Date.now() 真时钟,adapt 用 ctx.now()' +
178
+ 'DIVERGENCE-3:合并节流的**分片边界**不比对——cli 用 Date.now() 真时钟,adapt 用 ctx.now();' +
179
+ '且 B3 起节拍可由宿主 `ctx.coalesceIntervalMs` 覆盖(缺席=cli 同值 100ms)。' +
147
180
  '守卫比对的是 chrome 增量按 channel 拼接后的全文与顺序(语义不变量),不是 chunk 切分。',
181
+ 'DIVERGENCE-4(B3):流探针的**开关与时间戳**不同形——cli 是 `process.env.SEMA_STREAM_PROBE` ' +
182
+ '文件探针写 `${Date.now()} text_flush len=N`,adapt 是 `ctx.probe(\'stream\', …)` 注入口写 ' +
183
+ '`${ctx.now()} text_flush len=N`。行格式与触发点(每次文本 flush 一次)逐字相同,' +
184
+ '差在时钟源与落地方式(宿主决定落文件/上报/丢弃)。',
148
185
  ];
149
186
  const TASK_NOTIFICATION_TAG = 'task-notification';
150
187
  const TASK_ID_TAG = 'task-id';
@@ -270,6 +307,10 @@ class WireToCcAdapterImpl {
270
307
  let textBlockOpen = false;
271
308
  let thinkingBlockOpen = false;
272
309
  let lastFlushAt = 0;
310
+ /** T29 攒批节拍:宿主策略优先(桌面/web 可传 16ms 对齐 CC 桌面端),缺席=207 CLI 口径 100ms。 */
311
+ const flushEveryMs = typeof ctx.coalesceIntervalMs === 'number' && ctx.coalesceIntervalMs >= 0
312
+ ? ctx.coalesceIntervalMs
313
+ : FLUSH_INTERVAL_MS;
273
314
  let messageStartEmitted = false;
274
315
  let endEmitted = false;
275
316
  let assistantResponseId = null;
@@ -338,6 +379,10 @@ class WireToCcAdapterImpl {
338
379
  if (textPending.length > 0) {
339
380
  const opening = !textBlockOpen;
340
381
  textBlockOpen = true;
382
+ // B3:cli 的 `SEMA_STREAM_PROBE` 文件探针换成 ctx.probe 口(体检 #5 同款用途:Ink 挂载后
383
+ // console 被吞,harness 只能靠这条证明「引擎是**逐 token 增量**到的」而不是整块一次性 dump)。
384
+ // 载荷格式与 cli 逐字同形,只把 Date.now() 换成 ctx.now()(注入时钟纪律)。
385
+ ctx.probe?.('stream', `${ctx.now()} text_flush len=${textPending.length}`);
341
386
  yield chrome({
342
387
  kind: 'stream_delta',
343
388
  laneProof: MAIN,
@@ -349,417 +394,508 @@ class WireToCcAdapterImpl {
349
394
  textPending = '';
350
395
  }
351
396
  }
352
- // turn 开场卫生(cli:清上一 turn 的 retry 覆盖层 + 思考活动行,再发 request-start)。
397
+ // turn 开场卫生(T38 三清,cli 逐条同序):清上一 turn 的 retry 覆盖层 + **上一轮的建议批** +
398
+ // 思考活动行,再发 request-start(T39)。三清缺一都是"上一轮的东西挂着不掉"。
353
399
  yield chrome({ kind: 'retry_status', laneProof: MAIN, status: null });
400
+ yield chrome({ kind: 'prompt_suggestions', laneProof: MAIN, suggestions: null });
354
401
  yield chrome({ kind: 'thinking_activity', laneProof: MAIN, active: false });
355
402
  yield chrome({ kind: 'request_start', laneProof: MAIN });
356
- for await (const rawFrame of frames) {
357
- const m = rawFrame;
358
- if (m === null || typeof m !== 'object')
359
- continue;
360
- if (m.type === 'stream_event' && !messageStartEmitted) {
361
- messageStartEmitted = true;
362
- yield chrome({
363
- kind: 'response_metrics',
364
- laneProof: MAIN,
365
- phase: 'start',
366
- ttftMs: ctx.now() - turnStartAt,
367
- });
368
- }
369
- // 持久臂之前先把活体增量泄干净(活体预览恒先于它 settle 成的 committed 消息)。
370
- if (m.type !== 'stream_event')
371
- yield* drainLive();
372
- switch (m.type) {
373
- case 'assistant': {
374
- const message = m.message;
375
- const blocks = Array.isArray(message?.content)
376
- ? message.content
377
- : [];
378
- const toolUseIdx = blocks.findIndex(b => b?.type === 'tool_use');
379
- if (toolUseIdx >= 0) {
380
- const original = blocks[toolUseIdx];
381
- const decision = decisionOf(original);
382
- const san = sanitizeToolUseBlock(original);
383
- const renderBlocks = blocks.map((b, i) => (i === toolUseIdx ? san.block : b));
384
- const toolUseId = String(san.block.id);
385
- const toolName = typeof san.block.name === 'string' ? san.block.name : 'unknown';
386
- // 引擎模式下壳工具 call() 从不执行 —— ctrl+t 面板台账只能靠这条侧信道(宿主义务)。
387
- if (TASK_TOOL_NAMES.has(toolName)) {
388
- yield chrome({
389
- kind: 'task_ledger_sync',
390
- laneProof: MAIN,
391
- source: 'tool_use',
392
- toolName,
393
- toolUseId,
394
- input: san.block.input,
395
- });
396
- }
397
- // #27 段序:先把这张卡之前的散文提交,再提交它之前的思考(思考恒在卡上方)。
398
- yield* takeAnswerSegment();
403
+ // ── T40/T58/T59:手动 iterator + IDLE-FLUSH 竞速 + abort 断流 ────────────────────────────────
404
+ // `for await` 换成手动 next():① 才能与 idle 计时器 race(T40);② 才能在 abort 时立刻停;
405
+ // 代价是失去 for-await 的隐式 cleanup finally 里手动 return()(T58,少了它 = SSE 不拆)。
406
+ const iterator = frames[Symbol.asyncIterator]();
407
+ let iteratorDone = false;
408
+ try {
409
+ for (;;) {
410
+ const nextPromise = iterator.next();
411
+ let step;
412
+ while (step === undefined) {
413
+ const hasPendingContent = thinking.length > 0 ||
414
+ answerSegment.length > 0 ||
415
+ thinkingPending.length > 0 ||
416
+ textPending.length > 0;
417
+ // 缓冲全空 ⇒ 零开销路径;宿主没给定时器 ⇒ 竞速整条不启用(绝不 setTimeout 兜底)
418
+ if (!hasPendingContent || ctx.setTimer === undefined) {
419
+ step = await nextPromise;
420
+ break;
421
+ }
422
+ let idleTimer;
423
+ const raced = await Promise.race([
424
+ nextPromise.then(r => ({ tag: 'next', r })),
425
+ new Promise(resolve => {
426
+ idleTimer = ctx.setTimer?.(IDLE_FLUSH_MS, () => resolve({ tag: 'idle' }));
427
+ }),
428
+ ]);
429
+ if (idleTimer !== undefined)
430
+ ctx.clearTimer?.(idleTimer);
431
+ if (raced.tag === 'next') {
432
+ step = raced.r;
433
+ }
434
+ else {
435
+ if (ctx.signal?.aborted)
436
+ break;
437
+ // Idle ≥1.5s 且有已生成未提交的内容:现在就上屏(活体预览在前,再按 思考→文本 的
438
+ // committed 序 —— 与工具卡前的边界 flush 逐字同序,只是提前发生)。
439
+ yield* drainLive();
399
440
  yield* takeThinking();
400
- yield transcript({
401
- type: 'assistant',
402
- // S1:per-RESPONSE 铸 id —— CC 的 grouped-agent 渲染按 `${message.id}:${toolName}`
403
- // 分组;id-less 帧会把整个 session 的 Agent 卡塌成一张。tool_use 帧才盖章。
404
- message: { id: mintResponseId(m), role: 'assistant', content: renderBlocks },
405
- uuid: idOf(m),
406
- session_id: ctx.sessionId,
407
- parent_tool_use_id: null,
408
- });
409
- if (decision === 'reject' || decision === 'cancel') {
441
+ yield* takeAnswerSegment();
442
+ // 缓冲已空 ⇒ 下一圈直接 plain-await。
443
+ }
444
+ }
445
+ if (step === undefined)
446
+ break; // abort while idle-waiting
447
+ if (step.done) {
448
+ iteratorDone = true;
449
+ break;
450
+ }
451
+ const rawFrame = step.value;
452
+ // T59 — 信号一跳就停止投影(后续任何臂都不再渲染)。
453
+ if (ctx.signal?.aborted)
454
+ break;
455
+ const m = rawFrame;
456
+ if (m === null || typeof m !== 'object')
457
+ continue;
458
+ if (m.type === 'stream_event' && !messageStartEmitted) {
459
+ messageStartEmitted = true;
460
+ yield chrome({
461
+ kind: 'response_metrics',
462
+ laneProof: MAIN,
463
+ phase: 'start',
464
+ ttftMs: ctx.now() - turnStartAt,
465
+ });
466
+ }
467
+ // 持久臂之前先把活体增量泄干净(活体预览恒先于它 settle 成的 committed 消息)。
468
+ if (m.type !== 'stream_event')
469
+ yield* drainLive();
470
+ switch (m.type) {
471
+ case 'assistant': {
472
+ const message = m.message;
473
+ const blocks = Array.isArray(message?.content)
474
+ ? message.content
475
+ : [];
476
+ const toolUseIdx = blocks.findIndex(b => b?.type === 'tool_use');
477
+ if (toolUseIdx >= 0) {
478
+ const original = blocks[toolUseIdx];
479
+ const decision = decisionOf(original);
480
+ const san = sanitizeToolUseBlock(original);
481
+ const renderBlocks = blocks.map((b, i) => (i === toolUseIdx ? san.block : b));
482
+ const toolUseId = String(san.block.id);
483
+ const toolName = typeof san.block.name === 'string' ? san.block.name : 'unknown';
484
+ // 引擎模式下壳工具 call() 从不执行 —— ctrl+t 面板台账只能靠这条侧信道(宿主义务)。
485
+ if (TASK_TOOL_NAMES.has(toolName)) {
486
+ yield chrome({
487
+ kind: 'task_ledger_sync',
488
+ laneProof: MAIN,
489
+ source: 'tool_use',
490
+ toolName,
491
+ toolUseId,
492
+ input: san.block.input,
493
+ });
494
+ }
495
+ // #27 段序:先把这张卡之前的散文提交,再提交它之前的思考(思考恒在卡上方)。
496
+ yield* takeAnswerSegment();
497
+ yield* takeThinking();
410
498
  yield transcript({
411
- type: 'user',
412
- message: {
413
- role: 'user',
414
- content: [
415
- {
416
- type: 'tool_result',
417
- tool_use_id: san.block.id,
418
- content: decision === 'reject' ? REJECT_MESSAGE : CANCEL_MESSAGE,
419
- is_error: false,
420
- },
421
- ],
422
- },
423
- uuid: idOf(m, decision),
499
+ type: 'assistant',
500
+ // S1:per-RESPONSE 铸 id —— CC 的 grouped-agent 渲染按 `${message.id}:${toolName}`
501
+ // 分组;id-less 帧会把整个 session 的 Agent 卡塌成一张。tool_use 帧才盖章。
502
+ message: { id: mintResponseId(m), role: 'assistant', content: renderBlocks },
503
+ uuid: idOf(m),
424
504
  session_id: ctx.sessionId,
505
+ parent_tool_use_id: null,
425
506
  });
426
- assistantResponseId = null; // 一个结果关闭该 model response
507
+ if (decision === 'reject' || decision === 'cancel') {
508
+ yield transcript({
509
+ type: 'user',
510
+ message: {
511
+ role: 'user',
512
+ content: [
513
+ {
514
+ type: 'tool_result',
515
+ tool_use_id: san.block.id,
516
+ content: decision === 'reject' ? REJECT_MESSAGE : CANCEL_MESSAGE,
517
+ is_error: false,
518
+ },
519
+ ],
520
+ },
521
+ uuid: idOf(m, decision),
522
+ session_id: ctx.sessionId,
523
+ });
524
+ assistantResponseId = null; // 一个结果关闭该 model response
525
+ break;
526
+ }
527
+ if (decision === 'error') {
528
+ yield transcript({
529
+ type: 'user',
530
+ message: {
531
+ role: 'user',
532
+ content: [
533
+ {
534
+ type: 'tool_result',
535
+ tool_use_id: san.block.id,
536
+ content: 'The tool failed to execute.',
537
+ is_error: true,
538
+ },
539
+ ],
540
+ },
541
+ uuid: idOf(m, 'error'),
542
+ session_id: ctx.sessionId,
543
+ });
544
+ assistantResponseId = null;
545
+ break;
546
+ }
547
+ pending.set(toolUseId, {
548
+ id: toolUseId,
549
+ name: toolName,
550
+ rawInput: san.rawInput,
551
+ ...(san.eventId !== undefined ? { eventId: san.eventId } : {}),
552
+ ...(san.parentToolCallId !== undefined ? { parentToolCallId: san.parentToolCallId } : {}),
553
+ });
554
+ if (WORKFLOW_TOOL_NAMES.has(toolName))
555
+ this.seenWorkflowToolCallIds.add(toolUseId);
427
556
  break;
428
557
  }
429
- if (decision === 'error') {
558
+ const textBlock = blocks.find(b => b?.type === 'text');
559
+ if (textBlock && typeof textBlock.text === 'string' && textBlock.text.length > 0) {
560
+ answer += textBlock.text;
561
+ yield* takeThinking();
430
562
  yield transcript({
431
- type: 'user',
432
- message: {
433
- role: 'user',
434
- content: [
435
- {
436
- type: 'tool_result',
437
- tool_use_id: san.block.id,
438
- content: 'The tool failed to execute.',
439
- is_error: true,
440
- },
441
- ],
442
- },
443
- uuid: idOf(m, 'error'),
563
+ type: 'assistant',
564
+ message: { role: 'assistant', content: [{ type: 'text', text: textBlock.text }] },
565
+ uuid: idOf(m),
444
566
  session_id: ctx.sessionId,
567
+ parent_tool_use_id: null,
568
+ });
569
+ emittedAssistantText = true;
570
+ }
571
+ const thinkingBlock = blocks.find(b => b?.type === 'thinking');
572
+ if (thinkingBlock && typeof thinkingBlock.thinking === 'string') {
573
+ yield transcript({
574
+ type: 'assistant',
575
+ message: { role: 'assistant', content: [thinkingBlock] },
576
+ uuid: idOf(m, 'durable-thinking'),
577
+ session_id: ctx.sessionId,
578
+ parent_tool_use_id: null,
445
579
  });
446
- assistantResponseId = null;
447
- break;
448
580
  }
449
- pending.set(toolUseId, {
450
- id: toolUseId,
451
- name: toolName,
452
- rawInput: san.rawInput,
453
- ...(san.eventId !== undefined ? { eventId: san.eventId } : {}),
454
- ...(san.parentToolCallId !== undefined ? { parentToolCallId: san.parentToolCallId } : {}),
455
- });
456
- if (WORKFLOW_TOOL_NAMES.has(toolName))
457
- this.seenWorkflowToolCallIds.add(toolUseId);
458
581
  break;
459
582
  }
460
- const textBlock = blocks.find(b => b?.type === 'text');
461
- if (textBlock && typeof textBlock.text === 'string' && textBlock.text.length > 0) {
462
- answer += textBlock.text;
463
- yield* takeThinking();
464
- yield transcript({
465
- type: 'assistant',
466
- message: { role: 'assistant', content: [{ type: 'text', text: textBlock.text }] },
467
- uuid: idOf(m),
468
- session_id: ctx.sessionId,
469
- parent_tool_use_id: null,
470
- });
471
- emittedAssistantText = true;
583
+ // DIVERGENCE-1 —— 现役 wire 不产 user 帧;透传是给 desktop 重放存量转录留的门。
584
+ case 'user': {
585
+ if (m.message === undefined)
586
+ break;
587
+ yield transcript({ ...m, uuid: idOf(m), session_id: ctx.sessionId });
588
+ break;
472
589
  }
473
- const thinkingBlock = blocks.find(b => b?.type === 'thinking');
474
- if (thinkingBlock && typeof thinkingBlock.thinking === 'string') {
475
- yield transcript({
476
- type: 'assistant',
477
- message: { role: 'assistant', content: [thinkingBlock] },
478
- uuid: idOf(m, 'durable-thinking'),
479
- session_id: ctx.sessionId,
480
- parent_tool_use_id: null,
481
- });
590
+ case 'tool_end_result': {
591
+ // 本批只做**卡台账出栈 + response-id 复位**(S1:一个结果关闭该 response,后续 tool_use
592
+ // 必须换新 id 才不会被 CC 分组塌成一张卡)。tool_result 消息本体见 ADAPTER_COVERAGE.todo。
593
+ const callId = typeof m.toolCallId === 'string' ? m.toolCallId : undefined;
594
+ if (callId !== undefined && pending.has(callId)) {
595
+ pending.delete(callId);
596
+ assistantResponseId = null;
597
+ }
598
+ break;
482
599
  }
483
- break;
484
- }
485
- // DIVERGENCE-1 —— 现役 wire 不产 user 帧;透传是给 desktop 重放存量转录留的门。
486
- case 'user': {
487
- if (m.message === undefined)
600
+ case 'stream_event': {
601
+ const ev = m.event;
602
+ if (ev && ev.type === 'text_delta' && typeof ev.delta === 'string') {
603
+ // MOD-1 思考→回答边界:回答一起头就把思考块 committed 上屏(它推理的内容之前)。
604
+ if (thinking.length > 0 || thinkingPending.length > 0) {
605
+ yield* drainLive();
606
+ yield* takeThinking();
607
+ }
608
+ if (answerSegment.length === 0)
609
+ segmentAnchor = m;
610
+ answer += ev.delta;
611
+ answerSegment += ev.delta;
612
+ textPending += ev.delta;
613
+ }
614
+ else if (ev && ev.type === 'thinking_delta' && typeof ev.delta === 'string') {
615
+ // 子流(parentToolCallId)的推理绝不喂 leader 的计数与预览(#9 的壳侧半场)。
616
+ if (ev.parentToolCallId !== undefined)
617
+ break;
618
+ if (thinking.length === 0) {
619
+ thinkingAnchor = m;
620
+ // P2d:elapsed 锚在**首条** leader 推理增量(token 累计由 stream_delta.estimatedTokens
621
+ // 承载,宿主自加;>30s 无增量的 stall 提示同样由宿主按增量时间戳判)。
622
+ yield chrome({ kind: 'thinking_activity', laneProof: MAIN, active: true });
623
+ }
624
+ thinking += ev.delta;
625
+ thinkingPending += ev.delta;
626
+ }
488
627
  break;
489
- yield transcript({ ...m, uuid: idOf(m), session_id: ctx.sessionId });
490
- break;
491
- }
492
- case 'tool_end_result': {
493
- // 本批只做**卡台账出栈 + response-id 复位**(S1:一个结果关闭该 response,后续 tool_use
494
- // 必须换新 id 才不会被 CC 分组塌成一张卡)。tool_result 消息本体见 ADAPTER_COVERAGE.todo。
495
- const callId = typeof m.toolCallId === 'string' ? m.toolCallId : undefined;
496
- if (callId !== undefined && pending.has(callId)) {
497
- pending.delete(callId);
498
- assistantResponseId = null;
499
628
  }
500
- break;
501
- }
502
- case 'stream_event': {
503
- const ev = m.event;
504
- if (ev && ev.type === 'text_delta' && typeof ev.delta === 'string') {
505
- // MOD-1 思考→回答边界:回答一起头就把思考块 committed 上屏(它推理的内容之前)。
506
- if (thinking.length > 0 || thinkingPending.length > 0) {
629
+ case 'turn_usage': {
630
+ const ot = typeof m.outputTokens === 'number' ? m.outputTokens : undefined;
631
+ if (ot != null) {
507
632
  yield* drainLive();
508
- yield* takeThinking();
633
+ yield chrome({ kind: 'response_metrics', laneProof: MAIN, phase: 'end', outputTokens: ot });
634
+ endEmitted = true;
635
+ messageStartEmitted = false; // 下一 model round 重新压基线
509
636
  }
510
- if (answerSegment.length === 0)
511
- segmentAnchor = m;
512
- answer += ev.delta;
513
- answerSegment += ev.delta;
514
- textPending += ev.delta;
637
+ break;
515
638
  }
516
- else if (ev && ev.type === 'thinking_delta' && typeof ev.delta === 'string') {
517
- // 子流(parentToolCallId)的推理绝不喂 leader 的计数与预览(#9 的壳侧半场)
518
- if (ev.parentToolCallId !== undefined)
519
- break;
520
- if (thinking.length === 0) {
521
- thinkingAnchor = m;
522
- // P2d:elapsed 锚在**首条** leader 推理增量(token 累计由 stream_delta.estimatedTokens
523
- // 承载,宿主自加;>30s 无增量的 stall 提示同样由宿主按增量时间戳判)。
524
- yield chrome({ kind: 'thinking_activity', laneProof: MAIN, active: true });
639
+ case 'result': {
640
+ yield chrome({ kind: 'retry_status', laneProof: MAIN, status: null });
641
+ yield* takeThinking();
642
+ if (!endEmitted) {
643
+ const u = m.usage;
644
+ if (typeof u?.outputTokens === 'number') {
645
+ yield chrome({
646
+ kind: 'response_metrics',
647
+ laneProof: MAIN,
648
+ phase: 'end',
649
+ outputTokens: u.outputTokens,
650
+ });
651
+ endEmitted = true;
652
+ }
525
653
  }
526
- thinking += ev.delta;
527
- thinkingPending += ev.delta;
528
- }
529
- break;
530
- }
531
- case 'turn_usage': {
532
- const ot = typeof m.outputTokens === 'number' ? m.outputTokens : undefined;
533
- if (ot != null) {
534
- yield* drainLive();
535
- yield chrome({ kind: 'response_metrics', laneProof: MAIN, phase: 'end', outputTokens: ot });
536
- endEmitted = true;
537
- messageStartEmitted = false; // 下一 model round 重新压基线
538
- }
539
- break;
540
- }
541
- case 'result': {
542
- yield chrome({ kind: 'retry_status', laneProof: MAIN, status: null });
543
- yield* takeThinking();
544
- if (!endEmitted) {
545
- const u = m.usage;
546
- if (typeof u?.outputTokens === 'number') {
547
- yield chrome({
548
- kind: 'response_metrics',
549
- laneProof: MAIN,
550
- phase: 'end',
551
- outputTokens: u.outputTokens,
552
- });
553
- endEmitted = true;
654
+ yield* takeAnswerSegment();
655
+ if (!emittedAssistantText) {
656
+ const text = typeof m.result === 'string' ? m.result : '';
657
+ if (text.length > 0) {
658
+ yield transcript({
659
+ type: 'assistant',
660
+ message: { role: 'assistant', content: [{ type: 'text', text }] },
661
+ uuid: idOf(m, 'terminal-text'),
662
+ session_id: ctx.sessionId,
663
+ parent_tool_use_id: null,
664
+ });
665
+ emittedAssistantText = true;
666
+ }
554
667
  }
668
+ break;
555
669
  }
556
- yield* takeAnswerSegment();
557
- if (!emittedAssistantText) {
558
- const text = typeof m.result === 'string' ? m.result : '';
559
- if (text.length > 0) {
560
- yield transcript({
561
- type: 'assistant',
562
- message: { role: 'assistant', content: [{ type: 'text', text }] },
563
- uuid: idOf(m, 'terminal-text'),
564
- session_id: ctx.sessionId,
565
- parent_tool_use_id: null,
566
- });
567
- emittedAssistantText = true;
670
+ case 'system': {
671
+ yield transcript(m);
672
+ // core 1.219 attachWorkingFiles:compact_boundary 可携 attachedFiles CC
673
+ // "Referenced file {path}" 行(post-compact attachment,零新 UI)
674
+ const attachedFiles = m.attachedFiles;
675
+ if (Array.isArray(attachedFiles)) {
676
+ let i = 0;
677
+ for (const f of attachedFiles) {
678
+ if (typeof f?.path !== 'string' || f.path.length === 0)
679
+ continue;
680
+ yield chrome({
681
+ kind: 'attachment',
682
+ laneProof: MAIN,
683
+ id: idOf(m, `attached-file-${i++}`),
684
+ attachment: {
685
+ type: 'compact_file_reference',
686
+ filename: f.path,
687
+ displayPath: f.path,
688
+ },
689
+ });
690
+ }
568
691
  }
692
+ break;
569
693
  }
570
- break;
571
- }
572
- case 'system': {
573
- yield transcript(m);
574
- // core 1.219 attachWorkingFiles:compact_boundary 可携 attachedFiles → CC 的
575
- // "Referenced file {path}" 行(post-compact attachment,零新 UI)。
576
- const attachedFiles = m.attachedFiles;
577
- if (Array.isArray(attachedFiles)) {
578
- let i = 0;
579
- for (const f of attachedFiles) {
580
- if (typeof f?.path !== 'string' || f.path.length === 0)
581
- continue;
694
+ case 'task_notification': {
695
+ const n = (m.notification ?? {});
696
+ const fields = normalizeTaskNotification(n);
697
+ if (fields === null)
698
+ break; // 空/缺 task_id:RB-75 同族,丢弃
699
+ const taskId = fields.taskId;
700
+ const rawStatus = fields.status;
701
+ const terminal = rawStatus === 'completed' || rawStatus === 'failed' || rawStatus === 'killed';
702
+ // #117a settle 半场 —— **故意在渲染去重之前**:settle 幂等,重放帧仍须 settle 一条
703
+ // 首投之后才注册的行。#47 矩阵 #7:只有 bash 行(或老引擎无 task_type)能被 settle。
704
+ const settleAllowed = n.task_type === undefined || n.task_type === 'background_bash';
705
+ if (settleAllowed && terminal) {
582
706
  yield chrome({
583
- kind: 'attachment',
707
+ kind: 'bgshell_settle',
584
708
  laneProof: MAIN,
585
- id: idOf(m, `attached-file-${i++}`),
586
- attachment: {
587
- type: 'compact_file_reference',
588
- filename: f.path,
589
- displayPath: f.path,
590
- },
709
+ taskId,
710
+ status: rawStatus,
711
+ ...(typeof n.output_file === 'string' && n.output_file.length > 0
712
+ ? { outputPath: n.output_file }
713
+ : {}),
591
714
  });
592
715
  }
593
- }
594
- break;
595
- }
596
- case 'task_notification': {
597
- const n = (m.notification ?? {});
598
- const fields = normalizeTaskNotification(n);
599
- if (fields === null)
600
- break; // 空/缺 task_id:RB-75 同族,丢弃
601
- const taskId = fields.taskId;
602
- const rawStatus = fields.status;
603
- const terminal = rawStatus === 'completed' || rawStatus === 'failed' || rawStatus === 'killed';
604
- // #117a settle 半场 —— **故意在渲染去重之前**:settle 幂等,重放帧仍须 settle 一条
605
- // 首投之后才注册的行。#47 矩阵 #7:只有 bash 行(或老引擎无 task_type)能被 settle。
606
- const settleAllowed = n.task_type === undefined || n.task_type === 'background_bash';
607
- if (settleAllowed && terminal) {
608
- yield chrome({
609
- kind: 'bgshell_settle',
610
- laneProof: MAIN,
611
- taskId,
612
- status: rawStatus,
613
- ...(typeof n.output_file === 'string' && n.output_file.length > 0
614
- ? { outputPath: n.output_file }
615
- : {}),
616
- });
617
- }
618
- const dedupKey = taskNotificationDedupKey({
619
- taskId,
620
- status: rawStatus,
621
- ...(typeof n.seq === 'number' || typeof n.seq === 'string' ? { seq: n.seq } : {}),
622
- ...(typeof n.task_type === 'string' ? { taskType: n.task_type } : {}),
623
- });
624
- if (this.renderedNotifications.has(dedupKey))
625
- break;
626
- this.renderedNotifications.add(dedupKey);
627
- // B1 + #6 —— 必须在 cardEnqueued 早退**之前**:probe 合成先入队的场景恰是双投主形。
628
- if (n.task_type !== 'external' && terminal) {
629
- yield chrome({
630
- kind: 'notification_terminal',
631
- laneProof: MAIN,
716
+ const dedupKey = taskNotificationDedupKey({
632
717
  taskId,
633
718
  status: rawStatus,
719
+ ...(typeof n.seq === 'number' || typeof n.seq === 'string' ? { seq: n.seq } : {}),
634
720
  ...(typeof n.task_type === 'string' ? { taskType: n.task_type } : {}),
635
721
  });
636
- if (n.task_type !== 'background_bash') {
637
- yield chrome({
638
- kind: 'panel_task',
639
- laneProof: MAIN,
640
- event: { kind: 'end', taskId, isError: rawStatus !== 'completed' },
641
- });
722
+ if (this.renderedNotifications.has(dedupKey))
723
+ break;
724
+ this.renderedNotifications.add(dedupKey);
725
+ // B1 + #6 —— 必须在 cardEnqueued 早退**之前**:probe 合成先入队的场景恰是双投主形。
726
+ if (n.task_type !== 'external' && terminal) {
642
727
  yield chrome({
643
- kind: 'subagent_lifecycle',
728
+ kind: 'notification_terminal',
644
729
  laneProof: MAIN,
645
- phase: 'stop',
646
730
  taskId,
647
- guard: 'if-started',
731
+ status: rawStatus,
732
+ ...(typeof n.task_type === 'string' ? { taskType: n.task_type } : {}),
648
733
  });
734
+ if (n.task_type !== 'background_bash') {
735
+ yield chrome({
736
+ kind: 'panel_task',
737
+ laneProof: MAIN,
738
+ event: { kind: 'end', taskId, isError: rawStatus !== 'completed' },
739
+ });
740
+ yield chrome({
741
+ kind: 'subagent_lifecycle',
742
+ laneProof: MAIN,
743
+ phase: 'stop',
744
+ taskId,
745
+ guard: 'if-started',
746
+ });
747
+ }
649
748
  }
650
- }
651
- // 双通道反向去重:probe 合成的完成卡已入队 后到的推送帧只做上面的记账,不渲第二条。
652
- if (n.task_type !== 'external' && this.cardEnqueuedRuns.has(taskId))
653
- break;
654
- // 跨通道记账(cli markEngineWorkflowNotified 同位、同条件:非 external,不看终态):
655
- // 这帧到达 = 引擎已 server-side steer-inject 过,补发通道(workflow_complete)不得再注入。
656
- // external 豁免([769] 对抗复审收紧:external 从不经补发通道,mark 只会缴械 probe 兜底)
657
- if (n.task_type !== 'external')
658
- this.notifiedRuns.add(taskId);
659
- yield transcript({
660
- type: 'user',
661
- message: { role: 'user', content: [{ type: 'text', text: renderTaskNotificationXml(fields) }] },
662
- uuid: idOf(m),
663
- session_id: ctx.sessionId,
664
- });
665
- break;
666
- }
667
- case 'workflow_complete': {
668
- const runId = typeof m.runId === 'string' ? m.runId : undefined;
669
- if (runId === undefined)
670
- break;
671
- // 跨通道去重(cli enqueueEngineWorkflowNotification 首行):该 run 已由推送帧记账过
672
- // (引擎 server-side 注入过)⇒ Path B 绝不再喂模型一遍。适配器自持,不摊给宿主。
673
- if (this.notifiedRuns.has(runId))
749
+ // 双通道反向去重:probe 合成的完成卡已入队 ⇒ 后到的推送帧只做上面的记账,不渲第二条。
750
+ if (n.task_type !== 'external' && this.cardEnqueuedRuns.has(taskId))
751
+ break;
752
+ // 跨通道记账(cli markEngineWorkflowNotified 同位、同条件:非 external,不看终态):
753
+ // 这帧到达 = 引擎已 server-side steer-inject 过,补发通道(workflow_complete)不得再注入。
754
+ // external 豁免([769] 对抗复审收紧:external 从不经补发通道,mark 只会缴械 probe 兜底)
755
+ if (n.task_type !== 'external')
756
+ this.notifiedRuns.add(taskId);
757
+ yield transcript({
758
+ type: 'user',
759
+ message: { role: 'user', content: [{ type: 'text', text: renderTaskNotificationXml(fields) }] },
760
+ uuid: idOf(m),
761
+ session_id: ctx.sessionId,
762
+ });
674
763
  break;
675
- this.notifiedRuns.add(runId);
676
- const wfStatus = m.status === 'failed' ? 'failed' : 'completed';
677
- const wfSummary = typeof m.summary === 'string' ? m.summary : '';
678
- yield chrome({
679
- kind: 'workflow_notification_enqueue',
680
- laneProof: MAIN,
681
- runId,
682
- status: wfStatus,
683
- summary: wfSummary,
684
- // 模型面正文照铸(宿主直接入队即可,不必各自拼 XML;与 core 的 tag/段序同形)
685
- message: renderTaskNotificationXml({ taskId: runId, status: wfStatus, summary: wfSummary }),
686
- });
687
- this.cardEnqueuedRuns.add(runId);
688
- break;
689
- }
690
- case 'diagnostics': {
691
- if (typeof m.parentToolCallId === 'string')
692
- break; // 子流帧不上 leader 面
693
- const att = projectDiagnosticsFrame(m);
694
- if (att === null)
764
+ }
765
+ case 'workflow_complete': {
766
+ const runId = typeof m.runId === 'string' ? m.runId : undefined;
767
+ if (runId === undefined)
768
+ break;
769
+ // 跨通道去重(cli enqueueEngineWorkflowNotification 首行):该 run 已由推送帧记账过
770
+ // (引擎 server-side 注入过)⇒ Path B 绝不再喂模型一遍。适配器自持,不摊给宿主。
771
+ if (this.notifiedRuns.has(runId))
772
+ break;
773
+ this.notifiedRuns.add(runId);
774
+ const wfStatus = m.status === 'failed' ? 'failed' : 'completed';
775
+ const wfSummary = typeof m.summary === 'string' ? m.summary : '';
776
+ yield chrome({
777
+ kind: 'workflow_notification_enqueue',
778
+ laneProof: MAIN,
779
+ runId,
780
+ status: wfStatus,
781
+ summary: wfSummary,
782
+ // 模型面正文照铸(宿主直接入队即可,不必各自拼 XML;与 core 的 tag/段序同形)
783
+ message: renderTaskNotificationXml({ taskId: runId, status: wfStatus, summary: wfSummary }),
784
+ });
785
+ this.cardEnqueuedRuns.add(runId);
695
786
  break;
696
- yield chrome({ kind: 'attachment', laneProof: MAIN, id: idOf(m), attachment: att });
697
- break;
698
- }
699
- case 'steering_injected': {
700
- if (typeof m.parentToolCallId === 'string')
787
+ }
788
+ case 'diagnostics': {
789
+ if (typeof m.parentToolCallId === 'string')
790
+ break; // 子流帧不上 leader 面
791
+ const att = projectDiagnosticsFrame(m);
792
+ if (att === null)
793
+ break;
794
+ yield chrome({ kind: 'attachment', laneProof: MAIN, id: idOf(m), attachment: att });
701
795
  break;
702
- const source = typeof m.source === 'string' ? m.source : '';
703
- if (source.length === 0)
796
+ }
797
+ case 'steering_injected': {
798
+ if (typeof m.parentToolCallId === 'string')
799
+ break;
800
+ const source = typeof m.source === 'string' ? m.source : '';
801
+ if (source.length === 0)
802
+ break;
803
+ const preview = typeof m.preview === 'string' ? m.preview : '';
804
+ let i = 0;
805
+ for (const att of steeringInjectedToAttachments(source, preview)) {
806
+ yield chrome({
807
+ kind: 'attachment',
808
+ laneProof: MAIN,
809
+ id: idOf(m, `steering-${i++}`),
810
+ attachment: att,
811
+ });
812
+ }
704
813
  break;
705
- const preview = typeof m.preview === 'string' ? m.preview : '';
706
- let i = 0;
707
- for (const att of steeringInjectedToAttachments(source, preview)) {
814
+ }
815
+ case 'workspace_changed': {
816
+ if (typeof m.parentToolCallId === 'string')
817
+ break;
818
+ const cwd = typeof m.cwd === 'string' ? m.cwd : '';
819
+ if (cwd.length === 0)
820
+ break;
708
821
  yield chrome({
709
822
  kind: 'attachment',
710
823
  laneProof: MAIN,
711
- id: idOf(m, `steering-${i++}`),
712
- attachment: att,
824
+ id: idOf(m),
825
+ attachment: { type: 'workspace_changed', cwd },
713
826
  });
827
+ break;
714
828
  }
715
- break;
716
- }
717
- case 'workspace_changed': {
718
- if (typeof m.parentToolCallId === 'string')
829
+ case 'prompt_suggestions': {
830
+ // T57 第五处断闸(#47 矩阵 #5 同族):子流(parentToolCallId 标)的建议绝不骑主 composer。
831
+ if (typeof m.parentToolCallId === 'string')
832
+ break;
833
+ const list = Array.isArray(m.suggestions)
834
+ ? m.suggestions.filter((s) => typeof s === 'string')
835
+ : [];
836
+ // 空批不发(cli 同门:`if (list.length > 0)`)—— 作废由 turn 开场那一发负责,
837
+ // 这里再发一次 null 会把本轮真产出的建议在极端交错下擦掉。
838
+ if (list.length > 0) {
839
+ yield chrome({ kind: 'prompt_suggestions', laneProof: MAIN, suggestions: list });
840
+ }
719
841
  break;
720
- const cwd = typeof m.cwd === 'string' ? m.cwd : '';
721
- if (cwd.length === 0)
842
+ }
843
+ case 'retry_status': {
844
+ // #47 矩阵 #5:子代 lane 帧绝不骑主 spinner(无判别守卫的无条件 store 写=默认可疑)。
845
+ if (typeof m.parentToolCallId === 'string')
846
+ break;
847
+ yield chrome({
848
+ kind: 'retry_status',
849
+ laneProof: MAIN,
850
+ status: mapBrainStatusToRetry({
851
+ phase: typeof m.phase === 'string' ? m.phase : '',
852
+ ...(typeof m.detail === 'string' ? { detail: m.detail } : {}),
853
+ ...(typeof m.retryInSec === 'number' ? { retryInSec: m.retryInSec } : {}),
854
+ }, ctx.now()),
855
+ });
722
856
  break;
723
- yield chrome({
724
- kind: 'attachment',
725
- laneProof: MAIN,
726
- id: idOf(m),
727
- attachment: { type: 'workspace_changed', cwd },
728
- });
729
- break;
730
- }
731
- case 'retry_status': {
732
- // #47 矩阵 #5:子代 lane 帧绝不骑主 spinner(无判别守卫的无条件 store 写=默认可疑)。
733
- if (typeof m.parentToolCallId === 'string')
857
+ }
858
+ default:
734
859
  break;
735
- yield chrome({
736
- kind: 'retry_status',
737
- laneProof: MAIN,
738
- status: mapBrainStatusToRetry({
739
- phase: typeof m.phase === 'string' ? m.phase : '',
740
- ...(typeof m.detail === 'string' ? { detail: m.detail } : {}),
741
- ...(typeof m.retryInSec === 'number' ? { retryInSec: m.retryInSec } : {}),
742
- }, ctx.now()),
743
- });
744
- break;
745
860
  }
746
- default:
747
- break;
861
+ // COALESCER 节拍:一个间隔至多一次 flush(同步 SSE 突发先攒着,过闸再一次泄干)。
862
+ if (m.type === 'stream_event') {
863
+ const now = ctx.now();
864
+ if ((textPending.length > 0 || thinkingPending.length > 0) && now - lastFlushAt >= flushEveryMs) {
865
+ yield* drainLive();
866
+ lastFlushAt = now;
867
+ }
868
+ }
748
869
  }
749
- // COALESCER 节拍:一个间隔至多一次 flush(同步 SSE 突发先攒着,过闸再一次泄干)。
750
- if (m.type === 'stream_event') {
751
- const now = ctx.now();
752
- if ((textPending.length > 0 || thinkingPending.length > 0) && now - lastFlushAt >= FLUSH_INTERVAL_MS) {
753
- yield* drainLive();
754
- lastFlushAt = now;
870
+ }
871
+ finally {
872
+ // T58 手动 iterator 没有 for-await 的隐式 cleanup:early break(abort)或抛错都必须关上游
873
+ // 生成器(SSE 拆链)。正常跑完(done)不用关,上游自己结束了。
874
+ if (!iteratorDone) {
875
+ try {
876
+ void iterator.return?.();
877
+ }
878
+ catch {
879
+ // best-effort close — 绝不遮蔽原本的退出路径
755
880
  }
756
881
  }
757
882
  }
758
- // turn 收口(cli 尾部同序):泄尾增量 liveness 提交尾思考 → 提交尾文本段。
883
+ // T59 abort 后到此为止:不发合成终答、不做兜底(这个 turn 被打断了;CC 自己的 abort 路径
884
+ // 渲 InterruptedByUser chrome,那是宿主的事)。但**瞬态覆盖层必须收干净**,否则 Esc 之后
885
+ // 上一轮的 retry 行/思考活动行会一直挂着。
886
+ if (ctx.signal?.aborted) {
887
+ yield chrome({ kind: 'retry_status', laneProof: MAIN, status: null });
888
+ yield chrome({ kind: 'thinking_activity', laneProof: MAIN, active: false });
889
+ ctx.log?.('debug', 'turn aborted (Esc) — no terminal answer emitted');
890
+ return;
891
+ }
892
+ // T60 —— turn 收口(cli 尾部同序,覆盖"流结束了但没有 result 臂"的路径):
893
+ // 泄尾增量 → 清 liveness → 提交尾思考 → 提交尾文本段。
759
894
  yield* drainLive();
760
895
  yield chrome({ kind: 'retry_status', laneProof: MAIN, status: null });
761
896
  yield* takeThinking();
762
897
  yield* takeAnswerSegment();
898
+ ctx.log?.('debug', `turn done · emittedAssistantText=${emittedAssistantText} answerLen=${answer.length}`);
763
899
  void answer; // 全流无 text 臂时的兜底源(result 臂已消费);保留供调试/后续批
764
900
  }
765
901
  }