@sema-agent/client-core 0.74.4 → 0.75.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.
package/CHANGELOG.md CHANGED
@@ -49,6 +49,44 @@
49
49
  > 挡住 ⇒ 本批把它机械化——④a0 对 `pending` 行**要求段头已是日期形**(`(未发布)` 直接红),阶段一
50
50
  > commit 漏转在发布前就红,不再靠人记。
51
51
 
52
+ ## 0.75.0(2026-09-20)
53
+
54
+ > 主题:**压缩的非 compacted 结局上 chrome 面**(CC-89;此前三端都看不见压缩失败)+ **peer sdk 地板 `>=9.8.1`**(CC-81)+ **短命子代的最终用量不再丢**(0.74.5 首见即终态零事件的代价面,外部复验读数)。**minor**:① 新内部臂 `compaction_outcome` 进 `INTERNAL_SDK_ARM_TYPES` —— 自建 print / stream-json 出口的端**必须**先给它表态(吞或转),否则它会顺着兜底透传成越界帧;② peer 地板抬升。型面 additive(`ChromeEvent` +1 臂 `CompactionOutcomeChromeEvent`;值导出零增)。接入面 §74。
55
+
56
+ ### Added
57
+
58
+ - **chrome 臂 `compaction_outcome`(CC-89)**:引擎的 `compaction_outcome` 帧(一次压缩**没有**以 compacted 收尾:被任务终态抢先而作废、熔断开着而失败等)此前在投影口落 `not_in_slice`,包内与三端零消费者 —— 压缩失败对用户不可见。现在:投影口出内部臂 `{type:'compaction_outcome', outcome, trigger?, reason?, …identity}`,适配层出 `ChromeEvent{kind:'compaction_outcome', laneProof, outcome, trigger?, reason?, eventId?}`,转录面零行(CC 转录里只有成功压缩的 `compact_boundary`,不替它编消息)。`outcome` / `trigger` 是引擎开集原词,逐字不折;`trigger` / `reason` 非空串才在场;`outcome` 缺席 / 非串 / 空串 ⇒ malformed。lane 如实:`parentToolCallId` 在场 ⇒ `laneProof.lane === 'subagent'`;只能靠 `sourceTaskId` / `bgAgentId` 归因的帧不发(没有诚实的 lane 可挂,不落到主会话名下;KNOWN-LIMITS KL-22)。臂表 `required:false`。
59
+
60
+ ### Fixed
61
+
62
+ - **短命子代的最终用量不再被「首见即终态零事件」吞掉(0.74.5 CC-86 的代价面;外部复验 W2 读数)**:子代在面板两拍之间起步并结束、从没投影过 running 时,通知车道的 `end` 已递达(消费端建了行、状态 ended),终态 `fleet-row` 却被当成历史行零事件 ⇒ 行在、`totalTokens` / `toolUses` 两位空。「首见」的判据从「本进程没发布过 running」改成「消费端没有这一行」= 没发布过 running **也没递达过 end**:历史叶加第二本账(漏斗里任何 lane 的当代 `end` 递达后记 ended{周期身份},有界 4096)。结过的行再来终态帧:同代 / 判不出代际 ⇒ 恰一条 `fleet-row` 交最终用量、**不再第二次 `end`**;可证更新的一代(结过之后复活又结束)⇒ 照发 `fleet-row` + `end`。反序(终态 `fleet-row` 先到、`end` 后到)⇒ 终态行先缓住(有界 4096),`end` 到漏斗时**先于一切身份归一**放出(放出的行帧登记的钥匙 / 所有权,后面的 `end` 归一必须看到)、按代际 peek 后再放(`end` 可证更旧 ⇒ 缓行留着;可证更新 ⇒ 作废;同代 / 判不出 ⇒ 放);复活为 running 作废缓行;真历史行永远等不到 `end`,不上屏。结过的行再来的终态帧按**方向**判:可证更旧 ⇒ 整帧丢(不倒写用量、不第二次 `end`);ended 账单调写、`end` 的陈旧判据同时参考行帧水位与 ended 账(短命子代常没有行帧水位)。0.74.5 的两格「零事件」改口为「零 `end`、至多一条 `fleet-row`」。
63
+ - `compacted.freedTokens` 负值 / 非有限在本包按缺席(不夹到 0):注释此前写「夹 ≥ 0」与代码不符,改注释不改代码 —— 缺席比编一个 0 诚实(外部复验读数)。
64
+
65
+ ### Changed
66
+
67
+ - **peer `@sema-agent/sdk` 地板 `>=9.7.1` → `>=9.8.1`(CC-81)**:9.8.x 给 `Capabilities.deviceExecutor` 的对象臂声明了 `management?: boolean`(本包 0.74.2 起的读器此前按结构读),包现在对 9.8.1 编译,消费端实装全在 9.8.1 ⇒ 9.7.1 失去物料见证。读器运行时行为零变化(入参仍按不可信结构防御读);device 读器门 +7 格把 sdk 声明形(可选键 / false 臂 / `management` 可选布尔;readonly / `| undefined` 算兼容形)钉住,声明删除 / 行注释伪证 / string 退化 / 改必填 各一枚负控;读器本身仍按不可信结构读,关联只在门。
68
+ - 两只测试钩(`__resetEngineAgentPanelIdentityForTests` / `__resetPanelRunningHistoryForTests`)的注释逐表列出清什么、不清什么,并写明连续场景中途别调(外部验收方的请求)。
69
+
70
+ ### Guards
71
+
72
+ - panel-cycle 门 +19 格(S1v / S1w / S1x / S1y / S1z 族:两序 / 真历史行 / 复活新一代 / 前代 end 不放当代缓行 / 前代迟到帧不作废缓行;S1α–S1δ 族:异源对抗复审第三轮四条 [high] 反例 —— 缓行放出后重新归一 / 迟到前代终态帧整帧丢 / ended 账不倒写 / 前代 end 不取走当代缓行)、pure 门与身份归一门两格改口;singleton +2 high(ended 账 / 缓行表,上限 116 → 118)。
73
+ - 新门 `scripts/run-compaction-outcome-projection-test.mjs` 18 格(投影口 10 / 适配层 8;异源对抗复审 R1 [medium] 采修 —— 投影器不浅拷帧:展开会擦掉原型链上的身份键、还执行无关的可枚举 getter,与兄弟臂同律逐键读);device 读器门 +7(T1–T7);sdk 地板门 FLOOR 9.8.1,负控锚同批。新门 A3 改成同序列有 / 无结局帧的转录面逐字对拍(异源对抗复审 R2 [medium]:只排除同名 type 的行守不住「零新增转录行」)。
74
+
75
+ ## 0.74.5(2026-09-19)
76
+
77
+ > 主题:**core 7.24.0 提货**(CC-85 `compacted.freedTokens?`)+ **首见即终态的行零事件**(CC-86;cli L-415 幽灵完成行)+ **`isNewEngineAgentPanelCycle` 上公面**(CC-87)。**patch**:型面 additive(+2 常量 +2 函数,公面 1061 → 1065;`compact_metadata._sema_freed_tokens?` 超集键;零 wire 新键 —— `freedTokens` 由 core 7.24.0 铸;peer 不动)。接入面 §73。
78
+
79
+ ### Added
80
+
81
+ - **`compact_metadata._sema_freed_tokens?`(CC-85;core 7.24.0 #946)**:`compacted.freedTokens?` 是引擎自己测的结构性差值(夹 ≥ 0),旧的 `tokensBefore` / `tokensAfter` **不是可相减的一对**;在场(有限非负数)才带,缺席 = 没测出来 ⇒ 键不落,渲「未知」不是 0。CC 同名键 `pre_tokens` 一字不动(它是 CC 必填数,`tokensBefore` 缺席仍兜 0 —— 记 KNOWN-LIMITS)。
82
+ - **`approvalOutcomeNoteOf(frame, outcome)`(CC-88;cli 归包候票)**:流内帧腿审批结局 → 宿主便签 `{approvalId, settled, retracted?, detail?}` 的单源映射(三端装配点此前各手拼一份;语义按 cli 1.0.123 现行:`settled = decision !== 'unresolved'`,`retracted` 只在 true 时带键且不含 editRefused,`detail` 三支优先序撤卡句 / 编辑被拒句 / `respondRefusal` 码·文案,空 ⇒ 缺席);两句固定文案作常量出。
83
+ - **`isNewEngineAgentPanelCycle(prev, next)`(CC-87)**:「这一帧是不是新周期」的单源判据(`isStaleEngineAgentPanelEnd` 的对偶;两边都带 `cycleSeq` 只比它,否则比 `startedAt`,任一边判不出 ⇒ 不是新周期);缓冲合并屏障 `launchAnchorChanged` 改为委托它。
84
+
85
+ ### Fixed
86
+
87
+ - **反向所有权按 transcriptId 到期(test [7718] 契约层反例)**:0.74.4 的「尾段 → 先发布的 UUID」所有权只按周期键到期,行帧两个周期键全缺(真字节里只有 root 形行如此,子代行恒带)时复活的新一代被改写到旧 UUID ⇒ 消费端一条已结的行又收新帧;现在登记时记 transcriptId,换了 = 新一代子会话,所有权同样到此为止。
88
+ - **首次见到就已终态的 fleet 行零事件(CC-86;cli L-415)**:此前对 prev 缺席且 status 终态的行先发 `fleet-row`(交最终用量)再发 `end` ⇒ 宿主为本会话从没跑过的任务建行随即 settle = 面板幽灵完成行(reattach / resume 后引擎 fleet 列表里的历史终态行全部上屏)。现在只在台账记 settled、不发事件;「首见」= **本进程**从没发布过它在跑(历史作用域 = 面板订阅作用域,不按会话分桶 —— A 会话发的 running 行由 B 会话的终态帧结掉是常态;发布过 running 的行即使台账已回收,迟到终态仍照发 end;历史结掉即删 —— **任何 lane 的 `end` 到漏斗都销账**(通知车道的 end 不经投影;只销当代,前代迟到的 end 不擦新一代)、有界 4096、溢出后转保守不再压制,直到整表重置;共享叶 `src/panelRunningHistory.ts`;异源对抗复审 R1 / R2 三条所抓;R3 四条:父键登记的所有权按 wireId 作转录身份到期 / 归一 end 的退役按**事件自带**周期判(缺席才借钥匙,与递达同一身份)/ 已发布 UUID 的 end 连它名下尾段的历史一起销 / 只在真发布了 running 行帧时记历史,未变的陈旧快照不把结掉的 id 记回去);本会话内起步又结束而面板从没见过 running 的后台任务由通知车道的 `end` 收尸;复活(已 settled 且周期更新)照发。
89
+
52
90
  ## 0.74.4(2026-09-19)
53
91
 
54
92
  > 主题:**面板键「先发布的键为准」**(CC-83;test [7702] 证伪 0.74.3「面板桥零改」)+ **前代 UUID 的迟到 tick 丢弃**(CC-84;test [7702] W2 tick 腿)+ `readRunCancelContext` 下钻收紧。**patch**:型面 additive(`fleet-row` 臂 +`wireTaskId?` / `taskIdOrigin?`,`end` 臂 `taskIdOrigin` 多一词 `'wire'`,`EngineAgentPanelAbsence` +`wireTaskId?`;公面导出名零增减 1061;零 wire 新键;peer 不动)。接入面 §72。
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.74.4
38
+ **Version:** 0.75.0
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
@@ -67,7 +67,7 @@ Renamed from **`@sema-agent/wire-cc-adapter`** (0.1.x, deprecated — see *Migra
67
67
  against — the tables live upstream precisely so this package does not keep a second copy that can
68
68
  fall behind. The browser bundle really bundles the SDK through (the portability guard would
69
69
  exit 3 rather than quietly mark it external).
70
- - The declared floor is `>=9.7.1` (raised from `>=9.6.0` in 0.74.0: `Capabilities.approvalsStreamLive` / `.executionLane`, `LivePendingRow.frame`, the `live_*` approval-stream events and `gates[].toolCallId` are typed from 9.7.x on, and no consumer ships 9.6.0 any more, so the older floor lost its witness; before that raised from `>=9.4.0` in 0.71.0: the `tool_disclosure` / `tool_progress` frames and `ToolApprovalFrame.readRootCandidate` are typed there; earlier: raised from `>=8.8.0` in 0.69.0: the `reasoning_end` frame and `McpStatusPanel.lastLegMcp` are typed from 9.4.0 on), and it is *witnessed*: the guard checks that an actually
70
+ - The declared floor is `>=9.8.1` (raised from `>=9.7.1` in 0.75.0: `Capabilities.deviceExecutor.management` is typed from 9.8.x on, the package now compiles against 9.8.1, and no consumer ships 9.7.x any more, so the older floor lost its witness; before that raised from `>=9.6.0` in 0.74.0: `Capabilities.approvalsStreamLive` / `.executionLane`, `LivePendingRow.frame`, the `live_*` approval-stream events and `gates[].toolCallId` are typed from 9.7.x on, and no consumer ships 9.6.0 any more, so the older floor lost its witness; before that raised from `>=9.4.0` in 0.71.0: the `tool_disclosure` / `tool_progress` frames and `ToolApprovalFrame.readRootCandidate` are typed there; earlier: raised from `>=8.8.0` in 0.69.0: the `reasoning_end` frame and `McpStatusPanel.lastLegMcp` are typed from 9.4.0 on), and it is *witnessed*: the guard checks that an actually
71
71
  installed SDK at that line still exports every value-level symbol this package imports and still
72
72
  declares `TaskStats.costMicroUsd` (the key `costOrNull` reads). A floor nobody ever ran is a
73
73
  promise, not a contract.
@@ -317,7 +317,8 @@ public-surface guard checks that last one).
317
317
  | `scripts/run-run-cancel-context-test.mjs` | The run record's `cancelContext` side-note (engine ≥7.87.3) read structurally, and the cause of a `turn_aborted{engine_error}` classified from machine-readable evidence only: `cancelled` (code `cancelled`, with the cancel-time context when present) / `engine_error` (any other failure code, passed through verbatim) / `run_still_live` (the record is not terminal — a dropped stream is a client-side fact, not the run's cause) / `unknown` (never guessed). An absent `cancelContext` reads as *not reported*, never as "not cancelled"; `elapsedMs` is never folded to 0. |
