@sema-agent/client-core 0.62.1 → 0.62.2

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.
package/CHANGELOG.md CHANGED
@@ -49,6 +49,46 @@
49
49
  > 挡住 ⇒ 本批把它机械化——④a0 对 `pending` 行**要求段头已是日期形**(`(未发布)` 直接红),阶段一
50
50
  > commit 漏转在发布前就红,不再靠人记。
51
51
 
52
+ ## 0.62.2(2026-09-09)
53
+
54
+ > patch 内容批(一件)。**本段只记内容**:`package.json.version` 未动、README `Version` 行未动、
55
+ > `FROZEN` 未动 —— bump 与段头转日期形归**发包批**(阶段一义务见本档头注)。**零新导出、零型面变化**,
56
+ > 老宿主一个字节不改照常跑;`peerDependencies` 未动。
57
+
58
+ - 🔴 **子代内容账本:工具卡的阶段账与卡上的内容改成同源**(行为**修复**,异源复审第四轮四条)。
59
+ 0.62.1 把「这张卡现在承载的是哪一阶段」记在一本**只在 `eventId` 在场时才写、且 `start`/`end`
60
+ 两半各自比较**的水位上,而内容(名 / 参 / 结果 / 段边界)不论身份在不在都照落 —— 身份账与内容账
61
+ **不同源**。同一个病根开出四个用户可见的口子,本批一并修掉:
62
+ - **无身份帧改了内容,旧水位还替它背书**:先落新参数 + 真结果(带身份),再收一发**不带**
63
+ `eventId` 的旧参数 / 失败结果 ⇒ 内容按「后到落账」被覆盖,而水位仍停在覆盖**之前**那一阶段
64
+ ⇒ 随后正确的那一发**原样重放**被自己的水位整帧拦掉,卡**永久**停在旧参数与失败结果上。
65
+ 改为:身份缺席的那一发**若真的改了**那一半的内容,落账时同拍把对应那一半水位清掉(判不出新旧
66
+ 就诚实地说判不出);**内容一个字节没变**(重放送来的是同一份值)⇒ 水位原样保留 —— 它仍如实描述
67
+ 卡上这份内容,清掉等于顺手把「旧一阶段不许盖新一阶段」那道闸一起卸了。一句话:**这一半的水位
68
+ 永远描述卡上这一半现在这份内容,内容没动它就没理由动**。
69
+ - **收口不看 `start` 半边**:两条独立的流交错时(恢复阶段的 `tool_start` 已到、旧一阶段的
70
+ `tool_end` 才姗姗来迟),旧收口会落到新阶段那张**开着**的卡上并把它关掉 ⇒ 新参数配上旧阶段的
71
+ 失败结果;新流若中断,这个错误状态一直留在屏上。改为:收口**两半都比**,早于当前 `start` 的
72
+ 整帧丢弃 —— 更新的 `start` 已经建立了新的一阶段,旧收口不属于它(那张卡确实还在飞,
73
+ 显示在飞是**如实**的)。
74
+ - **「水位缺席」被当成「首个段边界还没给过」**:上游不给 `eventId` 时 `start` 水位永远抬不起来
75
+ ⇒ **每一次重放都再段闭合一次**,还在流的活体尾段被切成历史 item,随后该段的聚合全文在空缓冲上
76
+ 吸不到前缀、整只落回缓冲 ⇒ 前缀重复上屏。改为:段边界给过没有是**卡上的一位**,不依赖可选身份。
77
+ - **段边界只按内容串记、不按段身份记**:`AAA` → 收口先到 → `BBB` → 迟到的 `tool_start` 这种乱序里,
78
+ 那一发 start 把 `AAABBB` 整只闭合成**一段** ⇒ 整轮重放送来 `AAA`、`BBB` 两段时两道内容闸都不命中,
79
+ 历史与缓冲各留一份。改为:收口先到那一刻把缓冲上的位置记进**这条子代的账本**(不是记在卡上),
80
+ 段闭合时在**所有**还没兑现的位置上一起切,并把在场每张卡的「还欠不欠一次段闭合」一并置上。
81
+ 位置是**缓冲的**属性、不是那张卡的私产:①兑现者不限于那一发迟到的 `tool_start`(别的工具起头、
82
+ `settle` 同样要切);②那一发迟到的 start 若还当自己欠着,会在别人切完之后再切一刀,切掉的是
83
+ 当前那条与它无关的活体尾段;③卡被帽丢掉不等于缓冲被丢掉(帽先丢 item、丢不够才裁缓冲),
84
+ 边界跟着卡消失就是把「这里有过一次工具」丢了;④缓冲头部被裁时边界同拍左移。上限 64 条。
85
+ 同批把「开着的卡 → 下标」那张表删掉(卡在不在、在哪里只由 `items` 说了算,那是同一件事的第二处
86
+ 真源),`tool_end` 的两支从此共用同一条定位与同一道闸。接入面逐条见 `docs/INTEGRATION-CLIENTS.md`
87
+ §26b;门 `run-subagent-durable-divert-test.mjs` 新增 G13–G16 四块(90 ⇒ 108 checks),每块除终态外都
88
+ 断言**中间快照**、**段条数**与**字节账**(留痕 `droppedBytes` 与门自算的真实字节逐字节相等);另含
89
+ 代理对被拆两帧时段边界的退让格、`settle` 兑现边界格、两张收口先到的卡交错格、切点被别人兑现之后
90
+ 迟到 start 不再切格、卡被裁而缓冲完好格、缓冲头部被裁后边界左移格。
91
+
52
92
  ## 0.62.1(2026-09-09)
53
93
 
54
94
  > patch 内容批(两件)。**本段只记内容**:`package.json.version` 未动、README `Version` 行未动、
