@sema-agent/client-core 0.40.0 → 0.41.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.
@@ -24,8 +24,16 @@ import type { GateLedger } from './gateLedger.js';
24
24
  * 🔴 pure 门 B7 段:包内冻结字面量(无条件)+ 壳树全树扫描「每一处声明都逐字节相同」(壳树缺席=跳过)。
25
25
  */
26
26
  export declare const HITL_REJECT_MESSAGE = "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.";
27
- /** core 对被 gate/连坐 abort 的 call 铸的 tool_end 载体(逐字;desktop session-host 真引擎实测
28
- * 同款)——HOLD 谓词锚它做**精确等值**,普通工具错的输出是各自错误文案,永不进 HOLD。 */
27
+ /**
28
+ * core 对**被 gate park 的那个 call**(gate 主角)铸的 tool_end 载体(逐字;desktop session-host
29
+ * 真引擎实测同款)。HOLD 谓词锚它做**精确等值** —— 普通工具错的输出是各自的错误文案,永不进 HOLD。
30
+ *
31
+ * 🔴 **勘误(件⑤,2026-08-22,[4970] 真机取证)**:此处原话是「core 对被 gate/**连坐** abort 的
32
+ * call **恒**铸这一串」——对连坐的旁观者**不成立**。core 5.52.0 dist 直证两个短路点两种文案:
33
+ * gate 主角走 abort 早退 = 本串;**执行前被连坐**的旁观者 = `"operation aborted before execution"`
34
+ * (小写 + 后缀)。所以本串只是判据的**兜底臂**(旧 core 唯一的信号),主锚是机读码
35
+ * `gate.parked`——见 {@link isEngineAbortToolEnd}。
36
+ */
29
37
  export declare const ENGINE_ABORT_TOOL_RESULT = "Operation aborted";
30
38
  /**
31
39
  * tool_end.output 的文本归一(P0 案B,2026-08-03,server 6.0.0 wire 实测):sync leg 的毒化帧
@@ -63,6 +71,16 @@ export interface GatePark {
63
71
  gate: GateKind;
64
72
  /** sync leg 的 `done{suspended}` 终帧 —— fail-soft 回退时原样吐回去(durable leg 的 park 无此帧)。 */
65
73
  pendingDone?: AgentEvent | undefined;
74
+ /**
75
+ * 这张 park 的**待批 call 身份**(server ≥7.41.0,[4913]/[4914] 请托① 到货件)。
76
+ * 供给见 {@link parkGatedCallId};消费者今天只有一个:`parkResolver` 决断成功臂的
77
+ * `dropHeldForDecidedPark` —— 它此前只能拿 `findPendingForTask` 那个**卡面渲染宽口**当身份,
78
+ * 同族多行排队时会摘错 call 的扣留帧([4933] 立案的存量)。
79
+ * 🔴 缺席是**常态**(旧引擎 / tool-less park / 键畸形),消费方必须自带无身份也能走的臂。
80
+ * 🔴 刻意**不**喂给 `#324` 的连坐判别子:那条判据的换锚是独立一件事(S3/S33 的「已知局限锁」
81
+ * 断言到时会翻红 = 点亮信号),两件事混在一起改就分不清谁引入了假阳。
82
+ */
83
+ gatedCallId?: string | undefined;
66
84
  }
67
85
  /** 帧路由的产物:驱动只认这四种,自己不再判帧型。 */