318
318
  | `scripts/run-suspended-reopen-projection-test.mjs` | The durable `suspended` event's `reopened` key read as three distinct states — `reopened` (with the engine's code, verbatim), `not_reopened` (an explicit `null`), `unstated` (key absent or unreadable) — and carried on the HITL bridge's active gate (`currentGateReopen()`), re-read on every `suspended` and cleared with the gate. |
319
319
  | `scripts/run-panel-identity-normalization-test.mjs` | One background subagent has two ids on the wire — the fleet row id tail and the `task_progress` task id (its transcript id). Every panel event goes through one funnel that rewrites the `tick` / `end` task id onto the fleet row's id once a `fleet-row` has registered the key (`transcriptId` first, `parentToolCallId` as the fallback), carrying the original as `wireTaskId` and marking `taskIdOrigin`; an unbound tick whose row has not arrived yet waits one beat (bounded) and is released verbatim on the next tick / `end`, when the buffer is full, or after `MAX_HELD_WIRE_TICK_BEATS` other fleet-row / end / sweep events (a `sweep` itself leaves it alone: there is no row to settle yet); a normalized `end` that carries no cycle identity borrows the registering row's, so a late close of a revived task is recognized as stale; the key table is an LRU (a task that keeps ticking is never evicted by newer registrations); the residency mark migrates with the id and both keys are cleared on settle — except that a stale (previous-cycle) terminal never clears the revived row's mark — so the notification lane can clear it. Once a tick has been delivered verbatim under its UUID, that UUID is the subagent's key: later fleet rows and fleet-side ends are rewritten onto it (the tail kept in `wireTaskId`), so a consumer sees one row in every arrival order; a late tick from a previous cycle is dropped rather than folded into the revived row. |