package/README.md CHANGED
@@ -35,7 +35,7 @@ Renamed from **`@sema-agent/wire-cc-adapter`** (0.1.x, deprecated — see *Migra
35
35
 
36
36
  ## Scope
37
37
 
38
- **Version:** 0.62.1
38
+ **Version:** 0.62.2
39
39
 
40
40
  - **Today** — the adapter seam, the whole `adapt()` pipeline (all 14 A-layer arms plus the
41
41
  B/D/E tool-card layers), the notification/caps/model families, the adapter kernel (stream driver
@@ -196,6 +196,22 @@ function appendBytes(prev, chunk) {
196
196
  const joinsPair = lastPrev >= 0xd800 && lastPrev <= 0xdbff && firstNew >= 0xdc00 && firstNew <= 0xdfff;
197
197
  return joinsPair ? add - 2 : add;
198
198
  }
199
+ /**
200
+ * 工具参数(开集 `unknown`)的**规范序列化** —— 字节账({@link itemBytesOf})与「这一发到底改了
201
+ * 没有」({@link startContentKey})共用这**一口**,两处对同一份参数的看法因此不可能分叉。
202
+ * 不可序列化(循环引用等)⇒ 空串:它进不了展示面,也就占不了这本账的预算、也不算内容变化。
203
+ */
204
+ function serializeToolInput(input) {
205
+ if (input === undefined)
206
+ return '';
207
+ try {
208
+ return JSON.stringify(input) ?? '';
209
+ }
210
+ catch {
211
+ // 循环引用/不可序列化 ⇒ 这一位按空串计(见本函数头注)
212
+ return '';
213
+ }
214
+ }
199
215
  /** 一条 item 的字节账(`truncated` 是关于预算的元信息,自身不计)。 */
200
216
  function itemBytesOf(it) {
201
217
  switch (it.kind) {
@@ -203,18 +219,9 @@ function itemBytesOf(it) {
203
219
  case 'thinking':
204
220
  case 'echo':
205
221
  return utf8Len(it.text);
206
- case 'tool': {
207
- let n = utf8Len(it.name) + utf8Len(it.output ?? '');
208
- // 工具参数是**开集** `unknown`:序列化一次算体量(每张卡只算一次,不在增量热路径上)
209
- try {
210
- if (it.input !== undefined)
211
- n += utf8Len(JSON.stringify(it.input) ?? '');
212
- }
213
- catch {
214
- // 循环引用/不可序列化 ⇒ 这一位按 0 计(它进不了展示面,也就占不了这本账的预算)
215
- }
216
- return n;
217
- }
222
+ case 'tool':
223
+ // 工具参数序列化一次算体量(每张卡只算一次,不在增量热路径上)
224
+ return utf8Len(it.name) + utf8Len(it.output ?? '') + utf8Len(serializeToolInput(it.input));
218
225
  case 'truncated':
219
226
  return 0;
220
227
  }
@@ -256,27 +263,39 @@ function noteDropped(s, dropped) {
256
263
  }
257
264
  s.items.unshift({ kind: 'truncated', droppedBytes: s.droppedBytes, keptFrom: s.droppedBytes });
258
265
  s.itemBytes.unshift(0);
259
- rebuildOpenTools(s);
266
+ pruneCardPhases(s);
260
267
  }
261
- /** items 被增删之后重建「开着的工具卡 → 下标」表(下标随 splice/unshift 整体漂移)。
262
- * 🔴 同批把**卡已不在 items 上**的阶段水位清掉:水位是「这张卡现在承载的是哪一阶段」,卡没了它
263
- * 就没有指涉对象,而这张表按 `toolCallId` 一条一条长 —— 不跟着卡清 = 一条长 turn 上的无界增长
264
- * (本账本的立身之本就是有界)。守的是内存面,不是行为面,理由见 `toolPhase` 头注的那一笔。 */
265
- function rebuildOpenTools(s) {
266
- s.openTools.clear();
268
+ /**
269
+ * items 被增删之后,把**卡已不在 items 上**的阶段账清掉。
270
+ *
271
+ * 🔴 这里此前还维护第二张表(`toolCallId` 开着的卡下标)。删掉它是本批「按类修」的一部分:
272
+ * 一次工具调用的卡**在不在、在哪里**只有 `items` 说了算(本文件 {@link toolCardIndexOf} 头注
273
+ * 逐字写过这条),而下标表是同一件事的第二处真源 —— 它与 items 脱节的方式是可预期的
274
+ * (splice/unshift 之后下标整体漂移),而本批四条的病根正是「同一件事记了两本各自记的账」。
275
+ * 收口那一支改成每次按 `toolCallId` 现查(至多 200 条,一次线性扫),两处真源就只剩一处。
276
+ */
277
+ function pruneCardPhases(s) {
278
+ // 🔴 只清**卡的**阶段账。段边界不在这里 —— 它压在缓冲上(TaskContentState.pendingCuts),卡被丢掉
279
+ // 时缓冲里那截正文一个字都没少,边界跟着卡消失就等于把「这里有过一次工具」这件事丢了。
267
280
  const live = new Set();
268
- s.items.forEach((it, i) => {
269
- if (it.kind === 'tool') {
281
+ for (const it of s.items) {
282
+ if (it.kind === 'tool')
270
283
  live.add(it.id);
271
- if (it.output === undefined)
272
- s.openTools.set(it.id, i);
273
- }
274
- });
284
+ }
275
285
  for (const id of s.toolPhase.keys()) {
276
286
  if (!live.has(id))
277
287
  s.toolPhase.delete(id);
278
288
  }
279
289
  }
290
+ /** 取(必要时新建)一张卡的阶段账 —— 卡与账同生:建卡/改卡的每一支都经这一口。 */
291
+ function phaseSlot(s, id) {
292
+ const cur = s.toolPhase.get(id);
293
+ if (cur !== undefined)
294
+ return cur;
295
+ const next = { boundary: false };
296
+ s.toolPhase.set(id, next);
297
+ return next;
298
+ }
280
299
  /**
281
300
  * 每子代帽:超出就**从最早的内容开始丢**,丢不动了再从缓冲头部裁。
282
301
  *
@@ -289,6 +308,13 @@ function enforceTaskCap(s) {
289
308
  if (s.bytes <= cap)
290
309
  return false;
291
310
  let dropped = 0;
311
+ // 「有没有卡离开 items」按**条数**量,不拿 `dropped`(字节)代答。
312
+ // 📋 如实(变异自证):这一改**今天零行为差异** —— 每子代帽恒 ≥ 1(configureSubagentContentStore
313
+ // 的 fail-loud 挡住 0 与负数),而循环只在「还装不下」时继续,所以有卡离开就一定有字节离开,
314
+ // 两个条件当下等价;门对它没有判别力是**正常**的,别拿门当它的证明。这样写的理由是:
315
+ // 「卡走了、它的阶段账同批清」是一条**行为面**不变量(陈旧的 `end` 会把重建那一轮的收口整帧
316
+ // 拦下 —— 卡建回来却永远显示在飞,G9 那一格量的就是它),它不该靠一条关于**字节**的推论维持。
317
+ let removedItems = 0;
292
318
  const first = () => (s.items[0]?.kind === 'truncated' ? 1 : 0);
293
319
  while (s.bytes > cap && s.items.length > first()) {
294
320
  const idx = first();
@@ -297,25 +323,32 @@ function enforceTaskCap(s) {
297
323
  s.itemBytes.splice(idx, 1);
298
324
  addBytes(s, -b);
299
325
  dropped += b;
326
+ removedItems += 1;
300
327
  }
328
+ // 🔴 裁的是缓冲**头部** ⇒ 压在这条缓冲上的段边界同拍左移(边界是位置,前缀短了它就得跟着短;
329
+ // 不移的话那一刀会切在错的字上)。段边界活得比卡长,所以这一步是真会走到的:上面那个循环
330
+ // 可以只丢掉卡而缓冲一个字没少,反过来这里裁了缓冲而边界还压着。
301
331
  if (s.bytes > cap) {
302
332
  const cut = trimHeadBytes(s.thinkBuf, s.bytes - cap);
333
+ shiftPendingCuts(s, 'think', s.thinkBuf.length - cut.rest.length);
303
334
  s.thinkBuf = cut.rest;
304
335
  addBytes(s, -cut.removed);
305
336
  dropped += cut.removed;
306
337
  }
307
338
  if (s.bytes > cap) {
308
339
  const cut = trimHeadBytes(s.textBuf, s.bytes - cap);
340
+ shiftPendingCuts(s, 'text', s.textBuf.length - cut.rest.length);
309
341
  s.textBuf = cut.rest;
310
342
  addBytes(s, -cut.removed);
311
343
  dropped += cut.removed;
312
344
  }
345
+ if (removedItems > 0)
346
+ pruneCardPhases(s);
313
347
  if (dropped > 0) {
314
- rebuildOpenTools(s);
315
348
  noteDropped(s, dropped);
316
349
  return true;
317
350
  }
318
- return false;
351
+ return removedItems > 0;
319
352
  }
320
353
  /**
321
354
  * 总帽:整条**最久未用**的子代账本被清掉(`tasks` 的迭代序就是 LRU 序,活跃项每次访问都会重插)。
@@ -375,12 +408,12 @@ function stateFor(taskId, parentToolCallId) {
375
408
  itemBytes: [],
376
409
  textBuf: '',
377
410
  thinkBuf: '',
378
- openTools: new Map(),
379
411
  parentToolCallId,
380
412
  bytes: 0,
381
413
  droppedBytes: 0,
382
414
  seenAggregateIds: new Set(),
383
415
  recordedSegments: new Set(),
416
+ pendingCuts: [],
384
417
  toolPhase: new Map(),
385
418
  };
386
419
  tasks.set(taskId, s);
@@ -405,29 +438,81 @@ function pushItem(s, item) {
405
438
  addBytes(s, -freed);
406
439
  // 条数帽丢掉的同样是**内容**,同样要留痕(此前这条路是无声的)。
407
440
  noteDropped(s, freed);
408
- // open-tool indexes shifted — rebuild from the surviving items
409
- rebuildOpenTools(s);
441
+ // 卡被裁掉了 它的阶段账同批作废(卡没了,那本账就没有指涉对象)
442
+ pruneCardPhases(s);
410
443
  }
411
444
  }
412
- /** Close out streaming buffers into items (segment boundary: a tool starts, or the run settles). */
445
+ /**
446
+ * 段闭合:把两条缓冲收成 item(段边界 = 工具起头,或这一轮 settle)。
447
+ *
448
+ * 🔴 **在所有还没兑现的段边界处一起切**({@link CardPhase.cut};收口先于开始帧到达时记下的位置)。
449
+ * 为什么不是「只切触发这一次闭合的那张卡的那一条」:边界记的是**缓冲上的位置**,不是某张卡的
450
+ * 私产 —— 一条缓冲上可以同时压着好几条(两张 orphan 卡交错),而闭合一旦发生缓冲就整只清空,
451
+ * 没被兑现的那些位置从此无处可切。异源复审 R5 的两个反例都是这一形:①兑现者是 `settle` 或
452
+ * 别的工具的 `tool_start`(它们此前根本不看这些位置)⇒ 两段黏成一段;②两张 orphan 交错时第一发
453
+ * 迟到的 start 只兑现自己那条、把另一条丢掉 ⇒ 后两段黏成一段。黏成一段的代价是整轮重放时那一段
454
+ * 的内容闸不命中,历史与缓冲各留一份。
455
+ */
413
456
  function flushBuffers(s) {
457
+ const asc = (a, b) => a - b;
458
+ const textCuts = [...new Set(s.pendingCuts.map((c) => c.text))].sort(asc);
459
+ const thinkCuts = [...new Set(s.pendingCuts.map((c) => c.think))].sort(asc);
414
460
  // 🔴 缓冲的字节此前已经计过账;搬进 item 时先扣掉缓冲那一份,再由 pushItem 计 item 那一份
415
461
  // (纯空白段被丢弃时同样要扣 —— 少扣一次,总账就会一路虚高到把别人的账本淘汰掉)。
416
462
  addBytes(s, -utf8Len(s.thinkBuf));
417
463
  const think = s.thinkBuf;
418
464
  s.thinkBuf = '';
419
- if (think.trim()) {
420
- pushItem(s, { kind: 'thinking', text: think });
421
- rememberSegment(s, think);
422
- }
465
+ closeSegments(s, 'thinking', think, thinkCuts);
423
466
  addBytes(s, -utf8Len(s.textBuf));
424
467
  const text = s.textBuf;
425
468
  s.textBuf = '';
426
- if (text.trim()) {
427
- pushItem(s, { kind: 'text', text });
428
- rememberSegment(s, text);
469
+ closeSegments(s, 'text', text, textCuts);
470
+ // 缓冲已空 压在它上面的位置一条不剩地兑现完了(指涉对象没有了)
471
+ s.pendingCuts.length = 0;
472
+ // 🔴 段闭合发生过了 ⇒ 在场每张卡都不再欠一次段闭合。这一位问的是「自这张卡出现以来发生过段闭合
473
+ // 没有」而不是「它自己切过没有」:兑现的可以是别人,而那一发迟到的 `tool_start` 若还当自己欠着,
474
+ // 就会在别人切完之后**再切一刀** —— 切掉的是当前那条与它无关的活体尾段。
475
+ for (const p of s.toolPhase.values())
476
+ p.boundary = true;
477
+ }
478
+ /** 一条缓冲收成 item:在每一条**落在中间**的边界上切一刀(空白段照旧丢弃、不铸空 item)。 */
479
+ function closeSegments(s, kind, buf, cuts) {
480
+ const push = (part) => {
481
+ if (!part.trim())
482
+ return;
483
+ pushItem(s, kind === 'text' ? { kind: 'text', text: part } : { kind: 'thinking', text: part });
484
+ rememberSegment(s, part);
485
+ };
486
+ let from = 0;
487
+ for (const at of cuts) {
488
+ const n = safeCut(buf, at);
489
+ if (n <= from || n >= buf.length)
490
+ continue;
491
+ push(buf.slice(from, n));
492
+ from = n;
429
493
  }
494
+ push(buf.slice(from));
495
+ }
496
+ /**
497
+ * 把段边界夹进 `[0, buf.length]`,并且**绝不劈开合法代理对**。
498
+ *
499
+ * 🔴 为什么会劈到:边界是「记下它那一刻的缓冲长度」,而上游真的会把一对代理对拆成**两帧**送
500
+ * (本文件 {@link appendBytes} 头注就是为这一形写的)—— 记边界那一刻缓冲尾部可能正停在高位
501
+ * 代理项上。从那里切会切出两个孤代理项:屏上两段各挂半个字符,两段的字节账合计也比整条多 2。
502
+ * ⇒ 撞上就把边界**退**到那一对之前(宁可把这半个字符归给后一段)。
503
+ */
504
+ function safeCut(buf, at) {
505
+ let n = at < 0 ? 0 : at > buf.length ? buf.length : at;
506
+ if (n > 0 && n < buf.length) {
507
+ const prev = buf.charCodeAt(n - 1);
508
+ const here = buf.charCodeAt(n);
509
+ if (prev >= 0xd800 && prev <= 0xdbff && here >= 0xdc00 && here <= 0xdfff)
510
+ n -= 1;
511
+ }
512
+ return n;
430
513
  }
514
+ /** 两次段闭合之间最多记多少条段边界(见 {@link TaskContentState.pendingCuts} 的有界那一笔)。 */
515
+ const MAX_PENDING_CUTS = 64;
431
516
  /** 有界 FIFO 记号(两本小账共用;只用来做幂等判定,不参与预算)。 */
432
517
  const MAX_REPLAY_KEYS = 64;
433
518
  function remember(set, key) {
@@ -555,20 +640,66 @@ function notNewer(eventId, mark) {
555
640
  return false;
556
641
  return eventId <= mark;
557
642
  }
558
- /** 读一张卡的阶段水位(没有 ⇒ 这张卡还没落过对应那一半的帧)。 */
643
+ /** 读一张卡的阶段账(没有 ⇒ 这张卡还没落过任何一半的帧)。 */
559
644
  function phaseOf(s, id) {
560
645
  return id === undefined ? undefined : s.toolPhase.get(id);
561
646
  }
562
- /** 抬一张卡的阶段水位(身份缺席 ⇒ 不抬:一个抬不动的水位比一个抬错的水位诚实)。 */
563
- function markPhase(s, id, half, eventId) {
564
- if (id === undefined || typeof eventId !== 'string' || eventId.length === 0)
647
+ /**
648
+ * 内容落账之后给这张卡盖章:**水位与卡上现在这份内容同源**。三形:
649
+ * · 身份在场 抬到这一发;
650
+ * · 身份缺席**且这一发真的改了卡上那一半的内容** ⇒ 把这一半**清掉**。此前缺席时一律「不抬」,
651
+ * 于是无身份的那一发把内容换掉了、水位还停在**换掉之前**那一阶段上 —— 随后正确的那一发原样
652
+ * 重放被自己的水位 `notNewer` 整帧拦掉,卡**永久**停在旧内容上;
653
+ * · 身份缺席**而内容一个字节没变**(重放送来的是同一份值)⇒ **原样保留**。水位仍然如实描述卡上
654
+ * 这份内容,清掉它等于顺手把「旧一阶段不许盖新一阶段」那道闸一起卸了 —— 异源复审 R5 的反例:
655
+ * 一发无身份的**同值**重放之后,旧一阶段的 start 就能把改后参数盖回原参,而更新的结果还留在
656
+ * 卡上,屏上成了「这个参数产生了那个结果」。喂点真的会混来源(tail 腿的工具帧不带 `eventId`)。
657
+ *
658
+ * 一句话:**这一半的水位,永远描述卡上这一半现在这份内容**——内容没动它就没理由动。
659
+ */
660
+ function stampPhase(s, id, half, eventId, changed) {
661
+ if (id === undefined)
565
662
  return;
566
- const cur = s.toolPhase.get(id);
567
- if (cur === undefined) {
568
- s.toolPhase.set(id, { [half]: eventId });
663
+ const p = phaseSlot(s, id);
664
+ if (typeof eventId === 'string' && eventId.length > 0)
665
+ p[half] = eventId;
666
+ else if (changed)
667
+ delete p[half];
668
+ }
669
+ /** 卡上「开始那一半」的内容指纹(名 + 参数的规范序列化)—— 只用来回答「这一发真的改了吗」。 */
670
+ function startContentKey(it) {
671
+ return `${it.name}\u0000${serializeToolInput(it.input)}`;
672
+ }
673
+ /** 这张卡不欠段闭合了(建卡那一支刚刚无条件闭合过一次)。 */
674
+ function markBoundary(s, id) {
675
+ phaseSlot(s, id).boundary = true;
676
+ }
677
+ /** 收口先到 ⇒ 把**此刻**两条缓冲的长度记成这条缓冲上的一个段边界(见 TaskContentState.pendingCuts)。 */
678
+ function recordCut(s) {
679
+ if (s.pendingCuts.length >= MAX_PENDING_CUTS)
569
680
  return;
681
+ s.pendingCuts.push({ text: s.textBuf.length, think: s.thinkBuf.length });
682
+ }
683
+ /** 缓冲头部被裁掉 `removed` 个 UTF-16 单元 ⇒ 压在这条缓冲上的段边界一律左移(夹到 0)。 */
684
+ function shiftPendingCuts(s, half, removed) {
685
+ if (removed <= 0)
686
+ return;
687
+ for (const c of s.pendingCuts) {
688
+ const next = c[half] - removed;
689
+ c[half] = next > 0 ? next : 0;
570
690
  }
571
- cur[half] = eventId;
691
+ }
692
+ /**
693
+ * 这一发**收口**比卡上现在承载的那一阶段旧吗(旧 ⇒ 整帧丢弃)。
694
+ *
695
+ * 🔴 **两半都要问**,这是 0.62.2 修的第二条:只问 `end` 的话,两条独立的流交错时(恢复阶段的
696
+ * `start` 已经到、旧一阶段的 `end` 才姗姗来迟)旧收口会落到新阶段那张开着的卡上 —— 新参数
697
+ * 配上旧阶段的失败结果,新流若中断这个错误状态就一直留在屏上。更新的 `start` 已经**建立了
698
+ * 新的一阶段**,旧收口不属于它;「避免永远在飞」不构成接受一份已知过期结果的理由(那张卡
699
+ * 确实还在飞,显示在飞是**如实**的)。
700
+ */
701
+ function staleEnd(phase, eventId) {
702
+ return notNewer(eventId, phase?.start) || notNewer(eventId, phase?.end);
572
703
  }
573
704
  /**
574
705
  * 这条 `toolCallId` 在本账本里已经有卡了吗(有 ⇒ 返回下标,没有 ⇒ `-1`)。
@@ -665,32 +796,41 @@ export function publishSubagentContentEvent(ev) {
665
796
  // 刻意**不写** `seenAggregateIds`:那本记号账是有界 FIFO,额度按「聚合帧只在重放腿上来」定的
666
797
  // (活体走 `*_delta`,一个字都不写);工具帧**活体也来**,跟着写会把还在屏上的散文身份整窗
667
798
  // 挤掉 —— 修一个臂的重放去重、弄坏另一个臂的,不是修。
668
- // 🔴 顺序上「开着的卡恒收口」排在最前:一张在飞的卡如果被任何一道闸拦掉收口,它就**永远在飞**
669
- // (屏上显示成还在跑),而那比多渲一次结果坏得多。
799
+ // 🔴 收口那一支**不按「卡开着没开着」分岔**(0.62.2 合并):卡在不在只按 `toolCallId` 现查
800
+ // `items`,在账就过同一道闸、走同一条落账。此前开着那一支另有一张下标表、且无条件落结果
801
+ // (理由写的是「在飞的卡被拦掉收口就永远在飞」)—— 那条理由在**旧一阶段的收口迟到**这一形上
802
+ // 是错的:更新的 `start` 已经建立了新的一阶段,那张卡确实还在跑,显示在飞是**如实**的,而
803
+ // 把旧阶段的失败结果配到新参数上并关掉卡才是用户看得见的损坏。
670
804
  case 'tool_start': {
671
805
  const startId = ev.toolCallId;
672
806
  const seen = startId !== undefined && toolCardIndexOf(s, startId) >= 0;
673
- if (seen && notNewer(ev.eventId, phaseOf(s, startId)?.start))
807
+ const known = phaseOf(s, startId);
808
+ if (seen && notNewer(ev.eventId, known?.start))
674
809
  break;
675
810
  // 段闭合可能触发条数裁剪(pushItem 从头 splice + unshift 留痕)⇒ 裁剪**前**取的下标与引用一律
676
811
  // 作废:flush 之后按 toolCallId **重新**找卡。找不到 = 这张卡刚被裁掉 ⇒ 落到下面「新的一次」
677
812
  // 建卡那一支(§26b 留白①),绝不对着已删的引用改名补参、也绝不把差额记到顶上那条留痕上。
678
- if (seen && phaseOf(s, startId)?.start === undefined)
813
+ // 这张卡还欠一次段闭合 现在补做(段闭合会在**所有**还没兑现的段边界上一起切,并把在场
814
+ // 每张卡的这一位置上 —— 兑现者不限于「欠着的那一张」)。
815
+ if (seen && known?.boundary !== true)
679
816
  flushBuffers(s);
680
817
  const at = startId !== undefined ? toolCardIndexOf(s, startId) : -1;
681
818
  if (at >= 0) {
682
- // 这次调用的卡已在账 ⇒ **不铸第二张**。剩下的两件按阶段水位判:
819
+ // 这次调用的卡已在账 ⇒ **不铸第二张**。剩下的两件按这张卡的**阶段账**判:
683
820
  // 🔴 ① 这一发比卡上现在承载的那一阶段旧(或就是同一条帧又来一遍)⇒ 整帧丢弃。少了这一条,
684
821
  // 门 park 那一阶段的 start 重放会把批复时**改过的执行参数**盖回原参数 —— 屏上就成了
685
822
  // 「这个参数产生了那个结果」,而那个结果根本不是它跑出来的。
686
- // 🔴 ② 这张卡有没有落过 `tool_start`:收口先到时下面那支会合成一张占位卡(名字 `Tool`、
687
- // 参数 `{}`),那张卡**没有** start 水位 —— 迟到的这一发是它的**第一个段边界**,要补做
688
- // 一次段闭合(不补的话工具前后两段散文会黏成一段、且一段都进不了已上屏正文那本记号账,
689
- // 随后整轮重放时两段都拦不住)。反过来,水位已在 = 这一发是重放或下一阶段,**不**段闭合:
690
- // 边界在活体那一轮已经给过,在这里切开会把还在流的那一段提前截断。
823
+ // 🔴 ② 这张卡的**首个段边界**给过没有(收口先到时下面那支会合成一张占位卡:名字 `Tool`、
824
+ // 参数 `{}`、边界还没给)—— 没给过 迟到的这一发就是它的第一个段边界,要补做一次段闭合,
825
+ // 并且按合成那一刻记下的边界把「工具之前 / 之后」两段**分开**收(不分开的话两段黏成
826
+ // 一段、只进一条记号,随后整轮重放时两段的内容闸都不命中)。给过了 这一发是重放或
827
+ // 下一阶段,**不**段闭合:边界在活体那一轮已经给过,在这里切会把还在流的那一段提前截断。
828
+ // 🔴 判据是卡上那一位,**不是**「start 水位在不在」:水位是可选位,上游不给 `eventId` 时
829
+ // 它永远抬不起来 ⇒ 每一次重放都会再切一刀(0.62.2 修的第三条)。
691
830
  // 名字与参数就地补齐/更新(重放送来的是同一份值,补齐是空操作;字节按**差额**记账)。
692
831
  const it = s.items[at];
693
832
  const before = itemBytesOf(it);
833
+ const keyBefore = startContentKey(it);
694
834
  if (ev.toolName !== undefined)
695
835
  it.name = ev.toolName;
696
836
  if (ev.args !== undefined)
@@ -698,54 +838,47 @@ export function publishSubagentContentEvent(ev) {
698
838
  const delta = itemBytesOf(it) - before;
699
839
  s.itemBytes[at] = (s.itemBytes[at] ?? 0) + delta;
700
840
  addBytes(s, delta);
701
- markPhase(s, startId, 'start', ev.eventId);
841
+ stampPhase(s, it.id, 'start', ev.eventId, startContentKey(it) !== keyBefore);
702
842
  break;
703
843
  }
704
844
  flushBuffers(s);
705
845
  const id = startId ?? `${ev.taskId}-tool-${s.items.length}`;
706
846
  pushItem(s, { kind: 'tool', id, name: ev.toolName ?? 'Tool', input: ev.args ?? {} });
707
- s.openTools.set(id, s.items.length - 1);
708
- markPhase(s, id, 'start', ev.eventId);
847
+ // 建卡这一支刚刚无条件段闭合过 边界就是此刻,同拍记在卡上
848
+ markBoundary(s, id);
849
+ // 卡是这一刻新铸的 ⇒ 这一半的内容当然是「变了」(此前没有任何一份)
850
+ stampPhase(s, id, 'start', ev.eventId, true);
709
851
  break;
710
852
  }
711
853
  case 'tool_end': {
712
854
  const id = ev.toolCallId;
713
- const idx = id !== undefined ? s.openTools.get(id) : undefined;
714
- if (idx !== undefined && s.items[idx]?.kind === 'tool') {
715
- // 卡还开着 ⇒ 结果照落。这一支**排在所有闸之前**:一张在飞的卡被任何一道闸拦掉收口,
716
- // 它就永远显示成还在跑,那比多渲一次结果坏得多。
717
- const it = s.items[idx];
718
- it.output = ev.output ?? '';
719
- it.isError = ev.isError === true;
720
- // 结果正文是**这一刻**才落到已在账的那条 item 上的补记它的字节。
721
- const add = utf8Len(it.output);
722
- s.itemBytes[idx] = (s.itemBytes[idx] ?? 0) + add;
723
- addBytes(s, add);
724
- if (id !== undefined)
725
- s.openTools.delete(id);
726
- markPhase(s, id, 'end', ev.eventId);
727
- break;
728
- }
729
- const settledAt = id !== undefined ? toolCardIndexOf(s, id) : -1;
730
- if (settledAt >= 0) {
731
- // 卡在账、却已不在「开着」表上 = 这次调用的结果**早就落过**。两种可能,判据是阶段水位:
732
- // · 比卡上现在那一份**旧或就是同一条帧**(durable 重放 / 旧一阶段迟到)⇒ 整帧丢弃 ——
733
- // 再落一次会把结果字节重复计进预算,走到下面那支还会另铸一张已完成的卡;更坏的是
734
- // 旧一阶段会把屏上的真结果盖回去;
735
- // · **更新的一发**收口(门 park 掉这次调用、批复后恢复执行,上游按同一个 `toolCallId`
736
- // 再发一对 start/end)⇒ **后到的才是真结果**,就地替换。把「已 settled」当成永久完成
737
- // 会把真结果吞掉,屏上停在那句「被门拦下」上。
738
- // 🔴 身份缺席(判不出新旧)时走替换那一支:重放送的是同一份值,替换是空操作(差额恒 0)。
739
- if (notNewer(ev.eventId, phaseOf(s, id)?.end))
855
+ // 卡在不在、在哪里,只按 `toolCallId` 现查 items —— **开着**与**已收口**两支从此共用同一条
856
+ // 定位与同一道闸(0.62.2 合并):此前开着那一支走的是另一张「开着的卡 → 下标」表、且**无条件**
857
+ // 落结果,于是同一件事有两处真源、两套规矩,而两条独立的流交错时旧收口正是从那一支进来的。
858
+ const at = id !== undefined ? toolCardIndexOf(s, id) : -1;
859
+ if (at >= 0) {
860
+ // 🔴 一道闸管两支:比卡上现在承载的那一阶段**旧、或就是同一条帧** 整帧丢弃。
861
+ // · 同一条帧重放 再落一次会把结果字节重复计进预算,走到下面那支还会另铸一张已完成的卡;
862
+ // · 旧一阶段的收口(park end 在恢复阶段之后才姗姗来迟)会把屏上的真结果盖回去,
863
+ // 卡还开着时更会**关掉**一张属于新阶段、其实还在跑的卡(见 {@link staleEnd} 头注);
864
+ // · 反过来,**更新的一发**收口( park 掉这次调用、批复后恢复执行,上游按同一个
865
+ // `toolCallId` 再发一对 start/end)⇒ 后到的才是真结果,就地替换/落账。把「已 settled」
866
+ // 当成永久完成会把真结果吞掉,屏上停在那句「被门拦下」上。
867
+ // 🔴 身份缺席(判不出新旧)⇒ 闸不拦,照落(重放送的是同一份值,落账是空操作),同拍把
868
+ // `end` 那一半的水位清掉( {@link stampPhase})
869
+ if (staleEnd(phaseOf(s, id), ev.eventId))
740
870
  break;
741
- const it = s.items[settledAt];
871
+ const it = s.items[at];
742
872
  const before = itemBytesOf(it);
873
+ const outBefore = it.output;
874
+ const errBefore = it.isError;
743
875
  it.output = ev.output ?? '';
744
876
  it.isError = ev.isError === true;
877
+ // 结果正文可能是**这一刻**才落到已在账的那条 item 上的 ⇒ 按差额补记(替换时差额可正可负)。
745
878
  const delta = itemBytesOf(it) - before;
746
- s.itemBytes[settledAt] = (s.itemBytes[settledAt] ?? 0) + delta;
879
+ s.itemBytes[at] = (s.itemBytes[at] ?? 0) + delta;
747
880
  addBytes(s, delta);
748
- markPhase(s, id, 'end', ev.eventId);
881
+ stampPhase(s, it.id, 'end', ev.eventId, it.output !== outBefore || it.isError !== errBefore);
749
882
  break;
750
883
  }
751
884
  // orphan close (start lost to the ring / arrived first) — synthesize a resolved card.
@@ -761,7 +894,12 @@ export function publishSubagentContentEvent(ev) {
761
894
  output: ev.output ?? '',
762
895
  isError: ev.isError === true,
763
896
  });
764
- markPhase(s, synthId, 'end', ev.eventId);
897
+ // 🔴 边界虽然不在这里切,**位置**要在这里记下来:此刻缓冲里的那一截是「工具之前」那一段,
898
+ // 之后再流进来的是「工具之后」那一段。迟到的 `tool_start` 按这个位置切开两段(见
899
+ // {@link CardPhase.cut})—— 不记的话它只能把整条缓冲当成一段闭合,两段就此黏死。
900
+ recordCut(s);
901
+ // 卡是这一刻新铸的 ⇒ 结果那一半当然是「变了」
902
+ stampPhase(s, synthId, 'end', ev.eventId, true);
765
903
  break;
766
904
  }
767
905
  }
@@ -3913,24 +3913,53 @@ text?: string // 聚合两臂的整段全文(刻意不复用 delta 位)
3913
3913
  - 但「卡在账」**不等于**「这一发 `tool_start` 处理过」—— 收口先到时库会合成一张占位卡(名 `Tool`、
3914
3914
  参数 `{}`),迟到/重放的 `tool_start` 带的才是真的 ⇒ **就地补齐名字与参数**(不覆盖已落的结果,
3915
3915
  字节按差额记账,重放多少次都不虚涨);段边界怎么给见下面那一条;
3916
- - `tool_end` **先问「卡还开着吗」**:开着就落结果 —— 一张在飞的卡若被任何一道闸拦掉收口,它就
3917
- **永远在飞**(屏上一直显示在跑),那比多渲一次结果坏得多;
3918
- - 卡在账、却已不在开着表上 = 结果**早就落过**。这一档**不是**「整帧丢弃」那么简单,因为同一次调用
3919
- 真的会有**第二对** `tool_start`/`tool_end`:门把调用 park 掉、批复之后恢复执行时,引擎复用
3920
- **同一个** `toolCallId` 再发一对,第二对带的才是真结果(批复还可能**改过参数**,两次 start 的
3921
- 参数因此可以不同)。所以判据不是「见过没见过」而是**阶段的先后**;
3922
- - **阶段水位**:库为每张卡记住它当下承载的那一阶段(已落账的 `tool_start` / `tool_end` 身份),
3923
- `eventId` 的先后判新旧 —— 比水位**旧或就是同一条帧** ⇒ 整帧丢弃(重放不重复计字节、旧一阶段
3916
+ - `tool_end` 只按 `toolCallId` **现查 `items`**:卡在账(不论开着还是已收口)走同一支、过同一道闸,
3917
+ 卡不在账才合成。**0.62.2 改**:此前「开着」那一支走的是另一张「`toolCallId` → 开着的卡下标」表、
3918
+ 且**无条件**落结果(理由写成「一张在飞的卡被拦掉收口就永远在飞」)—— 同一件事两处真源、两套规矩,
3919
+ 而下面那条真病正是从那一支进来的;
3920
+ - **闸的判据是阶段的先后,不是「见过没见过」**:同一次调用真的会有**第二对**
3921
+ `tool_start`/`tool_end` —— 门把调用 park 掉、批复之后恢复执行时,引擎复用**同一个** `toolCallId`
3922
+ 再发一对,第二对带的才是真结果(批复还可能**改过参数**,两次 start 的参数因此可以不同);
3923
+ - **阶段账**:库为每张卡记住它当下承载的那一阶段(已落账的 `tool_start` / `tool_end` 身份),
3924
+ 按 `eventId` 的先后判新旧 —— 比它**旧或就是同一条帧** ⇒ 整帧丢弃(重放不重复计字节、旧一阶段
3924
3925
  不许把屏上的真结果或改后参数盖回去);**更新** ⇒ 后到的才算数,就地替换/更新(按差额记账)。
3926
+ 🔴 **收口两半都比**(**0.62.2 改**):`tool_end` 除了跟 `end` 比,还要跟当前的 `start` 比 ——
3927
+ 两条独立的流交错时(恢复阶段的 `tool_start` 已到、旧一阶段的 `tool_end` 才姗姗来迟),只比 `end`
3928
+ 的话旧收口会落到新阶段那张**开着**的卡上并把它关掉,新参数配上旧阶段的失败结果,新流若中断这个
3929
+ 错误状态就一直留在屏上。**更新的 `start` 已经建立了新的一阶段,早于它的收口不属于它**;
3930
+ 「避免永远在飞」不构成接受一份已知过期结果的理由 —— 那张卡确实还在跑,显示在飞是**如实**的。
3925
3931
  🔴 **可比性的前提**:`eventId` 是上游铸的**时间可排序**句柄(uuidv7);端若自己造内容帧喂进来,
3926
3932
  这一位必须同样时间可排序,否则新旧判不出。**缺席** ⇒ 判不出 ⇒ 退回「后到的落账」(重放送的是
3927
- 同一份值,落账是空操作)。水位与卡**同生共死**(卡被帽丢掉时一并清掉);
3928
- - `tool_start` 除了不铸第二张卡,还负责**段边界**:这张卡**还没落过** `tool_start`(= 它是收口先到时
3929
- 合成的占位卡)时,这一发是它的第一个段边界 **先段闭合再补齐名参**;水位已在(重放或下一阶段)
3930
- **不**段闭合,边界在活体那一轮已经给过;
3931
- - `tool_end` 找不到卡(收口先于开始帧到达)⇒ 合成一张已完成的卡,**不**在那里段闭合 —— 边界由那一发
3932
- 迟到的 `tool_start` 补做。在收口这一支闭合会把**还在流的**那一段提前切成一条历史 item,每子代帽
3933
- 配得很紧时它随即变成「最早的内容」被帽整只丢掉,而那正是用户眼前在看的字;
3933
+ 同一份值,落账是空操作)。🔴 缺席的那一发**若真的改了**卡上那一半的内容,**同拍把对应那一半的
3934
+ 记录清掉**(**0.62.2 改**):旧身份留着就是**替一份不再对应的内容背书** —— 实翻形是「先落新参数 +
3935
+ 真结果(带身份),再收一发无身份的旧参数 / 失败结果,随后正确的那一发**原样重放**被自己的记录
3936
+ 拦掉,卡永久停在旧内容上」。反过来,缺席的那一发**内容一个字节没变**(重放送来的是同一份值)
3937
+ 记录**原样保留** —— 它仍然如实描述卡上这份内容,清掉等于顺手把「旧一阶段不许盖新一阶段」那道闸
3938
+ 一起卸了(那时旧 `start` 就能把改后参数盖回原参,而更新的结果还留在卡上)。一句话:**这一半的
3939
+ 记录永远描述卡上这一半现在这份内容,内容没动它就没理由动**。阶段账与卡**同生共死**(卡被帽丢掉时
3940
+ 一并清掉;清不掉的话卡被重建那一轮的收口会被陈旧记录整帧拦下 —— 卡建回来却永远显示在飞);
3941
+ - `tool_start` 除了不铸第二张卡,还负责**段边界**:每张卡记一位「**还欠不欠一次段闭合**」——
3942
+ 欠着(= 它是收口先到时合成的占位卡,而自它出现以来一次段闭合都没发生)⇒ 这一发**先段闭合再补齐
3943
+ 名参**;不欠了 ⇒ **不**段闭合。🔴 这一位问的是「**自这张卡出现以来发生过段闭合没有**」,不是
3944
+ 「这张卡自己切过没有」(**0.62.2**):兑现一条边界的可以是别人(另一张卡的 `tool_start`、
3945
+ `settle`),所以**任何**一次段闭合都把在场每张卡的这一位置上;只记「自己切过没有」的话,那一发
3946
+ 迟到的 `tool_start` 会在别人切完之后**再切一刀**,而这一刀切的是**当前**那条与它无关的活体尾段。
3947
+ 🔴 同样**不是**拿「`start` 身份在不在」代答:身份是可选位,上游不给 `eventId` 时它永远抬不起来 ⇒
3948
+ 每一次重放都会再切一刀。两种代答的坏结局是同一个:还在流的活体尾段被切成历史 item,随后该段的
3949
+ **聚合全文**在空缓冲上吸不到前缀、整只落回缓冲 ⇒ 前缀重复上屏;
3950
+ - `tool_end` 找不到卡(收口先于开始帧到达)⇒ 合成一张已完成的卡,**不**在那里段闭合 —— 边界由随后
3951
+ **第一次段闭合**补做(那一发迟到的 `tool_start` 只是其中一种触发者,见下)。在收口这一支闭合会把
3952
+ **还在流的**那一段提前切成一条历史 item,每子代帽配得很紧时它随即变成「最早的内容」被帽整只丢掉,
3953
+ 而那正是用户眼前在看的字。🔴 但边界的**位置**
3954
+ 在这一刻就记下来(**0.62.2 补**):此刻缓冲里的那一截是「工具之前」那一段,之后再流进来的是
3955
+ 「工具之后」那一段,迟到的 `tool_start` 按这个位置把两段**分开**收。不记的话它只能把整条缓冲当成
3956
+ 一段闭合 —— `AAA` → 收口先到 → `BBB` → 迟到的 start 这种乱序会合成 `AAABBB` 一段,整轮重放送来
3957
+ `AAA`、`BBB` 两段时两道内容闸都不命中,历史与缓冲各留一份。🔴 位置是**缓冲的**属性、**不是那张卡的
3958
+ 私产**,库因此把它记在这条子代的账本上而不是卡上:①兑现它的不一定是那一发迟到的 `tool_start`
3959
+ (别的工具起头、`settle` 同样要切),任何一次段闭合都在**所有**还没兑现的位置上一起切 —— 一条缓冲上
3960
+ 可以同时压着好几条(两张收口先到的卡交错),而闭合一发生缓冲就整只清空,没兑现的位置从此无处可切;
3961
+ ②卡被条数帽/字节帽丢掉**不等于**缓冲被丢掉(帽先丢 item、丢不够才裁缓冲),那截正文一个字都没少,
3962
+ 边界跟着卡消失就等于把「这里有过一次工具」这件事丢了;
3934
3963
  - 去重射程严格是**同一次调用**:另一个 `toolCallId` 的卡照常进(子代真的调了两次工具时两张卡都要在)。
3935
3964
  - 迟到的 `tool_start` 先段闭合、再找卡:段闭合本身可能触发条数裁剪(从头删卡 + 顶上留痕),所以库在 flush
3936
3965
  **之后**才按 `toolCallId` 定位那张卡;找不到 = 刚被裁掉 ⇒ 按「新的一次」重建(留白①),绝不对着裁剪前的
@@ -3945,7 +3974,15 @@ text?: string // 聚合两臂的整段全文(刻意不复用 delta 位)
3945
3974
  - ②`toolCallId` 缺席的工具帧无从去重(库会为它合成一个位置相关的 id)—— wire 上的工具帧恒带这一位,
3946
3975
  这一格只对自造帧的宿主成立。
3947
3976
  - ③收口**先于**开始帧到达的那种乱序里,卡的位置是收口到达的位置(库不把它挪到开始帧那一刻),
3948
- 但它前后的散文段各自完整、各留一份。
3977
+ 但它前后的散文段各自完整、各留一份 —— 包括**后一段先于迟到的 start 到达**那一形
3978
+ (`AAA` → 收口 → `BBB` → 迟到的 start):两段按收口到达那一刻的位置切开,各成一段、各进一条记号。
3979
+ - ④段边界记的是**位置**(收口到达那一刻缓冲的长度),不是缓冲的一份快照 —— 快照能有 MB 级,复制它
3980
+ 等于把那份内容再占一遍而且不进字节账。位置的维护只有三处:缓冲**头部**被字节帽裁 ⇒ 同拍左移;
3981
+ 段闭合 ⇒ 整表兑现后清空;别处缓冲只在尾部增长,前缀恒定。位置万一还是落到缓冲之外(端自造帧的
3982
+ 乱序),库夹回去、退回**整条缓冲当一段**的老形(= 0.62.1 的行为),并且绝不劈开合法代理对
3983
+ (上游把一对代理对拆成两帧送时,记边界那一刻缓冲尾部可能正停在高位代理项上;撞上就把边界退到
3984
+ 那一对之前)。两次段闭合之间最多记 **64** 条边界,满了之后再来的收口不再记(如实:那之后的边界会
3985
+ 丢、两段黏一段;一次段闭合就整表清空,所以这只在「两次闭合之间来一长串收口先到的乱序帧」时才够得着)。
3949
3986
  📋 **如实留白**:内容闸的代价是「一条 turn 里出现两段**逐字节相同**的正文时,durable 腿上只留一段」。
3950
3987
  取舍明写:每次重连把整轮正文再渲一遍是**必然**发生的用户可见损坏,而同一条 turn 说两遍一模一样的话
3951
3988
  是罕见形,且活体腿上两段都在。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/client-core",
3
- "version": "0.62.1",
3
+ "version": "0.62.2",
4
4
  "description": "Client-side session runtime shared by every sema human client (TUI / web / desktop): sema wire frames (AgentEvent) -> CC session vocabulary (SDKMessage) with dual-plane output (transcript/chrome), deterministic transcript ids, lane discipline as a type, and the notification/dedup ledgers. Every CC-skin shape is collected here so the wire itself stays neutral. Renamed from @sema-agent/wire-cc-adapter (0.1.x).",
5
5
  "license": "MIT",
6
6
  "type": "module",