@sema-agent/client-core 0.64.2 → 0.65.1

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 (48) hide show
  1. package/CHANGELOG.md +154 -0
  2. package/README.md +11 -3
  3. package/dist/adapt/arms.js +125 -7
  4. package/dist/adapt/ids.d.ts +22 -0
  5. package/dist/adapt/ids.js +29 -0
  6. package/dist/adapt/panelTasks.d.ts +24 -2
  7. package/dist/adapt/panelTasks.js +65 -15
  8. package/dist/adapt/textStream.js +6 -3
  9. package/dist/adapt.d.ts +1 -1
  10. package/dist/adapt.js +3 -0
  11. package/dist/adapter/downstream/eventToSdkMessage.d.ts +0 -10
  12. package/dist/adapter/downstream/eventToSdkMessage.js +207 -65
  13. package/dist/adapter/downstream/terminalToSdkResult.d.ts +4 -4
  14. package/dist/adapter/downstream/terminalToSdkResult.js +43 -12
  15. package/dist/adapter/downstream/turnUsageToModelUsage.d.ts +23 -2
  16. package/dist/adapter/downstream/turnUsageToModelUsage.js +7 -1
  17. package/dist/adapter/runStream.js +43 -2
  18. package/dist/adapter/types.d.ts +12 -0
  19. package/dist/autoModeUnavailable.d.ts +39 -83
  20. package/dist/autoModeUnavailable.js +58 -111
  21. package/dist/classifierStatus.d.ts +25 -71
  22. package/dist/classifierStatus.js +110 -105
  23. package/dist/decideReceipt.d.ts +117 -0
  24. package/dist/decideReceipt.js +142 -0
  25. package/dist/engineErrorCodes.d.ts +32 -0
  26. package/dist/engineErrorCodes.js +42 -0
  27. package/dist/fleet/fleetProjection.d.ts +24 -1
  28. package/dist/fleet/fleetProjection.js +26 -1
  29. package/dist/fleetAgentPanelProjection.js +6 -1
  30. package/dist/gateVocabulary.d.ts +9 -1
  31. package/dist/gateVocabulary.js +46 -4
  32. package/dist/hitl/askGateWire.js +22 -1
  33. package/dist/hitl/gateLedger.d.ts +24 -0
  34. package/dist/hitl/gateLedger.js +8 -0
  35. package/dist/hitl/hitlBridge.js +14 -2
  36. package/dist/hitl/parkResolver.d.ts +23 -2
  37. package/dist/hitl/parkResolver.js +34 -6
  38. package/dist/hitl/toolApprovalWire.d.ts +12 -1
  39. package/dist/hitl/toolApprovalWire.js +15 -6
  40. package/dist/index.d.ts +1 -0
  41. package/dist/index.js +16 -6
  42. package/dist/notifications.js +11 -2
  43. package/dist/runTerminal.d.ts +48 -0
  44. package/dist/runTerminal.js +59 -0
  45. package/dist/seam.d.ts +131 -1
  46. package/dist/seam.js +22 -0
  47. package/docs/INTEGRATION-CLIENTS.md +680 -63
  48. package/package.json +2 -2
@@ -12,8 +12,8 @@
12
12
  *