320
- | `scripts/run-approval-card-retract-test.mjs` | The approval card's **decision-free retraction** and the in-stream frame leg's **outcome hand-back**: a host that must withdraw a card that no longer has a decision channel (session switch, engine switch, a tracker reporting the ask gone) answers `{ kind: 'retracted' }` and the package sends nothing on any of the three legs (in-stream frame, suspended ask, durable park), reporting `decision: 'unresolved'` with a `retracted` flag; `aborted` / `failed` / `deny` keep their meaning (a real deny is still posted), and `onToolApprovalOutcome` hands every in-stream outcome back to the host exactly once, tolerating a throwing or rejecting callback |
320
+ | `scripts/run-compaction-outcome-projection-test.mjs` | The `compaction_outcome` frame (a compaction that did **not** end as compacted: mooted by the task ending, failed, …) projected to an internal arm and then to the additive `compaction_outcome` chrome event — `outcome` required and verbatim (open set), `trigger` / `reason` present only when the engine sent a non-empty string, malformed frames dropped, zero transcript rows, the lane told honestly (`parentToolCallId` ⇒ subagent lane; a frame attributable only by `sourceTaskId` / `bgAgentId` is not surfaced on the main lane). |
321
+ | `scripts/run-approval-card-retract-test.mjs` | The approval card's **decision-free retraction** and the in-stream frame leg's **outcome hand-back**: a host that must withdraw a card that no longer has a decision channel (session switch, engine switch, a tracker reporting the ask gone) answers `{ kind: 'retracted' }` and the package sends nothing on any of the three legs (in-stream frame, suspended ask, durable park), reporting `decision: 'unresolved'` with a `retracted` flag; `aborted` / `failed` / `deny` keep their meaning (a real deny is still posted), and `onToolApprovalOutcome` hands every in-stream outcome back to the host exactly once, tolerating a throwing or rejecting callback Also the single source for the host-side approval-outcome note (`approvalOutcomeNoteOf`): `settled` is whether the decision was delivered, `retracted` is an independent key present only when the card was retracted, and `detail` is the retraction / edit-refused sentence or the refusal code and message — never a fabricated sentence. |
321
322
  | `scripts/run-panel-cycle-identity-test.mjs` | The **cycle identity** on agent-panel events and the fleet ledger's **departure read-out**: a background agent may be revived under the same id, so `fleet-row` and `end` events now carry the wire's own `cycleSeq` / `startedAt` when present (absent means the row has no notion of generations, never "generation one"), `isStaleEngineAgentPanelEnd` is the single rule for ignoring a late `end` from a previous cycle (only when both sides carry a comparable identity; absence never drops a real terminal), a changed `cycleSeq` is a new cycle for usage stickiness and buffer coalescing, the notification lane carries `cycleSeq` only when the wire really sent `seq`, and `task_remove` frames reach the host through `onTaskRemoved` with `removeReason` / `cycleSeq` verbatim, a stale previous-generation removal leaving the newer row in place |
322
323
  | `scripts/run-workflow-size-warning-test.mjs` | The **workflow size warning** verdict shared by every host footer / panel: a three-state result (`warn` / `ok` / `unknown`) read off the optional fleet view keys, where an unknown size is never reported as a normal one (absent `totalCount` / `tokens` without positive over-cap evidence is `unknown`, naming the missing keys), positive evidence on either axis wins regardless of absent keys, the per-agent denominator uses the engine's started count only when it is not below done+failed (a smaller value is a stale reading), otherwise falls back to the done+failed lower bound only when both keys are present — and a lower-bound denominator only yields an upper bound of the projection, which can prove *within cap* (`ok`, flagged) but never *over cap* (`unknown`, with the upper bound exposed) — and the prior is used only when the engine itself reports zero started agents; cap precedence env > explicit guideline > default, prototype keys never act as a guideline, the env reader is pure and does not fall through to the second name on a bad first value; caps, guideline table, env names and the three copy variants are single-sourced |
323
324
  | `scripts/run-approvals-stream-live-capability-test.mjs` | The engine's live-approval-push self-description (`capabilities.approvalsStreamLive`, engine ≥7.87.1), read the same four-state way as its four sibling capability readers: an absent key is reported as not reported (never folded into `false`), the value must be a strict boolean, and the one decision the feed consumer needs — whether it must keep pulling suspended asks itself — is answered by `livePendingNeedsReconcile`, which only says no when the engine explicitly says it pushes. |
@@ -557,6 +557,35 @@ const contextUsageArm = function* (m) {
557
557
  ...(typeof m.eventId === 'string' && m.eventId.length > 0 ? { eventId: m.eventId } : {}),
558
558
  });
559
559
  };
