@sema-agent/client-core 0.40.0 → 0.42.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +450 -0
  2. package/README.md +1 -1
  3. package/dist/adapter/downstream/eventToSdkMessage.js +24 -0
  4. package/dist/attachmentsWireCaps.d.ts +43 -8
  5. package/dist/attachmentsWireCaps.js +64 -14
  6. package/dist/classifierVerdictWire.d.ts +0 -25
  7. package/dist/classifierVerdictWire.js +27 -7
  8. package/dist/engineAgentPanelStore.js +102 -13
  9. package/dist/hitl/approvalsFeed.d.ts +2 -2
  10. package/dist/hitl/approvalsFeed.js +57 -2
  11. package/dist/hitl/askGateWire.d.ts +4 -2
  12. package/dist/hitl/askGateWire.js +2 -0
  13. package/dist/hitl/editedRuleTextPrecheck.d.ts +102 -0
  14. package/dist/hitl/editedRuleTextPrecheck.js +91 -0
  15. package/dist/hitl/frameRouter.d.ts +21 -2
  16. package/dist/hitl/frameRouter.js +114 -12
  17. package/dist/hitl/gateLedger.d.ts +27 -0
  18. package/dist/hitl/gateLedger.js +64 -9
  19. package/dist/hitl/hitlBridge.d.ts +70 -14
  20. package/dist/hitl/hitlBridge.js +117 -34
  21. package/dist/hitl/hitlHostSurface.d.ts +33 -3
  22. package/dist/hitl/hitlHostSurface.js +33 -3
  23. package/dist/hitl/parkResolver.js +39 -13
  24. package/dist/hitl/toolApprovalWire.d.ts +133 -3
  25. package/dist/hitl/toolApprovalWire.js +143 -6
  26. package/dist/index.d.ts +1 -0
  27. package/dist/index.js +7 -0
  28. package/dist/model/catalogLoader.js +151 -45
  29. package/dist/printToolResultFrame.d.ts +19 -0
  30. package/dist/seatContract.d.ts +25 -1
  31. package/dist/seatContract.js +29 -2
  32. package/dist/subagent/engineDelegatedPrompt.js +10 -3
  33. package/dist/systemReminderTag.d.ts +52 -0
  34. package/dist/systemReminderTag.js +73 -0
  35. package/docs/INTEGRATION-CLIENTS.md +123 -7
  36. package/package.json +2 -2