13
13
  * 🔴 拆分定案两条(矩阵 §3.2 #4/#6):
14
14
  * ① `endedPanelTasks` 拆分前被 `settlePanelTasks` **和** task_notification 臂两处直写。
15
- * 现在通知臂那半场收成 `settleFromNotification()`(clearResident + panel end +
16
- * endedPanelTasks 判重 + if-started stop 整段),通知臂只调一次 ⇒ 跨臂直写消失。
15
+ * 现在通知臂那半场收成 `settleFromNotification()`(clearResident + endedPanelTasks 判重 +
16
+ * panel end + if-started stop 整段;0.65.1 起判重在 end 之前),通知臂只调一次 ⇒ 跨臂直写消失。
17
17
  * ② `firedSubagentStartHookTaskIds` 的读者(通知臂)随之进了本模块内部 —— 通知臂**不再直读**
18
18
  * 实例台账,那条跨模块读同批消失。
19
19
  *
@@ -43,6 +43,17 @@ export interface PanelTaskLedger {
43
43
  /** 这条行已经绑过卡了吗(含糊回落的前置判位;别拿 `laneOf(...) === MAIN` 代替 —— 那是
44
44
  * 按引用相等在推断,判据锚在会决定结果的量上才对)。 */
45
45
  isBound(taskId: string): boolean;
46
+ /**
47
+ * B-074 —— 这条行绑在一张**本 turn 还开着**的卡上吗(`liveBound`,#6 的 live/inert 判位)。
48
+ *
49
+ * 它回答的是「**谁来收这条行的终态**」:
50
+ * · `true` ⇒ 关卡那条腿({@link settle})会收,而且它带着**子代的最终报告**(委派卡的 output);
51
+ * · `false` ⇒ 未绑 / inert 绑定(跨 turn bg 子代的典型形)= **session 常驻行**,关卡腿不碰它,
52
+ * 它的终态今天只认通知帧 —— 这正是 core 的终态 tick 要救的那条 uuid lane。
53
+ * 🔴 判别力承重:终态 tick 在 `true` 的行上**不许**抢着落终态 —— 抢了会让后到的关卡 settle 撞
54
+ * `endedPanelTasks` 判重而整条让位,子代的最终报告从此到不了面板行(异源对抗复审实抓)。
55
+ */
56
+ isLiveBound(taskId: string): boolean;
46
57
  /** 本 turn 发布过面板行了吗(首 tick 判别 = 子代在壳侧可观测的起点)。 */
47
58
  isLive(taskId: string): boolean;
48
59
  /** 登记「本 turn 发布过面板行」。 */
@@ -55,5 +66,16 @@ export interface PanelTaskLedger {
55
66
  settle(cardId: string | null, isError: boolean, report?: string): Generator<AdapterOutput>;
56
67
  /** #6 通知-settle 边(推送半场):真终态在此落行,session 常驻行不再被 turn sweep 假结。 */
57
68
  settleFromNotification(taskId: string, isError: boolean): Generator<AdapterOutput>;
69
+ /**
70
+ * B-074(0.65.0)**终态 tick 边** —— core 明写的那一拍(`task_progress.status` = `completed`/
71
+ * `failed`)按 taskId 直接落行。
72
+ *
73
+ * 为什么不复用 {@link settleFromNotification}:那一条是**通知帧**那半场,它按 `MAIN` 车道发
74
+ * end(bg 子代行本来就是 unbound 的),而终态 tick 的行**可能绑着一张前台委派卡** ⇒ 车道证明
75
+ * 必须走 {@link laneOf},否则一条 subagent lane 的行会拿 main lane 的证明去落终态(帧×lane
76
+ * 矩阵那一族的病)。也不复用 {@link settle}:那一条按**卡 id** 收(关卡/防御 sweep 的语义),
77
+ * 而这里的权威键是 taskId —— 引擎说的是「这个子代结束了」,不是「这张卡关了」。
78
+ */
79
+ settleFromTerminalTick(taskId: string, isError: boolean): Generator<AdapterOutput>;
58
80
  }
59
81
  export declare function createPanelTaskLedger(ctx: AdapterContext, cards: OpenCardView, inst: AdapterInstanceLedger): PanelTaskLedger;
@@ -25,6 +25,7 @@ export function createPanelTaskLedger(ctx, cards, inst) {
25
25
  return {
26
26
  laneOf,
27
27
  isBound: (taskId) => taskCardBinding.has(taskId),
28
+ isLiveBound: (taskId) => liveBoundPanelTasks.has(taskId),
28
29
  /**
29
30
  * T32 行绑定。**typed 双键在场恒赢**(B6 定案):wire 点名了 `parentToolCallId` 时,下面的
30
31
  * FIFO 臂**一次都不会被考虑** —— 连「点名的卡不合格」那一支也是直接返回 unbound,而不是
@@ -162,32 +163,81 @@ export function createPanelTaskLedger(ctx, cards, inst) {
162
163
  /**
163
164
  * 通知臂(task_notification)那半场,整段收进来 —— 拆分前它在臂里直写 `endedPanelTasks`
164
165
  * 与直读 `firedSubagentStartHookTaskIds`,是矩阵点名的两条跨模块面。
165
- * 产出与判重次序**逐字保持**拆分前的形:先清常驻 end 再判重 → 再 if-started stop。
166
+ * 🔴 **判重在唯一的 yield 之前**(0.65.1 / B-087;test [6961] 对抗复审轨实抓):拆分时这里
167
+ * 「逐字保持」了拆分前的次序 —— 先发 end 行、再判重 —— 判重只护着 if-started stop,end
168
+ * 行本身无条件发。B-074 让终态 tick 也能结 bg 行之后,`tick(completed) → task_notification`
169
+ * 这个顺序就在面板上落**两条** end(反序只有一条,因为 tick 腿判重在前)。三条腿共用
170
+ * `endedPanelTasks` 的承诺(§30d G30-11)要在**每条腿**的 yield 之前成立;常驻账照清
171
+ * (清一次幂等,真终态权威性不变)。
166
172
  */
167
173
  *settleFromNotification(taskId, isError) {
168
174
  // #6 通知-settle 边(推送半场):session 常驻的 bg 子代行不再被 turn sweep 假结,
169
175
  // 真终态在此落行(failed/killed ⇒ isError 真归因)。
170
176
  clearEnginePanelTaskResident(taskId);
177
+ if (endedPanelTasks.has(taskId))
178
+ return;
179
+ endedPanelTasks.add(taskId);
171
180
  yield chrome({
172
181
  kind: 'panel_task',
173
182
  laneProof: MAIN,
174
183
  event: { kind: 'end', taskId, isError },
175
184
  });
176
- if (!endedPanelTasks.has(taskId)) {
177
- endedPanelTasks.add(taskId);
178
- // SubagentStop 只对**真 fire 过 Start** 的子代(workflow runId / 外来 id 不配对乱 fire)。
179
- // cli 同门 `firedSubagentStartHookTaskIds.has(taskId)` —— B4 前本臂无条件发 stop,
180
- // 宿主若照 guard 注释自建台账才不会乱 fire;台账在库里就该库来判。
181
- if (inst.hasFiredSubagentStart(taskId)) {
182
- yield chrome({
183
- kind: 'subagent_lifecycle',
184
- laneProof: MAIN,
185
- phase: 'stop',
186
- taskId,
187
- guard: 'if-started',
188
- });
189
- }
185
+ // SubagentStop 只对**真 fire 过 Start** 的子代(workflow runId / 外来 id 不配对乱 fire)
186
+ // cli 同门 `firedSubagentStartHookTaskIds.has(taskId)` —— B4 前本臂无条件发 stop,
187
+ // 宿主若照 guard 注释自建台账才不会乱 fire;台账在库里就该库来判。
188
+ if (inst.hasFiredSubagentStart(taskId)) {
189
+ yield chrome({
190
+ kind: 'subagent_lifecycle',
191
+ laneProof: MAIN,
192
+ phase: 'stop',
193
+ taskId,
194
+ guard: 'if-started',
195
+ });
196
+ }
197
+ },
198
+ /**
199
+ * B-074(0.65.0)**终态 tick 边**(契约见端口声明)。
200
+ *
201
+ * 🔴 **幂等在最前面**:同一条行可能被三条腿同时收到终态(终态 tick / 关卡 tool_end /
202
+ * task_notification),`endedPanelTasks` 是它们共用的那本账 —— 判重放在**唯一的 yield 之前**
203
+ * (与 `start()` 同一条零残余窗纪律),否则一条 end 会在面板上重复出现。
204
+ * 🔴 **inline 群组行孪生同拍冻结**:绑了卡的行要把 tick 终值冻住,否则转录里那条
205
+ * "Running N agents…" 会被后到的 `settle_all` 把真实工具数重置回 0(clay dogfood 那一例)。
206
+ * 🔴 **常驻账要清**:终态 tick 是**权威**终点 ⇒ 这条行不再是「等通知帧收尸」的常驻行。
207
+ */
208
+ *settleFromTerminalTick(taskId, isError) {
209
+ if (endedPanelTasks.has(taskId))
210
+ return;
211
+ endedPanelTasks.add(taskId);
212
+ clearEnginePanelTaskResident(taskId);
213
+ const lane = laneOf(taskId);
214
+ const card = taskCardBinding.get(taskId);
215
+ if (card !== undefined) {
216
+ yield chrome({
217
+ kind: 'inline_task_stats',
218
+ laneProof: { lane: 'subagent', parentToolCallId: card },
219
+ op: 'settle',
220
+ cardId: card,
221
+ isError,
222
+ });
190
223
  }
224
+ // SubagentStop 只对**真 fire 过 Start** 的子代(同 settleFromNotification 的 if-started 门)。
225
+ if (inst.hasFiredSubagentStart(taskId)) {
226
+ const agentType = subagentTypeById.get(taskId);
227
+ yield chrome({
228
+ kind: 'subagent_lifecycle',
229
+ laneProof: lane,
230
+ phase: 'stop',
231
+ taskId,
232
+ ...(agentType !== undefined ? { agentType } : {}),
233
+ guard: 'if-started',
234
+ });
235
+ }
236
+ yield chrome({
237
+ kind: 'panel_task',
238
+ laneProof: lane,
239
+ event: { kind: 'end', taskId, isError },
240
+ });
191
241
  },
192
242
  };
193
243
  }
@@ -1,4 +1,4 @@
1
- import { chrome, MAIN, transcript } from './ids.js';
1
+ import { chrome, MAIN, transcript, messageIdentityOf } from './ids.js';
2
2
  import { estimateCjkTokens, FLUSH_INTERVAL_MS } from './wireShapes.js';
3
3
  /**
4
4
  * 件 A(#323 症状①)判据 (b) 的句末字符集 —— 中英文各一套,**闭集**(开集匹配会把「代码里的点」
@@ -76,7 +76,9 @@ export function createTextStream(ctx, idOf) {
76
76
  const anchor = thinkingAnchor ?? {};
77
77
  const msg = {
78
78
  type: 'assistant',
79
- message: { role: 'assistant', content: [{ type: 'thinking', thinking }] },
79
+ // L-215③:上游投影器铸在 durable 内容帧上的两个身份位在这里**接力**,不是重铸
80
+ // (本构造点是重建 `{role,content}` 的,不接就原地丢掉;见 `ids.ts` 的 messageIdentityOf)。
81
+ message: { role: 'assistant', content: [{ type: 'thinking', thinking }], ...messageIdentityOf(anchor, ctx) },
80
82
  uuid: idOf(anchor, 'thinking'),
81
83
  session_id: ctx.sessionId,
82
84
  parent_tool_use_id: null,
@@ -96,7 +98,8 @@ export function createTextStream(ctx, idOf) {
96
98
  const anchor = segmentAnchor ?? {};
97
99
  const msg = {
98
100
  type: 'assistant',
99
- message: { role: 'assistant', content: [{ type: 'text', text: answerSegment }] },
101
+ // L-215③:同 takeThinking —— 身份位接力(帧优先、ctx 兜底;两位都读不出就一个都不铸)。
102
+ message: { role: 'assistant', content: [{ type: 'text', text: answerSegment }], ...messageIdentityOf(anchor, ctx) },
100
103
  uuid: idOf(anchor, 'text'),
101
104
  session_id: ctx.sessionId,
102
105
  parent_tool_use_id: null,
package/dist/adapt.d.ts CHANGED
@@ -50,7 +50,7 @@ export interface AdapterLedgerState {
50
50
  /** 本批覆盖清单——差分守卫按它区分「已覆盖臂必须逐字段等价」与「已声明未覆盖臂」。 */
51
51
  export declare const ADAPTER_COVERAGE: {
52
52
  /** 已落码的帧臂。 */
53
- readonly frames: readonly ["assistant", "user", "stream_event", "turn_usage", "result", "system", "task_notification", "workflow_complete", "diagnostics", "steering_injected", "workspace_changed", "human_input", "engine_notice", "text_end", "prompt_suggestions", "retry_status", "task_progress", "tool_end_result(label 补位 + 关卡 settle + response-id 复位 + 开卡台账出栈 + 经 cards.close 铸 tool_result)"];
53
+ readonly frames: readonly ["assistant", "user", "stream_event", "turn_usage", "result", "system", "task_notification", "workflow_complete", "diagnostics", "steering_injected", "workspace_changed", "human_input", "message_committed", "engine_notice", "text_end", "prompt_suggestions", "retry_status", "task_progress", "tool_end_result(label 补位 + 关卡 settle + response-id 复位 + 开卡台账出栈 + 经 cards.close 铸 tool_result)"];
54
54
  /** 已落码臂产出的 transcript 消息类目(差分守卫的比对域)。 */
55
55
  readonly transcriptKinds: readonly ["assistant_text", "assistant_thinking", "assistant_tool_use", "user_tool_result_decision", "user_task_notification", "system_passthrough"];
56
56
  /** B3(0.5.0)新落码的臂/半场 —— 从 todo 移过来的,别再在 todo 里留同名条目。 */
package/dist/adapt.js CHANGED
@@ -33,6 +33,9 @@ export const ADAPTER_COVERAGE = {
33
33
  'workspace_changed',
34
34
  // FIX⑦(2026-08-07):`human_input` 账本帧 —— 投 chrome 记账,**不铸 transcript 行**(不带正文)。
35
35
  'human_input',
36
+ // B-072 ④(0.65.0):`message_committed` 定位账本帧 —— 同 human_input 归 chrome(不带正文,
37
+ // 绝不铸 transcript 行);端拿它自建 entryId→已渲消息 映射,解析压缩分割线的保留段锚。
38
+ 'message_committed',
36
39
  // #318 件①(2026-08-21):`engine_notice` 引擎结构化通告 —— 投 chrome(开集,一个码都不判)。
37
40
  'engine_notice',
38
41
  // #323 / core #447:assistant 散文段边界 → chrome text_segment_end(子流断闸;本批**不**动文本缓冲)。
@@ -35,7 +35,6 @@
35
35
  */
36
36
  import type { AgentEvent, CheckpointGate } from '@sema-agent/sdk';
37
37
  import { type SDKMessage, type EmitContext, type ModelUsage } from '../types.js';
38
- import { type ClassifierBreakerView } from '../../autoModeUnavailable.js';
39
38
  /**
40
39
  * 本适配器铸出的**内部臂** `type` 全集(A-004.4 上游半场,2026-08-08)——「内部臂」= 桥面
41
40
  * (bridge-facing)消息,**不是** CC SDK stdout 联合体里的帧形(那些是 assistant/stream_event/
@@ -170,15 +169,6 @@ export interface WiringManifestAutoMode {
170
169
  armed: boolean;
171
170
  /** core **六词逐字透传**。🔴 不映射 `/v1/capabilities.permissionModeAuto.reason` —— 见投影函数头注。 */
172
171
  reason: string;
173
- /**
174
- * 这个**会话**上最近一次熔断闩合上的记录(core 7.10.0 `AutoModeBreakerTrip`;
175
- * **server ≥7.69.0 才投** ⇒ 缺席 = 老引擎,**不是**「没熔断过」)。
176
- * 🔴 与 {@link armed} / {@link reason} 之间**没有**互证关系:闩合上之后被闩住的那条腿不再把
177
- * auto 意图传给后续腿,于是后续腿读作 `no_intent` 而不是 `latch_open` ——
178
- * `{armed:false, reason:"no_intent", breaker:{…}}` 是最常见的合法形。理由与三态优先序写在
179
- * `src/classifierStatus.ts` 的模块顶注。
180
- */
181
- breaker?: ClassifierBreakerView;
182
172
  }
183
173
  /**
184
174
  * {@link wiringManifestSupersetBody} 的 `mcp[]` 一条目(S-124 / core 7.5.0,server ≥7.60.0 的形)。
@@ -2,10 +2,9 @@ import { stamp, } from '../types.js';
2
2
  import { turnUsageToModelUsage } from './turnUsageToModelUsage.js';
3
3
  // 0.60.0(engine ≥7.64.0 / sdk 8.4.0):`tool_end.gate` 的三端共用窄读器(判定归包,端不许自拼)。
4
4
  import { gateOutcomeOf } from '../../gateOutcome.js';
5
- // 0.64.0 件②(core 7.10.0 / server ≥7.69.0):`autoMode.breaker` 的**唯一**窄读器 —— 本投影臂与
6
- // `classifierStatus.ts` 的状态读器共用它,各写一份就是两份台账各漂各的。它住在那张熔断轴表的
7
- // 同一个文件里( import 的纯叶),内核闭包因此只多一件叶子。
8
- import { classifierBreakerOf } from '../../autoModeUnavailable.js';
5
+ // 0.65.0(core 7.12.0 CHANGELOG「Removed (BREAKING)」`WiringManifest.autoMode.breaker`):
6
+ // 熔断族整只退役 ⇒ 本臂此前对 `autoMode.breaker` 的窄读(`classifierBreakerOf`)与它的型位一并
7
+ // 删除(clean-cut,不留兼容读)。`autoMode` 段剩下的两位 `armed` / `reason` 一个字节不动。
9
8
  // #51: `stamp<T extends { uuid?: string; session_id?: string }>` — passing an
10
9
  // arm-body object literal straight to `stamp` triggers a TS quirk: the
11
10
  // literal gets excess-property-checked against the CONSTRAINT (which only
@@ -56,14 +55,51 @@ export const INTERNAL_SDK_ARM_TYPES = new Set([
56
55
  // 超集投影(`_sema_modelGate` / `_sema_autoMode`)。整份 manifest 的其余段仍不在本切片里 ——
57
56
  // 射程写在 `case 'wiring_manifest'` 头注,别读成「manifest 接上了」。
58
57
  'wiring_manifest',
58
+ // B-078 / L-208(0.65.0;design/172 流内审批协议):呈卡 / 撕卡两帧的内部臂。
59
+ // 载荷是**信封原样**(不窄化到 v1,不摘键重铸)——理由见两个 `case` 的头注。
60
+ 'approval_request',
61
+ 'approval_revoke',
62
+ // B-072 ④(0.65.0):`message_committed` 会话条目落账帧 —— 从「本切片不投影」改为中性内部臂
63
+ // (chrome 记账,绝不铸 transcript 行)。理由见 `case 'message_committed'` 头注:它是
64
+ // `compacted.preserved_segment` 的**唯一**解析腿。
65
+ 'message_committed',
59
66
  ]);
60
- /** Wrap neutral content blocks in the CC `assistant` message envelope. */
61
- function assistantArm(ctx, content) {
67
+ /**
68
+ * Wrap neutral content blocks in the CC `assistant` message envelope.
69
+ *
70
+ * ── L-215③(0.65.0):`message` 上补两个**身份**键 ─────────────────────────────────────────
71
+ * 此前交给渲染端的是**裸** `{role, content}` —— CC 的 `SDKAssistantMessage.message` 上那四个键
72
+ * (`id` / `model` / `usage` / `stop_reason`)一个都没有,于是宿主十几处消费面只能各自**估算**。
73
+ * 本批补**两个**,另外两个**刻意不补**,理由逐条如下(不是遗漏,别下一棒又来补一遍):
74
+ *
75
+ * · **`id` ⇐ wire 的 `eventId`**(core 铸的 uuidv7,§E2)。取它而不是现铸,是因为它**确定性**:
76
+ * 同一条消息在 live 与 durable replay 两条腿上必须同 id,否则宿主的去重与 R8 rewind 会把一次
77
+ * 重放当成一条新消息。wire 没给 ⇒ **键不铸**(诚实缺席),绝不塞一个随机 v4 冒充身份。
78
+ * ⚠️ 与信封自己的 `uuid` 是**两个**身份位:后者是本地渲染键(`stamp` 现铸),前者是引擎身份。
79
+ * · **`model` ⇐ `ctx.model`**(宿主开流时钉的那一个,契约见 {@link EmitContext.model})。
80
+ * 缺席 ⇒ 键不铸,**绝不猜**。
81
+ * · 🔴 **`usage` 不铸,理由是帧序**:wire 上 `text`/`text_end` 这些内容臂**先**到、
82
+ * `turn_end.usage` **后**到 —— 臂发出的那一刻,引擎还没说这一轮花了多少。在这里铸一个数只能是
83
+ * **估算**,而估算正是本件要根治的东西(cli 1.0.107 的壳侧回落补丁就是它的症状)。
84
+ * 真值走 `turn_usage` 臂的 `_sema_last_assistant_usage` 超集键(见 runStream 的 turn_end 折叠点)。
85
+ * ⚠️ 也**不**采「turn_end 到达时补发一条 assistant 更新」的形:CC 的消息面没有这种形,
86
+ * 合成一条会让宿主的转录出现一条引擎从没发过的消息。
87
+ * · 🔴 **`stop_reason` 同理不铸**:`turn_end.stopReason` 与 usage 同帧,同一条帧序结论;
88
+ * 它走 `turn_usage` 臂的 `_sema_stop_reason`。
89
+ */
90
+ function assistantArm(ctx, content, eventId) {
91
+ const id = typeof eventId === 'string' && eventId.length > 0 ? eventId : undefined;
92
+ const model = typeof ctx.model === 'string' && ctx.model.length > 0 ? ctx.model : undefined;
62
93
  return stamp(ctx, armBody({
63
94
  type: 'assistant',
64
95
  // `message` is coreSchemas APIAssistantMessagePlaceholder = z.unknown(): we
65
96
  // hand the renderer a neutral { role, content } it can read, not the raw wire.
66
- message: { role: 'assistant', content },
97
+ message: {
98
+ role: 'assistant',
99
+ content,
100
+ ...(id !== undefined ? { id } : {}),
101
+ ...(model !== undefined ? { model } : {}),
102
+ },
67
103
  parent_tool_use_id: null,
68
104
  }));
69
105
  }
@@ -132,6 +168,43 @@ export function eventToSdkMessage(ev, ctx) {
132
168
  // 比本包类型新一版,类型收窄不构成「字段一定合法」的保证。
133
169
  case 'human_input':
134
170
  return humanInputProjection(ev, ctx);
171
+ /**
172
+ * B-072 ④(0.65.0;core message-identity Phase 1 / server ≥7.69.0 live 腿真发)——
173
+ * 一条**可渲染消息**的会话树条目已落账。此前它落在 `not_in_slice`,理由写着「本切片无对位
174
+ * 渲染物」;那句话对**转录行**成立,对**定位**不成立:
175
+ * · core 的 `compacted.preserved_segment.firstKeptEntryId` 是一个 **entryId**,
176
+ * · core 同一段逐字说消费方**用本帧**自建 `entryId → message` 映射来解析它,
177
+ * ⇒ 只投 `preserved_segment` 而把本帧丢掉,等于把一个**解析不了的锚**交给端。
178
+ *
179
+ * 处置与同族 `human_input` 逐条相同:**中性内部臂**(不是 transcript 行 —— 本帧不带正文,
180
+ * 铸一行就是替引擎编一条它没说过的话),由 `adapt` 的臂转成 chrome 记账事件。
181
+ *
182
+ * 🔴 **两个必填位任一读不出 ⇒ `malformed` 留痕**,绝不铸半个账本行:一个空 `entryId` 落进端的
183
+ * 映射表会静默匹配掉一条真消息(比没有这一帧更坏)。`role` 按**开集**读(core 今天三词,
184
+ * 上游加第四词照过);`toolCallId` 只在 `role==='toolResult'` 腿上有,缺席不铸。
185
+ * ⚠️ **投递腿只有 live**(server 7.69.0 亲读):durable/bg 腿把本帧喂给了 rewind 锚而**不**入
186
+ * 账本 ⇒ 端在 replay/resume 上看不到它。这是上游的投递事实,不是本包的剥离 —— 端据此
187
+ * 建的映射表在 resume 腿上会是空的,`_sema_preserved_segment` 那一格因此要按「解析不出
188
+ * 就不画分割线」处理,不许回退成「画在最后一条」。
189
+ */
190
+ case 'message_committed': {
191
+ const entryId = typeof ev.entryId === 'string' && ev.entryId.length > 0 ? ev.entryId : undefined;
192
+ const role = typeof ev.role === 'string' && ev.role.length > 0 ? ev.role : undefined;
193
+ if (entryId === undefined || role === undefined)
194
+ return dropped('malformed', 'message_committed');
195
+ const toolCallId = typeof ev.toolCallId === 'string' && ev.toolCallId.length > 0 ? ev.toolCallId : undefined;
196
+ return projected(stamp(ctx, armBody({
197
+ type: 'message_committed',
198
+ entryId,
199
+ role,
200
+ ...(toolCallId !== undefined ? { toolCallId } : {}),
201
+ // §E2 身份:durable 重放同一条账本帧必须落成同一笔(同 human_input 的 eventId 纪律)。
202
+ ...(typeof ev.eventId === 'string' && ev.eventId.length > 0 ? { eventId: ev.eventId } : {}),
203
+ ...(typeof ev.parentToolCallId === 'string' && ev.parentToolCallId.length > 0
204
+ ? { parentToolCallId: ev.parentToolCallId }
205
+ : {}),
206
+ })));
207
+ }
135
208
  // service 1.75 — `workflow_complete`: an out-of-band background-workflow completion push, ridden onto
136
209
  // the session's next stream open (BEFORE the run's own events). Projects to a NEUTRAL internal arm;
137
210
  // the upstream bridge turns it into CC's `<task-notification>` injection.
@@ -164,10 +237,10 @@ export function eventToSdkMessage(ev, ctx) {
164
237
  }
165
238
  // CS-1 §2.2 — turn-aggregated answer text (durable). Drop raw block array.
166
239
  case 'text':
167
- return projected(assistantArm(ctx, [{ type: 'text', text: ev.text }]));
240
+ return projected(assistantArm(ctx, [{ type: 'text', text: ev.text }], ev.eventId));
168
241
  // CS-3 §2.1 — turn-aggregated thinking (durable). Distinct collapsible block.
169
242
  case 'reasoning':
170
- return projected(assistantArm(ctx, [{ type: 'thinking', thinking: ev.text }]));
243
+ return projected(assistantArm(ctx, [{ type: 'thinking', thinking: ev.text }], ev.eventId));
171
244
  // CS-2 §2.4 — per-token answer fragment (live only). Typewriter append.
172
245
  case 'text_delta':
173
246
  return projected(streamEventArm(ctx, { type: 'text_delta', delta: ev.delta }));
@@ -198,19 +271,56 @@ export function eventToSdkMessage(ev, ctx) {
198
271
  ? { parentToolCallId: ev.parentToolCallId }
199
272
  : {}),
200
273
  },
201
- ]));
202
- // CS-8 §2.8 — compaction divider. tokensBefore ⇐ pre_tokens. trigger rides through when the
203
- // engine sends it (core 1.156 manual verb → "manual"; absent ⇒ 'auto'). core 1.219
274
+ ], ev.eventId));
275
+ // CS-8 §2.8 — compaction divider. tokensBefore ⇐ pre_tokens. core 1.219
204
276
  // (attachWorkingFiles default ON): `attachedFiles:[{path,chars,truncated}]` rides through raw so
