@sema-agent/client-core 0.57.0 → 0.58.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,180 @@
49
49
  > 挡住 ⇒ 本批把它机械化——④a0 对 `pending` 行**要求段头已是日期形**(`(未发布)` 直接红),阶段一
50
50
  > commit 漏转在发布前就红,不再靠人记。
51
51
 
52
+ ## 0.58.0(2026-09-06)
53
+
54
+ > 内容批(2026-09-06,隔离树交付):三件 —— ①`@sema-agent/sdk` **8.2.0 提货**(peer 地板抬版,
55
+ > 本版**唯一**的非 additive 面)、②**B-025 修根**(「不再询问」候选窄读器不再手抄上游闭词表)、
56
+ > ③**L-102 下半场**(resume 拒绝**文案铸点**上收,壳侧改薄成适配层)。**本段只记内容**:
57
+ > `package.json.version` 未动、README Version 行未动、`FROZEN` 未动 —— bump 与段头转日期形归
58
+ > **发包批**(阶段一义务见本档头注)。
59
+ > 接入面详报见 `docs/INTEGRATION-CLIENTS.md` **§22**(三件各一小节 + 端的迁移动作 + 门表)。
60
+
61
+ **🔴 唯一的非 additive 面 = peer 地板抬到 `@sema-agent/sdk >=8.2.0`**(上一次是 0.48.0 的 `>=7.4.0`)。
62
+ 四条硬理由(逐条也写在 `scripts/run-sdk-floor-test.mjs` 的 `FLOOR` 注里,不是顺手跟版本):
63
+
64
+ 1. **三张 `as const` 闭词表是运行期判据** ⇒ 值级 import:`RULE_OFFER_MATCHES` /
65
+ `RULE_OFFER_BATCH_MEMBER_KINDS` / `RULE_OFFER_UNCOVERED_REASONS`(sdk 8.2.0 S-134 锚②)。
66
+ `hitl/toolApprovalWire.ts` 的窄读器直接读它们,<8.2.0 上这三个名字不存在,**整包编译不过**。
67
+ 这一条单独就足以钉死地板。
68
+ 2. `RuleOffer` / `RuleOfferMatch` / `RuleOfferBatchMember` / `RuleOfferUncoveredDetail` /
69
+ `RuleOfferUncoveredReason` **五个类型面**(同锚)—— 本包归一形现由它们派生。
70
+ 3. `ToolApprovalRespondAck` 上的 `persistedRule` / `persistedRules` / `persistedRuleAnchors` 三位 +
71
+ 导出的 `PersistedRuleAnchor`(S-134 锚③)—— 0.43.0 起的包内 additive 超集
72
+ `ToolApprovalRespondAckView` 按它**自己写下的退役条款**回收成 sdk 形的别名。
73
+ 4. `AgentEvent` 新增 `approval_revoke` 臂 —— 臂一进 union,`eventToSdkMessage` 的 B5 编译期穷举断言
74
+ `assertNeverArm` **当场红**(本批实翻:装上 8.2.0 的当拍 `tsc` 就是这一条错)。
75
+
76
+ 🔴 **同批亲验(别只信版本号)**:实装 8.2.0 的 `dist/resources/tool-approvals.js` 直读 ——
77
+ `RULE_OFFER_MATCHES` = `["exact","prefix","wildcard","subpath"]`(**四员**;⚠️ 不是 B-025 票面写的
78
+ "wildcard/glob")、`RULE_OFFER_BATCH_MEMBER_KINDS` = `["command","directoryRead"]`、
79
+ `RULE_OFFER_UNCOVERED_REASONS` = `["redirection","no_rule_form","cap_overflow"]`、
80
+ `TOOL_APPROVAL_FRAME_KEYS` **18 项且含 `ruleOffers`**。
81
+
82
+ ---
83
+
84
+ ### 件① sdk 8.2.0 提货(锚追平 / 两笔到期记账退役 / 一条新臂进 switch)
85
+
86
+ - **`AHEAD_OF_ANCHOR` 的 `ruleOffers` 领先登记退役**(`run-approval-frame-keys-test.mjs`)——
87
+ 这是**登记自己的退出条件到期**,不是清理:sdk 8.2.0 的运行期锚已含该键 ⇒ 门先**自红一次**
88
+ (红词逐字「领先登记 ruleOffers:SDK 锚**仍无**此键 —— 锚补上了就该删这条登记」)再删,与
89
+ `persistedRuleShadowed`(#229)/ `probeCause`(#280 件1)两次走同一条路。
90
+ ⚠️ **同批不顺手退别的**:`requiresRealApproval` / 窗三键 / `inputHasBidi` / `parked` 在 8.2.0 的锚上
91
+ **仍然没有**(同一次 node 直读)—— 退出条件是**逐条**的,不是整表的,表回空才是健康态。
92
+ - **`ToolApprovalRespondAckView` 与 `PersistedRuleAnchor` 回收成 sdk 形的别名**(名字保留 ⇒ 端源码
93
+ 兼容,读法逐字不变)。判形与窄读**一个字节没改**:`readToolApprovalRespondAck` 的每一条纪律照旧
94
+ (三位规范文本回显整只判形 / 单复数同场即矛盾 / 回显在场而 `rulePersisted !== true` 即矛盾 /
95
+ 锚与清单逐位置同文本)—— 上游补的是**类型**,不是判官。
96
+ - **`approval_revoke` 臂进 switch**,处置 = `dropped/unsupported_arm`(**有痕**,经
97
+ `reportDroppedFrame` 走宿主 sink),与兄弟 `approval_request` 同姿势、同理由:本包今天对新族两帧
98
+ **零消费口**(`isToolApprovalFrame` 只认 legacy 两词),而撤卡帧带的是用户可见的状态迁移,
99
+ 悄悄丢掉比吼一行更坏。缺口如实记账,接那条新卡链是另一批。
100
+ - **§7d P-44 转「已收」**:sdk 8.1.0 那一族型面缺口在 8.2.0 全部补齐。🔴 本包侧**本来就零 cast**
101
+ (P-44 原文已写清:本包不铸 respond 的 wire 体,吃缺口的是三端**注入面**)⇒ 剩余动作在三端 ——
102
+ 注入面那处宽 `cast` 可以删掉,改用 sdk 8.2.0 的 `PersistRuleSelection`(两臂互斥 `?: never` 钉在编译期)。
103
+
104
+ ### 件② B-025 修根:窄读器不再手抄上游闭词表(病形 = 手抄的判据跟不上单源)
105
+
106
+ **病形**(0.43.0 铸、0.57.0 仍在;由 sdk [6464] 两侧源码对读点名,坐标
107
+ `src/hitl/toolApprovalWire.ts:1772` / `:209`,cli [6465] 认账立 B-025):
108
+
109
+ | 症状 | 0.57.0 实际行为 | 用户看到什么 |
110
+ |---|---|---|
111
+ | ① `match` 词表**手抄**成两员 `'exact' \| 'prefix'`,而 core design/382 / #510 已扩到四员 | 一条合法的 `match:"wildcard"`(或 `"subpath"`)single **整条丢** | 卡上那一格「不再询问」**凭空消失** |
112
+ | ② batch 成员**手抄**成「三元组 + segment」,没有 `directoryRead` 臂 | 一只带 `cd` 段目录只读授权的 batch **整只丢** | 同上;且与该函数自己注释承诺的「逐条丢坏、不整拒」相反 |
113
+ | ③ 没有 `uncoveredDetail` 座 | design/382 §3.5 的 additive 明细整段读不到 | 「为什么这段还会问」无从渲 |
114
+
115
+ **修**(不是补两个词,是**取消抄本**):三张闭词表单源化到 sdk 8.2.0,本包再导出同一份数组对象
116
+ (端拿它判定,不再手抄字面量);`RuleOfferMatch` / `RuleOfferBatchMember` /
117
+ `RuleOfferUncoveredDetail` / `RuleOfferUncoveredReason` 四形改由 sdk 派生。
118
+
119
+ - 🔴 **`match` 词集双向全等钉**(写进门):⊇ 表里**每个**词的 single 都必须被读器认下(手抄的窄表
120
+ 在新词上当场红,那正是症状①);⊆ 构造出来**保证在表外**的词必须被丢(读器放宽成「是串就收」
121
+ 时当场红)。退役键 `ruleSuggestions` 那条归一臂共用同一把三位窄化 ⇒ 新词表在那条腿上同样生效
122
+ (族扫:病形不许只修当格)。
123
+ - 🔴 **成员 `kind` 不识 ⇒ 丢整只 batch,绝不丢单个成员,也绝不丢整卡** —— 这是 core design/382 §2.3
124
+ 的**规范性降级臂**(逐字 "drops the WHOLE batch offer — never the single member ... and never the
125
+ whole card";sdk 8.2.0 顶注同文,上游发布帖 ④2 的黑盒判据亦同)。⚠️ **B-025 票面「成员级容错」
126
+ 那句与三处上游原文相反,本批按上游原文实现并把它钉成正向断言**(直接影响:`directoryRead` 因为
127
+ **被认回**而整只留下,而不是因为「容错」被拆散)——理由见 §20c 第 3 条。
128
+ - ⚠️ **另收一条 pre-B3 兼容臂**:`kind` **缺席**的成员按 `kind:'command'` 归一(server ≥7.46.0 到
129
+ design/382 §2.3 B3 落地之间铸的成员没有判别位;窄读域只许等于或宽于铸点域)。归一形上 `kind`
130
+ **恒在场**,端拿到的成员永远是判别联合,零分支差异。
131
+ - **additive `uncoveredDetail` 透传**:🔴 **缺席 ≠「没有未覆盖段」**(server 对坏形的座只丢座不丢批,
132
+ 老引擎压根不铸;真源恒是 `uncoveredSegments` 那个 count)⇒ 缺席时**不铸空数组**;坏行**只丢那一行**,
133
+ 坏载体只让**本位**降缺席,**绝不**因为一个 additive 位否决整只 batch;行数与 count **不强制相等**。
134
+
135
+ **异源对抗复审 r1 两条 [medium] 全采(真病,均在本批修掉)**:
136
+ - 🔴 **`uncoveredDetail` 行数帽 8 → 32(比铸点域更窄的窄读域,[4050] 禁的形)**:初稿按「与成员帽
137
+ 同源同量级」类比出一个 8,而那是**类比不是取证**、且判错了源 —— server 的执法帽是
138
+ `MAX_UNCOVERED_DETAIL_ROWS = 32`,且**未覆盖段数根本不受成员帽约束**(core 逐字
139
+ `uncoveredSegments: uncoveredDetail.length`,一只**只有 1 个成员**的 batch 可以带 9 条明细:
140
+ 9 个带重定向的段各自 mint 不出规则形,进不了 `rules[]` 却每段都要给一个「为什么还会问」)。
141
+ 后果 = 一只合法的 9~32 行明细座被整座静默丢掉,人失去「这几段为什么还会问」。取**等于**铸点域
142
+ 而不是再自行加倍(与 `MAX_RULE_OFFERS_TOLERATED` 不同:那一位没有可锚的上游常量,余量是纯容忍;
143
+ 本位有具名铸点常量,再加倍就是又一次凭感觉编数)。边界钉进门 ⑨d:9 / 32 必收、33 才丢。
144
+ - 🔴 **成员 `kind` 的判据本身还是手抄的**(同形残余,异源对抗复审的负控质疑逼出来的):初稿把
145
+ `'command'` / `'directoryRead'` 两个词**手写在分支条件里**,导入的 `RULE_OFFER_BATCH_MEMBER_KINDS`
146
+ 只被再导出、运行期一次都没读 —— 那正是 B-025 本身的病形(`match` 手抄两员)在成员这一维上的
147
+ 残余,只是当天恰好抄对了。修 = 成员性判到闭词表 + 臂分派兜底(**表内但本包还没有读器**的 kind
148
+ 与表外同处置:丢 ⇒ 整只 batch 丢,fail toward asking;而门 ⑨c 的 ⊇ 腿会当天红逼人补读器)。
149
+ 同批把该条负控从「缺字段的坏成员」换成「**完全合形、只有 kind 在表外**」——否则 `readRuleTriple`
150
+ 会替 kind 判据把它拒掉,负控量不到该量的东西。
151
+ ⚠️ 诚实记录:修完之后产品侧有**两道**判据一起挡它,**只删表检查**是一次**行为不变**的变异(门照绿
152
+ 是正确的);把两道**一起**削回初稿结构才是真回归形状,门当场红(收货亲跑实证:该变异下读器返回
153
+ `["single@0","batch@1"]`,坏批被放行)。
154
+
155
+ **⚠️ 端要跟的型面差分**(本件唯一会让端编译红的地方,而红是要的):`RuleOfferMatch` 从两员变四员 ⇒
156
+ 对它写穷尽 `switch` 的端会在 `wildcard` / `subpath` 两格红。那两格此前在端上是**静默不可达**的
157
+ (包在读器里就丢掉了),不是「新增的空格子」。`RuleOfferBatchMember` 变判别联合 ⇒ 渲成员前先读 `kind`。
158
+
159
+ **同形存量族扫**(病形 = 「本包手抄上游 `as const` 闭词表」):sdk 8.2.0 一共导出 5 张这样的表,
160
+ 另两张(`CONTROL_ROLES` / `NOT_FOUND_TASK_OWNER_CODES`)在本包 `src/**` **零命中**(亲跑 grep,
161
+ 含各自的成员字面量)⇒ 本族**只有规则报价这三张**,已全部单源化,无残余。
162
+
163
+ ### 件③ L-102 下半场:resume 拒绝**文案铸点**上收(新公面 `src/resumeRefusalCopy.ts`)
164
+
165
+ 0.57.0 上收的是**事实读数**(§21 `resumeRetryLaterFromError`);留在壳里的另一半是**人话** ——
166
+ cli 1.0.x `src/sema/resumeRefusalCopy.ts` 自铸了一份判型 + 三句 `·` 分段文案,而 web-client /
167
+ desktop 要么没有、要么将来会再抄一份。三端各抄一份 = 同一次拒绝在三个端上说三句不一样的话,
168
+ 而这三句回答的是**同一个安全问题**:「这次拒绝有没有消费掉我的决定 / 这张卡还能不能再决」。
169
+
170
+ **新增公面 7 件**(公面导出 **821 → 828**):`RESUME_REFUSAL_CODES` · `resumeRefusalFromError` ·
171
+ `resumeRefusalContent` · `RESUME_PLACEMENT_MISMATCH`(码常量,`engineErrorCodes.ts`)+ 件② 的三张闭词表再导出。
172
+
173
+ - 🔴 **两个闭集刻意分家**:`RESUME_RETRY_LATER_CODES`(§21)闭的是「**server 在哪些码上铸
174
+ `retryAfterSec`**」⇒ 答「能不能等」;`RESUME_REFUSAL_CODES`(本件)闭的是「**哪些 resume 拒绝有
175
+ 人话可补**」⇒ 答「该对人说什么」。两集**交于** `resume.preflight_rejected` 一码、**各有**一个独占
176
+ 成员 —— `usage_window_exhausted` 只在前者(没有专属人话,「你可以等」就是全部信息);
177
+ `placement_mismatch` 只在后者(server 不给它铸窗,而且**等一会儿对它毫无用处**:出路是换 root,
178
+ 是**换参数**不是**等时间**;放进时间性闭集 = 让人白等一个永远不会自己好的拒绝)。
179
+ - 🔴 **不复制判定**:交点码的窗与可等性一律**转调** `resumeRetryLaterFromError`,本模块里没有第二个
180
+ `retryAfterSec` 窄读器(`paired-mechanisms-must-share-premise`:两处各判必然在某一格分叉)。
181
+ 门用九形入参驱动两口,`waitable` 与 `retryAfterSec` 必须**逐字相同**(含「缺席」那一格)。
182
+ - 🔴 **文案第三句由 `waitable` 选,绝不由 message 文本选**(按上游散文分支正是「上游改一个词、端的
183
+ 判定静默空转」的形):`placement_mismatch` ⇒ 说出路(换 root);`preflight_rejected` **无窗** ⇒
184
+ **不可判**,说两条臂都成立的那句、把「还能不能再赎」交给屏上那行引擎原文(cli 1.0.x 现行文案逐字);
185
+ **有窗** ⇒ 那是 retry_later 臂的**充分证据**(core 的 terminal 臂抛的 `CheckpointError` 一个 detail
186
+ 都不带,窗结构上到不了客户端)⇒ 才敢说「障碍清掉之后这个 token 还能再赎」。
187
+ - **闭集是 `Object.freeze` 数组**(与 `RESUME_RETRY_LATER_CODES` 同一条已定谳的病形):公开面与判定源
188
+ 是同一个物,`ReadonlySet` 只在类型面只读,一次 `.add` 就能把「重开吧」挂上安全声明。
189
+ - **文案里不铸秒数**:等待那一行由端单独渲(壳的 `retryAfterHint` 是那一位的唯一取值口),
190
+ 写进文案 = 同一个数字上屏两遍。
191
+
192
+ **⚠️ 壳提货后的行为差分(是修好不是回归)**:`retryAfterSec` 窄读域从 cli 自铸的「有限数 ∧ ≥0」
193
+ 收到与 §21 同律的「**整数 ∧ ≥1**」。server 的铸键逐字是「ms → 秒**向上取整**、**下限 1**」⇒ 真供给里
194
+ 不存在 0 / 负数 / 小数;放行 `0` 就是对端说「立刻重试」,而 resume 是 **AT-MOST-ONCE** 的有副作用动作
195
+ (叫醒 = 真跑一轮)—— 一个 0 会把「等一会儿」变成热循环。
196
+
197
+ **可达性如实登记**(不许把预置说成到货):两码在 server 7.51 当版**结构性不可达**(四条腿都不供
198
+ `placementRoot`,也没有 `resumePreflight` 座)。本模块与壳那份一样是**预置臂**,收进包是为了
199
+ 「码到货那天三端不至于各说各话」,不是在声称这条指路已经上过屏。
200
+
201
+ ---
202
+
203
+ ### 常驻门
204
+
205
+ - 🆕 `scripts/run-resume-refusal-copy-test.mjs`(**103 checks**):公面在场 · 两个闭集关系**双向钉** ·
206
+ 不复制判定(九形入参两口逐字相同)· `placement_mismatch` 恒无窗恒不可等**且带窗也不翻、那个窗也不透传** ·
207
+ 窄读域「整数 ∧ ≥1」· 文案三句由 `waitable` 选而非 message(两族原话灌进 message 而文案一字不动)·
208
+ 负控(同族六码 + 429 同名不同门 + 下划线两族 + 别族 + 裸 404 + 码在 message 里被引用 + 六形坏载体)·
209
+ 结构读不 instanceof · 退役 `code` 槽两码各走一遍都不认 · 纯函数。
210
+ - `scripts/run-rule-offers-reader-test.mjs`:**109 → 169 checks**(新增段 ⑨ 六小节,判据见 §22d)。
211
+ - `scripts/run-approval-frame-keys-test.mjs`:`ruleOffers` 领先登记退役后回到逐元素相等
212
+ (SDK 锚 18 项 / 本仓镜像 24 项 / 领先登记 6 项)。
213
+ - `scripts/run-sdk-floor-test.mjs`:`FLOOR` 8.2.0 与 peer 声明**三位逐位等值**互绑。
214
+ - `scripts/run-public-surface-test.mjs`:公面导出基线 **821 → 828**;peer 地板 + README 三处逐字核。
215
+
216
+ ### 已知局限(本版新增)
217
+
218
+ - **`approval_revoke` / `approval_request` 两帧本包仍零消费口**:处置是有痕的
219
+ `dropped/unsupported_arm`。端不要把「撤卡帧没反应」读成「引擎没发」;接那条新卡链是另一批。
220
+ - **`run-approval-frame-keys-test.mjs` 的 fixture 腿在 server ≥7.57 上红**(本批**非引入方**,
221
+ 实测 7.57/7.58 缺 `ruleOffersAbsence`、7.59 起再缺 `denialLimitFallback`、7.60 起再缺 `origin`)——
222
+ 这是另一条已知病形(「镜像滞后于 server 实发键」)的存量,与本批的病形(「手抄闭词表」)不同族,
223
+ 按坐标上报另立。本批 `npm test` 走的是 SDK 锚腿(fixture 腿在本仓默认不跑,`@sema-agent/server`
224
+ 不是本包的 dep)。
225
+
52
226
  ## 0.57.0(2026-09-05)
53
227
 
54
228
  > 内容批 D(2026-09-05,隔离树交付):三件 —— L-103/[C228] 窄读器公面化、L-102 resume 时间性拒绝
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.57.0
38
+ **Version:** 0.58.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
@@ -63,9 +63,11 @@ Renamed from **`@sema-agent/wire-cc-adapter`** (0.1.x, deprecated — see *Migra
63
63
  it ships no runtime code at all.
64
64
  - `@sema-agent/sdk` — the wire contract. **Value-level, not type-only**: `AgentClient`,
65
65
  `SseIdleError`, `probeHealth`, `APIError` and `TaskStopConflictError` are imported as values in
66
- five modules, and the browser bundle really bundles the SDK through (the portability guard would
66
+ five modules, and so are the three frozen rule-offer vocabularies the approval reader narrows
67
+ against — the tables live upstream precisely so this package does not keep a second copy that can
68
+ fall behind. The browser bundle really bundles the SDK through (the portability guard would
67
69
  exit 3 rather than quietly mark it external).
68
- - The declared floor is `>=7.4.0`, and it is *witnessed*: the guard checks that an actually
70
+ - The declared floor is `>=8.2.0`, and it is *witnessed*: the guard checks that an actually
69
71
  installed SDK at that line still exports every value-level symbol this package imports and still
70
72
  declares `TaskStats.costMicroUsd` (the key `costOrNull` reads). A floor nobody ever ran is a
71
73
  promise, not a contract.
@@ -265,7 +267,8 @@ public-surface guard checks that last one).
265
267
  | `scripts/run-esc-halt-plan-test.mjs` | The Esc stop decision every client shares: fire the **turn-level** halt first, and escalate to a **run-level** cancel in exactly two cases — the engine itself answered with a 409 from the closed code set (it is saying "there is no in-flight turn here; use cancel for a run-level stop"), or that shot came back with no verdict at all *and* the shell can independently prove a permission card was on screen. Everything else does not escalate. The asymmetry is the whole point and every negative control guards the same direction — deciding *not* to escalate costs the user one more choice on a busy-session card (recoverable), deciding to escalate wrongly tears down a run that was alive and takes every in-flight tool with it (not). So: the closed code set is a **frozen** value, not a `ReadonlySet` — type-level immutability does not stop a consumer's `.add()`, and the guard proves it by really trying to mutate the exported value and then checking the verdict did not drift; the escalation gate is the **conjunction** of that closed set and the 409 status, since honouring the code alone lets a 500 that merely quotes it drive a destructive call; `interrupt.not_held` and `steering.not_running` are deliberately outside the set (the first means *this replica* has no live face — the run may be perfectly alive on another); an unreadable code falls to the no-escalation side; a `parked` flag never overrides a verdict the engine did give, and only strict `true` counts when it did not. The first shot is unconditional by construction — it does not consult `parked`, because the 409 it earns is exactly the verdict the gate wants — and the verdict itself is a closed machine-readable reason word, not display copy |
266
268
  | `scripts/run-peer-frame-projection-test.mjs` | The three engine-injected lanes design/385 puts on the **one** `task_notification` carrier, which are not the same kind of thing at all: a delegated child's uplink (`agentMessage`), another session's message drained from this session's own box (`crossSessionMessage`), and a receipt about one of *this* session's own outbound messages (`crossSessionNotice`). The engine renders none of them inside a `<task-notification>` shell, so a client that projects them as the generic completion card shows "background task finished" while the model read a colleague's sentence — two faces describing different events. The discriminator is pinned to the **typed carrier being present**, never to the `summary` text: those carriers can only be minted by the engine's injection legs (the external `notify()` input is a strict subset of the payload and can wear none of them), while `summary` is filled by every notification there is — so anchoring on text would let any background task impersonate a colleague's message by writing `<agent-message from="…">` into its own summary, and a positive control asserts exactly that payload still projects as the generic card. Fail-closed has two tiers rather than one: a broken **required** field (empty `from`, a non-string `body`, a notice `kind` outside the closed set) returns absence so the caller falls back to the generic card — an honest downgrade where the user still sees the notification — while a broken **optional** field drops only itself, because losing an attribution note and losing a colleague's whole message are not the same magnitude. The provenance side record is **required and must agree on four points** (`kind` matches the lane; `from`/`taskId`/`seq` are present and equal the carrier/payload — each equality is anchored on a core mint site and pinned by the cli wire-anchor A-K24), so a carrier signed with a trusted name but a disagreeing provenance falls back to the generic card; peer bodies pass the same authority-envelope neutralization core applies (`<task-notification>` etc. are defused) so a colleague's text can never seed the resume dedup ledger. Lane precedence copies the engine renderer's own order, because the model already read the frame in that order and a client ordering of its own would put a card on screen that disagrees with the frame the model saw. Rendering and parsing of the transcript line live in the same module and are round-tripped in both directions, including a body carrying a forged closing tag (a parser fooled there hands half a message to the next row) and a quote inside the sender label (which must not forge a second attribute); the notice lane is deliberately kept **out** of the parser, since recognising it would mean anchoring the `[Cross-session …]` prefix and a user typing that same line would be rendered as engine speech. Hostile carriers are read as own **data** descriptors only and accessors are never invoked at all — `catch` catches throwing, not never returning — proven by a counting getter that must stay at zero calls, alongside a revoked proxy and a prototype-only carrier; and four legacy payload shapes assert the no-carrier path is byte-identical to before, which is the executable form of "zero difference for an older host" |
267
269
  | `scripts/run-wiring-manifest-projection-test.mjs` | The two end-user facts carried on the engine's `wiring_manifest` frame (`modelGate`: which tools this run's model gate removed and the verbatim restore hint; `autoMode`: whether auto mode is actually armed and the engine's own reason word). Projection: both sections ride as `_sema_`-prefixed superset keys, verbatim, and no SDK-named key is minted; a frame where neither section is well-formed projects to `none/not_in_slice` (no empty arm); `modelGate` needs all three keys and treats `removed: []` as a bad value rather than a reading; `autoMode` needs a boolean plus a non-empty reason that agrees with it, and the reason word is never mapped onto the capabilities vocabulary; the frame is flat (a nested `manifest:{}` wrapper is not a supply); `eventId` rides like every other arm. Adapter: exactly one chrome event on the main lane, a sub-flow frame (any `parentToolCallId`, `null` included) yields nothing, and an absent `eventId` leaves the key absent. Added at receiving time because the shell-side gate could not see this package's behaviour: two mutations (empty `removed` accepted, sub-flow gate removed) had passed the package suite untouched |
268
- | `scripts/run-rule-offers-reader-test.mjs` | The narrowing reader behind the "don't ask again" options, now a public entry point rather than a card-port-only one. Hosts that render the frame themselves (a browser has no three-way terminal card) previously had to rebuild this reader on their side, and what it carries is a **redemption-safety** judgement, not a convenience: the batch arm is redeemed by **index**, so a reader that compacts the array after dropping a malformed entry makes the k-th option a person clicked and the k-th rule the server writes two different rules. So: a bad entry is dropped **on its own** (one bad option must not make a real one disappear) while every surviving entry keeps its **original wire index** — pinned from both ends, with the bad entries leading and trailing. A batch's *members* are the opposite: any malformed member drops the whole batch, because a conjunctive batch is one "yes" to all of them and a batch missing a member is a different grant; its honest-remainder count is a reading, not decoration, so a non-integer or negative value drops the batch rather than rendering a fabricated zero. An empty array, a non-array, an over-cap array and an all-bad array all read as **absence** rather than an empty list, because an empty list renders as "there is an option lane with nothing in it". The two wire generations are ordered by a rule, not a preference: the newer key wins outright, a newer key that is **present but unreadable** does not fall back to the retired key (borrowing the older material would pass someone else's options off as this request's), and a `null` newer key reads as absence so a relaying layer that serialises "missing" as null cannot delete the whole lane on older engines. The public entry is finally reconciled against **both** card-port legs on the same material, byte for byte, so the exported reader and the one the card sees can never become two |
270
+ | `scripts/run-rule-offers-reader-test.mjs` | The narrowing reader behind the "don't ask again" options, now a public entry point rather than a card-port-only one. Hosts that render the frame themselves (a browser has no three-way terminal card) previously had to rebuild this reader on their side, and what it carries is a **redemption-safety** judgement, not a convenience: the batch arm is redeemed by **index**, so a reader that compacts the array after dropping a malformed entry makes the k-th option a person clicked and the k-th rule the server writes two different rules. So: a bad entry is dropped **on its own** (one bad option must not make a real one disappear) while every surviving entry keeps its **original wire index** — pinned from both ends, with the bad entries leading and trailing. A batch's *members* are the opposite: any malformed member drops the whole batch, because a conjunctive batch is one "yes" to all of them and a batch missing a member is a different grant; its honest-remainder count is a reading, not decoration, so a non-integer or negative value drops the batch rather than rendering a fabricated zero. An empty array, a non-array, an over-cap array and an all-bad array all read as **absence** rather than an empty list, because an empty list renders as "there is an option lane with nothing in it". The two wire generations are ordered by a rule, not a preference: the newer key wins outright, a newer key that is **present but unreadable** does not fall back to the retired key (borrowing the older material would pass someone else's options off as this request's), and a `null` newer key reads as absence so a relaying layer that serialises "missing" as null cannot delete the whole lane on older engines. The public entry is finally reconciled against **both** card-port legs on the same material, byte for byte, so the exported reader and the one the card sees can never become two. Two upstream vocabularies used to be **hand-copied** here, and both had fallen behind: a match word outside the copied pair dropped an otherwise valid option outright, and a batch carrying a directory-read member — a member kind the copy did not know — dropped the whole batch. Both tables now come from one place upstream and are re-exported verbatim, pinned in both directions: every word in the table must be accepted (a narrower copy reds on the words it never learned) and a word constructed to be outside it must still be refused (a reader widened to "any string" reds too), with the retired-key normalising leg sharing the same narrowing so the fix cannot land on one leg only. A member whose kind is genuinely unknown still drops **the whole batch and only that batch** — never one member, because a conjunctive batch one member short renders "yes to N" as "yes to N−1", and never the card, because the honest single beside it is intact — while a member from before the discriminant existed normalises to the historical kind rather than being refused. The additive per-segment reasons ride through verbatim, drop only the row that is malformed, and stay **absent rather than empty** when nothing survives, since an empty list would read as "confirmed nothing uncovered" while the count remains the only source of truth |
271
+ | `scripts/run-resume-refusal-copy-test.mjs` | The **words** a client says when a resume is refused, minted once here instead of three times. The facts behind them already lived in this package; the sentences did not, so each client wrote its own — and those sentences answer a safety question (was my decision consumed, can this token still be redeemed), which is exactly the kind of answer that must not vary by client. Two closed sets meet here and the guard pins their relationship in both directions, because it is a premise rather than a coincidence: one set answers *can waiting help* (the codes the server mints a wait on), the other answers *what should a person be told*, they **intersect in exactly one code**, and each keeps a member the other must not have — a placement mismatch is never waitable no matter what arrives on the response, since its remedy is a changed argument rather than elapsed time, and a full governance window needs no prose because "you can wait" is the whole message. The overlapping code delegates its wait and its disposition to the existing reading rather than judging again: nine shapes of input drive both entry points and the two readings must agree byte for byte, the absent case included, because two judges always diverge somewhere. The wait is narrowed to the domain the server mints it in, which is **stricter than the shell's own copy was** — a zero now reads as no window rather than as "retry now", and the wake-up it would retry is an at-most-once action with real side effects. The third sentence is chosen by the disposition, never by the engine's prose: rewriting the message to either upstream branch's exact wording, with the window untouched, must leave all three sentences unchanged, while adding a window must change the third one and only the third one |
269
272
  | `scripts/run-resume-retry-later-test.mjs` | The two resume refusals that carry a **wait quantity** — the only members of that refusal family that do, which is the whole reason they form a closed set. Carrying a wait is not the same as being the only ones worth waiting on: a sibling refusal in the same family clears on its own and the engine says so in words, it just cannot put a number on it, so *not recognised here* must never be read as *waiting will not help*. One of the two also has a *terminal* upstream branch that arrives under the same code with the distinguishing detail only in prose, so recognition alone is not permission to say "try again": the disposition is decided by **positive evidence** and pinned from both directions — the quota-window code is evidence in itself, the preflight code counts only when the server really supplied a wait (an upstream fact, not a convention: the terminal branch throws with no detail at all, so a wait value cannot reach the client on that path), and a preflight refusal with no wait reads as *undecidable* (say what is true of both branches — nothing was consumed — and leave redeemability to the engine's own line) rather than being rendered as either a retry or an ending. Every other member means waiting will not help (change a setting, relaunch, the retained session is gone), so the recognition is a **closed set of two codes**: widening it to a family prefix would tell half the users to wait and the other half to keep waiting for something that will never arrive, and the negative controls drive exactly those codes through it, plus a same-named code on a different door (the submission-side quota refusal), the two underscore-form siblings, and a code merely quoted inside a message body. The wait value is narrowed to the same domain the server mints it in (a whole number of seconds, at least one): zero, a negative, a fraction and a non-number all read as **no window given** rather than as zero, because a zero tells the caller to retry immediately and the wake-up it would retry is an at-most-once action with real side effects. Reading is structural rather than `instanceof`, since the client is host-injected and the same class name across two bundles is two classes, and a null-prototype plain object must still be recognised. The failure classifier gains this one disposition without any existing one moving, an unknown code still falls to the honest open-set arm and its wait value is **not** believed, and an end-to-end call proves the disposition and the window reach the host while the call itself is still attempted exactly once. The recognised code set is a **frozen array**, not a type-level readonly set: the latter is a plain mutable collection at runtime and the decision reads the same instance, so one `.add` from any consumer would turn a refusal that waiting cannot fix into one that claims it can — the guard proves it by really trying to mutate the exported value and then checking the verdict did not drift |
270
273
 
271
274
  Each suite carries a floor that only moves up — a refactor that stops executing a group of
@@ -721,6 +721,25 @@ export function eventToSdkMessage(ev, ctx) {
721
721
  */
722
722
  case 'approval_request':
723
723
  return dropped('unsupported_arm', 'approval_request');
724
+ /**
725
+ * `approval_revoke`(design/172 撤卡帧;server ≥7.55.0 起也进重放,SDK 8.x 才把它写进
726
+ * `AgentEvent` union —— 0.58.0 sdk 8.2.0 提货批**编译期逼进来**的臂,与 `engine_notice`
727
+ * / `human_input` 走的是同一条路:臂一进 union,`default` 的 B5 穷举断言当场红)。
728
+ *
729
+ * 🔴 **与 `approval_request` 同源同处置,所以并**在它旁边而不是并进上面那两条 —— 它是
730
+ * 同一条流内审批协议的**撤销**半场(载荷同是 `ApprovalFrameEnvelope`,`schemaVersion`
731
+ * 未知的帧要按信封先读再窄化)。本包今天**零消费口**:`isToolApprovalFrame` 只认
732
+ * legacy 的 `tool_approval` / `tool_approval_complete` 两词,新族两帧一个都不接。
733
+ * 🔴 分类 = `dropped/unsupported_arm` 而**不是** `nothing/not_in_slice`(与
734
+ * `approval_request` 同一条理由,逐字见上一臂):`none` 是静默的,只有 `dropped` 会经
735
+ * `reportDroppedFrame` 走宿主 sink + console 留痕。撤卡帧带的是**用户可见的状态迁移**
736
+ * (那张卡已经不该再被决断了),悄悄丢掉它比吼一行更坏。
737
+ * 📋 缺口如实记账(**不**在本批修 —— 与 `approval_request` 是同一条新卡链,属功能不属修复):
738
+ * 接法在上一臂的「接法」段里写着,撤卡半场按 `approvalId` 撤同一张卡;在那条链落地之前
739
+ * **不许**由本切片顺手编一个假 transcript 形来假装接上了。
740
+ */
741
+ case 'approval_revoke':
742
+ return dropped('unsupported_arm', 'approval_revoke');
724
743
  // ── `engine_notice`(#310 / #318 件①,server ≥7.36,契约 = ASSISTANT-WIRE-CONTRACT 附录 D)────
725
744
  // 🔴 **到期复核已兑现(sdk 7.4.0 提货,0.48.0)** —— 与 `human_input`(sdk 6.9.0)逐字同一条路。
726
745
  // 本臂此前是 switch **之前**的一条 raw 预分派,理由 = 它还没进已发布 SDK 的 `AgentEvent`
@@ -198,6 +198,21 @@ export declare const RESUME_USAGE_WINDOW_EXHAUSTED = "resume.usage_window_exhaus
198
198
  * 没被消费」;「还能不能再赎」交给引擎那行原文去说,别替它下结论。
199
199
  */
200
200
  export declare const RESUME_PREFLIGHT_REJECTED = "resume.preflight_rejected";
201
+ /**
202
+ * `resume.placement_mismatch`(#376 / core 5.65,design/380 O1③;server ≥7.51.0)—— 这次 resume
203
+ * 显式带的 `internals.placementRoot` 与 suspend 铸点记下的 `CheckpointState.placementRootSessionId`
204
+ * **不同**。目标绑定的 env factory 按那个固定点查 placement,静默换根会把这条腿(以及它往下传的
205
+ * 每一个子代)重新落在**另一台目标**上,而 park 的工作区在原来那台 ⇒ **拒在 CAS 之前**,行仍 `pending`。
206
+ *
207
+ * 🔴 **它不在 {@link RESUME_RETRY_LATER_CODES} 里,而且不该在**:server 只在
208
+ * `resume.usage_window_exhausted` / `resume.preflight_rejected` 两码上铸 `retryAfterSec`
209
+ * (server 7.51.0 `dist/http/server.js` 的 `CheckpointError` 出口逐字),本码恒无窗;更要紧的是
210
+ * **等一会儿对它毫无用处** —— 出路是「用记录里的那个 root 重来,或干脆不传 `internals.placementRoot`
211
+ * 继承记录值」,是**换参数**不是**等时间**。把它放进时间性闭集会让人白等一个永远不会自己好的拒绝。
212
+ * ⇒ 它与 `resume.preflight_rejected` 一起构成**另一个**闭集:{@link import('./resumeRefusalCopy.js').RESUME_REFUSAL_CODES}
213
+ * (「有人话可补」的拒绝),两个闭集刻意分家 —— 一个回答「能不能等」,一个回答「该对人说什么」。
214
+ */
215
+ export declare const RESUME_PLACEMENT_MISMATCH = "resume.placement_mismatch";
201
216
  /**
202
217
  * 时间性拒绝族的**闭集**。
203
218
  *
@@ -281,6 +281,21 @@ export const RESUME_USAGE_WINDOW_EXHAUSTED = 'resume.usage_window_exhausted';
281
281
  * 没被消费」;「还能不能再赎」交给引擎那行原文去说,别替它下结论。
282
282
  */
283
283
  export const RESUME_PREFLIGHT_REJECTED = 'resume.preflight_rejected';
284
+ /**
285
+ * `resume.placement_mismatch`(#376 / core 5.65,design/380 O1③;server ≥7.51.0)—— 这次 resume
286
+ * 显式带的 `internals.placementRoot` 与 suspend 铸点记下的 `CheckpointState.placementRootSessionId`
287
+ * **不同**。目标绑定的 env factory 按那个固定点查 placement,静默换根会把这条腿(以及它往下传的
288
+ * 每一个子代)重新落在**另一台目标**上,而 park 的工作区在原来那台 ⇒ **拒在 CAS 之前**,行仍 `pending`。
289
+ *
290
+ * 🔴 **它不在 {@link RESUME_RETRY_LATER_CODES} 里,而且不该在**:server 只在
291
+ * `resume.usage_window_exhausted` / `resume.preflight_rejected` 两码上铸 `retryAfterSec`
292
+ * (server 7.51.0 `dist/http/server.js` 的 `CheckpointError` 出口逐字),本码恒无窗;更要紧的是
293
+ * **等一会儿对它毫无用处** —— 出路是「用记录里的那个 root 重来,或干脆不传 `internals.placementRoot`
294
+ * 继承记录值」,是**换参数**不是**等时间**。把它放进时间性闭集会让人白等一个永远不会自己好的拒绝。
295
+ * ⇒ 它与 `resume.preflight_rejected` 一起构成**另一个**闭集:{@link import('./resumeRefusalCopy.js').RESUME_REFUSAL_CODES}
296
+ * (「有人话可补」的拒绝),两个闭集刻意分家 —— 一个回答「能不能等」,一个回答「该对人说什么」。
297
+ */
298
+ export const RESUME_PLACEMENT_MISMATCH = 'resume.placement_mismatch';
284
299
  /**
285
300
  * 时间性拒绝族的**闭集**。
286
301
  *