68
86
  export type FrameAction = {
@@ -74,6 +92,7 @@ export type FrameAction = {
74
92
  kind: 'park';
75
93
  gate: GateKind;
76
94
  pendingDone?: AgentEvent | undefined;
95
+ gatedCallId?: string | undefined;
77
96
  } | {
78
97
  kind: 'end';
79
98
  events: readonly AgentEvent[];
@@ -11,8 +11,16 @@ export const HITL_REJECT_MESSAGE = "The user doesn't want to proceed with this t
11
11
  function rejectMessageForRender() {
12
12
  return HITL_REJECT_MESSAGE;
13
13
  }
14
- /** core 对被 gate/连坐 abort 的 call 铸的 tool_end 载体(逐字;desktop session-host 真引擎实测
15
- * 同款)——HOLD 谓词锚它做**精确等值**,普通工具错的输出是各自错误文案,永不进 HOLD。 */
14
+ /**
15
+ * core 对**被 gate park 的那个 call**(gate 主角)铸的 tool_end 载体(逐字;desktop session-host
16
+ * 真引擎实测同款)。HOLD 谓词锚它做**精确等值** —— 普通工具错的输出是各自的错误文案,永不进 HOLD。
17
+ *
18
+ * 🔴 **勘误(件⑤,2026-08-22,[4970] 真机取证)**:此处原话是「core 对被 gate/**连坐** abort 的
19
+ * call **恒**铸这一串」——对连坐的旁观者**不成立**。core 5.52.0 dist 直证两个短路点两种文案:
20
+ * gate 主角走 abort 早退 = 本串;**执行前被连坐**的旁观者 = `"operation aborted before execution"`
21
+ * (小写 + 后缀)。所以本串只是判据的**兜底臂**(旧 core 唯一的信号),主锚是机读码
22
+ * `gate.parked`——见 {@link isEngineAbortToolEnd}。
23
+ */
16
24
  export const ENGINE_ABORT_TOOL_RESULT = 'Operation aborted';
17
25
  /**
18
26
  * tool_end.output 的文本归一(P0 案B,2026-08-03,server 6.0.0 wire 实测):sync leg 的毒化帧
@@ -82,6 +90,56 @@ function isGatedToolName(name) {
82
90
  function isFsOrShellToolName(name) {
83
91
  return typeof name === 'string' && (toolNameIsFsWrite(name) || toolNameIsShellExec(name));
84
92
  }
93
+ /**
94
+ * core 对**因 gate park 而中止**的 call 铸的机读短码(`tool_end.errorCode`,core ≥5.9.0)。
95
+ *
96
+ * 🔴 **契约,不是巧合**(core 官方答复 [4973]):两个 abort 短路点(gate 主角 / 执行前被连坐的
97
+ * 旁观者)**消费同一个分类席**,而 park 提交**先于** abort 发射 —— 铸点读那一席时无竞态,
98
+ * 于是主角与旁观者**恒**同席同码。所以本码可以当**主锚**用,而不是「我们在消费侧观察到它总在」
99
+ * 那种推断。SDK 的 `Event_tool_end.errorCode` 声明处也逐字点名这个码 =「门把这次调用 park 了」;
100
+ * 供给点见 core 5.52.0 dist `prepare-task` 的 `abortResultDetails: () => ({ code: "gate.parked" })`。
101
+ *
102
+ * 🔴 开集里的**具名一员**,不是前缀族:判据只认这一个码。放宽成「有 errorCode 就算」会把
103
+ * `path_not_in_root` 这类普通工具错拖进 HOLD —— 那正是 [2084]①-b 收窄掉的病(真错迟到上屏)。
104
+ * 常驻反证:hitl 门 F11-d(表外码的普通工具错必须**零滞后**上屏,判据锚在帧的**位置**上)。
105
+ *
106
+ * 🔴 **在场 ≠ 这次 park 有 call 身份**([4973]③):plan-review / resource-slice 这类不持有 call
107
+ * 的 park 照样带本码,但诚实缺席 call id。所以本判据只锚 errorCode,**不**顺手去锚任何 id —— 锚了
108
+ * 就等于把那几族 park 的连坐帧判成「认不出」。(core 5.55 的 `tool_end.gatedCallId` **顶层键**
109
+ * 到货后可用于「折叠归组」那一面,与本判据是两件事,跟车另批。)
110
+ */
111
+ const ENGINE_GATE_PARKED_ERROR_CODE = 'gate.parked';
112
+ /**
113
+ * 「这一帧是**引擎因 gate 而中止**的毒化帧」——双判据 OR(件⑤,2026-08-22,[4970] G1 端到端 RED)。
114
+ *
115
+ * 🔴 病形(test 线真机取证:真 server 7.41.0 + 真 Postgres,1 Bash 待审批 + 2 并行 WebFetch):
116
+ * 判据原来只有下面第②条(精确串),而 core 的**两个短路点产两种串**——
117
+ * · gate 主角(`agent-loop` abort 早退,dist 两处)= `"Operation aborted"`(精确);
118
+ * · **同批被连坐的旁观者**(执行前短路)= `"operation aborted before execution"`(小写 + 后缀)。
119
+ * ⇒ 判据对旁观者**恰好全不命中**:它们的毒化帧根本没进 HOLD(当场上屏 + 被 generic-close 记
120
+ * `markEnded`,resume 后重放的真结果被静默去重),而 #324 的连坐机读位在真实现网**一次都没出现过**。
121
+ * 本文件旧注写的「core 对被 gate/连坐 abort 的 call **恒**铸这一串」对旁观者不成立,已按实证撤回。
122
+ *
123
+ * 两臂各自的适用域(都**不**放宽成模糊匹配):
124
+ * ① **主锚** `errorCode === "gate.parked"`([4973] 契约:双短路点同席恒铸)—— 机读位,
125
+ * 两种文案都覆盖,core ≥5.9.0 起在场;
126
+ * ② **兜底** 输出等于 {@link ENGINE_ABORT_TOOL_RESULT} —— 覆盖两类:老 core(还没有 errorCode
127
+ * 这个位)**以及** core 现役唯一**不铸**本码的那一条臂 —— **post-hook signal re-check**
128
+ * ([4973]②:工具**已经执行完**、hook 之后签核那一拍才发现 abort,历来铸的 abort 结果
129
+ * 不带 details ⇒ 无 errorCode)。这条臂正是双判据设计成立的理由:少了兜底臂,它整条落空。
130
+ * 🔴 缺席不反推:两臂都不命中 = 这一帧没有「因 gate 中止」的任何证据,照旧走普通收口
131
+ * (老 core + 变体串就落在这一格 —— 诚实缺席,不靠猜文案家族把普通工具错也收进来)。
132
+ *
133
+ * 🔴 **两串族不会被上游合并**([4973],core 显式拒绝改串向,**不是**「core 待修」):两条串各自
134
+ * 承的是真语义 —— `"Operation aborted"` = **执行中被中止**,`"operation aborted before execution"`
135
+ * = **从未执行**。消费端要的是「归不归 gate 管」这个判定,那件事归机读码;文案面归 core 的语义面,
136
+ * 两者不该互相迁就。
137
+ */
138
+ function isEngineAbortToolEnd(ev) {
139
+ if (ev.errorCode === ENGINE_GATE_PARKED_ERROR_CODE)
140
+ return true;
141
+ return toolEndOutputText(ev.output) === ENGINE_ABORT_TOOL_RESULT;
142
+ }
85
143
  /**
86
144
  * 「这一帧 tool_end 归 gate 管」的判据 —— 上面那条**名字腿**加一条 kind 腿的替身([2393] hitl-F1,
87
145
  * 2026-08-02)。
@@ -96,9 +154,10 @@ function isFsOrShellToolName(name) {
96
154
  * 🔴 为什么不能在名字腿里补 kind:`tool_end` 帧上**根本没有 kind 位** —— gate 的 kind 只在
97
155
  * `done{suspended}.checkpointGate` / `suspended.gate` 上,而那两帧**晚于**毒化 tool_end 到达
98
156
  * (park 帧序:tool_start → tool_end(毒化)→ done{suspended})。「park 时按 callId 打标、
99
- * tool_end 时读台账」这条路对**这一帧**来得太晚。帧面上唯一可读的信号是引擎铸的**确切 abort
100
- * 标记** `ENGINE_ABORT_TOOL_RESULT`(见下方 臂头注:core 对被 gate/连坐 abort call 恒铸
101
- * 这一串),所以本判据 = 名字腿 ∪ abort 标记腿。
157
+ * tool_end 时读台账」这条路对**这一帧**来得太晚。帧面上唯一可读的信号是引擎铸的 abort 标记
158
+ * ({@link isEngineAbortToolEnd} 的**双判据**:机读码 `gate.parked` 那条确切串 —— 件⑤ 之前
159
+ * 这里只有后者,而 core 对**旁观者**铸的是另一种文案,见该函数头注),所以本判据 =
160
+ * 名字腿 ∪ abort 标记腿。
102
161
  *
103
162
  * 🔴 集合关系(说清楚,别让下一棒再以为两边逐字相等):本判据是 park 判定的**超集**(它还会盖到
104
163
  * 被同 turn 连坐 abort 的非 gate call)。方向是有意的,且不对称成立 —— 多盖一帧的代价是那一帧
@@ -108,6 +167,26 @@ function isFsOrShellToolName(name) {
108
167
  * ⚠️ 普通工具错(非 abort 标记)照旧**不进** HOLD —— [2084]①-b 的收窄没有被放宽,负控 F1-c 钉着。
109
168
  */
110
169
  function isGatedToolEnd(ev) {
170
+ if (isGatedToolName(ev.toolName))
171
+ return true;
172
+ return isEngineAbortToolEnd(ev); // 件⑤:abort 标记腿单源(机读码 ∪ 确切串)
173
+ }
174
+ /**
175
+ * 上者的**窄口**,只给 ②`deny-stamp-next` 那条**位置性**判据用(件⑤ 的臂间掩蔽面,异源复审四审
176
+ * finding①)。
177
+ *
178
+ * 🔴 为什么这一条不能用超集谓词:deny stamp 是**一次性的全局记号**(同步审批帧腿 deny 且 callId
179
+ * 关联不上时才武装),语义是「**下一张** gated 报错帧就是那张门的收口帧」——**谁先到谁消费**。
180
+ * 件⑤ 把 abort 标记腿放宽到认 `gate.parked` 之后,同批被连坐的**旁观者**(WebFetch 之流)也成了
181
+ * 「gated 报错帧」:它先到就把记号吃掉、被改写成「用户拒绝了」并记 ended(它 resume 后的真结果
182
+ * 还会被去重),而真正被拒的那个 call 拿到的是引擎原文 —— 正是 [2393] hitl-F1 要修的那个用户面,
183
+ * 方向还反了。连坐帧带的是「我被 gate 连累中止」,**不是**「我就是那张被拒的门」,所以它没资格
184
+ * 认领一个**位置性**的身份记号。
185
+ * ⇒ 本判据 = 件⑤ **之前**的那一版(名字腿 ∪ 确切串),逐字保留;新加的连坐码臂**刻意不进来**。
186
+ * (确切串腿理论上也可能是一条正在执行中被连坐的帧 —— 那是本记号自带的既有近似,与件⑤ 无关,
187
+ * 不在本批放宽也不在本批收紧。)
188
+ */
189
+ function isDenyStampCandidate(ev) {
111
190
  if (isGatedToolName(ev.toolName))
112
191
  return true;
113
192
  return toolEndOutputText(ev.output) === ENGINE_ABORT_TOOL_RESULT;
@@ -133,6 +212,19 @@ function isFsApprovalPark(result) {
133
212
  !isAskToolLoose(r.checkpointGate?.toolName) &&
134
213
  isToolApprovalGate(r.checkpointGate));
135
214
  }
215
+ /**
216
+ * park 帧上的**待批 call 身份**(server ≥7.41.0;[4914] 施工帖逐字:数据源 = checkpoint 本体的
217
+ * `pendingAction.toolCallId`,投影到 **`done{suspended}` 的 result 顶层**与 durable **`suspended`
218
+ * 事件**的 `gate` **旁**,两处同键同义)。两个 park 腿各读各的载体,判据是同一只。
219
+ *
220
+ * 🔴 UNTRUSTED wire 值:只认**非空串**,其余(缺席 / 非串 / 空串)一律诚实缺席 —— tool-less park
221
+ * (resource_limit / plan_review / task_done)按契约本来就没有这个键,而一个坏值被当成身份用
222
+ * 会去摘一条不存在的扣留帧(等于什么都没摘,但把「判不出」的保守臂骗过去了)。
223
+ */
224
+ function parkGatedCallId(src) {
225
+ const v = src?.toolCallId;
226
+ return typeof v === 'string' && v.length > 0 ? v : undefined;
227
+ }
136
228
  /** 台账里那份 gated `tool_start` 当拍的 args 快照(UNTRUSTED,只搬运不解释)。 */
137
229
  function argsOfGatedStart(led, callId) {
138
230
  if (callId === undefined)
@@ -197,7 +289,9 @@ const TOOL_END_ARMS = [
197
289
  // 收口帧名字腿认不出,不盖它 = 用户按了 No 却看到引擎原文。
198
290
  id: 'deny-stamp-next',
199
291
  run(ev, callId, led) {
200
- if (ev.isError !== true || !isGatedToolEnd(ev) || led.isDenied(callId))
292
+ // 🔴 窄口(件⑤/四审 finding①):这条记号是**一次性 + 位置性**的,超集谓词会让连坐旁观者
293
+ // 抢走它 —— 理由见 `isDenyStampCandidate` 头注。
294
+ if (ev.isError !== true || !isDenyStampCandidate(ev) || led.isDenied(callId))
201
295
  return undefined;
202
296
  if (!led.takeDenyStamp())
203
297
  return undefined;
@@ -229,8 +323,12 @@ const TOOL_END_ARMS = [
229
323
  run(ev, callId, led) {
230
324
  if (ev.isError !== true || !isGatedToolEnd(ev))
231
325
  return undefined;
232
- // 6.0.0 wire:output 可为 content-block 数组形(见 toolEndOutputText 头注)—— 归一后仍做精确等值。
233
- if (toolEndOutputText(ev.output) !== ENGINE_ABORT_TOOL_RESULT)
326
+ // 件⑤(2026-08-22,[4970] 真机 RED):判据 = `isEngineAbortToolEnd` 的**双判据**(机读码
327
+ // `gate.parked` ∪ 那条确切串)。此前只有后者,而 core 对**被连坐的旁观者**铸的是
328
+ // `"operation aborted before execution"` —— 于是连坐帧恰好全不进 HOLD(当场上屏 + 被 ⑤ 臂
329
+ // 记 ended 吃掉重放的真结果),#324 的机读位在现网一次都没出现过。适用域与不放宽的理由
330
+ // 见该函数头注;6.0.0 wire 的 content-block 数组形由 `toolEndOutputText` 归一后仍做精确等值。
331
+ if (!isEngineAbortToolEnd(ev))
234
332
  return undefined;
235
333
  // #324 换锚(2026-08-22):这一拍**不判出身**。durable park 腿的帧序是
236
334
  // `tool_start → tool_end(毒化) → done{suspended}` —— gate 身份晚于本帧到达,此刻任何判词
@@ -364,16 +462,18 @@ function routeSuspended(ev, ctx) {
364
462
  // 🔴 两条 park 臂都要登记(判别子的归属证据只认 park)—— ask park 自己用不到兜底腿,
365
463
  // 但它必须把**上一张** gate 的准入证清掉,否则旧证会让兜底把一个 Bash 连坐帧反认成主角、
366
464
  // 再把真正的 ask 主角 stamp 掉(对抗复审实测 `ask-main=STAMP`)。
465
+ // [4914]:durable `suspended` 事件的待批 call 身份就挂在 `gate` **旁**(不是 gate 里)。
466
+ const gatedCallId = parkGatedCallId(ev);
367
467
  if (ev.gate && isAskToolLoose(ev.gate.toolName) && ctx.taskId.current) {
368
468
  ctx.led.noteParkGate({ fsOrShellFamily: false });
369
- return { kind: 'park', gate: 'ask' };
469
+ return { kind: 'park', gate: 'ask', ...(gatedCallId !== undefined ? { gatedCallId } : {}) };
370
470
  }
371
471
  if (ev.gate &&
372
472
  isToolApprovalGate(ev.gate) &&
373
473
  ctx.taskId.current) {
374
474
  // 准入证:park 的 gate 本体属 fs/shell 族(kind-only gate 的 toolName 在族外 ⇒ 不发证)。
375
475
  ctx.led.noteParkGate({ fsOrShellFamily: isFsOrShellToolName(ev.gate.toolName) });
376
- return { kind: 'park', gate: 'fs' };
476
+ return { kind: 'park', gate: 'fs', ...(gatedCallId !== undefined ? { gatedCallId } : {}) };
377
477
  }
378
478
  // 其余 gate:透传。⚠️ ADAPTER-F8 注纠(2026-08-02):原文写「eventToSdkMessage 出 null」——
379
479
  // 那个返回形已随 REF-CC-058 退役,今天它出的是 `EventProjection` 三态,永不是 null;本臂的
@@ -382,17 +482,19 @@ function routeSuspended(ev, ctx) {
382
482
  }
383
483
  function routeDone(ev, ctx) {
384
484
  const { led } = ctx;
485
+ // [4914]:sync 腿的待批 call 身份在 `done{suspended}` 的 **result 顶层**(与 checkpointId 同位)。
486
+ const gatedCallId = parkGatedCallId(ev.result);
385
487
  // 两条 park 臂都登记(理由同 routeSuspended)。
386
488
  if (isAskGatePark(ev.result)) {
387
489
  ctx.taskId.current = ctx.taskId.current ?? ev.result.taskId;
388
490
  led.noteParkGate({ fsOrShellFamily: false });
389
- return { kind: 'park', gate: 'ask', pendingDone: ev };
491
+ return { kind: 'park', gate: 'ask', pendingDone: ev, ...(gatedCallId !== undefined ? { gatedCallId } : {}) };
390
492
  }
391
493
  if (isFsApprovalPark(ev.result)) {
392
494
  ctx.taskId.current = ctx.taskId.current ?? ev.result.taskId;
393
495
  // 准入证:主语是 checkpointGate **本体**的 toolName,不是任何 tool_end 帧的。
394
496
  led.noteParkGate({ fsOrShellFamily: isFsOrShellToolName(ev.result.checkpointGate?.toolName) });
395
- return { kind: 'park', gate: 'fs', pendingDone: ev };
497
+ return { kind: 'park', gate: 'fs', pendingDone: ev, ...(gatedCallId !== undefined ? { gatedCallId } : {}) };
396
498
  }
397
499
  const events = [...led.flushHeld()]; // 真错(非 gate)的 Ask tool_end 此刻诚实渲染
398
500
  // durable 终帧的 result.taskId === sessionId(引擎 durable quirk)会让 captureSessionId 跳过
@@ -74,6 +74,33 @@ export interface GateLedger {
74
74
  */
75
75
  hold(callId: string, ev: AgentEvent): void;
76
76
  dropHeld(callId: string): void;
77
+ /**
78
+ * 件④([4933] 立案的存量根修,0.41.0):**决断成功**之后摘掉这张 park 主角的扣留帧。
79
+ *
80
+ * 🔴 与裸 {@link dropHeld} 的差别只有一个:**入参可信度**。内环那四个调用点摘的是**帧自己的**
81
+ * `toolCallId`(帧在手,身份无疑);而决断成功那一下,外环手上只有 `findPendingForTask` 给的
82
+ * callId —— 那是给**卡面渲染**用的宽口(同 taskId + 族谓词命中的**第一行**,谓词不命中还退
83
+ * 「任意行」),不是身份。同族多行排队时它可能指向旁观者,于是被摘掉的是**另一条** call 的
84
+ * 扣留帧,而那一帧从此不再吐:{@link flushHeld} 的「一帧不丢」在这一格上不成立
85
+ * (用户面 = dim 圆点永远等不到结果行)。
86
+ *
87
+ * 🔴 摘的**永远是真被决断的那一行**(`candidateCallId`);`trustedCallId` 的角色是**确证**,
88
+ * 不是第二个摘取目标 —— 两边指向不同 call 时摘谁都是 split-brain(决断绑一行、摘另一行的帧)。
89
+ *
90
+ * 两条腿:
91
+ * ① `trustedCallId`(server ≥7.41.0 的 park 身份:`done{suspended}.result.toolCallId` /
92
+ * durable `suspended` 事件 `gate` 旁同键,[4914])与 `candidateCallId` **相等** ⇒ 身份确证,
93
+ * 点名摘。取件腿今天已优先按同一个身份选行,所以正常路径上两者恒等;
94
+ * ② 身份缺席(旧引擎 / tool-less park / 键畸形)或两者不等(身份在场但队列没有那一行 ⇒ 决断
95
+ * 落在别的行上)⇒ 只在 `candidateCallId` 于扣留表里**同族唯一**
96
+ * 时才摘;同族多条 = 判不出谁是主角 ⇒ **一条都不摘**,帧照走 flush 的连坐判别
97
+ * (与 {@link flushHeld}「判不出整表不标」同轴的 fail-safe:宁可多渲一行 abort,
98
+ * 也不许让一帧永久消失)。
99
+ *
100
+ * 🔴 两条腿都要走「摘完看表空不空」那一步(见 {@link dropHeld} 尾注的批级退休不变量),
101
+ * 包括**什么都没摘**那条路 —— 零 HOLD 批正是每次 delete 都落空的形状。
102
+ */
103
+ dropHeldForDecidedPark(candidateCallId: string | undefined, trustedCallId?: string): void;
77
104
  heldCount(): number;
78
105
  /**
79
106
  * drain:逐帧吐出并**同批记 ended**(fail-soft 回退路径与终帧路径共用的唯一出口)。
@@ -62,6 +62,49 @@ export function createGateLedger() {
62
62
  const family = [...new Set([...heldAskEnds.keys(), ...droppedFromBatch])].filter((id) => fsOrShellGatedCalls.has(id));
63
63
  return family.length === 1 ? new Set(family) : undefined;
64
64
  };
65
+ /**
66
+ * `dropHeld` / `dropHeldForDecidedPark` 的**唯一**实现(两个动词共用一份,批级退休那条不变量
67
+ * 只写一处 —— 两份写法各自漂正是本模块存在的理由)。
68
+ * `callId === undefined` = 「这次什么都不摘」:退休检查照跑,别的一律不动。
69
+ */
70
+ const dropHeldImpl = (callId) => {
71
+ if (callId !== undefined && heldAskEnds.delete(callId))
72
+ droppedFromBatch.add(callId);
73
+ // 🔴 扣留表空 = 这一批没有(或不再有)任何待判的帧,批级判据当场退休 —— 不能等下一次
74
+ // flush:「决断成功但本批压根没有毒化帧」的路径上 `flushHeld` 一次都不会跑,判据会就这么
75
+ // 活到下一批去把那一批的**真主角**判成连坐(实测 `new-main=STAMP`)。
76
+ // 🔴 「摘的那个不在表里」也要走这一步(不能早退):零 HOLD 批正是**每次** delete 都落空的
77
+ // 形状,早退就等于那种批永远不退休 —— 上一版的漏洞就在这里。
78
+ if (heldAskEnds.size === 0)
79
+ retireBatchState();
80
+ };
81
+ /**
82
+ * 件④ 保守臂的判据:候选在扣留表里**同族唯一**吗。
83
+ *
84
+ * 「族」的口径与**取件谓词**同轴(那才是可能撞车的那一层 —— `findPendingForTask` 是按族选行的):
85
+ * · 候选属 fs 写 / shell 执行族(`fsOrShellGatedCalls`,tool_start 当拍记)⇒ 数扣留表里同属
86
+ * 该族的条数(两个 Bash / 一个 Write 一个 Edit 都算同族,因为取件谓词对它们不可分);
87
+ * · 否则(ask / kind-only 等)⇒ 数扣留表里 `toolName` 逐字相同的条数。
88
+ * 不在扣留表里 ⇒ `false`(没有可摘的帧,也就谈不上「唯一」)。
89
+ */
90
+ const soleOfItsFamilyInHold = (callId) => {
91
+ const ev = heldAskEnds.get(callId);
92
+ if (ev === undefined)
93
+ return false;
94
+ let n = 0;
95
+ if (fsOrShellGatedCalls.has(callId)) {
96
+ for (const id of heldAskEnds.keys())
97
+ if (fsOrShellGatedCalls.has(id))
98
+ n++;
99
+ }
100
+ else {
101
+ const name = ev.toolName;
102
+ for (const held of heldAskEnds.values())
103
+ if (held.toolName === name)
104
+ n++;
105
+ }
106
+ return n === 1;
107
+ };
65
108
  /** 一批判完(或整批被摘空)⇒ 四样批级判据同批退休:下一批从零证起。 */
66
109
  const retireBatchState = () => {
67
110
  fsOrShellFamilyGate = false;
@@ -109,15 +152,27 @@ export function createGateLedger() {
109
152
  heldAskEnds.set(callId, ev);
110
153
  },
111
154
  dropHeld(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();
155
+ dropHeldImpl(callId);
156
+ },
157
+ dropHeldForDecidedPark(candidateCallId, trustedCallId) {
158
+ // wire 身份**对上了这次真被决断的那一行** ⇒ 身份确证,点名摘。
159
+ // 🔴 摘的永远是「真的被决断的那一行」(`candidateCallId`),wire 身份在这里的角色是**确证**
160
+ // 而不是另一个独立的摘取目标 —— 异源复审 finding①:决断腿按队列首行选行、这里却按 wire
161
+ // 身份摘帧,两边指向不同 call 时就是 split-brain(用户批的是旁观者,被摘的是主角帧)。
162
+ // 取件腿今天已经优先按同一个身份选行( `surfaceFsApprovalAndDecide` 的 parkGatedCallId),
163
+ // 所以正常路径上两者恒相等;不相等只发生在「身份在场但队列里没有那一行」的回落形 ——
164
+ // 那时决断落在别的行上,任何一帧都证明不了自己已过时,走②/③ 的保守判。
165
+ if (trustedCallId !== undefined && trustedCallId === candidateCallId) {
166
+ dropHeldImpl(trustedCallId);
167
+ return;
168
+ }
169
+ // ② 无可信身份:候选同族唯一才摘 —— 同族多条时任何选择都是猜,而猜错的代价是一帧永久消失。
170
+ if (candidateCallId !== undefined && soleOfItsFamilyInHold(candidateCallId)) {
171
+ dropHeldImpl(candidateCallId);
172
+ return;
173
+ }
174
+ // ③ 判不出 ⇒ 一条都不摘(退休检查仍照跑,见 dropHeldImpl 头注)。
175
+ dropHeldImpl(undefined);
121
176
  },
122
177
  heldCount() {
123
178
  return heldAskEnds.size;
@@ -213,6 +213,14 @@ export type FindPendingOutcome = {
213
213
  * whose toolName the predicate doesn't recognize, but which still belongs to this run). Never `pending[0]`
214
214
  * — a typed failure when nothing for this taskId is queued.
215
215
  *
216
+ * F4947-1(2026-08-22,0.41.0):`callId` 是**更强的身份腿**,在场时先于上面两条跑。它来自
217
+ * `HitlBridge.decideTool`/`answerQuestion` 的 `toolUseID`(CC 的 tool_use id)—— 那两处此前走的是
218
+ * 本类里一个**同名私有** `findPending`,REF-CC-029 的收编没覆盖到它、P-30 的门种闸也没加到它,
219
+ * 于是真实生产入口(`makeHitlCanUseTool` → `decideTool`,从不传 `preResolvedPending`)一直在一条
220
+ * **没有闸**的复刻上跑(test 线 P0-KPI 复现)。收口方式是把私有件删掉改调本函数,而不是给它补第二
221
+ * 份过滤 —— 复刻正是这条缺陷的成因。身份腿同样只在**过完门种闸的行**里找:一个 `plan_review` 行
222
+ * 哪怕 callId 逐字命中也不该被工具审批 decide 掉(server 那边照样 409 `gate_not_tool_approval`)。
223
+ *
216
224
  * P-30(2026-08-14):both legs only consider rows whose `gateKind` belongs to the ask/approval family
217
225
  * ({@link askParkForeignGateKind} — the same single-source guard `classifyAskParkRows` uses). Without it
218
226
  * the ANY-row fallback would hand a `plan_review`/`resource_limit` row to the tool-approval wire — an
@@ -222,7 +230,34 @@ export type FindPendingOutcome = {
222
230
  */
223
231
  export declare function findPendingForTask(client: HitlClientLike, taskId: string, matches: (toolName: string | undefined) => boolean, opts?: {
224
232
  signal?: AbortSignal;
225
- }): Promise<FindPendingOutcome>;
233
+ },
234
+ /**
235
+ * F4947-1:调用方手上的**更强身份** —— CC 的 `toolUseID`(`HitlBridge`),或这张 park 的
236
+ * wire 身份(server ≥7.41.0,两条决断腿)。在场 ⇒ 先在**本 taskId** 的行里按
237
+ * `boundCallId`/`toolCallId` 逐字命中(仍在门种闸之内);不命中再走下面两条 taskId 腿。
238
+ *
239
+ * 🔴 **刻意不 fail-closed**(异源复审二审 finding① 的部分驳回,理由成文):身份在场而队列里
240
+ * 没有那一行时**照旧回落**,不铸「身份不匹配」的硬拒。两条理由:
241
+ * · 回落的失效面 = 与本参数出现之前**逐字节相同**(那时压根没有身份腿);fail-closed 的失效面
242
+ * = **每一次审批都决断不了**(用户点了 Yes 也过不去)。代价严重不对称;
243
+ * · 这个 join(checkpoint 的 `pendingAction.toolCallId` ↔ `/v1/approvals` 富行的
244
+ * `toolCallId`/`boundCallId`)本仓**没有对真 server 的实证**,只有上游帖面的口径。拿一个
245
+ * 没实证的键去关整条 HITL 的门,是把「可能对不上」赌成「一定对得上」。
246
+ * 跨 run 那一半的风险由上面的 taskId 约束关掉;「决断落在同 task 的另一行」这一格与本参数出现
247
+ * 之前同形,且下游 `dropHeldForDecidedPark` 对这一格自带保守判(身份对不上 ⇒ 一帧都不摘)。
248
+ */
249
+ callId?: string,
250
+ /**
251
+ * 身份腿的**族闸**(整行可读)。缺省 = 退回 `matches(toolName)`。
252
+ *
253
+ * 🔴 为什么必须能读**整行**而不是只读 toolName(五审 finding①):工具审批族有**两条腿** ——
254
+ * 一等 `kind === 'tool_approval'`(放行**任意** toolName)与 fs 写 / shell 名字腿。而 wire 模块
255
+ * 传下来的 `matches` 只有名字腿那半(它服务的是「同族第一行」那条 leg 2)。拿它当身份闸,一个
256
+ * **合法的 kind-only 行**即使 taskId+callId 逐字命中也会被判出局 ⇒ 回落挑了同 task 的另一行,
257
+ * 卡面与决断全落在别的审批上、真 gate 继续 suspended。⇒ 调用方按自己那条腿的**完整**族判据
258
+ * 供给本闸(fs 腿 = `isToolApprovalGate({kind: row.gateKind, toolName})`,ask 腿 = `isAskTool`)。
259
+ */
260
+ identityAccepts?: (row: PendingCheckpoint) => boolean): Promise<FindPendingOutcome>;
226
261
  /**
227
262
  * The bridge state. One per run/session. The shell feeds it `suspended` events (observe) and the human's
228
263
  * outcome (decide / answer / planReview); it owns the `approvals.list()` join + the verbatim binding echo.
@@ -240,17 +275,24 @@ export declare class HitlBridge {
240
275
  /** The gate currently awaiting a human, if any. The shell branches on `gate.kind` to pick the chrome. */
241
276
  currentGate(): CheckpointGate | null;
242
277
  /**
243
- * Find the `PendingCheckpoint` the human is about to decide on. Joins by `toolCallId` when CC hands a
244
- * `toolUseID`; otherwise falls back to the single pending row for this run's `taskId`. Returns null when
245
- * the queue is empty (e.g. resolved/expired under the human the caller must refetch + re-present).
278
+ * Fetch the `PendingCheckpoint` this decide/answer is about to resolve, for the callers that do NOT
279
+ * already have one in hand (see `decideTool`/`answerQuestion`'s `preResolvedPending` param the wire
280
+ * modules that surface a card/overlay BEFORE deciding fetch the row themselves and pass it straight
281
+ * through, closing the TOCTOU window between "the row the human saw" and "the row decide resolves",
282
+ * REF-CC-029/hitl2-08).
283
+ *
284
+ * 🔴 F4947-1(2026-08-22,0.41.0):本方法**没有自己的查找逻辑** —— 它是 {@link findPendingForTask}
285
+ * 的一层出参映射,一个字节的行过滤都不在这里。上一版是一个与那个单源导出**撞名**的私有复刻
286
+ * (`findPending`),REF-CC-029 的三合一没覆盖到它、P-30 的门种闸也只加在导出那一份上,于是
287
+ * `makeHitlCanUseTool` → `decideTool`(真实用户点 Yes/No 那条路,从不传 `preResolvedPending`)
288
+ * 一直在没有闸的老复刻上跑:同 task 停着 `plan_review` 行时,它被当成「这个 run 的 pending」
289
+ * 交给工具审批 decide(空 toolName 卡 / 409 `gate_not_tool_approval`)。
290
+ * 名字也一并换掉:撞名正是「看起来只有一份实现」的成因。
246
291
  *
247
- * Used only as the fallback when a caller does NOT already have the pending row in hand (see
248
- * `decideTool`/`answerQuestion`'s `preResolvedPending` param) — the wire modules that surface a card/
249
- * overlay BEFORE calling decide/answer fetch the row themselves via `findPendingForTask` (below) and
250
- * pass it straight through, closing the TOCTOU window between "the row the human saw" and "the row
251
- * decide resolves" (REF-CC-029/hitl2-08).
292
+ * 谓词传 {@link ANY_TOOL_FAMILY}:桥这一层对工具族**没有意见**(人刚刚决断的是哪张门就是哪张),
293
+ * 与旧私有件的 taskId 兜底逐字同语义;身份腿(`toolUseID`)由单源函数的 `callId` 参数承接。
252
294
  */
253
- private findPending;
295
+ private pendingForDecide;
254
296
  /**
255
297
  * Build the D-1 binding off the pending row the human SAW. Echo VERBATIM — NEVER recompute the hash
256
298
  * (contract/04 §2.2). `checkpointToken` is DELIBERATELY never set (deprecated legacy path;
@@ -288,7 +330,7 @@ export declare class HitlBridge {
288
330
  * its own `approvals.list()` — closing the TOCTOU window between "the row the human saw" and "the
289
331
  * row decide resolves" (two independent fetches could otherwise land on different rows if the
290
332
  * first one got resolved/expired in between, hitl2-01/hitl2-08). Omit only when the caller has no
291
- * such row in hand (falls back to `findPending`). */
333
+ * such row in hand(缺省回落 `pendingForDecide` 见其头注,那是单源 `findPendingForTask` 的一层出参映射). */
292
334
  preResolvedPending?: PendingCheckpoint): Promise<unknown>;
293
335
  /**
294
336
  * Answer an `AskUserQuestion` gate (contract/04 §4; 08 CS-16). Same suspended → decide loop as a