205
277
  // the bridge can project the CC post-compact "Read {path}" attachment rows (blackboard ask ②).
278
+ //
279
+ // 🔴 **B-072 ①(0.65.0):`trigger` 由三词折叠改为开集透传**。修前这一格逐字是
280
+ // `ev.trigger === 'manual' ? 'manual' : 'auto'` —— 一张**闭集折叠表**,于是 core 5.16.0 起
281
+ // 真发的第三个值 `forced`(prompt-too-long 恢复腿 / trim-pressure 腿:压缩**不是可选的**)
282
+ // 到了包边界被改写成 `auto`。core 的臂注逐字点名这件事:wire 现在带的是**钩子层看见的
283
+ // 那个值**(`the SAME value the hooks saw instead of flattening it to "auto"`)——
284
+ // 上游刚刚停止做的折叠,包在下游又做了一遍,这正是 B-025 家族的形。
285
+ // ⚠️ **不是破坏性变更**:core 同一句写明 `Additive value space — a consumer switching on
286
+ // auto/manual keeps its exact old behavior`,CC 消费者按 `!== 'manual'` 分支的行为逐字不变。
287
+ // 缺席 / 空串 / 非串仍回落 `'auto'`(sdk 臂注逐字「缺席或未知值一律按 auto 渲染」)——
288
+ // 回落的是**读不出来**那一档,不是「读出来了但不认识」那一档。
289
+ // 🔴 **两个超集键**(`_sema_` 前缀,CC 同名键语义零改;缺席一律不铸):
290
+ // · `_sema_preserved_segment` ⇐ core `preserved_segment`(CC `SDKCompactBoundary` parity 位)
291
+ // —— 保留尾段的起点 entryId,端拿它把压缩分割线画在**那条消息之前**并重新接上尾段。
292
+ // 解析腿 = 本文件同批新投的 `message_committed` 臂(core 亲口:消费方用它自建
293
+ // `entryId → message` 映射)。半个锚不铸:载体在而 id 读不出 ⇒ 整键不出现。
294
+ // · `_sema_clamped_ratio` ⇐ core `clampedRatio`(design/145 §3)—— 这次压缩的摘要**输入**
295
+ // 被钳掉了多大比例。core 逐字 `bounded loss, disclosed either way` ⇒ 它是**披露**位,
296
+ // 真 0(「一点没钳」)是一条事实,**不是**缺席;缺席(旧引擎 / 没发生钳)则键不铸。
297
+ // 记账:server 7.69.0 `trace/project.js` 白名单真发这一键,而 sdk 8.8.0 的 `compacted`
298
+ // 臂上**还没有座位** ⇒ 这里是防御 raw 读(同 `label` / `model` 的既有姿势)。退役条件 =
299
+ // sdk 补上该位的当天,`run-compaction-boundary-projection-test.mjs` F10 行当场红逼复核。
206
300
  case 'compacted': {
207
- const attachedFiles = ev.attachedFiles;
301
+ const raw = ev;
302
+ const attachedFiles = raw.attachedFiles;
303
+ const seg = raw.preserved_segment;
304
+ const firstKeptEntryId = typeof seg === 'object' && seg !== null && !Array.isArray(seg)
305
+ ? seg.firstKeptEntryId
306
+ : undefined;
307
+ const clampedRatio = raw.clampedRatio;
208
308
  return projected(stamp(ctx, armBody({
209
309
  type: 'system',
210
310
  subtype: 'compact_boundary',
211
311
  compact_metadata: {
212
- trigger: (ev.trigger === 'manual' ? 'manual' : 'auto'),
312
+ // 开集透传:读得出的词原样过境;读不出(缺席/空串/非串)才回落 CC 'auto'
313
+ // 🔴 **不写 `as 'auto' | 'manual'`**(修前那条 cast 现在会变成谎言):这一格的运行期值
314
+ // 域已经是开集,断言成两个字面量 = 型面对下一棒撒谎([cast-lies-need-a-gate-not-a-rule])。
315
+ // 留 `string` 即可 —— 本臂体最终仍走 `as unknown as SDKMessage`,没有任何约束要求它窄。
316
+ trigger: typeof ev.trigger === 'string' && ev.trigger.length > 0 ? ev.trigger : 'auto',
213
317
  pre_tokens: ev.tokensBefore ?? 0,
318
+ ...(typeof firstKeptEntryId === 'string' && firstKeptEntryId.length > 0
319
+ ? { _sema_preserved_segment: { firstKeptEntryId } }
320
+ : {}),
321
+ ...(typeof clampedRatio === 'number' && Number.isFinite(clampedRatio)
322
+ ? { _sema_clamped_ratio: clampedRatio }
323
+ : {}),
214
324
  },
215
325
  ...(Array.isArray(attachedFiles) && attachedFiles.length > 0 ? { attachedFiles } : {}),
216
326
  })));
@@ -459,11 +569,23 @@ export function eventToSdkMessage(ev, ctx) {
459
569
  // 🔴 条件 spread 与 server 投影**同形同条件**:`string` 且非空才带。空串既不是模型 id 也不是
460
570
  // 「不知道」,透下去只会在面板上渲成一个空模型名([honest-absence-not-fabricated-zero])。
461
571
  const model = ev.model;
572
+ // 🔴 B-074(0.65.0)—— `status`:这一格是**行的终态信号**,而修前这张白名单里没有它,于是
573
+ // core 明写会发的那一拍(`ONE final tick fires at the sub-run's settle with the terminal
574
+ // face`)到了宿主手上与一次普通 running 拍**逐字节同形** ⇒ 面板行只能等防御 sweep 或一条
575
+ // 通知帧来收尸(core 那句话的下半段正是「so a uuid-lane consumer sees the row finish
576
+ // instead of a bare removal after the last running beat」)。与 `model` / `workflowRunId`
577
+ // 是**同一病形的第 N 例**:事实已经在 wire 上,而包边界这一格空转。
578
+ // server `taskProgressEventData` 白名单逐字 `...(ev.status !== undefined ? { status: ev.status } : {})`。
579
+ // **开集读**:core 今天三词、sdk 已标 `| (string & {})`;词表属主在 core,包只透传不裁决
580
+ // (谁是终态由 adapt 层按 core 明写的两个词判,见 `arms.ts` 的 `TERMINAL_TICK_STATUSES`)。
581
+ // 空串/非串不铸 —— 与本臂其余条件 spread 同一判据(空串既不是词也不是「不知道」)。
582
+ const status = ev.status;
462
583
  return projected(stamp(ctx, armBody({
463
584
  type: 'task_progress',
464
585
  taskId: ev.taskId,
465
586
  ...(ev.name !== undefined ? { name: ev.name } : {}),
466
587
  ...(ev.usage !== undefined ? { usage: ev.usage } : {}),
588
+ ...(typeof status === 'string' && status.length > 0 ? { status } : {}),
467
589
  ...(typeof model === 'string' && model.length > 0 ? { model } : {}),
468
590
  ...(typeof currentAction === 'string' && currentAction.length > 0
469
591
  ? { currentAction }
@@ -660,15 +782,16 @@ export function eventToSdkMessage(ev, ctx) {
660
782
  // `meta`:流首帧的 taskId/sessionId 一等信号 —— 身份面由 SDK/传输层自己消费(runStream 只按
661
783
  // `eventSeq` 去重),没有可渲染内容。
662
784
  case 'meta':
663
- // `file_link` / `prompt_assembled` / `model_usage` / `context_usage` / `config_assembled` /
664
- // `message_committed`:引擎的可观测/审计面帧,本切片(CC transcript 投影)无对位渲染物;
785
+ // `file_link` / `prompt_assembled` / `model_usage` / `context_usage` / `config_assembled`:
786
+ // 引擎的可观测/审计面帧,本切片(CC transcript 投影)无对位渲染物;
665
787
  // 消费它们的是 fleet/审计面而不是会话转录。
788
+ // ⚠️ `message_committed` **已在 0.65.0 搬出本档**(B-072 ④):它不是「没有对位物」——
789
+ // 它是 `compacted.preserved_segment` 的唯一解析腿。新臂见本文件 `case 'message_committed'`。
666
790
  case 'file_link':
667
791
  case 'prompt_assembled':
668
792
  case 'model_usage':
669
793
  case 'context_usage':
670
794
  case 'config_assembled':
671
- case 'message_committed':
672
795
  // `needs_review`:plan-review park 的**事件**形;壳消费的是 done{status:'needs_review'} 终帧
673
796
  // (runStream 的 plan_review_park chrome 臂),事件形在本切片重复且更早,故不投影。
674
797
  case 'needs_review':
@@ -690,48 +813,39 @@ export function eventToSdkMessage(ev, ctx) {
690
813
  /**
691
814
  * `approval_request`(design/172 流内审批协议,server ≥7.3.0、7.5.0 起默认开)。
692
815
  *
693
- * 🔴 与上面两条**不同源**,所以单列(2026-08-08 对抗复审第 4 轮命中:首版把三条并成一句
694
- * 「都由 frameRouter 消费」—— 而 `isToolApprovalFrame` 只认 tool_approval/tool_approval_complete,
695
- * 这条帧在**包内今天零消费口**,那句话是假的;pure 058⑬ 段把这个事实钉住了)。
696
- *
697
- * 🔴 分类 = `dropped/unsupported_arm` 而不是 `none/not_in_slice`(第 5 轮命中):`none` 是**静默**的,
698
- * 只有 `dropped` 会经 `reportDroppedFrame` 走宿主 sink + console 留痕。本帧带着用户可见的决策内容
699
- * (风险两轴 irreversible/egress/requiresRealApproval、窗口倒计时、`askId` 的持久 CAS 决策语义),
700
- * 却没人接 —— 把这种缺口做成静默 none 就是本仓 P0 共性病那个形。宁可每次都吼一行。
701
- * (本批之前它落 `default:` 的 `dropped/unknown_arm`,也是有痕的;#185a 把它加进 union 之后必须进
702
- * switch 才编译得过,那一步不该顺手把痕迹弄丢。)
703
- *
704
- * 📋 缺口如实记账(**不**在本批修 —— 那是一条新卡链,是功能不是修复):
705
- * · 存量路径今天仍能决断:SDK 头注(tool-approvals.d.ts)载明开关打开时同一只 ask 出两帧、顺序
706
- * 钉死「先 legacy `tool_approval`、后 `approval_request`」,两帧同带 `approvalId`,legacy 腿在。
707
- * · 但**降级路径不保证**:legacy respond 是 live-only + same-replica(错副本 404),而重连时
708
- * pending 卡是靠 `approval_request` preamble 对账的 —— 「收到 legacy 帧后断线、按 Last-Event-ID
709
- * 重连只再看到 approval_request」以及非粘性多副本部署这两条路上,今天只能等窗口到期→park/deny。
710
- * · 接法(留给专门的一车):`isApprovalRequestFrameV1` 校形 → 按 approvalId/askId 去重并处理重连
711
- * preamble → 带幂等键的 `decideAsk` 持久 CAS → 仅在能力位关闭/新帧不可用时回落 legacy respond。
712
- * 在那条链落地之前,**不许**由本切片顺手编一个假 transcript 形来假装接上了。
816
+ * 🔴 **0.65.0(B-078 / L-208)起真接上了**:本臂此前是 `dropped('unsupported_arm')` —— 有痕,
817
+ * 但**包内零消费口**,于是壳(cli)在自己的 `approvalStreamWire.ts` 里单独接了一份,而
818
+ * desktop / web 只走包管线 ⇒ 它们开流 preamble 恢复不了 pending 卡集。同一条 wire 上的同一
819
+ * 件事,一个端有、两个端没有,正是归层要根治的形。现改投 chrome 臂
820
+ * ({@link import('../../seam.js').ApprovalRequestChromeEvent}),消费义务写在那只接口的头注里。
821
+ * 🔴 **与 `tool_approval` / `tool_approval_complete` 仍不同源**(上面那两条走
822
+ * `isToolApprovalFrame` + `hitl/frameRouter` 的审批卡链,本臂走 chrome 平面):开关打开时同一
823
+ * ask 出两帧、顺序钉死「先 legacy、后本帧」、两帧同带 `approvalId` **去重是宿主的动作**
824
+ * (它才有卡集状态),库不在这一层替它做。
825
+ * 🔴 载荷**不窄化到 v1**(sdk events.d.ts:961-968 的理由逐字搬在 `approvalFrameProjection` 头注)
713
826
  */
714
- case 'approval_request':
715
- return dropped('unsupported_arm', 'approval_request');
827
+ case 'approval_request': {
828
+ const body = approvalFrameBody(ev);
829
+ if (body === undefined)
830
+ return dropped('malformed', 'approval_request');
831
+ return projected(stamp(ctx, armBody({ type: 'approval_request', ...body })));
832
+ }
716
833
  /**
717
- * `approval_revoke`(design/172 撤卡帧;server ≥7.55.0 起也进重放,SDK 8.x 才把它写进
718
- * `AgentEvent` union —— 0.58.0 sdk 8.2.0 提货批**编译期逼进来**的臂,与 `engine_notice`
719
- * / `human_input` 走的是同一条路:臂一进 union,`default` 的 B5 穷举断言当场红)。
834
+ * `approval_revoke`(design/172 撤卡帧;[5924]/S-58,server ≥7.55.0 起也进 durable 账本重放)。
720
835
  *
721
- * 🔴 **与 `approval_request` 同源同处置,所以并**在它旁边而不是并进上面那两条 —— 它是
722
- * 同一条流内审批协议的**撤销**半场(载荷同是 `ApprovalFrameEnvelope`,`schemaVersion`
723
- * 未知的帧要按信封先读再窄化)。本包今天**零消费口**:`isToolApprovalFrame` 只认
724
- * legacy `tool_approval` / `tool_approval_complete` 两词,新族两帧一个都不接。
725
- * 🔴 分类 = `dropped/unsupported_arm` 而**不是** `nothing/not_in_slice`(与
726
- * `approval_request` 同一条理由,逐字见上一臂):`none` 是静默的,只有 `dropped` 会经
727
- * `reportDroppedFrame` 走宿主 sink + console 留痕。撤卡帧带的是**用户可见的状态迁移**
728
- * (那张卡已经不该再被决断了),悄悄丢掉它比吼一行更坏。
729
- * 📋 缺口如实记账(**不**在本批修 —— 与 `approval_request` 是同一条新卡链,属功能不属修复):
730
- * 接法在上一臂的「接法」段里写着,撤卡半场按 `approvalId` 撤同一张卡;在那条链落地之前
731
- * **不许**由本切片顺手编一个假 transcript 形来假装接上了。
836
+ * 🔴 **与 `approval_request` 同源同处置**(载荷同是 `ApprovalFrameEnvelope` 信封),所以并在它
837
+ * 旁边 —— 它是同一条流内审批协议的**撤销**半场。0.65.0(B-078 / L-208)同批接上:此前
838
+ * `dropped('unsupported_arm')` ⇒ 只走包管线的宿主在撤销之后**本地卡不清**,用户对着一张
839
+ * 已经决断不了的卡按 Yes。现改投 chrome
840
+ * ({@link import('../../seam.js').ApprovalRevokeChromeEvent}),三条消费纪律(按 `taskId` 归属 /
841
+ * 账本重放只渲时间线 / DECIDED 的兄弟不在 `askIds` 里)写在那只接口的头注里。
732
842
  */
733
- case 'approval_revoke':
734
- return dropped('unsupported_arm', 'approval_revoke');
843
+ case 'approval_revoke': {
844
+ const body = approvalFrameBody(ev);
845
+ if (body === undefined)
846
+ return dropped('malformed', 'approval_revoke');
847
+ return projected(stamp(ctx, armBody({ type: 'approval_revoke', ...body })));
848
+ }
735
849
  // ── `engine_notice`(#310 / #318 件①,server ≥7.36,契约 = ASSISTANT-WIRE-CONTRACT 附录 D)────
736
850
  // 🔴 **到期复核已兑现(sdk 7.4.0 提货,0.48.0)** —— 与 `human_input`(sdk 6.9.0)逐字同一条路。
737
851
  // 本臂此前是 switch **之前**的一条 raw 预分派,理由 = 它还没进已发布 SDK 的 `AgentEvent`
@@ -908,14 +1022,9 @@ function projectModelGateSection(raw) {
908
1022
  * 理由不是「重复上游」而是**非投影口**(宿主自建管线 / 重放存量转录)喂进来的帧不过 server ——
909
1023
  * 一个 `{armed:true, reason:'settings_denied'}` 会让消费端同时读到「武装了」和「被拒了」。
910
1024
  *
911
- * 🆕 **`breaker`(0.64.0;core 7.10.0 `AutoModeBreakerTrip`,server ≥7.69.0 投)**:
912
- * 窄读**整只委托** {@link classifierBreakerOf} —— 会话轴那一处只有一只读器(见
913
- * `src/classifierStatus.ts` 顶注)。三条纪律:
914
- * · **缺席 = 老引擎**,不是「没熔断过」;不铸 `null`、不铸空对象;
915
- * · **坏形只丢自己**:熔断记录读不出来时,`armed`/`reason` 那两格仍是真读数,照旧过帧
916
- * (丢整段 = 把「这条腿的 auto 姿态」一起判没,而它与熔断记录是两件事);
917
- * · 🔴 **绝不**给它与 `armed`/`reason` 补互证:合闩后的腿正是 `{armed:false, reason:"no_intent"}`,
918
- * 补一条互证会把最常见的那一种真读数整段判没。
1025
+ * 🔴 **`breaker` 一位 0.65.0 退役**(core 7.12.0 `WiringManifest.autoMode.breaker`):本臂不再
1026
+ * 读它、不再投它。老引擎的帧上若仍带这个键,它落在本函数的**未读键**里被丢弃 —— 与本层对
1027
+ * 一切未声明键的处置逐字相同(投影是白名单,不是透传)
919
1028
  */
920
1029
  function projectAutoModeSection(raw) {
921
1030
  if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
@@ -927,8 +1036,7 @@ function projectAutoModeSection(raw) {
927
1036
  return undefined;
928
1037
  if (armed !== (reason === 'armed'))
929
1038
  return undefined;
930
- const breaker = classifierBreakerOf(a);
931
- return { armed, reason, ...(breaker !== undefined ? { breaker } : {}) };
1039
+ return { armed, reason };
932
1040
  }
933
1041
  /**
934
1042
  * `wiring_manifest` 帧 → 两个超集键的**纯投影**(公面导出;三端共用,壳侧绝不自抄一份形校验)。
@@ -993,6 +1101,40 @@ function engineNoticeProjection(ev, ctx) {
993
1101
  ...(typeof ev.id === 'string' && ev.id.length > 0 ? { eventSeq: ev.id } : {}),
994
1102
  })));
995
1103
  }
1104
+ /**
1105
+ * `approval_request` / `approval_revoke` 的**信封级成形判**(B-078 / L-208,0.65.0;design/172)——
1106
+ * 两条 `case` 共用这一只(判据单源),而 `type` 字面量留在各自的 `case` 里:
1107
+ * 内部臂词表的常驻对账门(pure 门 A-004.4)按 `armBody({ type: '<字面量>'` 抽取铸点 ——
1108
+ * 把字面量藏进参数会让那道门对这两条臂整个失明。
1109
+ *
1110
+ * ── 🔴 载荷是**信封原样**,本层一个键都不摘 ─────────────────────────────────────────────────
1111
+ * sdk 把这两条臂的载荷声明成 `ApprovalFrameEnvelope`(`type` + `schemaVersion: number` + 开集键),
1112
+ * **刻意不是** v1 帧型 —— 理由 sdk events.d.ts:961-968 逐字:SSE 解析腿只做
1113
+ * `JSON.parse(...) as AgentEvent`、**不跑**谓词,臂若窄化到 v1,一条 `schemaVersion: 2` 的合法帧会
1114
+ * 被类型系统当成 v1 端上来,消费端于是去取 `card.risk.requiresRealApproval` —— 而未知版本的卡
1115
+ * 根本不保证有这些键。
1116
+ * ⇒ 本层只做**信封级**成形判(是不是一只对象 / `schemaVersion` 是不是有限数),把**整只帧**交下去;
1117
+ * 窄化(`isApprovalRequestFrameV1` / `isApprovalRevokeFrameV1`)是**宿主**的动作,契约写在
1118
+ * `seam.ts` 的两个臂头注里。在这里挑键重铸 = 在包里立第二份卡形台账,而那正是上游这条设计要防的。
1119
+ *
1120
+ * ── 成形判不过 ⇒ 调用方报 `dropped('malformed')`,**不是** `none` ─────────────────────────────
1121
+ * 与 `engine_notice` 同规:静默的 `none` 会让「协议帧坏了」这件事在用户面上什么痕迹都不留,而这
1122
+ * 两帧带的是**用户可见的决策内容/状态迁移**。`dropped` 经 `reportDroppedFrame` 走宿主 sink + 留痕。
1123
+ *
1124
+ * 🔴 **不在本层做去重/归属过滤**:`approvalId` 去重、按 `taskId` 归属、开流 preamble 对账,三件都
1125
+ * 需要宿主的**卡集状态**(库这一层没有,也不该有)—— 契约逐条写在 `seam.ts` 的臂注里。
1126
+ */
1127
+ function approvalFrameBody(ev) {
1128
+ // 🔴 判据锚在**真正决定宿主能不能窄化**的那一个量:`schemaVersion`。它不是有限数 ⇒ 这一帧连
1129
+ // 信封都不成立(宿主的两条腿——v1 窄化与通用卡兜底——都需要它来分流),如实报 malformed。
1130
+ const schemaVersion = typeof ev.schemaVersion === 'number' && Number.isFinite(ev.schemaVersion)
1131
+ ? ev.schemaVersion
1132
+ : undefined;
1133
+ if (schemaVersion === undefined)
1134
+ return undefined;
1135
+ // 🔴 帧**原样**(含 `type` 回声与全部开集键)。宿主拿它去跑 sdk 的窄化谓词。
1136
+ return { frame: ev, schemaVersion };
1137
+ }
996
1138
  /**
997
1139
  * `human_input` → 中性内部账本臂(见 `eventToSdkMessage` 顶部的 raw 预分派注释)。
998
1140
  *