@sema-agent/client-core 0.39.0 → 0.40.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
@@ -28,6 +28,93 @@
28
28
  > 不许悄悄漂:豁免登记的 `releasedAt` 与 `FROZEN` 账上 0.36.0 那一行逐字相等;本段(点名版本号
29
29
  > `0.36.0` + 关键字「勘误」)必须还在这份头注里 —— 删掉本段而不同批把门侧豁免一起处理,门当场红。
30
30
 
31
+ ## 0.40.0(2026-08-22)
32
+
33
+ **minor:连坐 abort 机读位 `_sema_collateral_abort`(#324 / [4907] 裁定)。**
34
+
35
+ - **🆕 连坐帧机读位 `_sema_collateral_abort`(#324)**:`gateLedger.flushHeld()` 对**已知不是 gate
36
+ 主角**的 HOLD 帧 stamp additive 超集键 `_sema_collateral_abort: true`。`eventToSdkMessage` 的
37
+ `case 'tool_end'` 臂同批放行这一位到内部 `tool_end_result` 臂(严格 `true` 才铸,缺席不铸键)
38
+ ——不放行就等于本包把**自己刚打上去**的事实在自己的包边界上剥掉,即该臂头注点名的
39
+ [1947] `toolCallId` / [2942] `governanceForced` / #263 两键同款病形的下一例。
40
+ 病形:同 turn 多 call 在飞 + 一个撞 gate ⇒ 引擎对整批在飞 call 铸同一串 `Operation aborted`
41
+ ⇒ HOLD 表按超集谓词全收 ⇒ 任一 flush 出口一次倾泻,端上满屏同文红行,而其中只有一条是用户
42
+ 刚决断的那个 call。
43
+ 🔴 **一帧不丢的不变量不变**:修的是「出口处分不清谁是谁」,不是丢帧 —— 丢帧的后果是永久
44
+ 假排队(dim 圆点等不到结果行),比多渲一行更坏。
45
+ - **🔴 判别子在 `flushHeld` 当拍求值、且只认 park 供证(#324 换锚)**:出身**不**在入表当拍
46
+ (hold-poison 臂)冻结 —— durable park 腿的帧序是 `tool_start → tool_end(毒化) →
47
+ done{suspended}`,**gate 身份晚于毒化帧到达**,那一拍的任何判词都是猜的。按工具名猜双向都错:
48
+ 一等 `kind==='tool_approval'` gate 的主角(名字腿认不得的 toolName)被判成连坐 = **假阳打在
49
+ 主角上**;同族兄弟(两个 `Bash` 只有一个撞 gate)两个都被判成主角 = 连坐帧拿不到位。
50
+ 🔴 判别子真正要回答的是「**这一批** abort 帧属于哪张 gate」,而 wire 上**没有任何位**做这个
51
+ 关联。定谳:只有 **park** 具备结构性归属(`flushHeld` 的出口就长在那张 park 自己的决断/回退
52
+ 路径上),所以判据**只从 park 取证** —— 同步 `tool_approval` 帧腿不供证、整条不标(已知局限二);
53
+ **pending 队列也不供证**(`durable checkpointGate` 不带 callId,`findPendingForTask` 取的是
54
+ 「同 taskId + 谓词命中的第一行」,那是卡面渲染的宽口不是身份 —— 同族两行排队时它会给出旁观者,
55
+ 拿它当主角就把真主角挤成「非主角」)。判据全部**批级**、一批一套:①本批恰一张 park(硬门;
56
+ durable 重放同一张不算新的一张,判据 = 自上次登记以来有没有**新 call** 进扣留表,且去重分支
57
+ 一个字节都不写;ask park 臂同样要登记 —— 它是本批的第二张 park);②主角 = 本批(扣留帧 ∪ 已
58
+ 出批帧)里**唯一**属该 park 族的 call,前置是每条扣留帧的 `tool_start` 本连接都见过。
59
+ 🔴 出口方向 fail-safe:**判不出来 ⇒ 整表一个都不 stamp**(诚实缺席,字节零变),绝不按到达
60
+ 顺序编判词。批级判据退休点两处:`flushHeld` drain 完、`dropHeld` 之后**无条件**检查扣留表为空
61
+ (写成「摘中了才查」则零毒化帧的 park 批永不退休,判据活到下一批派错判词)。
62
+ 兜底腿读的是 tool_start 当拍写入的**单调集**,不是 `pendingFsCalls` —— 后者在 `routeToolEnd`
63
+ 跑臂表之前就被 `dropFsCall` 摘掉了当前 callId,拿它做判据会退化成「除最后一张毒化帧外全判
64
+ 连坐」的位置性裁决(主角被 stamp、真连坐豁免,方向恰好反)。
65
+ 随之删除未发布窗内的 `HoldOrigin` 类型与 `hold()` 第三参、`heldOrigin()`(干净切,零已发布
66
+ 消费者)。
67
+ 异源对抗复审**四轮**逮到九条残留路径(八条是「假阳打回 gate 主角」,一条是判据自失效),
68
+ 同批修 + 每条各配常驻反证:①兜底缺准入证(F8-S10);②身份 turn 级只增不删,多 hop 冒充已知
69
+ (F8-S11);③凭证在零 flush 路径滞留(F8-S12/S21);④ask park 不登记(F8-S13/S19/S25/S26);
70
+ ⑤同批两个主角被「新 gate 清空身份」抹掉先到的那个(F8-S17);⑥durable 重放同一张 park 抹掉
71
+ 刚登记的真身份(F8-S18);⑦历史主角在后一批变成连坐 call 时反向误标新主角(F8-S20);
72
+ ⑧旧 server 同批双 gate 双主角(F8-S22);⑨同批混入两张真 park 时挑一个当主角(F8-S23);
73
+ ⑩**零毒化帧的 park 批永不退休**(`dropHeld` 每次都落空 ⇒ 判据活到下一批,F8-S27);
74
+ ⑪扣留帧的 `tool_start` 落在本连接之外时,兜底把**旁观者**当主角、反手给真主角打位(F8-S28);
75
+ ⑫毒化帧与 park **一并重放**被当成第二张 park,本该带位的兄弟帧丢位(F8-S29,幂等性);
76
+ ⑬去重的**反向**误判 —— 一张真的新 park 同样可以零毒化帧,它顺手刷新族属就把本批判据改成
77
+ 另一族,兜底选中旁观者、反手误标真主角(F8-S30 ⇒ 去重分支一个字节都不写)。
78
+ ⑭被去重掉的 park **借决断成功**把自己的 callId 登记为本批主角,把真主角挤成「非主角」
79
+ (F8-S31 ⇒ 采信答案经 `GatePark.parkAccepted` 一路带到决断腿)。
80
+ ⑮`findPendingForTask` 的选行(谓词命中的**第一行**,不命中时退「任意行」)被当成身份 ⇒
81
+ 别族 / 同族的旁观 call 被登记为主角、反手给真主角打位(F8-S32/S33 ⇒ **pending 队列一概不参与
82
+ 判别**,决断结果只用于 gate 生命周期)。
83
+ 十五条同源:**「本 turn 存在过一张 gate 的证据」不等于「这一批帧属于那张 gate」** —— 与 v1
84
+ 名字锚同形(拿旁证冒充身份),故按同形存量宪法一并清,并补齐四条 park 臂的登记点。
85
+ 门:`run-hitl-gate-honesty-test.mjs` F8 组 **S1–S33**(70→126 checks),每形态钉死判词全串
86
+ 「谁带位谁不带」而不只是「有没有键」;**零已知假阳**是本批的验收硬线。
87
+ 变异自证十六发全抓(逐发只由目标格抓红,cp 复原后逐字节 cmp 一致):废兜底唯一性⇒F7-①;
88
+ 翻转 fail-safe⇒F8-S2;准入证恒放行⇒F8-S10;废「恰一张 park」硬门⇒F8-S23;废重放去重⇒F8-S18;
89
+ 去重分支写族属⇒F8-S30;废 flush 侧退休⇒F8-S24;废摘空退休⇒F8-S16;退休改「摘中才查」⇒
90
+ F8-S27;废出批帧并集⇒F7-①;废 start 全覆盖前置⇒F8-S28;`hold` 覆盖也置位⇒F8-S29;
91
+ 四条 park 臂逐臂不登记⇒F8-S25 / F7-① / F8-S26 / F8-S16。
92
+
93
+ **已知局限(本版新增)**:
94
+
95
+ - **端侧呈现半场不在本包**:本包只把机读位送到 `tool_end_result` 臂;「连坐簇怎么折叠」是各端
96
+ 的呈现设计。壳(cli)的消费件在 1.0.90 窗跟车。
97
+ - **@core 对表位未闭**:core 5.51 `[DEFERRED]` / `gate.batch_halted`([4826]③)到货前,本键是
98
+ 端能拿到的唯一机读判别子;到货后若 core 给出更强官方形,端侧换锚、本 additive 键保留兼容。
99
+ - **同族多兄弟 ⇒ 分不出主角**:两个 `Bash` 同时在飞、其中一个撞 gate 时,wire 上没有任何位能说
100
+ 「哪个撞的」——`lastFsOrShellGatedCallId()` 给「最后一个」、pending 队列给「第一行」,都不是身份。
101
+ **决断成功也不例外**。此格按诚实缺席处置(整表不 stamp,端侧逐字节退回本键出现前)。
102
+ 🔴 出路在 server:`durable checkpointGate` 带 `toolCallId`。常驻门 `F8-S3/S8/S33` 钉成「全
103
+ ABSENT」,server 补位后当场翻红,逼下一棒把新身份接进判据。
104
+ - **同步 `tool_approval` 帧腿整条不标(刻意为之)**:帧只证明「本 turn 出现过一张 gate」,证明
105
+ 不了「这一批 abort 帧属于它」——那张 gate 可能早已正常收口(零毒化帧 ⇒ 零扣留表 ⇒ 一次 flush
106
+ 都不跑),证却会活到下一批派判词。四轮复审里**全部**「假阳打在主角身上」的残留路径都出自帧腿
107
+ 取证。取舍:**宁可少标,绝不误标主角**(误标主角=把用户真正该看的那条红行折叠掉,比多渲 N 行
108
+ 更坏)。常驻门 `F8-S4/S5/S7/S11/S14/S17/S21/S22` 钉成「全 ABSENT」。🔴 出路同样在 server:
109
+ abort 批带 **gate 关联 id**,把归属从推断变成读数。
110
+ - **记案(存量,非本批根因):`dropHeld` 也在用不可信的 `gatedCallId`** —— 决断成功那一下按
111
+ pending 行的 callId 摘扣留帧,同族多行排队时可能摘掉**另一条** call 的帧,那一帧就再也不吐
112
+ (「一帧不丢」在这一格上不成立)。这条**早于本批**(与 4c42c8a 逐字相同),根因是「decide 结果
113
+ 没有可核验的 park 身份」,与本批修的判别面是两件事;改它要动 #110 / REF-CC-034 的承重路径,
114
+ 单独立案。server 补 `checkpointGate.toolCallId` 后两处一并收口。
115
+ - **缺席不是「这是主角」**:本键缺席同时覆盖「是主角」与「判别不出来」两种情况,端不得据此反推
116
+ 主角身份(要断言主角,端自己按 `toolCallId` 与手上的审批卡对账)。已写进 §3z 缺席语义段。
117
+
31
118
  ## 0.39.0(2026-08-21)
32
119
 
33
120
  **minor:tool_approval 帧窗三键过境 + 三新错误码消费件(0.38.0 已知局限「delegation.* 无机器可读载体」自本版解除)。**
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.39.0
38
+ **Version:** 0.40.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
@@ -245,6 +245,20 @@ export function eventToSdkMessage(ev, ctx) {
245
245
  // (core 不鉴别、不比对、不回读)—— 只渲染,绝不回喂模型/工具入参,更不许当鉴权判据。
246
246
  const toolEndSettledBy = ev.settledBy;
247
247
  const toolEndApprover = ev.approver;
248
+ // ── #324 半场(0.40.0):连坐 abort 机读位 `_sema_collateral_abort`([4907] 裁定)──
249
+ // 出处**不是引擎**,是本包自己的 HITL 桥:`gateLedger.flushHeld` 对「出身 = collateral」的
250
+ // 扣留帧 stamp(见 `SEMA_COLLATERAL_ABORT_KEY` 头注)。桥在本臂**上游**(宿主先用
251
+ // `bridgeAskUserQuestionGates` 包住事件流,再喂 `eventToSdkMessage`),所以这一位到本臂时
252
+ // 已经在帧上了。
253
+ // 🔴 不转发 = 本臂白名单把它整段剥掉 ⇒ 宿主无论怎么写都读不到一条**本包自己刚打上去**的事实,
254
+ // 正是上面那段头注点名的 [1947] `toolCallId` / [2942] `governanceForced` / #263 两键
255
+ // 同款病形的下一例。缺席不铸键(严格 `true` 才铸,别的值一律当没有 —— 机读位是二值的,
256
+ // 「在场但不是 true」没有语义,折成在场会让消费端把一个坏形当成肯定)。
257
+ // 🔴 **只上内部臂,不进转录卡**:本臂是壳内部侧信道(见文件头注),转录 `tool_result` block
258
+ // 会随 `/compact` 直发 provider —— 往 block 上加自铸键正是 0.5.0 `label` 那个真缺口的形状
259
+ // (provider 400)。要让这一位进呈现面,归宿主在臂上读(壳 upstreamBridge 的 tee 就是
260
+ // `errorCode` 指路行同款既有位),不是往卡体里塞。
261
+ const collateralAbort = ev._sema_collateral_abort;
248
262
  return projected(stamp(ctx, armBody({
249
263
  type: 'tool_end_result',
250
264
  toolCallId: ev.toolCallId,
@@ -267,6 +281,8 @@ export function eventToSdkMessage(ev, ctx) {
267
281
  ...(typeof toolEndApprover === 'string' && toolEndApprover.length > 0
268
282
  ? { approver: toolEndApprover }
269
283
  : {}),
284
+ // #324:连坐 abort 机读位(严格 true 才上臂;见上方头注)。
285
+ ...(collateralAbort === true ? { _sema_collateral_abort: true } : {}),
270
286
  ...(ev.eventId !== undefined ? { eventId: ev.eventId } : {}),
271
287
  ...(ev.parentToolCallId !== undefined
272
288
  ? { parentToolCallId: ev.parentToolCallId }
@@ -73,6 +73,7 @@ import { type AskGateWireDeps } from './frameRouter.js';
73
73
  export { HITL_REJECT_MESSAGE, ENGINE_ABORT_TOOL_RESULT } from './frameRouter.js';
74
74
  export type { AskGateWireDeps } from './frameRouter.js';
75
75
  export type { AskAnsweredOutput } from './gateLedger.js';
76
+ export { SEMA_COLLATERAL_ABORT_KEY } from './gateLedger.js';
76
77
  export { toAnsweredOutput, isAlreadyResolvedGateReason, GATE_FAILURE_CODES } from './parkResolver.js';
77
78
  export type { GateOutcome, GateFailureCode } from './parkResolver.js';
78
79
  /**
@@ -19,6 +19,12 @@ import { resolvePark } from './parkResolver.js';
19
19
  // `toAnsweredOutput` / 下方 `bridgeAskUserQuestionGates`)、类型四个。定义搬去了实现所在的
20
20
  // 那一刀,本文件是它们对外的**唯一门牌**——下游 import 路径不变,public-export 基线不变。
21
21
  export { HITL_REJECT_MESSAGE, ENGINE_ABORT_TOOL_RESULT } from './frameRouter.js';
22
+ // #324 / [4907]:连坐 abort 机读位的**单一真源**导出([C93] `toolEndOutputText` 同款理由 ——
23
+ // 三端各自手抄键名 = 漂移温床)。壳/web/desktop 认这个常量,不认文案。
24
+ export { SEMA_COLLATERAL_ABORT_KEY } from './gateLedger.js';
25
+ // 🔴 `HoldOrigin` 随 #324 换锚删除(2026-08-22):出身不再在入表当拍冻结成两值枚举,改在
26
+ // `flushHeld` 当拍按已登记的 gate 主角身份求值 —— 那个枚举没有对外语义了。干净切:它只在
27
+ // 未发布的 0.40.0 窗里存在过,零已发布消费者([clean-cut-no-legacy-compat])。
22
28
  export { toAnsweredOutput, isAlreadyResolvedGateReason, GATE_FAILURE_CODES } from './parkResolver.js';
23
29
  /**
24
30
  * 包一层 AgentEvent 流:把 AskUserQuestion 的 suspended park 变成「对话框 → decide → 续流」闭环。
@@ -76,6 +76,12 @@ function isGatedToolName(name) {
76
76
  return false;
77
77
  return toolNameIsFsWrite(name) || toolNameIsShellExec(name);
78
78
  }
79
+ /** 这个 gate 的**主角属 fs 写 / shell 执行族**吗(`gateLedger.noteParkGate` 的准入判据)。
80
+ * 🔴 主语是 **gate 本体**的 toolName(park 的 `checkpointGate`/`gate`),不是某张 tool_end
81
+ * 帧的 —— 拿「本 turn 有哪些 gate-capable 工具」当准入证正是 v1 名字锚那条病的同形残留。 */
82
+ function isFsOrShellToolName(name) {
83
+ return typeof name === 'string' && (toolNameIsFsWrite(name) || toolNameIsShellExec(name));
84
+ }
79
85
  /**
80
86
  * 「这一帧 tool_end 归 gate 管」的判据 —— 上面那条**名字腿**加一条 kind 腿的替身([2393] hitl-F1,
81
87
  * 2026-08-02)。
@@ -226,8 +232,12 @@ const TOOL_END_ARMS = [
226
232
  // 6.0.0 wire:output 可为 content-block 数组形(见 toolEndOutputText 头注)—— 归一后仍做精确等值。
227
233
  if (toolEndOutputText(ev.output) !== ENGINE_ABORT_TOOL_RESULT)
228
234
  return undefined;
229
- led.hold(callId, ev); // park 毒化帧,先 HOLD(gate 定性前不渲 Error 卡)
230
- return { kind: 'skip' };
235
+ // #324 换锚(2026-08-22):这一拍**不判出身**。durable park 腿的帧序是
236
+ // `tool_start → tool_end(毒化) → done{suspended}` —— gate 身份晚于本帧到达,此刻任何判词
237
+ // 都是猜的(名字腿猜法被双向证伪:一等 kind gate 的主角判成连坐、同族兄弟两个都判成主角)。
238
+ // 出身改在 `flushHeld` 当拍按**已登记的主角身份**求值,判据供给见 `gateLedger.noteGateSubject`。
239
+ led.hold(callId, ev);
240
+ return { kind: 'skip' }; // park 毒化帧,先 HOLD(gate 定性前不渲 Error 卡)
231
241
  },
232
242
  },
233
243
  {
@@ -266,6 +276,12 @@ async function routeToolApprovalFrame(ev, ctx) {
266
276
  const { led, deps } = ctx;
267
277
  if (ev.type === 'tool_approval_complete')
268
278
  return { kind: 'skip' }; // 收口帧:卡已由决断路径撤下,吞掉
279
+ // 🔴 #324:本臂**刻意不给连坐判别子供证**(异源对抗复审四轮的收口,2026-08-22)。
280
+ // 帧只证明「本 turn 出现过一张 gate」,证明不了「**这一批** abort 帧属于它」—— 那张 gate
281
+ // 可能早已正常收口(零毒化帧 ⇒ 零扣留表 ⇒ 一次 flush 都不跑),证却会活到下一批派错判词。
282
+ // 三条实测假阳(旧 server 双主角 / 零 HOLD 审批证跨批 / 历史身份跨批)都出自帧腿取证。
283
+ // 判别子只认 park(归属可结构性证明,见 `gateLedger.noteParkGate` 头注);同步帧腿一律
284
+ // 不标 —— 诚实缺席,登记在 §3z 已知局限。
269
285
  if (!deps.respondToolApproval) {
270
286
  hostLog('debug', `liveHitlAskWire: tool_approval frame ${ev.approvalId} but no respond wire — engine self-settles (TTL deny)`);
271
287
  return { kind: 'skip' };
@@ -345,11 +361,18 @@ function routeToolEnd(ev, callId, led) {
345
361
  function routeSuspended(ev, ctx) {
346
362
  // durable leg 的 park arm(续流中模型再次提问/再次撞写权限)。gate 只带 kind/toolName,
347
363
  // payload 走 approvals。
348
- if (ev.gate && isAskToolLoose(ev.gate.toolName) && ctx.taskId.current)
364
+ // 🔴 两条 park 臂都要登记(判别子的归属证据只认 park)—— ask park 自己用不到兜底腿,
365
+ // 但它必须把**上一张** gate 的准入证清掉,否则旧证会让兜底把一个 Bash 连坐帧反认成主角、
366
+ // 再把真正的 ask 主角 stamp 掉(对抗复审实测 `ask-main=STAMP`)。
367
+ if (ev.gate && isAskToolLoose(ev.gate.toolName) && ctx.taskId.current) {
368
+ ctx.led.noteParkGate({ fsOrShellFamily: false });
349
369
  return { kind: 'park', gate: 'ask' };
370
+ }
350
371
  if (ev.gate &&
351
372
  isToolApprovalGate(ev.gate) &&
352
373
  ctx.taskId.current) {
374
+ // 准入证:park 的 gate 本体属 fs/shell 族(kind-only gate 的 toolName 在族外 ⇒ 不发证)。
375
+ ctx.led.noteParkGate({ fsOrShellFamily: isFsOrShellToolName(ev.gate.toolName) });
353
376
  return { kind: 'park', gate: 'fs' };
354
377
  }
355
378
  // 其余 gate:透传。⚠️ ADAPTER-F8 注纠(2026-08-02):原文写「eventToSdkMessage 出 null」——
@@ -359,12 +382,16 @@ function routeSuspended(ev, ctx) {
359
382
  }
360
383
  function routeDone(ev, ctx) {
361
384
  const { led } = ctx;
385
+ // 两条 park 臂都登记(理由同 routeSuspended)。
362
386
  if (isAskGatePark(ev.result)) {
363
387
  ctx.taskId.current = ctx.taskId.current ?? ev.result.taskId;
388
+ led.noteParkGate({ fsOrShellFamily: false });
364
389
  return { kind: 'park', gate: 'ask', pendingDone: ev };
365
390
  }
366
391
  if (isFsApprovalPark(ev.result)) {
367
392
  ctx.taskId.current = ctx.taskId.current ?? ev.result.taskId;
393
+ // 准入证:主语是 checkpointGate **本体**的 toolName,不是任何 tool_end 帧的。
394
+ led.noteParkGate({ fsOrShellFamily: isFsOrShellToolName(ev.result.checkpointGate?.toolName) });
368
395
  return { kind: 'park', gate: 'fs', pendingDone: ev };
369
396
  }
370
397
  const events = [...led.flushHeld()]; // 真错(非 gate)的 Ask tool_end 此刻诚实渲染
@@ -19,6 +19,17 @@
19
19
  * 台账要能自描述,值类型必须与台账同层或更低。`askGateWire.ts` 原样 re-export 它,对外类型面不变。
20
20
  */
21
21
  import type { AgentEvent } from '@sema-agent/sdk';
22
+ /**
23
+ * 连坐 abort 帧的 additive 超集机读位(#324 / [4907] 裁定;登记见 `docs/type-superset.json`)。
24
+ *
25
+ * `flushHeld` 对**已知不是 gate 主角**的帧 stamp `true`,别的帧一个字节不改。消费方(壳 / web /
26
+ * desktop)据此把连坐簇按 repeated-error 族软化折叠,**不猜文案**。
27
+ *
28
+ * 🔴 刻意**不动** `isError` 与 `output`:core 5.51 的 `[DEFERRED]` / `gate.batch_halted` 词汇
29
+ * ([4826]③)尚未到货,那一面的定形归 core;本键只是机读位,到货后与官方形对表,
30
+ * additive 键保留兼容(裁定原文)。
31
+ */
32
+ export declare const SEMA_COLLATERAL_ABORT_KEY = "_sema_collateral_abort";
22
33
  /** CC AskUserQuestion outputSchema 形状(答过的问题卡):toolResult 的 `ask-user-question` arm
23
34
  * 消费它渲真实答案卡(否则「诚实缺席」路径会把这张卡渲成结果不可用)。 */
24
35
  export interface AskAnsweredOutput {
@@ -52,12 +63,44 @@ export interface GateLedger {
52
63
  gatedStartArgs(): ReadonlyMap<string, unknown>;
53
64
  isEnded(callId: string): boolean;
54
65
  markEnded(callId: string): void;
55
- /** park 毒化的 gated tool_end(isError:true "Operation aborted")——HOLD 到 gate 定性
56
- * (AskUserQuestion + [816] 放宽后的 fs 写工具 + [2150] Bash)。 */
66
+ /**
67
+ * park 毒化的 gated tool_end(isError:true "Operation aborted")——HOLD gate 定性
68
+ * (AskUserQuestion + [816] 放宽后的 fs 写工具 + [2150] Bash)。
69
+ *
70
+ * 🔴 **入表当拍不判出身**(#324 换锚,2026-08-22):durable park 腿的帧序是
71
+ * `tool_start → tool_end(毒化) → done{suspended}` —— gate 身份**晚于**毒化帧到达,入表那一拍
72
+ * 包层根本还不知道谁是主角。出身改在 {@link flushHeld} 当拍求值(五个 flush 出口全部晚于 park
73
+ * 被看见),判据见 {@link noteParkGate}。
74
+ */
57
75
  hold(callId: string, ev: AgentEvent): void;
58
76
  dropHeld(callId: string): void;
59
77
  heldCount(): number;
60
- /** drain:逐帧吐出并**同批记 ended**(fail-soft 回退路径与终帧路径共用的唯一出口)。 */
78
+ /**
79
+ * drain:逐帧吐出并**同批记 ended**(fail-soft 回退路径与终帧路径共用的唯一出口)。
80
+ *
81
+ * 🔴 **一帧不丢**(frameRouter:185 的不变量,#324 车实测确证它是刻意的):HOLD 过的帧在这里
82
+ * 全部吐出去 —— 丢帧的后果是永久假排队(dim 圆点永远等不到结果行),比多渲一行红字更坏。
83
+ * 本出口做的唯一一件事是给**已知不是 gate 主角**的帧 stamp {@link SEMA_COLLATERAL_ABORT_KEY}
84
+ * 机读位(additive 超集键;`isError` / `output` / 帧序一律不动),让消费端能把连坐簇与主角帧
85
+ * 分开呈现。
86
+ *
87
+ * 🔴 判词方向(fail-safe):**判不出本批主角 ⇒ 一个都不 stamp**(诚实缺席,与本键出现之前
88
+ * 逐字节相同);判得出 ⇒ 主角走原路零改动、其余一律 stamp。绝不按到达顺序编判词。
89
+ *
90
+ * 主角怎么判(实现见 `resolveGateSubjects`,判据全部**批级**、一批一套):
91
+ * ① **硬门:本批恰好一张 park**({@link noteParkGate})。零张 = 归属无从证明(同步帧腿 /
92
+ * 纯取消批都落这里);两张以上 = 这批扣留帧混了两次 gate 的 abort,谁属谁无解。一律不标。
93
+ * ② **主角 = 本批(扣留帧 ∪ 本批已出批的帧)里唯一属该 park 族的 call**。并上「已出批的帧」
94
+ * 是因为决断成功那一下会把主角的毒化帧 `dropHeld` 掉,只看扣留帧的话主角就「不在这一批」,
95
+ * 整批判据当场失效。族外 park(kind-only / ask)不发准入证 ⇒ 整批不标。
96
+ * ③ 前置:**每一条扣留帧的 `tool_start` 本连接都得见过** —— 族成员判据取自 tool_start 当拍
97
+ * 记的集合,没见过 start 的 call 在「唯一」这件事上是隐形的;续流只消费 `runs.events` 的
98
+ * 一段,主角的 start 完全可能落在这次连接之外,那时表里剩下的唯一族成员是**旁观者**。
99
+ *
100
+ * 🔴 **pending 队列一概不参与判别**:`durable checkpointGate` 不带 callId,`findPendingForTask`
101
+ * 取的是「同 taskId + 谓词命中的第一行」(不命中时退「任意行」)—— 那是给**卡面渲染**用的宽口,
102
+ * **不是身份**。同族两行排队时它会给出旁观者,拿它当主角就把真主角挤成「非主角」而 stamp。
103
+ */
61
104
  flushHeld(): Generator<AgentEvent>;
62
105
  /** 三选卡 deny 过的 call:重放 tool_end 的 render 面 stamp REJECT_MESSAGE(vendored
63
106
  * renderToolUseRejectedMessage 渲 `User rejected <op> to <path>`,CC 逐字)。 */
@@ -81,6 +124,28 @@ export interface GateLedger {
81
124
  * 时前进,天然把「这次 park 到底是哪个 call」的身份带过 decide→重放两轮。 */
82
125
  noteFsOrShellGate(callId: string): void;
83
126
  lastFsOrShellGatedCallId(): string | undefined;
127
+ /**
128
+ * 登记「**这一批**的 park」——判别子唯一认的归属证据。写者 = 四条 park 臂:`routeDone` 的
129
+ * ask/fs 两条、`routeSuspended` 的 ask/fs 两条。`fsOrShellFamily` = 这张 park 的 gate **本体**
130
+ * `toolName` 属 fs 写 / shell 执行族吗(唯一性兜底腿的准入证)。
131
+ *
132
+ * 🔴 **为什么只认 park、不认 `tool_approval` 帧**(异源对抗复审四轮的收口,2026-08-22):
133
+ * 判别子要回答的是「**这一批** abort 帧属于哪张 gate」,而 wire 上**没有**任何位做这个关联 ——
134
+ * 包层能算的都是推断。park 是唯一**归属可结构性证明**的证据:`flushHeld` 的出口就长在那张 park
135
+ * 自己的决断/回退路径上(`parkResolver` fail-soft / decide 成功后的续流放行 / 终帧),
136
+ * 「这一批是这张 park 的 abort 批」是结构事实,不是猜的。
137
+ * 审批帧腿不具备这个性质:帧只证明「本 turn 出现过一张 gate」,那张 gate 可能**早已正常收口**
138
+ * (零毒化帧 ⇒ 零扣留表 ⇒ 一次 flush 都不跑),它的证却会活到下一批去派判词。四轮实测里
139
+ * 三条「假阳打回主角」都出自帧腿证据(旧 server 双主角 / 零 HOLD 审批证跨批 / 历史身份跨批),
140
+ * 故本版**刻意**不从帧腿取证 —— 代价是同步帧腿整条不标(已知局限,见 §3z)。
141
+ *
142
+ * 🔴 **重放同一张 park 不算新的一张**:#110 成文「durable re-attach 必然把已决断的 park 再送
143
+ * 一遍」。判据 = 自上一次 park 登记以来**有没有新的帧进过扣留表**;没有就是同一张 park 被重放,
144
+ * 计数不动(否则本批会被自己的重放判成「多 park」而整批失效,真连坐帧拿不到位)。
145
+ */
146
+ noteParkGate(evidence: {
147
+ fsOrShellFamily: boolean;
148
+ }): void;
84
149
  /** 续流重放的解答 tool_end 无 output,不补就会渲成结果不可用;这里记下真实答案供该帧 stamp
85
150
  * `structured` 让卡片渲真实选择。 */
86
151
  rememberAnswer(callId: string, answered: AskAnsweredOutput): void;
@@ -1,14 +1,74 @@
1
+ /**
2
+ * 连坐 abort 帧的 additive 超集机读位(#324 / [4907] 裁定;登记见 `docs/type-superset.json`)。
3
+ *
4
+ * `flushHeld` 对**已知不是 gate 主角**的帧 stamp `true`,别的帧一个字节不改。消费方(壳 / web /
5
+ * desktop)据此把连坐簇按 repeated-error 族软化折叠,**不猜文案**。
6
+ *
7
+ * 🔴 刻意**不动** `isError` 与 `output`:core 5.51 的 `[DEFERRED]` / `gate.batch_halted` 词汇
8
+ * ([4826]③)尚未到货,那一面的定形归 core;本键只是机读位,到货后与官方形对表,
9
+ * additive 键保留兼容(裁定原文)。
10
+ */
11
+ export const SEMA_COLLATERAL_ABORT_KEY = '_sema_collateral_abort';
1
12
  /** 造一份 turn 级 gate 台账(**不是单例**,见文件头注)。 */
2
13
  export function createGateLedger() {
3
14
  const startedCalls = new Set();
4
15
  const endedCalls = new Set();
16
+ /** 只存帧:出身不在入表当拍冻结(那一拍 gate 身份还没到),改在 `flushHeld` 当拍求值。 */
5
17
  const heldAskEnds = new Map();
6
18
  const gatedStartArgsByCall = new Map();
7
19
  const deniedCalls = new Set();
8
20
  const pendingFsCalls = [];
9
21
  const resolvedAnswers = new Map();
10
22
  const decidedGates = new Set();
23
+ // ── 批级判据(四样同生共死,退休点见 `retireBatchState` 的两处调用)────────────────────────
24
+ /** 本批 park 属 fs/shell 族的准入证(唯一性兜底的前置)。 */
25
+ let fsOrShellFamilyGate = false;
26
+ /** 本批里被 `dropHeld` 摘出扣留表的 call(decide 成功会把主角摘走,判据还得认它)。 */
27
+ const droppedFromBatch = new Set();
28
+ /** 本批登记过几张 park。≠1 ⇒ 归属无从证明 ⇒ 整表不标(硬门)。 */
29
+ let batchParkCount = 0;
30
+ /** 上一次 park 登记之后有没有新帧进过扣留表 —— 用来把「同一张 park 被 durable 重放」认出来。 */
31
+ let heldSinceLastPark = false;
32
+ /** 本 turn 见过的 fs 写 / shell 执行族 gated tool_start —— 只做**族成员**判据,不做主角判据。 */
33
+ const fsOrShellGatedCalls = new Set();
11
34
  let lastFsOrShellGatedCall;
35
+ /**
36
+ * flush 当拍解析「谁是主角」:登记在案的优先;否则唯一性兜底(要准入证 + 扣留表内族成员唯一);
37
+ * 都拿不到 ⇒ `undefined` = 不知道,调用方一个都不 stamp。
38
+ *
39
+ * 🔴 返回 `undefined` 与返回空集合是**两件事**,别合并([honest-absence-not-fabricated-zero]):
40
+ * 空集合意味着「知道谁是主角,且一个都不是」。
41
+ * 🔴 兜底在**扣留表内**求唯一 —— 主角必在这一批被同一次 abort 连坐的 call 里;拿整个 turn 的
42
+ * 历史集求唯一会把「已经收口过的旧 call」算进来,又是一次「拿旁证冒充身份」。
43
+ */
44
+ const resolveGateSubjects = () => {
45
+ // 🔴 硬门:归属可结构性证明 = 本批恰好一张 park。零张(同步帧腿 / 纯取消批)与多张(两次
46
+ // gate 的 abort 混进同一张扣留表)都无解 —— 诚实不标,绝不猜。
47
+ if (batchParkCount !== 1)
48
+ return undefined;
49
+ // 准入证:这张 park 的 gate **本体**属 fs 写 / shell 执行族 —— 主角必是该族的某个 call。
50
+ if (!fsOrShellFamilyGate)
51
+ return undefined;
52
+ // 前置:每一条扣留帧的 `tool_start` 都得是本连接亲眼见过的。族成员判据取自 tool_start 当拍
53
+ // 记的集合,没见过 start 的 call 在「唯一」这件事上是隐形的;而续流只消费 `runs.events` 的一段
54
+ // (re-attach 从 `lastEventId` 起),主角的 start 完全可能落在这次连接之外 —— 那时表里剩下的
55
+ // 唯一族成员是**旁观者**,会被当成主角、反手给真主角打上连坐位。信息不全就不许声称「唯一」。
56
+ for (const id of heldAskEnds.keys()) {
57
+ if (!startedCalls.has(id))
58
+ return undefined;
59
+ }
60
+ // 主角 = 本批(扣留帧 ∪ 已出批帧)里**唯一**属该族的 call。并上出批帧是因为决断成功那一下会
61
+ // 把主角的毒化帧 `dropHeld` 掉;不并上就等于主角「不在这一批」,整批判据当场失效。
62
+ const family = [...new Set([...heldAskEnds.keys(), ...droppedFromBatch])].filter((id) => fsOrShellGatedCalls.has(id));
63
+ return family.length === 1 ? new Set(family) : undefined;
64
+ };
65
+ /** 一批判完(或整批被摘空)⇒ 四样批级判据同批退休:下一批从零证起。 */
66
+ const retireBatchState = () => {
67
+ fsOrShellFamilyGate = false;
68
+ droppedFromBatch.clear();
69
+ batchParkCount = 0;
70
+ heldSinceLastPark = false;
71
+ };
12
72
  let denyStampNextFsEnd = false;
13
73
  let seq;
14
74
  return {
@@ -41,20 +101,43 @@ export function createGateLedger() {
41
101
  endedCalls.add(callId);
42
102
  },
43
103
  hold(callId, ev) {
104
+ // 🔴 只有**新** call 进批才算「批里来了新东西」:durable re-attach 会把仍被扣留的毒化帧
105
+ // 连同 park 一起重放,那一下若也置位,同一张 park 就会被当成第二张而触发硬门弃判
106
+ // (实测:重放后本该带位的兄弟帧变成 ABSENT)。覆盖同一 callId = 重放,不置位。
107
+ if (!heldAskEnds.has(callId))
108
+ heldSinceLastPark = true;
44
109
  heldAskEnds.set(callId, ev);
45
110
  },
46
111
  dropHeld(callId) {
47
- heldAskEnds.delete(callId);
112
+ if (heldAskEnds.delete(callId))
113
+ droppedFromBatch.add(callId);
114
+ // 🔴 扣留表空 = 这一批没有(或不再有)任何待判的帧,批级判据当场退休 —— 不能等下一次
115
+ // flush:「决断成功但本批压根没有毒化帧」的路径上 `flushHeld` 一次都不会跑,判据会就这么
116
+ // 活到下一批去把那一批的**真主角**判成连坐(实测 `new-main=STAMP`)。
117
+ // 🔴 「摘的那个不在表里」也要走这一步(不能早退):零 HOLD 批正是**每次** delete 都落空的
118
+ // 形状,早退就等于那种批永远不退休 —— 上一版的漏洞就在这里。
119
+ if (heldAskEnds.size === 0)
120
+ retireBatchState();
48
121
  },
49
122
  heldCount() {
50
123
  return heldAskEnds.size;
51
124
  },
52
125
  *flushHeld() {
53
- for (const [callId, held] of heldAskEnds) {
126
+ // 🔴 出身在**这一拍**求值(不是入表当拍):五个 flush 出口全部晚于 park 被看见,
127
+ // gate 身份此时已在手。`undefined` = 不知道谁是主角 ⇒ 整表一个字节不改。
128
+ const subjects = resolveGateSubjects();
129
+ for (const [callId, ev] of heldAskEnds) {
54
130
  endedCalls.add(callId);
55
- yield held;
131
+ // 已知不是主角 ⇒ additive 机读位(帧的其余部分逐字节原样);主角与「不知道」走原路。
132
+ yield subjects !== undefined && !subjects.has(callId)
133
+ ? { ...ev, [SEMA_COLLATERAL_ABORT_KEY]: true }
134
+ : ev;
56
135
  }
57
136
  heldAskEnds.clear();
137
+ // 🔴 判完即弃:这一批帧就是这张 gate 的 abort 批。不退休 ⇒ 判完之后又来一批**背后没有
138
+ // 任何 gate**的 abort 帧(例如整轮被取消)时,上一批的证会给它们派判词。
139
+ // 身份不在退休之列(turn 级事实),它靠上面的「∩ 本批」求交集来限定作用域。
140
+ retireBatchState();
58
141
  },
59
142
  markDenied(callId) {
60
143
  deniedCalls.add(callId);
@@ -87,10 +170,26 @@ export function createGateLedger() {
87
170
  },
88
171
  noteFsOrShellGate(callId) {
89
172
  lastFsOrShellGatedCall = callId;
173
+ fsOrShellGatedCalls.add(callId);
90
174
  },
91
175
  lastFsOrShellGatedCallId() {
92
176
  return lastFsOrShellGatedCall;
93
177
  },
178
+ noteParkGate(evidence) {
179
+ // 自上次登记以来没有**新 call** 进过扣留表 ⇒ 按 durable 重放同一张 park 处置:不计新的一张。
180
+ // 🔴 而且**一个字节都不写**。「没有新帧进批」证明得了「这一批的组成没变」,证明不了
181
+ // 「这张 park 就是上一张」—— 一张**真的新 park** 同样可以零毒化帧(例如上一张 park 走
182
+ // already-resolved 重放救回、续流紧接着来一张别的 gate 的 park)。此时若顺手刷新族属,
183
+ // 本批的判据就被一张与这批帧无关的 gate 改写:实测 `WebFetch` 主角 + `Bash` 旁观者的批
184
+ // 被一张零毒化 `Bash` park 改成 fs/shell 族,兜底选中旁观者、反手给真主角打位
185
+ // (`web-main=STAMP`)。真是重放时族属本就相同,不写零损失;是新 park 时不写才安全。
186
+ // (本批的帧全部早于第一张 park,归属本来就该按第一张算。)
187
+ if (batchParkCount > 0 && !heldSinceLastPark)
188
+ return;
189
+ batchParkCount += 1;
190
+ fsOrShellFamilyGate = evidence.fsOrShellFamily;
191
+ heldSinceLastPark = false;
192
+ },
94
193
  rememberAnswer(callId, answered) {
95
194
  resolvedAnswers.set(callId, answered);
96
195
  },
@@ -289,6 +289,17 @@ export async function resolvePark(park, ctx) {
289
289
  // decide 成功:丢弃该 call 的毒化 HOLD(续流重放会带 isError:false 的解答帧收口卡片),
290
290
  // 并记下真实答案供该解答帧 stamp `structured`(否则卡片渲成结果不可用)。
291
291
  if (outcome.gatedCallId) {
292
+ // 🔴 #324:**刻意不把 pending 行的 callId 当成连坐判别的主角身份**。durable `checkpointGate`
293
+ // 不带 callId,`findPendingForTask` 取的是「同 taskId + 谓词命中的**第一行**」(谓词不命中
294
+ // 时还退「任意行」)—— 那是给**卡面渲染**用的宽口,证明不了这一行就是这张 park 的主角:
295
+ // 同族两行排队时它可能给出旁观者,于是真主角被挤成「非主角」而 stamp(异源复审实测)。
296
+ // 连坐判别改由台账按「本批唯一的该族 call」自证(`gateLedger.flushHeld` 头注),
297
+ // 分不出来就整批不标。下面两行是 gate 生命周期(#110 / REF-CC-034),与判别无关,照旧。
298
+ // ⚠️ **记案(存量,非本批根因)**:`dropHeld` 用的也是这个不可信的 `gatedCallId` —— 同族
299
+ // 多行排队时它可能摘掉**另一条** call 的扣留帧,那一帧就再也不吐了(「一帧不丢」在这一格
300
+ // 上不成立)。这条早于本批(4c42c8a 逐字相同),根因在「decide 结果没有可核验的 park 身份」,
301
+ // 与本批修的判别面是两件事;改它要动 #110 的承重路径,单独立案。server 补
302
+ // `checkpointGate.toolCallId` 后两处一并收口。
292
303
  led.dropHeld(outcome.gatedCallId);
293
304
  led.markDecided(outcome.gatedCallId); // #110 缺陷② b / REF-CC-034:身份留痕,上方候选匹配消费
294
305
  if ('answered' in outcome && outcome.answered)
@@ -23,7 +23,7 @@
23
23
  | peer:wire 契约 | `@sema-agent/sdk` **>=7.2.0**(value-level,非 type-only) | `package.json` `peerDependencies` |
24
24
  | peer:会话词汇表 | `@sema-agent/agent-types` **>=0.2.0**(type-only,零运行时) | 同上 |
25
25
  | runtime dep | `diff` ^9.0.0(**唯一**一条;portability 门按**等值**钉死) | `package.json` `dependencies` |
26
- | 公开导出面 | **766** 个运行期符号(+ 39 个测试钩;= 未发 `0.39.0` 的值,npm `0.38.0` 是 **764**;`0.37.0` 是 **753**,见 `CHANGELOG.md`) | `scripts/public-export-baseline.json` 的 `count` / `testHookCount` —— **别手抄进别处,以该文件为准** |
26
+ | 公开导出面 | **767** 个运行期符号(+ 39 个测试钩;= 未发 `0.40.0` 的值,npm `0.39.0` 是 **766**,`0.38.0` 是 **764**;`0.37.0` 是 **753**,见 `CHANGELOG.md`) | `scripts/public-export-baseline.json` 的 `count` / `testHookCount` —— **别手抄进别处,以该文件为准** |
27
27
  | 常驻门 | 以 `scripts/gates-manifest.json` 的 `suites` 长度为准(**本档不抄这个数**) | `scripts/gates-manifest.json`;`npm test` 的名单等值门与它逐名对账 |
28
28
  | 沿革档 | 0.29.0 起建 `CHANGELOG.md`;更早批次记账在 `src/index.ts` 文件头 + `docs/REFACTOR-LEDGER.md` | — |
29
29
 
@@ -101,7 +101,7 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
101
101
 
102
102
  ## §2 公共导出面地图(按域)
103
103
 
104
- > 全集真源 = `scripts/public-export-baseline.json` 的 `names`(**766** 项)。
104
+ > 全集真源 = `scripts/public-export-baseline.json` 的 `names`(**767** 项)。
105
105
  > 本节**不逐名抄**,只给「域 → 承重导出 → 用途 → 实现锚」。承重导出 = 一个端为了让这个域干活
106
106
  > **必须**直接调到的那几个符号;其余是它们的类型、变体与辅助位。
107
107
  > 单一入口:`import { … } from '@sema-agent/client-core'`(`exports` 只有 `.` 一个;
@@ -111,7 +111,7 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
111
111
 
112
112
  `public-export-baseline.json` 由 **`dist/index.js` 的运行期导出**生成(生成口径自述见
113
113
  `scripts/run-client-core-typeshape-test.mjs`,双向精确集合门在 `scripts/run-public-surface-test.mjs`)。
114
- 实测:766 项 **100% 是运行期导出,零 type-only**。
114
+ 实测:767 项 **100% 是运行期导出,零 type-only**。
115
115
 
116
116
  **推论(端必须知道)**:
117
117
  - barrel 导出的**类型**面比 707 大得多,且**不被这道门看守** —— `AdapterContext` / `SeamEvent` /
@@ -120,18 +120,18 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
120
120
  端依赖这些类型是合法的,但**不要**拿基线 diff 当"类型面没变"的证据。
121
121
  - `src/agentSession/contract.ts` 对基线贡献 **0** 项(纯类型模块,`export *` 在 dist 里是空转发)。
122
122
 
123
- 766 项的内部构成(帮助端估读表大小):**226** 项是 `SCREAMING_SNAKE` 常量数据表/词汇表
123
+ 767 项的内部构成(帮助端估读表大小):**227** 项是 `SCREAMING_SNAKE` 常量数据表/词汇表
124
124
  (矩阵、键集、env 名、锚串)而非可调用物;**5** 项是 PascalCase 运行期值
125
125
  (`ControlRouter` / `ControlSafetyError` / `HitlBridge` / `HitlSafetyError` / `DecideTransportRetryExhaustedError`);
126
126
  **39** 项是 `*For(sessionKey, …)` 的 per-session 变体(§6;其中 `engineNamespaceKeyFor` 是命名巧合 —— 参数是 baseUrl 不是 sessionKey,见域 14)。
127
127
 
128
- ### 2b. 域图(16 域,逐域计数之和 = 764)
128
+ ### 2b. 域图(16 域,逐域计数之和 = 767)
129
129
 
130
130
  | # | 域 | 名数 | 承重导出 | 用途 | 实现锚 |
131
131
  |---|---|---|---|---|---|
132
132
  | 1 | **适配内核(下行主链)** | 33 | `adapt` · `createWireToCcAdapter` · `runStream` · `eventToSdkMessage` · `terminalToSdkResult` · `turnUsageToModelUsage` · `isRunStreamActive` · `ADAPTER_DIVERGENCES` | 引擎 SSE `AgentEvent` → 端要渲的**双面输出**:transcript(`SDKMessage`)+ chrome(瞬态 `ChromeEvent`)。**本包存在的理由** | `src/adapt.ts`、`src/adapt/{arms,wireShapes,panelTasks}.ts`(经 `adapt.ts` 再导出)、`src/adapter/runStream.ts`、`src/adapter/downstream/*`、`src/adapter/types.ts` |
133
133
  | 2 | **seam 公共契约** | 2(其余为 type-only) | `CHROME_ARMS` · `deriveTranscriptId` | 公共词汇 + **id 确定性不变量**(同一条流重放 ⇒ 同一串 id)。`CHROME_ARMS` = 端「我要消费哪些 chrome 臂」的对照清单 | `src/seam.ts` |
134
- | 3 | **HITL 决断卡链**(§4/§5 主战场) | 120 | `makeHitlCanUseTool` · `HitlBridge` · `findPendingForTask` · `HitlSafetyError` · `bridgeAskUserQuestionGates` · `surfaceToolApprovalFrameAndRespond` / `surfaceFsApprovalAndDecide` · `readToolApprovalRespondAck` · `installApprovalCardPort(For)` · `installHitlHostSurface(For)` · `armPlanReviewApproval` · `reopenPlanReviewCard` · `decidePlanReview` · `startApprovalsFeed` · `pendingRowIsOwnedByThisSession` · `approvalCallKey`/`liveFrameCallKey`/`planReviewQuestionId` · `registerArmedGateFor`/`wasGateArmedFor`/`clearArmedGateFor` · `waitForGateArmed(For)`/`onGateArmed(For)`/`gateArmedWaitMs`(#244 F1 呈现回执事件源) · `planReviewArmedKey(For)`/`notePlanReviewAnswered(For)`/`notePlanReviewAnsweredIfDecisive(For)`(A-024.4 plan 呈现分代) · `toolEndOutputText` · `isAskTool` · `waitForParkRowBirth` · `classifyAskParkRows` / `askParkRowArm` / `classifyAskParkChainFailure` · `readDecisionNoteAudit` / `decisionNoteAuditLine` · `resumeRunningOptions` / `resumeChoiceFromLabels` · `persistedRulesLaneAvailable`/`persistedRulesGovernanceAvailable` · `classifyRulesFailure` · `listAllPersistedRules` · `classifySkippedReason` · `readRulePersistOutcome`(#244 F2:persist-ack 读口与 `readToolApprovalRespondAck` 合成一处) · `parseLocalAllowRule`(durable 腿本地落规则窄化骨架,谓词经 `LocalAllowRuleDeps` 注入) · `DecideTransportRetryExhaustedError`(Inkglow-1085 P0a:decide 出站瞬断重试耗尽的 typed 判别 —— HitlBridge 内建单次退避重试,耗尽走重呈臂不判死 turn;端一般只消费行为,不需要 instanceof) | suspended→decide→resume 环。🔴 **D-1 两元组 verbatim 回显**是字节级断言的安全不变量,端**不许重实现它的任何一段**。🔴 键空间边界(web [C1] d3 拦截):`gateIdentity` 四常量两函数只覆盖 HITL questionId/callKey 空间;seat 的 `TOOL_PERMISSION_REQUEST_ID_DOMAINS`(`plan:` 等)是另一键空间,**两者绝不合并**(合并=座位校验器静默拒全部 plan-review 卡) | `src/hitl/hitlBridge.ts`、`toolApprovalWire.ts`、`askGateWire.ts`、`planReviewWire.ts`、`hitlHostSurface.ts`、`gateIdentity.ts`、`armedGateRegistry.ts`、`parkOwnership.ts`、`parkResolver.ts`、`approvalsFeed.ts`、`frameRouter.ts`(**只挑名导出** `toolEndOutputText`/`ENGINE_ABORT_TOOL_RESULT`/`isAskTool`/`HITL_REJECT_MESSAGE`)、`parkRowBirthWait.ts`、`approvalDecisionNoteAudit.ts`、`askParkRowRouting.ts`、`resumeRunningCard.ts`(#265 上收的判定层)、`persistedRulesWire.ts`、`localAllowRule.ts`(#244 F2 规则侧) |
134
+ | 3 | **HITL 决断卡链**(§4/§5 主战场) | 121 | `makeHitlCanUseTool` · `HitlBridge` · `findPendingForTask` · `HitlSafetyError` · `bridgeAskUserQuestionGates` · `surfaceToolApprovalFrameAndRespond` / `surfaceFsApprovalAndDecide` · `readToolApprovalRespondAck` · `installApprovalCardPort(For)` · `installHitlHostSurface(For)` · `armPlanReviewApproval` · `reopenPlanReviewCard` · `decidePlanReview` · `startApprovalsFeed` · `pendingRowIsOwnedByThisSession` · `approvalCallKey`/`liveFrameCallKey`/`planReviewQuestionId` · `registerArmedGateFor`/`wasGateArmedFor`/`clearArmedGateFor` · `waitForGateArmed(For)`/`onGateArmed(For)`/`gateArmedWaitMs`(#244 F1 呈现回执事件源) · `planReviewArmedKey(For)`/`notePlanReviewAnswered(For)`/`notePlanReviewAnsweredIfDecisive(For)`(A-024.4 plan 呈现分代) · `toolEndOutputText` · `isAskTool` · `waitForParkRowBirth` · `classifyAskParkRows` / `askParkRowArm` / `classifyAskParkChainFailure` · `readDecisionNoteAudit` / `decisionNoteAuditLine` · `resumeRunningOptions` / `resumeChoiceFromLabels` · `persistedRulesLaneAvailable`/`persistedRulesGovernanceAvailable` · `classifyRulesFailure` · `listAllPersistedRules` · `classifySkippedReason` · `readRulePersistOutcome`(#244 F2:persist-ack 读口与 `readToolApprovalRespondAck` 合成一处) · `parseLocalAllowRule`(durable 腿本地落规则窄化骨架,谓词经 `LocalAllowRuleDeps` 注入) · `DecideTransportRetryExhaustedError`(Inkglow-1085 P0a:decide 出站瞬断重试耗尽的 typed 判别 —— HitlBridge 内建单次退避重试,耗尽走重呈臂不判死 turn;端一般只消费行为,不需要 instanceof) | suspended→decide→resume 环。🔴 **D-1 两元组 verbatim 回显**是字节级断言的安全不变量,端**不许重实现它的任何一段**。🔴 键空间边界(web [C1] d3 拦截):`gateIdentity` 四常量两函数只覆盖 HITL questionId/callKey 空间;seat 的 `TOOL_PERMISSION_REQUEST_ID_DOMAINS`(`plan:` 等)是另一键空间,**两者绝不合并**(合并=座位校验器静默拒全部 plan-review 卡) | `src/hitl/hitlBridge.ts`、`toolApprovalWire.ts`、`askGateWire.ts`、`planReviewWire.ts`、`hitlHostSurface.ts`、`gateIdentity.ts`、`armedGateRegistry.ts`、`parkOwnership.ts`、`parkResolver.ts`、`approvalsFeed.ts`、`frameRouter.ts`(**只挑名导出** `toolEndOutputText`/`ENGINE_ABORT_TOOL_RESULT`/`isAskTool`/`HITL_REJECT_MESSAGE`)、`parkRowBirthWait.ts`、`approvalDecisionNoteAudit.ts`、`askParkRowRouting.ts`、`resumeRunningCard.ts`(#265 上收的判定层)、`persistedRulesWire.ts`、`localAllowRule.ts`(#244 F2 规则侧) |
135
135
  | 4 | **子代 wire + 面板侧信道台账** | 82 | `tailEngineSubagent` · `installSubagentActivitySink` · `installSubagentTailMetaSink`(#280 件2:tail meta 帧发布口,`contentFrames` 判别位载体)· `stopEngineTask` + `classifyTaskStopConflict` · `fetchEngineSubagentReport` · `steerEngineSubagent`(0.32.0 未发布 #280 件A:additive 第三参 `childTaskId` —— 端有行上下文时**应当**传,传了就走「台账优先 / 缺席即诚实缺席 + `noteBgOwnerAbsence` 留痕」的 Q3 口径,与 tail·taskOutput·subagentOutput 三腿同姿势、与孪生 resume 腿共用同一个 `resolveOwnerRunId` 判据;**不传**则逐字维持旧行为=回落在飞 run)· `resumeSettledSubagent` + `resolveSubagentResumeContext` + `resolveOwnerRunId` + `classifySubagentResumeFailure` + `subagentResumeAvailable`(#242 批 2 A-028.7:resume 判定半场上收,与 steer 孪生同居;取址三态 = 台账有行用行值 / 指名了行但台账缺席则**诚实缺席绝不回落在飞 run** / 没指名行才回落。出路文案归端)· `recordSubagentOwnerFromProgress` + `getBgParentRunOwner`(A-028.6:「子代 → 宿主 run」**单表**,宿主 run 必须由持 stream-local 值的调用方显式传入,包内绝不从 `activeEngineRunId()` 推断)· `noteBgOwnerAbsence`(#242 批 3 [4000] Q3=B:tail/taskOutput·taskStop/subagentOutput 三腿台账缺席即诚实缺席**绝不回落在飞 run**,缺席 warn 留痕每 (腿,taskId) 一条)· `clearBgTerminalFacts`(#242 批 3 扫码修:复活=新周期,旧周期终态事实作废——fleetLedger 复活两腿按尾段清账,factsAccepted 方向核不再拿上周期终态当先例)· `auditRetainWithoutWake`([4000] Q5:引擎宣示 `subagentResume` + 本端在付 `retainSubagentSessions` + 端未实现 `wakeSubagent` ⇒ 响亮一条;`CLIENT_VERBS.wakeSubagent` 维持 fail-soft)· `subscribeSubagentContent` · `subscribeEngineAgentPanel` · `publishQuestionFrame` / `respondToQuestion` | 驱动与观测委派子代;经 module 级台账喂活体 agent/task 面板。全部**能力位 gate**(§5b) | `src/subagent/*.ts`、`src/subagentContentStore.ts`、`src/engineAgentPanelStore.ts`、`src/engineInlineTaskStats.ts`、`src/engineToolLabelStore.ts`、`src/liveQuestionStore.ts` |
136
136
  | 5 | **fleet 投影** | 46 | `createFleetLedger` · `projectTasks` · `projectWorkflows` · `projectFleetAgentRows` · `readEngineActiveBgTasks` · `FLEET_TASK_VIEW_KEYS` · `escapeDisplayControlChars`(不可见字符可见化,行标签/描述消毒的共享底座)· `wireCycleSeq` / `wireRetiredBy`(0.38.0 提货补投的 #261 §2 两位:代际号 = SendMessage 复活即 +1,**缺席 ≠ 第一代**;`retiredBy` 在场 = 这条终态是对账腿从 durable run 行投影出来的、**不是**发布方亲报 —— 幽灵行与正常收尾唯一的 wire 判据。两位都只在场才落键) | 老 `fleetClient` 那一刀的成品:**帧体归库、连接归端** —— 端持 SSE 连接,库做行投影 + 保留台账 | `src/fleet/fleetProjection.ts`、`src/fleet/fleetLedger.ts`、`src/fleetAgentPanelProjection.ts`、`src/fleetTaskDesc.ts` |
137
137
  | 6 | **请求装配(上行唯一构造口)** | 8 | `buildTaskRequest` · `REQUEST_FIELD_MATRIX` · `unregisteredRequestKeys` · `applyLiveRequestDefaults` · `taskNotificationToPrintFrame` | 两条车道(`interactive`/`print`)出站请求的**唯一**构造器;`unregisteredRequestKeys` 是可执行门 —— 端偷带一个未登记键上 wire 就红 | `src/request/taskRequest.ts`、`src/request/printNotification.ts` |
@@ -304,6 +304,100 @@ lane 归属改用 id 形状 / `workflowRunId` 启发式判(`src/adapt/arms.ts`)
304
304
  7.38 到)。**对本包与端零影响** —— 正因为消费面按开集写,白名单是 server 的投递判定,不是消费判据。
305
305
  已按接入文档宪法回报 server。
306
306
 
307
+ ### 3z. 🆕 `_sema_collateral_abort` —— 连坐 abort 机读位(0.40.0,#324 / [4907])
308
+
309
+ **病形**:同 turn 多个 call 在飞 + 一个撞 gate ⇒ 引擎对**整批**在飞 call 铸同一串
310
+ `Operation aborted`。本包 HITL 桥的 HOLD 表按 `isGatedToolEnd`(名字腿 ∪ abort 标记腿)
311
+ 把它们**全部**收进同一张表,任一 flush 出口一次倾泻 ⇒ 端上满屏同文红行,而其中只有一条是
312
+ 用户刚决断的那个 call,其余是**连坐**的旁观 call。
313
+
314
+ **本版做的事**(additive,`isError` / `output` / 帧序 / 帧数一律不动):
315
+
316
+ | 位置 | 行为 |
317
+ |---|---|
318
+ | `gateLedger.noteParkGate(...)` / `noteGateSubject(callId)` | 登记「本批那张 park」与「它决断出的主角」。判据只收 **park** 与 wire **身份**,绝不收工具名、也不收 `tool_approval` 帧 |
319
+ | `gateLedger.flushHeld()` | 在**这一拍**求出身:已知不是主角的帧 stamp `_sema_collateral_abort: true`。**一帧不丢**的不变量不变 —— 丢帧的后果是永久假排队(dim 圆点等不到结果行),比多渲一行更坏 |
320
+ | `eventToSdkMessage` 的 `case 'tool_end'` | 严格 `true` 才原样上内部 `tool_end_result` 臂;缺席不铸键 |
321
+
322
+ **判别子为什么在 flush 当拍求值(而不是入表当拍)**:durable park 腿的帧序是
323
+ `tool_start → tool_end(毒化) → done{suspended}` —— **gate 身份晚于毒化帧到达**。入表那一拍包层
324
+ 还不知道谁是主角,任何判词都是猜的。按工具名猜(「名字腿认得 = 可能是主角」)双向都错:一等
325
+ `kind==='tool_approval'` gate 的主角(名字腿认不得的 toolName,如 `WebFetch`)被判成连坐 ——
326
+ **假阳打在主角上**;同族兄弟(两个 `Bash` 只有一个撞 gate)两个都被判成主角 —— 连坐帧拿不到位。
327
+ 五个 flush 出口**全部**晚于 park 被看见,所以判词搬到那一拍才有真判据。
328
+
329
+ **判别子只认 park 供证 —— 归属必须可结构性证明**。判别子真正要回答的是「**这一批** abort 帧
330
+ 属于哪一张 gate」,而 wire 上**没有任何位**做这个关联,包层能算的都是推断。四轮异源对抗复审的
331
+ 定谳:只有 **park** 具备结构性归属 —— `flushHeld` 的出口就长在那张 park 自己的决断/回退路径上
332
+ (`parkResolver` fail-soft / 决断成功后的续流放行 / 终帧),「这一批是这张 park 的 abort 批」是
333
+ 结构事实,不是猜的。
334
+
335
+ 判据构成(**全部批级**,一批一套):
336
+ 1. **本批恰好一张 park**(硬门)。零张 ⇒ 归属无从证明;两张以上 ⇒ 两次 gate 的 abort 混进同一张
337
+ 扣留表,谁属谁无解。两种情形一律**整批不标**。
338
+ · durable **重放同一张 park** 不算新的一张 —— 判据是「自上次登记以来有没有**新的 call** 进过
339
+ 扣留表」(按 call 不按帧:re-attach 会把仍被扣留的毒化帧连同 park 一起重放)。
340
+ 🔴 走这条去重分支时**一个字节都不写**:该判据证明得了「这一批的组成没变」,证明**不了**
341
+ 「这张 park 就是上一张」—— 一张真的新 park 同样可以零毒化帧。顺手刷新族属就等于让一张与
342
+ 这批帧无关的 gate 改写本批判据。真是重放时族属本就相同,不写零损失。
343
+ · ask park 臂同样要登记:它自己用不到兜底腿,但它是本批的第二张 park,漏登会让上一张 fs park
344
+ 的判据被拿来给这一批派判词(连 ask 主角自己都会被 stamp)。
345
+ 2. **主角 = 本批(扣留帧 ∪ 本批已出批的帧)里唯一属该族的 call**;该族 = 这张 park 的 gate
346
+ **本体** `toolName` 所属的 fs 写 / shell 执行族(准入证;族外 park 不发证 ⇒ 整批不标)。
347
+ · 并上「已出批的帧」是因为决断成功那一下会把主角的毒化帧 `dropHeld` 掉;不并上就等于主角
348
+ 「不在这一批」,整批判据当场失效。
349
+ · 前置:**每一条扣留帧的 `tool_start` 都得是本连接亲眼见过的**。族成员判据取自 tool_start 当拍
350
+ 记的集合,没见过 start 的 call 在「唯一」这件事上是隐形的;而续流只消费 `runs.events` 的一段
351
+ (re-attach 从 `lastEventId` 起),主角的 start 完全可能落在这次连接之外 —— 那时表里剩下的
352
+ 唯一族成员是**旁观者**。信息不全就不许声称「唯一」。
353
+ · 🔴 **pending 队列一概不参与判别**:`durable checkpointGate` 不带 callId,
354
+ `findPendingForTask` 取的是「同 taskId + 谓词命中的**第一行**」(谓词不命中时还退「任意行」)
355
+ —— 那是给**卡面渲染**用的宽口,**不是身份**。同族两行排队时它可能给出旁观者,拿它当主角
356
+ 就会把真主角挤成「非主角」而 stamp(异源复审实测)。所以决断结果只用于 gate 生命周期
357
+ (`markDecided`/`dropHeld`),judgment 一步都不碰它。
358
+
359
+ 批级判据的**退休点两处**:`flushHeld` drain 完、以及 `dropHeld` 之后**扣留表为空**那一下。
360
+ 后者必须写成「无条件检查表空」而不是「摘中了才检查」—— 「park 决断成功但本批压根没有毒化帧」
361
+ 的批每次 delete 都落空,写成后者就等于那种批**永远不退休**,判据活到下一批派错判词。
362
+ drain 那条拦的是「判完之后又来一批背后没有 park 的 abort 帧」。
363
+
364
+ 🔴 **缺席语义(端必须按这条读)**:`_sema_collateral_abort` 缺席 = **「不是已知的连坐帧」**,它同时
365
+ 覆盖两种情况 —— (a) 这一帧是 gate 主角,(b) **本包判别不出来**(判据没到手)。**缺席不等于
366
+ 「这是主角」**,更不能据此渲「这条才是你刚决断的那个」。要断言主角身份,端自己按 `toolCallId`
367
+ 与手上的审批卡对账,别拿本键的缺席反推([honest-absence-not-fabricated-zero])。
368
+
369
+ 🔴 **端怎么读(唯一正道)**:从 **`tool_end_result` 臂**上读(`errorCode` / `settledBy` /
370
+ `approver` 同款位置),按 `toolCallId` 归集,自己决定呈现(建议:连坐簇按 repeated-error 族
371
+ 软化折叠,头卡留 + 计数尾)。**判别取机读位,不猜文案** —— `Operation aborted` 这串是
372
+ 引擎词汇,会随 core 版本变。键常量从本包取:`import { SEMA_COLLATERAL_ABORT_KEY }`
373
+ (单一真源,三端别各自手抄)。
374
+
375
+ 🔴 **这一位刻意不在转录 `tool_result` block 上,也不在转录消息顶层**(两向都有常驻断言钉着,
376
+ pure 门 062⑥/⑦):
377
+ - **block 上不行** —— 转录卡会随 `/compact` 直发 provider,往 block 上加自铸键正是 0.5.0
378
+ `label` 那个真缺口的形状(**provider 400**);
379
+ - **消息顶层不行** —— 壳的 `createUserMessage` 是闭形重建,顶层键在到达渲染层之前就没了
380
+ (`_sema_degraded` 至今壳侧零消费面,根因就是这个),放那儿等于假接线。
381
+
382
+ ⚠️ **已知局限一(在册,不当已办)**:**同族多兄弟分不出主角**。两个 `Bash` 同时在飞、其中一个
383
+ 撞 gate 时,wire 上没有任何位能说「这两个里哪个撞的」—— `lastFsOrShellGatedCallId()` 指向**最后
384
+ 一个** tool_start,pending 队列给的是「第一行」,两者都不是身份。**决断成功也不例外**(见判据②
385
+ 最后一条)。此格按诚实缺席处置:**整表一个都不 stamp**,端侧行为逐字节退回本键出现之前。
386
+ 🔴 出路在 server:`durable checkpointGate` 带上 `toolCallId`。常驻门 `F8-S3/S8/S33` 把这一格钉成
387
+ 「全 ABSENT」—— server 补位后这些条**当场翻红**,逼下一棒把新身份接进判据,而不是让局限长住。
388
+
389
+ ⚠️ **已知局限二(刻意为之)**:**同步 `tool_approval` 帧腿整条不标**。帧只证明「本 turn 出现过
390
+ 一张 gate」,证明不了「**这一批** abort 帧属于它」—— 那张 gate 完全可能早已正常收口(零毒化帧
391
+ ⇒ 零扣留表 ⇒ 一次 flush 都不会跑),它的证却会活到下一批去派判词。异源对抗复审四轮里,**全部**
392
+ 「假阳打在 gate 主角身上」的残留路径都出自帧腿取证(旧 server 同批双主角 / 零 HOLD 审批证跨批 /
393
+ 历史身份跨批)。取舍是明确的:**宁可少标,绝不误标主角**(误标主角 = 把用户真正该看的那条红行
394
+ 折叠掉,比多渲 N 行更坏)。常驻门 `F8-S4/S5/S7/S11/S14/S17/S21/S22` 把这一格钉成「全 ABSENT」。
395
+ 🔴 出路同样在 server:abort 批带 **gate 关联 id**(不只是主角 `toolCallId`),把归属从推断变成读数。
396
+
397
+ ⚠️ **@core 对表位(在册,不当已办)**:core 5.51 的 `[DEFERRED]` / `gate.batch_halted` 词汇
398
+ ([4826]③)到货时与本键**对表**。若 core 给出更强的官方形(例如 `errorCode` 上的专码),
399
+ 端侧换锚、本 additive 键**保留兼容**,不塌方。在那之前本键是端能拿到的唯一机读判别子。
400
+
307
401
  ---
308
402
 
309
403
  ## §4 回执消费义务(ack contract)—— 🔴 本档重点
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/client-core",
3
- "version": "0.39.0",
3
+ "version": "0.40.0",
4
4
  "description": "Client-side session runtime shared by every sema human client (TUI / web / desktop): sema wire frames (AgentEvent) -> CC session vocabulary (SDKMessage) with dual-plane output (transcript/chrome), deterministic transcript ids, lane discipline as a type, and the notification/dedup ledgers. Every CC-skin shape is collected here so the wire itself stays neutral. Blackboard [1832] design axioms; [1651]/[1652]/[1653] signed seam design. Renamed from @sema-agent/wire-cc-adapter (0.1.x).",
5
5
  "license": "MIT",
6
6
  "type": "module",