@@ -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).
246
283
  *
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).
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
+ * 名字也一并换掉:撞名正是「看起来只有一份实现」的成因。
291
+ *
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;
@@ -266,9 +308,23 @@ export declare class HitlBridge {
266
308
  *
267
309
  * - `approve` → `{decision:"approve", boundCallId, boundInputHash}`; `updatedInput` rides along for
268
310
  * approve-with-edit (applied AFTER the binding check — the hash still binds the ORIGINAL input).
269
- * - `deny` → `{decision:"deny", reason}`. CANCEL a suspended run by DENYING, never by `runs.cancel`
270
- * (which 409s on a suspended run — contract/04 §2.4); the deny-and-abort `interrupt:true` EFFECT is
271
- * "the run ends after the deny", carried by the backend, not a wire flag.
311
+ * - `deny` → `{decision:"deny", reason}`.
312
+ *
313
+ * 🔴 **§2.4「DENY-abort」撤稿(0.42.0;server [4833] 明请,契约成文 `4631a0f` 7.39 出)**。
314
+ * 本段原文写的是:「CANCEL a suspended run by DENYING, never by `runs.cancel` (**which 409s on a
315
+ * suspended run** — contract/04 §2.4); the deny-and-abort `interrupt:true` EFFECT is *the run ends
316
+ * after the deny*」。**三句话里有两句已被上游证伪,逐条**:
317
+ * · **「deny 用来 cancel 一条 run」** —— `ASSISTANT-WIRE-CONTRACT.md` §4a 逐字反过来说:
318
+ * **DENY is a TOOL-level answer, NEVER a run kill**;客户端不得把用户的拒绝译成 cancel。
319
+ * 两个动词的 wire 判别式是 `cancelled`(真取消)vs `gate.batch_halted`(裸拒的兄弟结算)。
320
+ * · **「`runs.cancel` 对 suspended run 回 409」** —— 自 server [868] 起**就地取消**:
321
+ * `runs.js` 的 cancel 腿对 SUSPENDED/needs_review 先结算 pending checkpoint(CAS expire)再
322
+ * 终态化,`cancelSuspended` 有实体,409 只剩 `conflict.approval_settled` 一条
323
+ * (本仓 `docs/fresh-scan-client-core-2026-08-08.md` B型-5 已按真字节证伪,当时未跟修注释)。
324
+ * · **仍然成立的那一句**:deny 之后 run 是否结束由**后端编排**决定,不是 wire 上的旗标
325
+ * (裸拒 ⇒ `gate.batch_halted` + `haltedOnUserRejection`;带留言拒 ⇒ run 续跑)。
326
+ * ⇒ 本方法的**行为一字未改**(它本来走的就是 §4a 说的那条 TOOL 级 decide 通路);改的是这段
327
+ * 引用错权威、并把一条早已失效的 409 断言当理由的散文。
272
328
  *
273
329
  * The resumed run continues its SAME durable stream. A binding mismatch (409) is re-raised as a
274
330
  * `HitlSafetyError('binding_mismatch')` — the caller re-presents, NEVER auto-retries.
@@ -288,7 +344,7 @@ export declare class HitlBridge {
288
344
  * its own `approvals.list()` — closing the TOCTOU window between "the row the human saw" and "the
289
345
  * row decide resolves" (two independent fetches could otherwise land on different rows if the
290
346
  * 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`). */
347
+ * such row in hand(缺省回落 `pendingForDecide` 见其头注,那是单源 `findPendingForTask` 的一层出参映射). */
292
348
  preResolvedPending?: PendingCheckpoint): Promise<unknown>;
293
349
  /**
294
350
  * Answer an `AskUserQuestion` gate (contract/04 §4; 08 CS-16). Same suspended → decide loop as a
@@ -213,6 +213,12 @@ export class DecideTransportRetryExhaustedError extends Error {
213
213
  this.name = 'DecideTransportRetryExhaustedError';
214
214
  }
215
215
  }
216
+ /**
217
+ * 「本层对工具族没有意见」的谓词(`HitlBridge.pendingForDecide` 用)。桥不是 wire 模块:它决断的
218
+ * 就是人刚刚看过的那张门,族属由那两条 wire 在**取件时**说了算。写成具名常量而不是就地 `() => true`
219
+ * —— 一个匿名恒真谓词读起来像「忘了填」,而这里的恒真是**有理由**的(理由即本注)。
220
+ */
221
+ const ANY_TOOL_FAMILY = () => true;
216
222
  /**
217
223
  * The single source for "which `PendingCheckpoint` row is the human about to decide on", used by the two
218
224
  * decision wires (`toolApprovalWire.surfaceFsApprovalAndDecide` / `askGateWire.surfaceGateAndDecide`)
@@ -233,6 +239,14 @@ export class DecideTransportRetryExhaustedError extends Error {
233
239
  * whose toolName the predicate doesn't recognize, but which still belongs to this run). Never `pending[0]`
234
240
  * — a typed failure when nothing for this taskId is queued.
235
241
  *
242
+ * F4947-1(2026-08-22,0.41.0):`callId` 是**更强的身份腿**,在场时先于上面两条跑。它来自
243
+ * `HitlBridge.decideTool`/`answerQuestion` 的 `toolUseID`(CC 的 tool_use id)—— 那两处此前走的是
244
+ * 本类里一个**同名私有** `findPending`,REF-CC-029 的收编没覆盖到它、P-30 的门种闸也没加到它,
245
+ * 于是真实生产入口(`makeHitlCanUseTool` → `decideTool`,从不传 `preResolvedPending`)一直在一条
246
+ * **没有闸**的复刻上跑(test 线 P0-KPI 复现)。收口方式是把私有件删掉改调本函数,而不是给它补第二
247
+ * 份过滤 —— 复刻正是这条缺陷的成因。身份腿同样只在**过完门种闸的行**里找:一个 `plan_review` 行
248
+ * 哪怕 callId 逐字命中也不该被工具审批 decide 掉(server 那边照样 409 `gate_not_tool_approval`)。
249
+ *
236
250
  * P-30(2026-08-14):both legs only consider rows whose `gateKind` belongs to the ask/approval family
237
251
  * ({@link askParkForeignGateKind} — the same single-source guard `classifyAskParkRows` uses). Without it
238
252
  * the ANY-row fallback would hand a `plan_review`/`resource_limit` row to the tool-approval wire — an
@@ -240,7 +254,34 @@ export class DecideTransportRetryExhaustedError extends Error {
240
254
  * out, not a rejection of the whole queue: a genuine approval row for the same task still resolves.
241
255
  * Rows with no `gateKind` (pre-`gate_kind` history) keep the old behaviour — no stronger signal exists.
242
256
  */
243
- export async function findPendingForTask(client, taskId, matches, opts) {
257
+ export async function findPendingForTask(client, taskId, matches, opts,
258
+ /**
259
+ * F4947-1:调用方手上的**更强身份** —— CC 的 `toolUseID`(`HitlBridge`),或这张 park 的
260
+ * wire 身份(server ≥7.41.0,两条决断腿)。在场 ⇒ 先在**本 taskId** 的行里按
261
+ * `boundCallId`/`toolCallId` 逐字命中(仍在门种闸之内);不命中再走下面两条 taskId 腿。
262
+ *
263
+ * 🔴 **刻意不 fail-closed**(异源复审二审 finding① 的部分驳回,理由成文):身份在场而队列里
264
+ * 没有那一行时**照旧回落**,不铸「身份不匹配」的硬拒。两条理由:
265
+ * · 回落的失效面 = 与本参数出现之前**逐字节相同**(那时压根没有身份腿);fail-closed 的失效面
266
+ * = **每一次审批都决断不了**(用户点了 Yes 也过不去)。代价严重不对称;
267
+ * · 这个 join(checkpoint 的 `pendingAction.toolCallId` ↔ `/v1/approvals` 富行的
268
+ * `toolCallId`/`boundCallId`)本仓**没有对真 server 的实证**,只有上游帖面的口径。拿一个
269
+ * 没实证的键去关整条 HITL 的门,是把「可能对不上」赌成「一定对得上」。
270
+ * 跨 run 那一半的风险由上面的 taskId 约束关掉;「决断落在同 task 的另一行」这一格与本参数出现
271
+ * 之前同形,且下游 `dropHeldForDecidedPark` 对这一格自带保守判(身份对不上 ⇒ 一帧都不摘)。
272
+ */
273
+ callId,
274
+ /**
275
+ * 身份腿的**族闸**(整行可读)。缺省 = 退回 `matches(toolName)`。
276
+ *
277
+ * 🔴 为什么必须能读**整行**而不是只读 toolName(五审 finding①):工具审批族有**两条腿** ——
278
+ * 一等 `kind === 'tool_approval'`(放行**任意** toolName)与 fs 写 / shell 名字腿。而 wire 模块
279
+ * 传下来的 `matches` 只有名字腿那半(它服务的是「同族第一行」那条 leg 2)。拿它当身份闸,一个
280
+ * **合法的 kind-only 行**即使 taskId+callId 逐字命中也会被判出局 ⇒ 回落挑了同 task 的另一行,
281
+ * 卡面与决断全落在别的审批上、真 gate 继续 suspended。⇒ 调用方按自己那条腿的**完整**族判据
282
+ * 供给本闸(fs 腿 = `isToolApprovalGate({kind: row.gateKind, toolName})`,ask 腿 = `isAskTool`)。
283
+ */
284
+ identityAccepts = (r) => matches(typeof r.toolName === 'string' ? r.toolName : undefined)) {
244
285
  let rows;
245
286
  try {
246
287
  rows = (await client.approvals.list(opts)).pending;
@@ -249,8 +290,33 @@ export async function findPendingForTask(client, taskId, matches, opts) {
249
290
  return { ok: false, reason: `approvals.list failed: ${String(e)}` };
250
291
  }
251
292
  const decidable = rows.filter((r) => askParkForeignGateKind(r) === null);
252
- const pending = decidable.find((r) => r.taskId === taskId && matches(typeof r.toolName === 'string' ? r.toolName : undefined)) ??
253
- decidable.find((r) => r.taskId === taskId);
293
+ // 🔴 身份腿**约束在本 taskId 之内**(异源复审二审 finding①):`/v1/approvals` scope 级单队列,
294
+ // 别的 run / 后台子代 lane 的行同在表里 —— 一个撞名或被复用的 callId 就能把**别人会话**的 pending
295
+ // 行交给这次 decide。REF-CC-022 拆掉 `?? pending[0]` 防的正是这件事;身份腿是「更强的身份」,
296
+ // 不是「可以跨 run 的身份」,不能自带一个新的绕行口。
297
+ const identityRow = callId !== undefined
298
+ ? decidable.find((r) => r.taskId === taskId && (r.boundCallId === callId || r.toolCallId === callId))
299
+ : undefined;
300
+ // 🔴 身份命中还要过**族闸**(四审 finding② / 五审 finding①,闸读整行见 `identityAccepts`):
301
+ // 门种闸只挡 plan_review/resource_limit 这类外族,AskUserQuestion 与工具审批**同在** `decidable` 里。
302
+ // 一个陈旧/错配/被复用的 park 身份否则就能让 fs 腿选中一行 ask(反之亦然),而那一行随后原样当
303
+ // `preResolvedPending` 去呈卡 + decide —— 卡面全错、决断打在另一个 checkpoint 上。
304
+ const identityConflicts = identityRow !== undefined && !identityAccepts(identityRow);
305
+ // 🔴 身份证据与本腿族属**冲突**时,这一轮的候选池整体收紧到族闸放行的行(六审 finding① +
306
+ // 七审 finding①)。两步演进的理由要写清楚:
307
+ // · 只拦身份腿是自相矛盾的 —— 最后那条「同 taskId 任意行」兜底会把**同一行**重新捞出来
308
+ // (队列里只剩它时必然发生),刚判定「这一行不是本腿的」,转头又拿它去呈卡 + 决断;
309
+ // · 只剔除命中的**那一个对象**也不够 —— 同 task 排着两条反族行时,删掉第一条,兜底照样捡起
310
+ // 第二条(七审实撞)。
311
+ // 冲突 = wire 数据自相矛盾(park 说 gate 是 A 族、身份却指着 B 族的行),这一轮**整体**保守:
312
+ // 只在族闸放行的行里选;一条都不剩 ⇒ 诚实 `no_pending`(调用方走既有 fail-soft / 重呈路)。
313
+ // 🔴 收紧**只发生在冲突路径上**:身份缺席(绝大多数路径)时候选池一个字节不动 —— 那条
314
+ // 「任意行」兜底本来就是给 kind-only / 名字腿认不得的合法行留的(见上方 leg 说明),不能被
315
+ // 一条与它无关的规则顺手削掉。
316
+ const pool = identityConflicts ? decidable.filter((r) => identityAccepts(r)) : decidable;
317
+ const pending = (identityConflicts ? undefined : identityRow) ??
318
+ pool.find((r) => r.taskId === taskId && matches(typeof r.toolName === 'string' ? r.toolName : undefined)) ??
319
+ pool.find((r) => r.taskId === taskId);
254
320
  if (!pending)
255
321
  return { ok: false, reason: 'no pending checkpoint for this run (resolved/expired?)', code: 'no_pending' };
256
322
  return { ok: true, pending, gatedCallId: pending.toolCallId ?? pending.boundCallId ?? undefined };
@@ -298,32 +364,33 @@ export class HitlBridge {
298
364
  return this.active?.gate ?? null;
299
365
  }
300
366
  /**
301
- * Find the `PendingCheckpoint` the human is about to decide on. Joins by `toolCallId` when CC hands a
302
- * `toolUseID`; otherwise falls back to the single pending row for this run's `taskId`. Returns null when
303
- * the queue is empty (e.g. resolved/expired under the human the caller must refetch + re-present).
367
+ * Fetch the `PendingCheckpoint` this decide/answer is about to resolve, for the callers that do NOT
368
+ * already have one in hand (see `decideTool`/`answerQuestion`'s `preResolvedPending` param the wire
369
+ * modules that surface a card/overlay BEFORE deciding fetch the row themselves and pass it straight
370
+ * through, closing the TOCTOU window between "the row the human saw" and "the row decide resolves",
371
+ * REF-CC-029/hitl2-08).
304
372
  *
305
- * Used only as the fallback when a caller does NOT already have the pending row in hand (see
306
- * `decideTool`/`answerQuestion`'s `preResolvedPending` param) — the wire modules that surface a card/
307
- * overlay BEFORE calling decide/answer fetch the row themselves via `findPendingForTask` (below) and
308
- * pass it straight through, closing the TOCTOU window between "the row the human saw" and "the row
309
- * decide resolves" (REF-CC-029/hitl2-08).
373
+ * 🔴 F4947-1(2026-08-22,0.41.0):本方法**没有自己的查找逻辑** —— 它是 {@link findPendingForTask}
374
+ * 的一层出参映射,一个字节的行过滤都不在这里。上一版是一个与那个单源导出**撞名**的私有复刻
375
+ * (`findPending`),REF-CC-029 的三合一没覆盖到它、P-30 的门种闸也只加在导出那一份上,于是
376
+ * `makeHitlCanUseTool` `decideTool`(真实用户点 Yes/No 那条路,从不传 `preResolvedPending`)
377
+ * 一直在没有闸的老复刻上跑:同 task 停着 `plan_review` 行时,它被当成「这个 run 的 pending」
378
+ * 交给工具审批 decide(空 toolName 卡 / 409 `gate_not_tool_approval`)。
379
+ * 名字也一并换掉:撞名正是「看起来只有一份实现」的成因。
380
+ *
381
+ * 谓词传 {@link ANY_TOOL_FAMILY}:桥这一层对工具族**没有意见**(人刚刚决断的是哪张门就是哪张),
382
+ * 与旧私有件的 taskId 兜底逐字同语义;身份腿(`toolUseID`)由单源函数的 `callId` 参数承接。
310
383
  */
311
- async findPending(toolUseID, opts) {
312
- const { pending } = await this.client.approvals.list(opts);
313
- if (pending.length === 0)
384
+ async pendingForDecide(toolUseID, opts) {
385
+ const found = await findPendingForTask(this.client, this.taskId, ANY_TOOL_FAMILY, opts, toolUseID);
386
+ if (found.ok)
387
+ return found.pending;
388
+ // 🔴 `no_pending`(良性缺席)才是「没有行可决」;`approvals.list` 自身失败必须照旧**抛**出去。
389
+ // 旧私有件让 list 的错原样逃逸,而下游 `parkResolver` 对 `no_pending` 的处置是「这张 gate
390
+ // 早就被解决了 ⇒ 继续读流」—— 把一次网络失败降级成那个码 = 谎报已解决(#110 烧会话的路径)。
391
+ if (found.code === 'no_pending')
314
392
  return null;
315
- if (toolUseID) {
316
- const byCall = pending.find((p) => p.boundCallId === toolUseID || p.toolCallId === toolUseID);
317
- if (byCall)
318
- return byCall;
319
- }
320
- // Fall back to this run's pending row (the suspended run holding the session claim).
321
- // REF-CC-022(2026-08-02): NEVER `?? pending[0]` — once this taskId's own rows are exhausted, the
322
- // first queue row can belong to a DIFFERENT session or a background subagent lane (they share the
323
- // same `/v1/approvals` queue); deciding on it would resolve someone else's gate. A miss here is a
324
- // real `no_pending` — the caller's existing typed-fail path (and exactly the signal #110's
325
- // cancel-by-deny-recovery arm expects), not "decide on whatever happens to be first".
326
- return pending.find((p) => p.taskId === this.taskId) ?? null;
393
+ throw new Error(found.reason);
327
394
  }
328
395
  /**
329
396
  * Build the D-1 binding off the pending row the human SAW. Echo VERBATIM — NEVER recompute the hash
@@ -348,9 +415,23 @@ export class HitlBridge {
348
415
  *
349
416
  * - `approve` → `{decision:"approve", boundCallId, boundInputHash}`; `updatedInput` rides along for
350
417
  * approve-with-edit (applied AFTER the binding check — the hash still binds the ORIGINAL input).
351
- * - `deny` → `{decision:"deny", reason}`. CANCEL a suspended run by DENYING, never by `runs.cancel`
352
- * (which 409s on a suspended run — contract/04 §2.4); the deny-and-abort `interrupt:true` EFFECT is
353
- * "the run ends after the deny", carried by the backend, not a wire flag.
418
+ * - `deny` → `{decision:"deny", reason}`.
419
+ *
420
+ * 🔴 **§2.4「DENY-abort」撤稿(0.42.0;server [4833] 明请,契约成文 `4631a0f` 7.39 出)**。
421
+ * 本段原文写的是:「CANCEL a suspended run by DENYING, never by `runs.cancel` (**which 409s on a
422
+ * suspended run** — contract/04 §2.4); the deny-and-abort `interrupt:true` EFFECT is *the run ends
423
+ * after the deny*」。**三句话里有两句已被上游证伪,逐条**:
424
+ * · **「deny 用来 cancel 一条 run」** —— `ASSISTANT-WIRE-CONTRACT.md` §4a 逐字反过来说:
425
+ * **DENY is a TOOL-level answer, NEVER a run kill**;客户端不得把用户的拒绝译成 cancel。
426
+ * 两个动词的 wire 判别式是 `cancelled`(真取消)vs `gate.batch_halted`(裸拒的兄弟结算)。
427
+ * · **「`runs.cancel` 对 suspended run 回 409」** —— 自 server [868] 起**就地取消**:
428
+ * `runs.js` 的 cancel 腿对 SUSPENDED/needs_review 先结算 pending checkpoint(CAS expire)再
429
+ * 终态化,`cancelSuspended` 有实体,409 只剩 `conflict.approval_settled` 一条
430
+ * (本仓 `docs/fresh-scan-client-core-2026-08-08.md` B型-5 已按真字节证伪,当时未跟修注释)。
431
+ * · **仍然成立的那一句**:deny 之后 run 是否结束由**后端编排**决定,不是 wire 上的旗标
432
+ * (裸拒 ⇒ `gate.batch_halted` + `haltedOnUserRejection`;带留言拒 ⇒ run 续跑)。
433
+ * ⇒ 本方法的**行为一字未改**(它本来走的就是 §4a 说的那条 TOOL 级 decide 通路);改的是这段
434
+ * 引用错权威、并把一条早已失效的 409 断言当理由的散文。
354
435
  *
355
436
  * The resumed run continues its SAME durable stream. A binding mismatch (409) is re-raised as a
356
437
  * `HitlSafetyError('binding_mismatch')` — the caller re-presents, NEVER auto-retries.
@@ -361,9 +442,9 @@ export class HitlBridge {
361
442
  * its own `approvals.list()` — closing the TOCTOU window between "the row the human saw" and "the
362
443
  * row decide resolves" (two independent fetches could otherwise land on different rows if the
363
444
  * first one got resolved/expired in between, hitl2-01/hitl2-08). Omit only when the caller has no
364
- * such row in hand (falls back to `findPending`). */
445
+ * such row in hand(缺省回落 `pendingForDecide` 见其头注,那是单源 `findPendingForTask` 的一层出参映射). */
365
446
  preResolvedPending) {
366
- const pending = preResolvedPending ?? (await this.findPending(toolUseID, opts));
447
+ const pending = preResolvedPending ?? (await this.pendingForDecide(toolUseID, opts));
367
448
  if (!pending) {
368
449
  throw new HitlSafetyError('no pending checkpoint to decide (resolved/expired under the human) — refetch + re-present', 'no_pending');
369
450
  }
@@ -408,10 +489,10 @@ export class HitlBridge {
408
489
  /** REF-CC-029(2026-08-02):see `decideTool`'s same-named param — the pending row the caller already
409
490
  * fetched via `findPendingForTask`. */
410
491
  preResolvedPending) {
411
- // 🔴 校验在 findPending **之前**:入参坏了跟 pending 行在不在场无关,而先 list 再拒会把
492
+ // 🔴 校验在取件**之前**:入参坏了跟 pending 行在不在场无关,而先 list 再拒会把
412
493
  // 「你的作答是空的」这条真判词换成「no_pending」那条假判词(行恰好过期时)。
413
494
  assertAnswersSendable(answers);
414
- const pending = preResolvedPending ?? (await this.findPending(toolUseID, opts));
495
+ const pending = preResolvedPending ?? (await this.pendingForDecide(toolUseID, opts));
415
496
  if (!pending) {
416
497
  throw new HitlSafetyError('no pending AskUserQuestion checkpoint to answer — refetch + re-present', 'no_pending');
417
498
  }
@@ -554,7 +635,9 @@ export function makeHitlCanUseTool(bridge, prompt) {
554
635
  const decision = forceDecision ??
555
636
  (await prompt({ toolName: tool.name, input, toolUseID, gate }));
556
637
  if (decision.behavior === 'deny') {
557
- // Deny → cancel-by-deny (contract/04 §2.4). The deny message rides `reason`.
638
+ // Deny → 一次 **TOOL 级**的 deny 应答(server `ASSISTANT-WIRE-CONTRACT.md` §4a;0.42.0 撤稿:
639
+ // 原文写的是「cancel-by-deny (contract/04 §2.4)」,而 §4a 逐字反对把拒绝读成 run kill ——
640
+ // 详见 `decideTool` 头注的撤稿段)。The deny message rides `reason`.
558
641
  // 0.28.0 发版扫描 F1(P2):message 逐字嵌原始命令(壳侧 bashPermissions 无上限)——必须与
559
642
  // 卡腿同门经窄化器截到 4096,否则 server 413 丢的是整次 deny(run 留 suspended)。
560
643
  await bridge.decideTool({ decision: 'deny', reason: denyReasonForWire(decision.message, `canUseTool ${toolUseID}`) ?? DEFAULT_DENY_REASON }, toolUseID);
@@ -31,9 +31,32 @@ export declare function _resetHitlHostSurfaceForTest(): void;
31
31
  /** 内部读点(计 miss)。`askGateWire.ts` 的 `surfaceClassifierDeny` 路径与本文件的
32
32
  * `surfaceCancelDenyWarn` 共用它。 */
33
33
  export declare function surfaceForCurrentSession(): HitlHostSurface | null;
34
- /** cancel-by-deny 的后台 settle 预算。decide 是 SYNC 驱动的(引擎跑到下一 park/终态才返,实测
35
- * 4-5s+),但 DENY-abort 语义上引擎收到即终结 run;2s 内连收都没收到 ⇒ 按丢失警示(晚到成功
36
- * 只是多一行良性 warn,比锁死无线索诚实)。 */
34
+ /**
35
+ * 中断-deny 的后台 settle 观察预算。
36
+ *
37
+ * ══ 🔴 §2.4「DENY-abort」撤稿 + 本常量的论证前提重审(0.42.0;server [4833] 明请)═══════════
38
+ *
39
+ * **本段 0.28.0 原文写的是**:「decide 是 SYNC 驱动的(引擎跑到下一 park/终态才返,实测 4-5s+),
40
+ * 但 **DENY-abort 语义上引擎收到即终结 run**;2s 内连收都没收到 ⇒ 按丢失警示」。
41
+ * 那句加粗的前提**已被上游撤稿**,来源是 server 自己的契约成文(`4631a0f`,随 7.39 出;
42
+ * `ASSISTANT-WIRE-CONTRACT.md` §4a):**DENY 是 TOOL 级的应答,永远不是 run kill**;客户端不得
43
+ * 把「拒绝」译成「取消」;两个动词的 wire 判别式是 `cancelled` vs `gate.batch_halted`。
44
+ * ⇒ 「引擎收到 deny 即终结 run」这条**不成立**:deny 只结算**这一只 ask**,run 按自己的编排继续
45
+ * (裸拒 ⇒ `gate.batch_halted` 兄弟 coded 结算 + `haltedOnUserRejection`;带留言拒 ⇒ run 续跑)。
46
+ *
47
+ * **重审结论(本批只改论证,不改数值 —— 理由写全)**:
48
+ * · 旧论证「2s 没结算 = deny 大概率丢了,因为收到就该终结」**作废**;
49
+ * · 但本常量守的那件事**换一个理由仍然成立**:它是一个**观察上限**,给「decide 永不返回」这种
50
+ * 形态一条出声的路 —— 没有它,一次真的丢失就只剩静默;
51
+ * · **数值不在本批动**:动它是行为面改动,而判据(多久算「没回来」)只有拿真实 decide 往返分布
52
+ * 说了算,那份实测在**消费端**(壳中断路径)而不在包里;且下游 `sema-cli` 的
53
+ * `src/sema/hitlCancelDeny.test.ts` 按现值锁着行为,单边改会当场把消费端打红。
54
+ * · **如实登记的残余**(接入档 §6e/§7 同批记):预算到点就发的那行 warn 措辞是
55
+ * {@link CANCEL_DENY_WARN_TEXT}(「the session may stay locked」),而在新契约下「decide 慢」
56
+ * 与「deny 丢了」这两件事在 2s 这个刻度上**不可分** —— 晚到的成功不会撤回那行 warn(只有 10s
57
+ * 自清)。要根治得做成两档(软档只记 debug、硬档才上屏),那是**跨仓一批**:包侧改时序、壳侧
58
+ * 同批换判据与用例。本批不做单边改动。
59
+ */
37
60
  export declare const CANCEL_DENY_BUDGET_MS = 2000;
38
61
  /** warn 行文案(测试锁字面)。 */
39
62
  export declare const CANCEL_DENY_WARN_TEXT = "could not cancel the pending question \u2014 the session may stay locked; the run may need engine-side recovery";
@@ -66,6 +89,13 @@ export declare function surfaceRememberNotApplied(): void;
66
89
  export declare function surfaceEditNotForwarded(): void;
67
90
  /**
68
91
  * 中断 deny 的有界观察(壳侧单测 `hitlCancelDeny.test.ts` 的被测面;REF-CC-023 起两条决断腿共用)。
92
+ *
93
+ * 🔴 **命名撤稿(0.42.0)**:函数名与日志里的「cancel-by-deny」是**历史词**,保留只为不打断下游
94
+ * 按名锚的用例。它描述的动作在现行契约(server `ASSISTANT-WIRE-CONTRACT.md` §4a)下的准确说法是
95
+ * 「**中断时把这只挂着的 ask 用一个 TOOL 级 deny 结算掉**,好让 run 不停在 suspended 上」——
96
+ * **不是**「用 deny 去 cancel 一条 run」。方向也别读反:这里是把**用户的中断**结算成一次 deny,
97
+ * 而 §4a 禁的是反向的那件事(把**用户的拒绝**译成 cancel),两者不是同一件事。
98
+ *
69
99
  * 铁律:不 await 进 abort 返回路径(用户立即拿回控制);这里只管后台 settle 的«观察»:
70
100
  * - 2s 内 settle 成功 ⇒ 零上屏(SEMA_DEBUG 记成功);
71
101
  * - 失败/超时 ⇒ 上屏一行 warn + SEMA_DEBUG 记原因(deny 丢失 = run 卡 suspended,下一条消息
@@ -61,9 +61,32 @@ export function surfaceForCurrentSession() {
61
61
  return hostSurface;
62
62
  }
63
63
  // ── 件3(中断事故修复批 G,2026-07-15)—— 中断 deny 的有界观察 ─────────────────────────────────
64
- /** cancel-by-deny 的后台 settle 预算。decide 是 SYNC 驱动的(引擎跑到下一 park/终态才返,实测
65
- * 4-5s+),但 DENY-abort 语义上引擎收到即终结 run;2s 内连收都没收到 ⇒ 按丢失警示(晚到成功
66
- * 只是多一行良性 warn,比锁死无线索诚实)。 */
64
+ /**
65
+ * 中断-deny 的后台 settle 观察预算。
66
+ *
67
+ * ══ 🔴 §2.4「DENY-abort」撤稿 + 本常量的论证前提重审(0.42.0;server [4833] 明请)═══════════
68
+ *
69
+ * **本段 0.28.0 原文写的是**:「decide 是 SYNC 驱动的(引擎跑到下一 park/终态才返,实测 4-5s+),
70
+ * 但 **DENY-abort 语义上引擎收到即终结 run**;2s 内连收都没收到 ⇒ 按丢失警示」。
71
+ * 那句加粗的前提**已被上游撤稿**,来源是 server 自己的契约成文(`4631a0f`,随 7.39 出;
72
+ * `ASSISTANT-WIRE-CONTRACT.md` §4a):**DENY 是 TOOL 级的应答,永远不是 run kill**;客户端不得
73
+ * 把「拒绝」译成「取消」;两个动词的 wire 判别式是 `cancelled` vs `gate.batch_halted`。
74
+ * ⇒ 「引擎收到 deny 即终结 run」这条**不成立**:deny 只结算**这一只 ask**,run 按自己的编排继续
75
+ * (裸拒 ⇒ `gate.batch_halted` 兄弟 coded 结算 + `haltedOnUserRejection`;带留言拒 ⇒ run 续跑)。
76
+ *
77
+ * **重审结论(本批只改论证,不改数值 —— 理由写全)**:
78
+ * · 旧论证「2s 没结算 = deny 大概率丢了,因为收到就该终结」**作废**;
79
+ * · 但本常量守的那件事**换一个理由仍然成立**:它是一个**观察上限**,给「decide 永不返回」这种
80
+ * 形态一条出声的路 —— 没有它,一次真的丢失就只剩静默;
81
+ * · **数值不在本批动**:动它是行为面改动,而判据(多久算「没回来」)只有拿真实 decide 往返分布
82
+ * 说了算,那份实测在**消费端**(壳中断路径)而不在包里;且下游 `sema-cli` 的
83
+ * `src/sema/hitlCancelDeny.test.ts` 按现值锁着行为,单边改会当场把消费端打红。
84
+ * · **如实登记的残余**(接入档 §6e/§7 同批记):预算到点就发的那行 warn 措辞是
85
+ * {@link CANCEL_DENY_WARN_TEXT}(「the session may stay locked」),而在新契约下「decide 慢」
86
+ * 与「deny 丢了」这两件事在 2s 这个刻度上**不可分** —— 晚到的成功不会撤回那行 warn(只有 10s
87
+ * 自清)。要根治得做成两档(软档只记 debug、硬档才上屏),那是**跨仓一批**:包侧改时序、壳侧
88
+ * 同批换判据与用例。本批不做单边改动。
89
+ */
67
90
  export const CANCEL_DENY_BUDGET_MS = 2000;
68
91
  /** warn 行文案(测试锁字面)。 */
69
92
  export const CANCEL_DENY_WARN_TEXT = 'could not cancel the pending question — the session may stay locked; the run may need engine-side recovery';
@@ -161,6 +184,13 @@ function isNoPendingError(e) {
161
184
  }
162
185
  /**
163
186
  * 中断 deny 的有界观察(壳侧单测 `hitlCancelDeny.test.ts` 的被测面;REF-CC-023 起两条决断腿共用)。
187
+ *
188
+ * 🔴 **命名撤稿(0.42.0)**:函数名与日志里的「cancel-by-deny」是**历史词**,保留只为不打断下游
189
+ * 按名锚的用例。它描述的动作在现行契约(server `ASSISTANT-WIRE-CONTRACT.md` §4a)下的准确说法是
190
+ * 「**中断时把这只挂着的 ask 用一个 TOOL 级 deny 结算掉**,好让 run 不停在 suspended 上」——
191
+ * **不是**「用 deny 去 cancel 一条 run」。方向也别读反:这里是把**用户的中断**结算成一次 deny,
192
+ * 而 §4a 禁的是反向的那件事(把**用户的拒绝**译成 cancel),两者不是同一件事。
193
+ *
164
194
  * 铁律:不 await 进 abort 返回路径(用户立即拿回控制);这里只管后台 settle 的«观察»:
165
195
  * - 2s 内 settle 成功 ⇒ 零上屏(SEMA_DEBUG 记成功);
166
196
  * - 失败/超时 ⇒ 上屏一行 warn + SEMA_DEBUG 记原因(deny 丢失 = run 卡 suspended,下一条消息
@@ -90,7 +90,10 @@ export function toAnsweredOutput(questions, answer) {
90
90
  * 弹既有 AskUserQuestion overlay → 等作答 → 经 HitlBridge decide(答案/拒答)。
91
91
  * decide 成功即引擎已把 run 驱到下一状态(sync);调用方随后 attach runs.events 续流。
92
92
  */
93
- async function surfaceGateAndDecide(deps, taskId, askArgsByCall, signal) {
93
+ async function surfaceGateAndDecide(deps, taskId, askArgsByCall, signal,
94
+ /** 这张 park 的待批 call 身份(server ≥7.41.0;缺席是常态)。语义与不 fail-closed 的理由
95
+ * 与 fs 腿同一条,见 `toolApprovalWire.surfaceFsApprovalAndDecide` 的同名参数头注。 */
96
+ parkGatedCallId) {
94
97
  if (!hasQuestionOverlay()) {
95
98
  return { kind: 'failed', reason: 'no question overlay mounted (print/non-REPL mode)' };
96
99
  }
@@ -98,7 +101,9 @@ async function surfaceGateAndDecide(deps, taskId, askArgsByCall, signal) {
98
101
  // REF-CC-029(单源取件)+ REF-CC-033(no_pending 判别码):findPendingForTask 是唯一取件口
99
102
  // (三份近似复刻已收敛),其失败形带 code 判别位,本处原样透传给 GateOutcome ——
100
103
  // 「没有待决 gate」(良性,park 重放常见)与「approvals.list 真失败」自此结构可分。
101
- const found = await findPendingForTask(deps.client, taskId, isAskTool, signal ? { signal } : undefined);
104
+ const found = await findPendingForTask(deps.client, taskId, isAskTool, signal ? { signal } : undefined, parkGatedCallId, // 身份在场 ⇒ 逐字命中优先;缺席/不命中 ⇒ 原两腿(不 fail-closed,理由见参数头注)
105
+ // 本腿的族就是 ask 一族(整行可读闸的 ask 半场,与 fs 腿的 `isToolApprovalGate` 对称)。
106
+ (row) => isAskTool(typeof row.toolName === 'string' ? row.toolName : undefined));
102
107
  if (!found.ok)
103
108
  return { kind: 'failed', reason: found.reason, ...(found.code !== undefined ? { code: found.code } : {}) };
104
109
  const { pending, gatedCallId } = found;
@@ -149,12 +154,24 @@ async function surfaceGateAndDecide(deps, taskId, askArgsByCall, signal) {
149
154
  });
150
155
  const bridge = new HitlBridge(deps.client, taskId);
151
156
  if (answer === null) {
152
- // turn 被中断(Esc/Ctrl+C):cancel-by-deny(contract/04 §2.4 —— suspended run 不 runs.cancel)。
157
+ // turn 被中断(Esc/Ctrl+C):把这只挂着的 ask 用一次 **TOOL 级 deny** 结算掉,好让 run 不停在
158
+ // suspended 上(server `ASSISTANT-WIRE-CONTRACT.md` §4a)。
159
+ // 🔴 **0.42.0 撤稿**:原文写的是「cancel-by-deny(contract/04 §2.4 —— suspended run 不
160
+ // runs.cancel)」。那条引用的两个前提都已作废 —— §4a 逐字说 DENY 是 tool 级应答**永远不是**
161
+ // run kill;而「suspended run 不能 runs.cancel」自 server [868] 起就不成立(就地取消已实装,
162
+ // 本仓 fresh-scan B型-5 按真字节证伪)。这里选 deny 不是因为 cancel 不可用,是因为**这一刻
163
+ // 要处理的就是一只挂着的 ask**:先把它结算掉,run 才走得下去。下方那句「引擎侧解锁腿到货前」
164
+ // 同批订正:那条腿早就到货了,本臂保留的理由变成「结算 ask 是这一步的正解」,不再是权宜。
153
165
  // 件3(中断事故修复批 G,2026-07-15,症状1 壳侧配套):此前 .catch(()=>{}) 全吞 = deny 丢失时
154
166
  // run 永卡 suspended,session 锁死,用户下一条消息撞 409「active run」还全无线索。改为有界观察
155
167
  // (observeCancelByDeny,2s 预算):abort 仍立即返回用户控制(不 await,交互时序不变),后台
156
- // settle 失败/超时上屏一行 warn + SEMA_DEBUG 记失败原因。引擎侧解锁腿([866] server:cancel
157
- // suspended 改语义 + reapSuspended TTL)到货前,这是壳能做的最诚实半场。
168
+ // settle 失败/超时上屏一行 warn + SEMA_DEBUG 记失败原因。
169
+ // 🔴 **0.42.0 订正**:本段原文以「引擎侧解锁腿([866] server:cancel suspended 改语义 +
170
+ // reapSuspended TTL)**到货前**,这是壳能做的最诚实半场」收尾 —— 那条腿早已到货
171
+ // (server [868] 起 suspended run 就地取消 + `reapSuspended` 实体在,fresh-scan B型-5 已按
172
+ // 真字节证伪)。本臂**不是**在等一条不存在的上游腿:它保留的理由是「这一刻要处理的就是一只
173
+ // 挂着的 ask,先结算它 run 才走得下去」。观察器的预算论证前提见 `CANCEL_DENY_BUDGET_MS` 头注
174
+ // 的重审段(同批 §2.4 撤稿件)。
158
175
  observeCancelByDeny(bridge.decideTool({ decision: 'deny', reason: 'Interrupted by user' }, gatedCallId, undefined, pending), taskId);
159
176
  return { kind: 'aborted', gatedCallId };
160
177
  }
@@ -208,7 +225,7 @@ export async function resolvePark(park, ctx) {
208
225
  else if (park.gate === 'fs') {
209
226
  candidateGatedCallId = led.lastFsOrShellGatedCallId();
210
227
  // [816] 放宽腿:fs 写权限 gate → CC 三选卡(vendored PermissionRequest)→ decide。
211
- outcome = await surfaceFsApprovalAndDecide({ client: deps.client }, taskId, argsByCallOf(led), ctx.signal);
228
+ outcome = await surfaceFsApprovalAndDecide({ client: deps.client }, taskId, argsByCallOf(led), ctx.signal, park.gatedCallId);
212
229
  // #51: `outcome`'s declared type is the wider `GateOutcome |
213
230
  // FsApprovalOutcome`; both unions share a 'decided' kind with
214
231
  // different optional fields (`answered` vs `denied`), so a plain
@@ -221,7 +238,7 @@ export async function resolvePark(park, ctx) {
221
238
  }
222
239
  }
223
240
  else {
224
- outcome = await surfaceGateAndDecide(deps, taskId, argsByCallOf(led), ctx.signal);
241
+ outcome = await surfaceGateAndDecide(deps, taskId, argsByCallOf(led), ctx.signal, park.gatedCallId);
225
242
  }
226
243
  // #110 缺陷② b/c —— **重放的、早已决断过的 park 不是失败**。
227
244
  // durable re-attach 必然会把 park 帧再送一遍(`lastEventId` 再准也只能精确到帧,park 就在
@@ -288,6 +305,15 @@ export async function resolvePark(park, ctx) {
288
305
  }
289
306
  // decide 成功:丢弃该 call 的毒化 HOLD(续流重放会带 isError:false 的解答帧收口卡片),
290
307
  // 并记下真实答案供该解答帧 stamp `structured`(否则卡片渲成结果不可用)。
308
+ //
309
+ // 🔴 件④([4933] 立案的存量,0.41.0 根修):摘扣留帧走 `dropHeldForDecidedPark` 而不是裸
310
+ // `dropHeld(outcome.gatedCallId)` —— 后者的入参是 `findPendingForTask` 的**卡面渲染宽口**
311
+ // (同 taskId + 谓词命中的第一行),同族多行排队时它可能是旁观者,于是摘掉的是**另一条**
312
+ // call 的扣留帧、那一帧再也不吐(「一帧不丢」在这一格上不成立)。新动词的两条腿见其头注:
313
+ // `park.gatedCallId`(server ≥7.41.0 的 wire 身份)在场就点名摘,缺席则只在同族唯一时摘。
314
+ // 这一行**刻意排在 `if (outcome.gatedCallId)` 之外**:身份在 wire 上时,摘不摘与 pending
315
+ // 行给没给出 callId 无关(park 有主角,decide 也成功了)。
316
+ led.dropHeldForDecidedPark(outcome.gatedCallId, park.gatedCallId);
291
317
  if (outcome.gatedCallId) {
292
318
  // 🔴 #324:**刻意不把 pending 行的 callId 当成连坐判别的主角身份**。durable `checkpointGate`
293
319
  // 不带 callId,`findPendingForTask` 取的是「同 taskId + 谓词命中的**第一行**」(谓词不命中
@@ -295,12 +321,12 @@ export async function resolvePark(park, ctx) {
295
321
  // 同族两行排队时它可能给出旁观者,于是真主角被挤成「非主角」而 stamp(异源复审实测)。
296
322
  // 连坐判别改由台账按「本批唯一的该族 call」自证(`gateLedger.flushHeld` 头注),
297
323
  // 分不出来就整批不标。下面两行是 gate 生命周期(#110 / REF-CC-034),与判别无关,照旧。
298
- // ⚠️ **记案(存量,非本批根因)**:`dropHeld` 用的也是这个不可信的 `gatedCallId` —— 同族
299
- // 多行排队时它可能摘掉**另一条** call 的扣留帧,那一帧就再也不吐了(「一帧不丢」在这一格
300
- // 上不成立)。这条早于本批(4c42c8a 逐字相同),根因在「decide 结果没有可核验的 park 身份」,
301
- // 与本批修的判别面是两件事;改它要动 #110 的承重路径,单独立案。server
302
- // `checkpointGate.toolCallId` 后两处一并收口。
303
- led.dropHeld(outcome.gatedCallId);
324
+ // **记案已销**(0.41.0 件④):此处原先那句「`dropHeld` 用的也是这个不可信 callId」的
325
+ // 存量记案,已由上方 `dropHeldForDecidedPark` 收口(wire 身份点名 / 判不出就不摘)。
326
+ // 🔴 `markDecided` **刻意仍用** `outcome.gatedCallId`:它喂的是 #110 那条「重放的 park 不是
327
+ // 失败」的承重判据,而那条判据的候选取自 `lastFsOrShellGatedCallId()`(同一条 pending 宽口
328
+ // 时代的口径),两边必须同源才对得上;换成 wire 身份是**另一件**改动(要连着上方
329
+ // `candidateGatedCallId` 一起换,且会改动烧会话那条路径的行为),不搭本批的车。
304
330
  led.markDecided(outcome.gatedCallId); // #110 缺陷② b / REF-CC-034:身份留痕,上方候选匹配消费
305
331
  if ('answered' in outcome && outcome.answered)
306
332
  led.rememberAnswer(outcome.gatedCallId, outcome.answered);