560
+ /**
561
+ * CC-89(0.75.0):`compaction_outcome` 内部臂 → chrome `compaction_outcome`。每帧一条;转录面零行。
562
+ * 🔴 lane 如实:`parentToolCallId` 非空串 ⇒ subagent;只有 `sourceTaskId` / `bgAgentId`(或父键空串)的帧没有诚实的 lane 可挂 ⇒ 不发,
563
+ * 绝不落到主会话名下。`outcome` 非空串才发(非投影口喂进来的坏帧兜底)。
564
+ */
565
+ const compactionOutcomeArm = function* (m) {
566
+ const str = (v) => (typeof v === 'string' && v.length > 0 ? v : undefined);
567
+ const outcome = str(m.outcome);
568
+ if (outcome === undefined)
569
+ return;
570
+ let laneProof = MAIN;
571
+ if (isSubFlowSegmentEnd(m)) {
572
+ const parent = str(m.parentToolCallId);
573
+ if (parent === undefined)
574
+ return;
575
+ laneProof = { lane: 'subagent', parentToolCallId: parent };
576
+ }
577
+ const trigger = str(m.trigger);
578
+ const reason = str(m.reason);
579
+ const eventId = str(m.eventId);
580
+ yield chrome({
581
+ kind: 'compaction_outcome',
582
+ laneProof,
583
+ outcome,
584
+ ...(trigger !== undefined ? { trigger } : {}),
585
+ ...(reason !== undefined ? { reason } : {}),
586
+ ...(eventId !== undefined ? { eventId } : {}),
587
+ });
588
+ };
560
589
  const textSegmentEndArm = function* (m, { text }) {
561
590
  // 🔴 断闸按「**键在不在**」判,不按「是不是串」判(异源对抗复审第三轮 [medium] 采纳)。
562
591
  // 投影层已对坏 lane 位整帧 fail-closed;这一道是给**非投影口**喂进来的帧(宿主自建管线 /
@@ -1269,6 +1298,8 @@ export const ARMS = new Map([
1269
1298
  ['tool_progress', toolProgressArm],
1270
1299
  ['tool_roster_delta', toolRosterDeltaArm],
1271
1300
  ['context_usage', contextUsageArm],
1301
+ // CC-89(0.75.0):压缩的非 compacted 结局(additive;不接 = 压缩失败对用户不可见)。
1302
+ ['compaction_outcome', compactionOutcomeArm],
1272
1303
  ['wiring_manifest', wiringManifestArm],
1273
1304
  // B-078 / L-208(0.65.0):design/172 流内审批两帧 —— 修前它们在投影层就 `dropped`,
1274
1305
  // 壳自己另接一份 ⇒ desktop/web 拿不到卡集与撤卡(归层违例)。
@@ -44,6 +44,8 @@ export const INTERNAL_SDK_ARM_TYPES = new Set([
44
44
  'tool_progress',
45
45
  'tool_roster_delta',
46
46
  'context_usage',
47
+ // CC-89(0.75.0):压缩的非 compacted 结局 → 中性内部臂(print 车道同律:吞或转,裸透传 = 越界帧)。
48
+ 'compaction_outcome',
47
49
  'workflow_complete',
48
50
  'tool_end_result',
49
51
  'retry_status',
@@ -338,6 +340,9 @@ export function eventToSdkMessage(ev, ctx) {
338
340
  // sdk 补上该位的当天,`run-compaction-boundary-projection-test.mjs` F10 行当场红逼复核。
339
341
  case 'compacted': {
340
342
  const raw = ev;
343
+ // 0.74.5 CC-85(core 7.24.0 #946):引擎自己测的结构性差值(core 侧夹 ≥ 0);旧的 tokensBefore / tokensAfter **不是可相减的一对**。
344
+ // 缺席 = 没测出来 ⇒ 键不落(渲「未知」不是 0);在场才带超集键。**负值 / 非有限在本包同样按缺席**(不夹到 0 —— 缺席比编一个 0 诚实;test 0.74.5 复验读数)。
345
+ const freedTokens = typeof raw.freedTokens === 'number' && Number.isFinite(raw.freedTokens) && raw.freedTokens >= 0 ? raw.freedTokens : undefined;
341
346
  const attachedFiles = raw.attachedFiles;
342
347
  const seg = raw.preserved_segment;
343
348
  const firstKeptEntryId = typeof seg === 'object' && seg !== null && !Array.isArray(seg)
@@ -360,6 +365,7 @@ export function eventToSdkMessage(ev, ctx) {
360
365
  ...(typeof clampedRatio === 'number' && Number.isFinite(clampedRatio)
361
366
  ? { _sema_clamped_ratio: clampedRatio }
362
367
  : {}),
368
+ ...(freedTokens !== undefined ? { _sema_freed_tokens: freedTokens } : {}),
363
369
  },
364
370
  ...(Array.isArray(attachedFiles) && attachedFiles.length > 0 ? { attachedFiles } : {}),
365
371
  })));
@@ -822,8 +828,8 @@ export function eventToSdkMessage(ev, ctx) {
822
828
  case 'failed':
823
829
  return nothing('terminal_delegated');
824
830
  // ── 已入 SDK union、本切片**有意**不投影的臂(REF-CC-060:进 switch 才受穷举保护)────────
825
- // `meta`:流首帧的 taskId/sessionId 一等信号 —— 身份面由 SDK/传输层自己消费(runStream 只按
826
- // `eventSeq` 去重),没有可渲染内容。
831
+ // `meta`:流首帧的 taskId/sessionId 一等信号 —— 本切片无可渲染内容(runStream 只按 `eventSeq` 去重)。
832
+ // ⚠️ 活性普查(2026-09-19)更正:传输层**不**替宿主消费它;需要 sessionId / taskId 的端(desktop / web-client)各自直读本帧。
827
833
  case 'meta':
828
834
  // `file_link` / `prompt_assembled` / `model_usage` / `context_usage` / `config_assembled`:
829
835
  // 引擎的可观测/审计面帧,本切片(CC transcript 投影)无对位渲染物;
@@ -836,12 +842,8 @@ export function eventToSdkMessage(ev, ctx) {
836
842
  case 'config_assembled':
837
843
  // `needs_review`:plan-review park 的**事件**形;壳消费的是 done{status:'needs_review'} 终帧
838
844
  // (runStream 的 plan_review_park chrome 臂),事件形在本切片重复且更早,故不投影。
845
+ // ⚠️ 活性普查(2026-09-19):web-client 仍直读事件形;本切片不投的理由不变(终帧是权威),但「没人消费」这句不成立。
839
846
  case 'needs_review':
840
- // `compaction_outcome`(sdk 4.0.1 新臂,[2373]A-2 三腿同源):压缩**非 compacted 结局**报告
841
- // (mooted/failed 等)。CC transcript 无对位物(compact_boundary 只对成功压缩,由 `compacted`
842
- // 臂投)——本切片不投影。⚠️ 「压缩失败让用户看见」是 chrome/HUD 面的活,今天两端都还没接
843
- // (adapt 臂表同样无此臂),缺口记 0.13.0 候选,不许由本切片顺手编一个假 transcript 形。
844
- case 'compaction_outcome':
845
847
  // ── sdk 6.9.0 提货批(2026-08-08)新入 union 的四条臂 ─────────────────────────────────────
846
848
  // `tool_approval` / `tool_approval_complete`:HITL 审批帧。**#185a 起它们是 `AgentEvent` 的臂**
847
849
  // (此前只是 named SSE frame),但本切片不是它们的消费口 —— 它们由
@@ -914,6 +916,10 @@ export function eventToSdkMessage(ev, ctx) {
914
916
  // 由哪些段组成、哪一段大」,fleet/HUD 面要渲;三个标量走 finite 门(非有限即缺席,永不 null)。
915
917
  case 'context_usage':
916
918
  return contextUsageProjection(ev, ctx);
919
+ // CC-89(0.75.0):`compaction_outcome` 从 not_in_slice 组里拿出来 —— 压缩的**非 compacted 结局**(被终态抢先 / 失败等)此前
920
+ // 没有任何一端看得见。转录里没有对位物(`compact_boundary` 只对成功压缩)⇒ 走内部臂 → chrome 臂,不编消息。
921
+ case 'compaction_outcome':
922
+ return compactionOutcomeProjection(ev, ctx);
917
923
  case 'tool_progress':
918
924
  return toolProgressProjection(ev, ctx);
919
925
  default:
@@ -1290,6 +1296,8 @@ function projectLspSection(raw) {
1290
1296
  return undefined;
1291
1297
  return { mounted: o.mounted, ...(typeof o.lane === 'string' && o.lane.length > 0 ? { lane: o.lane } : {}) };
1292
1298
  }
1299
+ /** 内部臂的唯一铸出口(内部臂不在 CC 的 SDKMessage 联合里,出境只此一处铸;新臂走这里,不再各写一处铸链)。 */
1300
+ const internalArmExit = (body) => body;
1293
1301
  function contextUsageProjection(ev, ctx) {
1294
1302
  const { identity, bad } = identityOrMalformed(ev, 'context_usage');
1295
1303
  if (bad !== null)
@@ -1315,14 +1323,39 @@ function contextUsageProjection(ev, ctx) {
1315
1323
  }
1316
1324
  if (usedTokens === undefined && windowTokens === undefined && compactAtTokens === undefined && sections === undefined)
1317
1325
  return nothing('empty_payload');
1318
- return projected(stamp(ctx, armBody({
1326
+ return projected(internalArmExit(stamp(ctx, armBody({
1319
1327
  type: 'context_usage',
1320
1328
  ...(usedTokens !== undefined ? { usedTokens } : {}),
1321
1329
  ...(windowTokens !== undefined ? { windowTokens } : {}),
1322
1330
  ...(compactAtTokens !== undefined ? { compactAtTokens } : {}),
1323
1331
  ...(sections !== undefined ? { sections } : {}),
1324
1332
  ...identity,
1325
- })));
1333
+ }))));
1334
+ }
1335
+ /**
1336
+ * CC-89(0.75.0):`outcome` 是主事实,非空串必在(否则 malformed —— 不投一条没有结局的结局帧),原词逐字(引擎开集,不按成员过滤);
1337
+ * `trigger` / `reason` 非空串才在场,缺席 / 坏形 ⇒ 键不在场(不编 'auto'、不编空串;主事实不因旁键丢)。
1338
+ */
1339
+ function compactionOutcomeProjection(frame, ctx) {
1340
+ // 🔴 不浅拷(异源复审 R1 [medium]):展开会擦掉原型链上的身份键(进程内对象把子代帧洗成主会话)、还会执行无关的可枚举 getter;
1341
+ // 与兄弟臂同律 —— 就在同一份记录上逐键各读一次。
1342
+ const ev = frame;
1343
+ const { identity, bad } = identityOrMalformed(ev, 'compaction_outcome');
1344
+ if (bad !== null)
1345
+ return bad;
1346
+ const str = (v) => (typeof v === 'string' && v.length > 0 ? v : undefined);
1347
+ const outcome = str(ev.outcome);
1348
+ if (outcome === undefined)
1349
+ return dropped('malformed', 'compaction_outcome');
1350
+ const trigger = str(ev.trigger);
1351
+ const reason = str(ev.reason);
1352
+ return projected(internalArmExit(stamp(ctx, armBody({
1353
+ type: 'compaction_outcome',
1354
+ outcome,
1355
+ ...(trigger !== undefined ? { trigger } : {}),
1356
+ ...(reason !== undefined ? { reason } : {}),
1357
+ ...identity,
1358
+ }))));
1326
1359
  }
1327
1360
  function engineNoticeProjection(ev, ctx) {
1328
1361
  const code = typeof ev.code === 'string' && ev.code.length > 0 ? ev.code : undefined;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * deviceExecutorManagementCapability — `GET /v1/capabilities.deviceExecutor.management`(server ≥7.88.0;契约附录 B.1
3
3
  * `deviceExecutor` 行 + §14)的三端共用读面(0.74.2;与 sql / writeProtection / webSearch.backend / executionLane /
4
- * approvalsStreamLive 五只兄弟同一套四态词汇)。sdk 对该位无型(9.7.1)⇒ 按契约结构读,sdk 补型当拍对表。
4
+ * approvalsStreamLive 五只兄弟同一套四态词汇)。sdk 9.7.1 对该位无型 ⇒ 按契约结构读;9.8.x 起声明 `management?: boolean`(0.75.0 CC-81 对表:读器不改,门钉声明形)。
5
5
  *
6
6
  * ── 这一位答的是哪一个问题 ────────────────────────────────────────────────────────────────
7
7
  * 「`/v1/devices/*` 的管理动词在这台 server 上在不在」(present-iff:与该族 501 判据逐字同源)。它挂在 `deviceExecutor`
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * deviceExecutorManagementCapability — `GET /v1/capabilities.deviceExecutor.management`(server ≥7.88.0;契约附录 B.1
3
3
  * `deviceExecutor` 行 + §14)的三端共用读面(0.74.2;与 sql / writeProtection / webSearch.backend / executionLane /
4
- * approvalsStreamLive 五只兄弟同一套四态词汇)。sdk 对该位无型(9.7.1)⇒ 按契约结构读,sdk 补型当拍对表。
4
+ * approvalsStreamLive 五只兄弟同一套四态词汇)。sdk 9.7.1 对该位无型 ⇒ 按契约结构读;9.8.x 起声明 `management?: boolean`(0.75.0 CC-81 对表:读器不改,门钉声明形)。
5
5
  *
6
6
  * ── 这一位答的是哪一个问题 ────────────────────────────────────────────────────────────────
7
7
  * 「`/v1/devices/*` 的管理动词在这台 server 上在不在」(present-iff:与该族 501 判据逐字同源)。它挂在 `deviceExecutor`
@@ -164,6 +164,14 @@ export declare function markEnginePanelTaskResident(taskId: string): void;
164
164
  export declare function clearEnginePanelTaskResident(taskId: string): void;
165
165
  export declare function isEnginePanelTaskResident(taskId: string): boolean;
166
166
  type Listener = (ev: EngineAgentPanelEvent) => void;
167
+ /**
168
+ * 0.74.5 CC-87 —— 「这一帧是不是**新周期**」的单源判据(`isStaleEngineAgentPanelEnd` 的对偶,壳侧此前各拼一份):
169
+ * · 两边都带 `cycleSeq` ⇒ **只**比它:不等 ⇒ 新周期;
170
+ * · 否则两边都带 `startedAt` ⇒ 不等 ⇒ 新周期;
171
+ * · 任一边判不出 ⇒ **不是**新周期(这一帧没说,不算变化 —— 方向与陈旧判据相同:缺席不制造周期)。
172
+ * 与缓冲合并屏障(`launchAnchorChanged`)同一条规则;消费端在 `fleet-row` 到时拿行上记的身份与帧上的身份调它。
173
+ */
174
+ export declare function isNewEngineAgentPanelCycle(prev: EngineAgentPanelCycleIdentity | null | undefined, next: EngineAgentPanelCycleIdentity | null | undefined): boolean;
167
175
  /** 周期身份的一格(`end` 事件上的,或消费端记在行上的当前周期)。 */
168
176
  export interface EngineAgentPanelCycleIdentity {
169
177
  readonly cycleSeq?: number;
@@ -201,7 +209,12 @@ export declare function resolveEnginePanelTaskId(wireTaskId: string, parentToolC
201
209
  * `cycle`(0.74.4):调用方(通知车道)带得出这条终态的周期时传进来 —— 尾段归某把 UUID 所有而通知比所有权旧 ⇒ 不清那把 UUID 的常驻标。
202
210
  */
203
211
  export declare function clearEnginePanelTaskResidentByWire(wireTaskId: string, cycle?: EngineAgentPanelCycleIdentity): void;
204
- /** 测试钩:清两张钥匙表、缓一拍与周期水位。 */
212
+ /**
213
+ * 测试钩。逐表清单(每加一张表同批更新这里):① 转录钥匙表(UUID → 行尾段)② 父调用钥匙表(parentToolCallId → 行尾段)
214
+ * ③ 缓着的未绑卡 tick ④ 每行最近一次行帧的周期水位 ⑤ 「先发布的键」集合(UUID)⑥ 同上按父调用记的那一份 ⑦ 反向所有权表(行尾段 → UUID)。
215
+ * 🔴 不清:订阅者 / 回放缓冲、常驻标、running 历史(那一本由 `__resetPanelRunningHistoryForTests` 清)。
216
+ * 🔴 连续场景(同一只子代跨多步的用例)**中途别调**:清掉 ④–⑦ 之后,陈旧判据与「先发布的键为准」都会从零开始,前后两半不再是同一段历史。
217
+ */
205
218
  export declare function __resetEngineAgentPanelIdentityForTests(): void;
206
219
  /** 宿主的唯一发布口(三条 lane 都经这里):先归一身份,再进主通道(订阅者 / 缓冲)。 */
207
220
  export declare function publishEngineAgentPanelEvent(ev: EngineAgentPanelEvent): void;
@@ -22,6 +22,7 @@
22
22
  * Buffering: ticks published before the panel mounts are buffered (bounded) and replayed to the first
23
23
  * subscriber, so early frames in a fast turn aren't lost to mount timing.
24
24
  */
25
+ import { retirePanelRowRunning, notePanelRowEnded, panelRowEndedIdentity, peekHeldNewbornTerminalRow, takeHeldNewbornTerminalRow, dropHeldNewbornTerminalRow } from './panelRunningHistory.js';
25
26
  const _panelToolUsesLanePins = [true, true];
26
27
  void _panelToolUsesLanePins;
27
28
  /**
@@ -56,9 +57,23 @@ const buffer = [];
56
57
  function launchAnchorChanged(prev, next) {
57
58
  if (prev.kind !== 'fleet-row' || next.kind !== 'fleet-row')
58
59
  return false;
59
- if (prev.cycleSeq !== undefined && next.cycleSeq !== undefined && prev.cycleSeq !== next.cycleSeq)
60
- return true;
61
- return prev.startedAt !== undefined && next.startedAt !== undefined && prev.startedAt !== next.startedAt;
60
+ return isNewEngineAgentPanelCycle(prev, next);
61
+ }
62
+ /**
63
+ * 0.74.5 CC-87 —— 「这一帧是不是**新周期**」的单源判据(`isStaleEngineAgentPanelEnd` 的对偶,壳侧此前各拼一份):
64
+ * · 两边都带 `cycleSeq` ⇒ **只**比它:不等 ⇒ 新周期;
65
+ * · 否则两边都带 `startedAt` ⇒ 不等 ⇒ 新周期;
66
+ * · 任一边判不出 ⇒ **不是**新周期(这一帧没说,不算变化 —— 方向与陈旧判据相同:缺席不制造周期)。
67
+ * 与缓冲合并屏障(`launchAnchorChanged`)同一条规则;消费端在 `fleet-row` 到时拿行上记的身份与帧上的身份调它。
68
+ */
69
+ export function isNewEngineAgentPanelCycle(prev, next) {
70
+ const pc = cycleSeqOf(prev);
71
+ const nc = cycleSeqOf(next);
72
+ if (pc !== undefined && nc !== undefined)
73
+ return pc !== nc;
74
+ const ps = startedAtOf(prev);
75
+ const ns = startedAtOf(next);
76
+ return ps !== undefined && ns !== undefined && ps !== ns;
62
77
  }
63
78
  function cycleSeqOf(v) {
64
79
  const n = v?.cycleSeq;
@@ -187,11 +202,13 @@ function publishedWireFor(ev) {
187
202
  return undefined;
188
203
  }
189
204
  /** 尾段今天归哪把 UUID(在场 = 这个子代已按 UUID 上屏;复活的新周期不算)。 */
190
- function wireOwnerOf(fleetId, current) {
205
+ function wireOwnerOf(fleetId, current, transcriptId) {
191
206
  const o = wireIdByFleetId.get(fleetId);
192
207
  if (o === undefined)
193
208
  return undefined;
194
- if (current !== undefined && isStaleEngineAgentPanelEnd(o, current)) {
209
+ // 0.74.5(test [7718] 契约层反例):行帧的 transcriptId 换了 = 新一代子会话,即使两个周期键全缺 —— 所有权同样到此为止
210
+ const newTranscript = transcriptId !== undefined && transcriptId !== (o.transcriptId ?? o.wireId); // 父键登记没记 transcriptId 时,wireId 本身就是那一代的转录身份(复审 R3)
211
+ if (newTranscript || (current !== undefined && isStaleEngineAgentPanelEnd(o, current))) {
195
212
  wireIdByFleetId.delete(fleetId); // 行帧已是更新的周期:旧 UUID 的所有权到此为止,新周期按常态登记
196
213
  retireWire(o.wireId); // 旧周期已过:那把 UUID 退役,退路键(同一张卡)不再把新周期的行帧拉回它(异源复审 R2 [high])
197
214
  return undefined;
@@ -314,7 +331,12 @@ export function clearEnginePanelTaskResidentByWire(wireTaskId, cycle) {
314
331
  return;
315
332
  residentTaskIds.delete(id.taskId);
316
333
  }
317
- /** 测试钩:清两张钥匙表、缓一拍与周期水位。 */
334
+ /**
335
+ * 测试钩。逐表清单(每加一张表同批更新这里):① 转录钥匙表(UUID → 行尾段)② 父调用钥匙表(parentToolCallId → 行尾段)
336
+ * ③ 缓着的未绑卡 tick ④ 每行最近一次行帧的周期水位 ⑤ 「先发布的键」集合(UUID)⑥ 同上按父调用记的那一份 ⑦ 反向所有权表(行尾段 → UUID)。
337
+ * 🔴 不清:订阅者 / 回放缓冲、常驻标、running 历史(那一本由 `__resetPanelRunningHistoryForTests` 清)。
338
+ * 🔴 连续场景(同一只子代跨多步的用例)**中途别调**:清掉 ④–⑦ 之后,陈旧判据与「先发布的键为准」都会从零开始,前后两半不再是同一段历史。
339
+ */
318
340
  export function __resetEngineAgentPanelIdentityForTests() {
319
341
  fleetIdByTranscriptId.clear();
320
342
  fleetIdByParentToolCallId.clear();
@@ -324,6 +346,35 @@ export function __resetEngineAgentPanelIdentityForTests() {
324
346
  publishedWireByParent.clear();
325
347
  wireIdByFleetId.clear();
326
348
  }
349
+ /**
350
+ * 0.75.0 W2:一条 end 递达之前,先把投影缓着的「首见即终态」行放出来(它带这一行唯一的一份最终用量),经漏斗原路发布(身份归一照走);
351
+ * 递达之后记「本进程结过这一行」—— 投影据此把之后到的终态行当成短命子代的收尾(照发用量)而不是历史行。两步都只对当代 end(陈旧判据与销 running 历史同一只)。
352
+ */
353
+ function releaseHeldNewbornTerminal(taskId, endCycle) {
354
+ // 先 peek 比周期再决定(异源复审 R3 [high]:先 take 会让前代 end 把当代唯一的缓行取走丢掉):
355
+ // end 可证比缓行**旧** ⇒ 缓行留着(等当代的 end);end 可证比缓行**新** ⇒ 缓行作废(前代用量不盖当代行);同代 / 判不出 ⇒ 放出。
356
+ const held = peekHeldNewbornTerminalRow(taskId);
357
+ if (held === undefined || held.kind !== 'fleet-row')
358
+ return;
359
+ const heldCycle = { ...(held.cycleSeq !== undefined ? { cycleSeq: held.cycleSeq } : {}), ...(held.startedAt !== undefined ? { startedAt: held.startedAt } : {}) };
360
+ if (isStaleEngineAgentPanelEnd(endCycle, heldCycle))
361
+ return;
362
+ if (isStaleEngineAgentPanelEnd(heldCycle, endCycle)) {
363
+ dropHeldNewbornTerminalRow(taskId);
364
+ return;
365
+ }
366
+ takeHeldNewbornTerminalRow(taskId);
367
+ publishEngineAgentPanelEvent(held);
368
+ }
369
+ /** 结算(end 已递达):running 历史销、ended 账单调写(可证比已结周期旧的 end 不倒写)。 */
370
+ function settleRowHistory(taskId, cycle) {
371
+ retirePanelRowRunning(taskId);
372
+ notePanelRowEnded(taskId, cycle, isStaleEngineAgentPanelEnd(cycle, panelRowEndedIdentity(taskId)));
373
+ }
374
+ /** end 的陈旧判据同时参考行帧水位与 ended 账(短命子代常常没有行帧水位 —— 异源复审 R3 [high])。 */
375
+ function endOlderThanKnown(end, taskId, rowCycle) {
376
+ return isStaleEngineAgentPanelEnd(end, rowCycle) || isStaleEngineAgentPanelEnd(end, panelRowEndedIdentity(taskId));
377
+ }
327
378
  /** 宿主的唯一发布口(三条 lane 都经这里):先归一身份,再进主通道(订阅者 / 缓冲)。 */
328
379
  export function publishEngineAgentPanelEvent(ev) {
329
380
  // 行帧先登记、先放自己匹配的 tick,再给别的缓着的 tick 记拍(异源复审 R2 [high]:先记拍会让第 16 拍那条正好是自己行帧的 tick 过期);
@@ -334,10 +385,10 @@ export function publishEngineAgentPanelEvent(ev) {
334
385
  case 'fleet-row': {
335
386
  // 先发布的键为准(CC-83):这个子代已经按 UUID 上过屏 ⇒ 行帧改写成 UUID 发,并记 尾段 → UUID 供 fleet 侧 end 改写;不登记尾段钥匙。
336
387
  const rowCycle = { ...(ev.cycleSeq !== undefined ? { cycleSeq: ev.cycleSeq } : {}), ...(ev.startedAt !== undefined ? { startedAt: ev.startedAt } : {}) };
337
- const published = wireOwnerOf(ev.taskId, rowCycle) ?? publishedWireFor(ev);
388
+ const published = wireOwnerOf(ev.taskId, rowCycle, ev.transcriptId) ?? publishedWireFor(ev);
338
389
  if (published !== undefined && published !== ev.taskId) {
339
390
  const prev = wireIdByFleetId.get(ev.taskId);
340
- boundedOwnership(wireIdByFleetId, ev.taskId, { wireId: published, retired: prev?.wireId === published ? prev.retired : false, ...rowCycle });
391
+ boundedOwnership(wireIdByFleetId, ev.taskId, { wireId: published, retired: prev?.wireId === published ? prev.retired : false, ...(ev.transcriptId !== undefined ? { transcriptId: ev.transcriptId } : prev?.transcriptId !== undefined ? { transcriptId: prev.transcriptId } : {}), ...rowCycle });
341
392
  deliverEngineAgentPanelEvent({ ...ev, taskId: published, wireTaskId: ev.taskId, taskIdOrigin: 'wire' });
342
393
  ageHeld(undefined);
343
394
  return;
@@ -405,27 +456,67 @@ export function publishEngineAgentPanelEvent(ev) {
405
456
  return;
406
457
  }
407
458
  case 'end': {
459
+ // 0.75.0 W2:缓着的「首见即终态」行**先于一切归一**放出(异源复审 R3 [high]:放出的行帧会登记钥匙 / 所有权,之后的 end 归一必须看到它,
460
+ // 否则同一子代长成 UUID 行 + 尾段行两行)。三把可能的键:尾段本身 / UUID 经钥匙解析到的尾段 / 这把 UUID 名下的尾段。
461
+ releaseHeldNewbornTerminal(ev.taskId, ev);
462
+ {
463
+ const pre = resolveEnginePanelTaskId(ev.taskId);
464
+ if (pre.origin === 'fleet-row' && pre.taskId !== ev.taskId) {
465
+ releaseHeldNewbornTerminal(pre.taskId, {
466
+ ...(ev.cycleSeq !== undefined ? { cycleSeq: ev.cycleSeq } : pre.cycleSeq !== undefined ? { cycleSeq: pre.cycleSeq } : {}),
467
+ ...(ev.startedAt !== undefined ? { startedAt: ev.startedAt } : pre.startedAt !== undefined ? { startedAt: pre.startedAt } : {}),
468
+ });
469
+ }
470
+ for (const [fleetId, o] of wireIdByFleetId)
471
+ if (o.wireId === ev.taskId && !o.retired)
472
+ releaseHeldNewbornTerminal(fleetId, ev);
473
+ }
408
474
  // 先发布的键为准(CC-83):尾段已被改写成 UUID 的子代,fleet 侧(投影 / 通知)的 end{尾段} 也改写成 UUID
409
475
  const wireOwner = wireOwnerOf(ev.taskId, ev);
410
476
  if (wireOwner !== undefined) {
411
477
  const ownerRec = wireIdByFleetId.get(ev.taskId);
478
+ const staleA = endOlderThanKnown(ev, ev.taskId, ownerRec) || isStaleEngineAgentPanelEnd(ev, panelRowEndedIdentity(wireOwner));
412
479
  deliverEngineAgentPanelEvent({ ...ev, taskId: wireOwner, wireTaskId: ev.taskId, taskIdOrigin: 'wire' });
413
- // 前代迟到的 end(比所有权登记时的周期旧)只过境让消费端判陈旧,不退役当代所有权(异源复审 R3)
414
- if (!isStaleEngineAgentPanelEnd(ev, ownerRec))
480
+ // 前代迟到的 end(比所有权登记时的周期旧)只过境让消费端判陈旧,不退役当代所有权(异源复审 R3)、不销当代 running 历史
481
+ if (!staleA) {
415
482
  retireWire(wireOwner);
483
+ settleRowHistory(ev.taskId, ev);
484
+ settleRowHistory(wireOwner, ev);
485
+ }
416
486
  return;
417
487
  }
418
488
  if (publishedWireIds.has(ev.taskId)) {
419
489
  // 这把 UUID 是先发布的键 ⇒ 它的 end 落在它自己的行上(不归一;一把已发布的键永远不改写)
420
490
  flushHeld(ev.taskId);
491
+ // 这把 UUID 名下的尾段(反向所有权 / 转录别名)在投影里以尾段记账(复审 R3 [medium]),各自带周期守卫:先放缓着的终态行,end 之后再销
492
+ const ownedFleetIds = [];
493
+ for (const [fleetId, o] of wireIdByFleetId) {
494
+ if (o.wireId === ev.taskId && !endOlderThanKnown(ev, fleetId, o))
495
+ ownedFleetIds.push(fleetId);
496
+ }
497
+ const alias = fleetIdByTranscriptId.get(ev.taskId);
498
+ if (alias !== undefined && !endOlderThanKnown(ev, alias.fleetId, alias) && !ownedFleetIds.includes(alias.fleetId))
499
+ ownedFleetIds.push(alias.fleetId);
421
500
  deliverEngineAgentPanelEvent(ev);
422
501
  retireWire(ev.taskId);
502
+ // UUID 自己的 ended 账也带守卫(end(U,2) 之后的 end(U,1) 不倒写)
503
+ if (!isStaleEngineAgentPanelEnd(ev, panelRowEndedIdentity(ev.taskId)))
504
+ settleRowHistory(ev.taskId, ev);
505
+ for (const fleetId of ownedFleetIds)
506
+ settleRowHistory(fleetId, ev);
423
507
  return;
424
508
  }
425
509
  const id = resolveEnginePanelTaskId(ev.taskId);
426
510
  if (id.origin === 'fleet-row' && id.taskId !== ev.taskId) {
427
511
  flushHeld(ev.taskId); // 同 UUID 还缓着一条 tick ⇒ 先归一放出(行先有最后一拍的数字与名字),再结
428
512
  migrateResidency(ev.taskId, id.taskId);
513
+ // 任何 lane 的 end 到漏斗 = 这一行结了 ⇒ running 历史销账(只销当代:前代迟到的 end 不擦新一代;异源复审 0.74.5 R2)
514
+ // 🔴 用与递达**同一个**有效身份判(事件自带的周期优先、缺席才借钥匙;复审 R3 [high]:按钥匙判会把 end{cycle 1} 当成当代)
515
+ const effectiveEnd = {
516
+ ...(ev.cycleSeq !== undefined ? { cycleSeq: ev.cycleSeq } : id.cycleSeq !== undefined ? { cycleSeq: id.cycleSeq } : {}),
517
+ ...(ev.startedAt !== undefined ? { startedAt: ev.startedAt } : id.startedAt !== undefined ? { startedAt: id.startedAt } : {}),
518
+ };
519
+ const staleC = endOlderThanKnown(effectiveEnd, id.taskId, latestCycleByFleetId.get(id.taskId));
429
520
  deliverEngineAgentPanelEvent({
430
521
  ...ev,
431
522
  taskId: id.taskId,
@@ -435,11 +526,17 @@ export function publishEngineAgentPanelEvent(ev) {
435
526
  ...(ev.cycleSeq === undefined && id.cycleSeq !== undefined ? { cycleSeq: id.cycleSeq } : {}),
436
527
  ...(ev.startedAt === undefined && id.startedAt !== undefined ? { startedAt: id.startedAt } : {}),
437
528
  });
529
+ if (!staleC)
530
+ settleRowHistory(id.taskId, effectiveEnd);
438
531
  return;
439
532
  }
440
533
  flushHeld(ev.taskId); // 缓着的 tick 先落行,终态才有行可结
534
+ // 通知车道 / fleet 投影铸的 end(尾段键):只对当代(前代迟到的 end 带旧 seq / cycleSeq ⇒ 不擦历史、不放缓着的行)
535
+ const staleD = endOlderThanKnown(ev, ev.taskId, latestCycleByFleetId.get(ev.taskId));
441
536
  deliverEngineAgentPanelEvent(ev);
442
537
  retireWire(ev.taskId); // 这把 UUID 若是先发布的键,所有权退役(容量满时先让位)
538
+ if (!staleD)
539
+ settleRowHistory(ev.taskId, ev);
443
540
  return;
444
541
  }
445
542
  default:
@@ -48,6 +48,7 @@
48
48
  * 就地维护第二份;该函数出过等价性事故,0.1.1 修,见 `workflow.ts` 头注)。
49
49
  */
50
50
  import { isStaleEngineAgentPanelEnd, publishEngineAgentPanelAbsence, publishEngineAgentPanelEvent } from './engineAgentPanelStore.js';
51
+ import { __resetPanelRunningHistoryForTests, notePanelRowRunning, panelRowUnknownToConsumer, panelRowEndedIdentity, holdNewbornTerminalRow, dropHeldNewbornTerminalRow } from './panelRunningHistory.js';
51
52
  import { rowIdTail } from './workflow.js';
52
53
  import { TERMINAL_FLEET_TASK_STATUSES } from './fleet/fleetProjection.js';
53
54
  import { DEFAULT_SESSION_KEY } from './sessionSlot.js';
@@ -80,6 +81,8 @@ function currentToolKeyOf(ct) {
80
81
  }
81
82
  /** REF-CC-045(fleet2-07):per-sessionKey 台账(W1 同款 `Map<sessionKey, Map<...>>` 形)。 */
82
83
  const seen = new Map();
84
+ // 0.74.5 CC-86:「发布过 running 而尚未结掉」的行 id 在 `panelRunningHistory.ts`(进程级共享叶;写方 = 本投影的非终态帧,
85
+ // 销账方 = 面板漏斗的任何 lane 的 end(通知车道的 end 不经本投影),读方 = 下面的终态分支)。
83
86
  function seenFor(sessionKey) {
84
87
  let m = seen.get(sessionKey);
85
88
  if (!m) {
@@ -102,6 +105,7 @@ function seenFor(sessionKey) {
102
105
  */
103
106
  export function resetFleetAgentPanelProjection() {
104
107
  seen.clear();
108
+ __resetPanelRunningHistoryForTests();
105
109
  }
106
110
  /** W1 keyed variant(REF-CC-045):只清**这一个会话**的桶。 */
107
111
  export function resetFleetAgentPanelProjectionFor(sessionKey) {
@@ -180,8 +184,38 @@ export function projectFleetAgentRowsFor(sessionKey, rows, nowMs = Date.now()) {
180
184
  if (TERMINAL_FLEET_TASK_STATUSES.has(status)) {
181
185
  // ② 终态一次性 settle(幂等台账;消费端 settle 也幂等)。0.74.0:去重按**周期** —— 已 settle 的是上一代、这一帧是
182
186
  // 更新的一代(中间没见到 running 帧)⇒ 新周期的终态照发,不被上一代的 settled 吞掉。
187
+ // 🔴 0.74.5 CC-86(cli L-415):**首次见到就已终态**的行(prev 缺席)零事件 —— 只在台账记 settled。此前先发 fleet-row
188
+ // (交最终用量)再发 end ⇒ 宿主为本会话从没跑过的任务建行随即 settle = 面板幽灵完成行(reattach / resume 后引擎
189
+ // fleet 列表里的历史终态行全部上屏)。本会话内起步又结束、面板从没见过 running 的后台任务由通知车道的 end 收尸;
190
+ // 复活(prev 已 settled 且这一帧周期更新)不在此列,照发。
191
+ // 0.75.0 W2:「首见」= 消费端没有这一行(没发布过 running **也没递达过 end**);通知 end 先到的短命子代 ⇒ 终态行照发(交最终用量,不再第二次 end)。
192
+ const newbornTerminal = prev === undefined && panelRowUnknownToConsumer(taskId);
183
193
  const settledThisCycle = prev?.settled === true && !rowNewerCycle;
184
- if (!settledThisCycle) {
194
+ // 结过的行(本进程递达过 end)再来终态帧:同代或判不出代际 = 短命子代的最终用量到了 ⇒ 只发 fleet-row、不再第二次 end;
195
+ // 可证更新的一代 = 结过之后复活又结束 ⇒ 照发 fleet-row + end。
196
+ const endedRec = prev === undefined ? panelRowEndedIdentity(taskId) : undefined;
197
+ const frameCycle = { ...(cycleSeq !== undefined ? { cycleSeq } : {}), ...(startedAt !== undefined ? { startedAt } : {}) };
198
+ // 方向判(异源复审 R3 [high]:只判「不等」会把迟到的前代终态当成新一代 ⇒ 倒写用量 + 第二次 end):
199
+ // 帧可证比已结周期**旧** ⇒ 整帧丢(不进台账,不发);可证**新** ⇒ 复活又结束,fleet-row + end;同代 / 判不出 ⇒ 只补用量。
200
+ if (endedRec !== undefined && isStaleEngineAgentPanelEnd(frameCycle, endedRec))
201
+ continue;
202
+ const endedSameCycle = endedRec !== undefined && !isStaleEngineAgentPanelEnd(endedRec, frameCycle);
203
+ if (newbornTerminal) {
204
+ // 反序保护:end 也许下一拍才到 ⇒ 终态行(带用量)缓在历史叶里,end 到漏斗时先放它再放 end;真历史行永远等不到 end,缓着直到被挤掉。
205
+ holdNewbornTerminalRow(taskId, {
206
+ kind: 'fleet-row',
207
+ taskId,
208
+ ...(row.name ? { name: row.name } : {}),
209
+ ...(row.description ? { description: row.description } : {}),
210
+ ...(knownTokens !== undefined ? { totalTokens: knownTokens } : {}),
211
+ ...(toolUses !== undefined ? { toolUses } : {}),
212
+ ...(transcriptId !== undefined ? { transcriptId } : {}),
213
+ ...(parentToolCallId !== undefined ? { parentToolCallId } : {}),
214
+ ...(knownStartedAt !== undefined ? { startedAt: knownStartedAt } : {}),
215
+ ...(knownCycleSeq !== undefined ? { cycleSeq: knownCycleSeq } : {}),
216
+ });
217
+ }
218
+ else if (!settledThisCycle) {
185
219
  // 终态行也把最后一拍的累计工具数/转录锚带上:详情页在行翻终态后仍要显示真计数与
186
220
  // 委派 prompt,而终态帧常常是这一行**唯一**带全 usage 的一帧(bg lane 只在活动边界发行帧)。
187
221
  if (
@@ -207,17 +241,18 @@ export function projectFleetAgentRowsFor(sessionKey, rows, nowMs = Date.now()) {
207
241
  ...(knownCycleSeq !== undefined ? { cycleSeq: knownCycleSeq } : {}),
208
242
  });
209
243
  }
210
- publishEngineAgentPanelEvent({
211
- kind: 'end',
212
- taskId,
213
- // L-215② 族扫(0.65.0):与 `notifications.enqueueBgChildNotification` 的 `isError` 位
214
- // **同一个病形**(内联两词 ⇒ core [6908] 的 `blocked` 漏成「成功」)。两处一次改齐,
215
- // 读同一个单铸谓词;只修当格 = 下一次加词又漏一处。
216
- isError: isTerminalNotSuccess(status),
217
- // 0.74.0:这条终态属于哪个周期(在场才带)—— 消费端据此挡掉上一周期迟到的 end。
218
- ...(knownCycleSeq !== undefined ? { cycleSeq: knownCycleSeq } : {}),
219
- ...(knownStartedAt !== undefined ? { startedAt: knownStartedAt } : {}),
220
- });
244
+ if (!endedSameCycle)
245
+ publishEngineAgentPanelEvent({
246
+ kind: 'end',
247
+ taskId,
248
+ // L-215② 族扫(0.65.0):与 `notifications.enqueueBgChildNotification` 的 `isError` 位
249
+ // **同一个病形**(内联两词 ⇒ core [6908] 的 `blocked` 漏成「成功」)。两处一次改齐,
250
+ // 读同一个单铸谓词;只修当格 = 下一次加词又漏一处。
251
+ isError: isTerminalNotSuccess(status),
252
+ // 0.74.0:这条终态属于哪个周期(在场才带)—— 消费端据此挡掉上一周期迟到的 end。
253
+ ...(knownCycleSeq !== undefined ? { cycleSeq: knownCycleSeq } : {}),
254
+ ...(knownStartedAt !== undefined ? { startedAt: knownStartedAt } : {}),
255
+ });
221
256
  }
222
257
  seenMap.set(taskId, {
223
258
  status,
@@ -251,6 +286,8 @@ export function projectFleetAgentRowsFor(sessionKey, rows, nowMs = Date.now()) {
251
286
  (startedAt !== undefined && prev.startedAt !== startedAt) ||
252
287
  (cycleSeq !== undefined && prev.cycleSeq !== cycleSeq) ||
253
288
  (currentToolKey !== undefined && prev.currentToolKey !== currentToolKey)) {
289
+ notePanelRowRunning(taskId); // CC-86:真发布了这一行在跑才记历史(未变的陈旧快照零事件也零记账,复审 R3)
290
+ dropHeldNewbornTerminalRow(taskId); // 复活为 running ⇒ 缓着的旧终态行作废(用量以新一代为准)
254
291
  publishEngineAgentPanelEvent({
255
292
  kind: 'fleet-row',
256
293
  taskId,