@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.
@@ -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
@@ -361,9 +428,9 @@ export class HitlBridge {
361
428
  * its own `approvals.list()` — closing the TOCTOU window between "the row the human saw" and "the
362
429
  * row decide resolves" (two independent fetches could otherwise land on different rows if the
363
430
  * 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`). */
431
+ * such row in hand(缺省回落 `pendingForDecide` 见其头注,那是单源 `findPendingForTask` 的一层出参映射). */
365
432
  preResolvedPending) {
366
- const pending = preResolvedPending ?? (await this.findPending(toolUseID, opts));
433
+ const pending = preResolvedPending ?? (await this.pendingForDecide(toolUseID, opts));
367
434
  if (!pending) {
368
435
  throw new HitlSafetyError('no pending checkpoint to decide (resolved/expired under the human) — refetch + re-present', 'no_pending');
369
436
  }
@@ -408,10 +475,10 @@ export class HitlBridge {
408
475
  /** REF-CC-029(2026-08-02):see `decideTool`'s same-named param — the pending row the caller already
409
476
  * fetched via `findPendingForTask`. */
410
477
  preResolvedPending) {
411
- // 🔴 校验在 findPending **之前**:入参坏了跟 pending 行在不在场无关,而先 list 再拒会把
478
+ // 🔴 校验在取件**之前**:入参坏了跟 pending 行在不在场无关,而先 list 再拒会把
412
479
  // 「你的作答是空的」这条真判词换成「no_pending」那条假判词(行恰好过期时)。
413
480
  assertAnswersSendable(answers);
414
- const pending = preResolvedPending ?? (await this.findPending(toolUseID, opts));
481
+ const pending = preResolvedPending ?? (await this.pendingForDecide(toolUseID, opts));
415
482
  if (!pending) {
416
483
  throw new HitlSafetyError('no pending AskUserQuestion checkpoint to answer — refetch + re-present', 'no_pending');
417
484
  }
@@ -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;
@@ -208,7 +213,7 @@ export async function resolvePark(park, ctx) {
208
213
  else if (park.gate === 'fs') {
209
214
  candidateGatedCallId = led.lastFsOrShellGatedCallId();
210
215
  // [816] 放宽腿:fs 写权限 gate → CC 三选卡(vendored PermissionRequest)→ decide。
211
- outcome = await surfaceFsApprovalAndDecide({ client: deps.client }, taskId, argsByCallOf(led), ctx.signal);
216
+ outcome = await surfaceFsApprovalAndDecide({ client: deps.client }, taskId, argsByCallOf(led), ctx.signal, park.gatedCallId);
212
217
  // #51: `outcome`'s declared type is the wider `GateOutcome |
213
218
  // FsApprovalOutcome`; both unions share a 'decided' kind with
214
219
  // different optional fields (`answered` vs `denied`), so a plain
@@ -221,7 +226,7 @@ export async function resolvePark(park, ctx) {
221
226
  }
222
227
  }
223
228
  else {
224
- outcome = await surfaceGateAndDecide(deps, taskId, argsByCallOf(led), ctx.signal);
229
+ outcome = await surfaceGateAndDecide(deps, taskId, argsByCallOf(led), ctx.signal, park.gatedCallId);
225
230
  }
226
231
  // #110 缺陷② b/c —— **重放的、早已决断过的 park 不是失败**。
227
232
  // durable re-attach 必然会把 park 帧再送一遍(`lastEventId` 再准也只能精确到帧,park 就在
@@ -288,6 +293,15 @@ export async function resolvePark(park, ctx) {
288
293
  }
289
294
  // decide 成功:丢弃该 call 的毒化 HOLD(续流重放会带 isError:false 的解答帧收口卡片),
290
295
  // 并记下真实答案供该解答帧 stamp `structured`(否则卡片渲成结果不可用)。
296
+ //
297
+ // 🔴 件④([4933] 立案的存量,0.41.0 根修):摘扣留帧走 `dropHeldForDecidedPark` 而不是裸
298
+ // `dropHeld(outcome.gatedCallId)` —— 后者的入参是 `findPendingForTask` 的**卡面渲染宽口**
299
+ // (同 taskId + 谓词命中的第一行),同族多行排队时它可能是旁观者,于是摘掉的是**另一条**
300
+ // call 的扣留帧、那一帧再也不吐(「一帧不丢」在这一格上不成立)。新动词的两条腿见其头注:
301
+ // `park.gatedCallId`(server ≥7.41.0 的 wire 身份)在场就点名摘,缺席则只在同族唯一时摘。
302
+ // 这一行**刻意排在 `if (outcome.gatedCallId)` 之外**:身份在 wire 上时,摘不摘与 pending
303
+ // 行给没给出 callId 无关(park 有主角,decide 也成功了)。
304
+ led.dropHeldForDecidedPark(outcome.gatedCallId, park.gatedCallId);
291
305
  if (outcome.gatedCallId) {
292
306
  // 🔴 #324:**刻意不把 pending 行的 callId 当成连坐判别的主角身份**。durable `checkpointGate`
293
307
  // 不带 callId,`findPendingForTask` 取的是「同 taskId + 谓词命中的**第一行**」(谓词不命中
@@ -295,12 +309,12 @@ export async function resolvePark(park, ctx) {
295
309
  // 同族两行排队时它可能给出旁观者,于是真主角被挤成「非主角」而 stamp(异源复审实测)。
296
310
  // 连坐判别改由台账按「本批唯一的该族 call」自证(`gateLedger.flushHeld` 头注),
297
311
  // 分不出来就整批不标。下面两行是 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);
312
+ // **记案已销**(0.41.0 件④):此处原先那句「`dropHeld` 用的也是这个不可信 callId」的
313
+ // 存量记案,已由上方 `dropHeldForDecidedPark` 收口(wire 身份点名 / 判不出就不摘)。
314
+ // 🔴 `markDecided` **刻意仍用** `outcome.gatedCallId`:它喂的是 #110 那条「重放的 park 不是
315
+ // 失败」的承重判据,而那条判据的候选取自 `lastFsOrShellGatedCallId()`(同一条 pending 宽口
316
+ // 时代的口径),两边必须同源才对得上;换成 wire 身份是**另一件**改动(要连着上方
317
+ // `candidateGatedCallId` 一起换,且会改动烧会话那条路径的行为),不搭本批的车。
304
318
  led.markDecided(outcome.gatedCallId); // #110 缺陷② b / REF-CC-034:身份留痕,上方候选匹配消费
305
319
  if ('answered' in outcome && outcome.answered)
306
320
  led.rememberAnswer(outcome.gatedCallId, outcome.answered);
@@ -344,7 +344,21 @@ export declare function surfaceApprovalCard(req: ApprovalCardRequest): Promise<A
344
344
  *
345
345
  * @param argsByCall tool_start.args(toolCallId → args)——题干/diff 的一手源;缺则 pending.input。
346
346
  */
347
- export declare function surfaceFsApprovalAndDecide(deps: FsApprovalWireDeps, taskId: string, argsByCall: Map<string, unknown>, signal?: AbortSignal): Promise<FsApprovalOutcome>;
347
+ export declare function surfaceFsApprovalAndDecide(deps: FsApprovalWireDeps, taskId: string, argsByCall: Map<string, unknown>, signal?: AbortSignal,
348
+ /**
349
+ * 这张 park 的**待批 call 身份**(server ≥7.41.0 的 `done{suspended}.result.toolCallId` /
350
+ * durable `suspended` 事件同键;供给见 `frameRouter.parkGatedCallId`,缺席是常态)。
351
+ *
352
+ * 🔴 它必须从**取件**这一步就起作用,不能只拿去删扣留帧(0.41.0 件④ 的异源复审 finding①):
353
+ * 取件按 taskId + 族谓词选**第一行**,同 task 同族多行排队时那可能是旁观者 —— 只用身份删帧就成了
354
+ * split-brain(决断绑的是旁观者行、删掉的是主角帧):用户其实在批准/拒绝**另一个**工具调用,
355
+ * 而真 park 原地不动、反复重挂。
356
+ * 🔴 **不 fail-closed**:身份在场但队列里没有对应行时**照旧回落**既有两条腿(taskId + 族谓词)。
357
+ * 这个 join(checkpoint 的 `pendingAction.toolCallId` ↔ `/v1/approvals` 行的 `toolCallId`/
358
+ * `boundCallId`)本仓没有对真 server 的实证,而 fail-closed 的失效面是**每一次审批都决断不了**;
359
+ * 回落的失效面则与本键出现之前逐字节相同。方向按代价不对称取:宁可退回原状,不赌一个没实证的键。
360
+ */
361
+ parkGatedCallId?: string): Promise<FsApprovalOutcome>;
348
362
  /** server 包 src/tool-approval.ts 头注的帧契约(redactDeep + 16KiB 帽;超帽 argsOmitted 仍出帧)。 */
349
363
  export interface ToolApprovalFrame {
350
364
  type: 'tool_approval' | 'tool_approval_complete';
@@ -221,7 +221,21 @@ function cardPortMissReason() {
221
221
  *
222
222
  * @param argsByCall tool_start.args(toolCallId → args)——题干/diff 的一手源;缺则 pending.input。
223
223
  */
224
- export async function surfaceFsApprovalAndDecide(deps, taskId, argsByCall, signal) {
224
+ export async function surfaceFsApprovalAndDecide(deps, taskId, argsByCall, signal,
225
+ /**
226
+ * 这张 park 的**待批 call 身份**(server ≥7.41.0 的 `done{suspended}.result.toolCallId` /
227
+ * durable `suspended` 事件同键;供给见 `frameRouter.parkGatedCallId`,缺席是常态)。
228
+ *
229
+ * 🔴 它必须从**取件**这一步就起作用,不能只拿去删扣留帧(0.41.0 件④ 的异源复审 finding①):
230
+ * 取件按 taskId + 族谓词选**第一行**,同 task 同族多行排队时那可能是旁观者 —— 只用身份删帧就成了
231
+ * split-brain(决断绑的是旁观者行、删掉的是主角帧):用户其实在批准/拒绝**另一个**工具调用,
232
+ * 而真 park 原地不动、反复重挂。
233
+ * 🔴 **不 fail-closed**:身份在场但队列里没有对应行时**照旧回落**既有两条腿(taskId + 族谓词)。
234
+ * 这个 join(checkpoint 的 `pendingAction.toolCallId` ↔ `/v1/approvals` 行的 `toolCallId`/
235
+ * `boundCallId`)本仓没有对真 server 的实证,而 fail-closed 的失效面是**每一次审批都决断不了**;
236
+ * 回落的失效面则与本键出现之前逐字节相同。方向按代价不对称取:宁可退回原状,不赌一个没实证的键。
237
+ */
238
+ parkGatedCallId) {
225
239
  // REF-CC-031(2026-08-02):卡口缺席判据不在此前置重复一遍 —— `surfaceApprovalCard`(下方调用)
226
240
  // 已经计 miss + 出 typed `{kind:'failed', reason:...}`,card.kind==='failed' 走既有汇流分支
227
241
  // (下面 switch 的 'failed' 臂);两处各写一份文案会在 SEMA_DEBUG 里出现两种措辞,取决于走的是
@@ -232,7 +246,11 @@ export async function surfaceFsApprovalAndDecide(deps, taskId, argsByCall, signa
232
246
  // 二次 list(),消掉 hitl2-01/hitl2-08 的 TOCTOU 窗。
233
247
  // 🔴 常驻判据不是「看得见 pending 这个实参」,是 pure 门那四条「approvals.list 全程恰好 1 次」——
234
248
  // 漏传一条腿,形状断言全绿而计数当场翻红(回炉车前的实况:只有 aborted 臂传了)。
235
- const found = await findPendingForTask(deps.client, taskId, (toolName) => toolName !== undefined && (toolNameIsFsWrite(toolName) || toolNameIsShellExec(toolName)), signal ? { signal } : undefined);
249
+ const found = await findPendingForTask(deps.client, taskId, (toolName) => toolName !== undefined && (toolNameIsFsWrite(toolName) || toolNameIsShellExec(toolName)), signal ? { signal } : undefined, parkGatedCallId, // 见本参数头注:身份在场 ⇒ 逐字命中优先于「同族第一行」;缺席/不命中 ⇒ 原两腿
250
+ // 身份腿的族闸走**整行**判据:本腿的族是 `isToolApprovalGate` 的**两条腿**(一等 kind 放行任意
251
+ // toolName + fs/shell 名字腿),而上面那个 `matches` 只有名字腿那半 —— 拿它当身份闸会把合法的
252
+ // kind-only 行判出局(五审 finding①)。gateKind = 行上的扁平投影,与 gate.kind 同一权威。
253
+ (row) => isToolApprovalGate({ kind: row.gateKind, toolName: row.toolName }));
236
254
  if (!found.ok)
237
255
  return { kind: 'failed', reason: found.reason };
238
256
  const { pending, gatedCallId } = found;
@@ -44,6 +44,7 @@
44
44
  * 绝不用「子代的首条输出」或行描述冒充委派 prompt(那是把一个看起来合理的错答案摆上屏)。
45
45
  * 一切失败 fail-soft,绝不 throw。
46
46
  */
47
+ import { stripSystemReminderBlocks } from '../systemReminderTag.js';
47
48
  import { hostLog } from '../host.js';
48
49
  import { makeEngineWireClient } from '../engineWireSdk.js';
49
50
  import { engineWireTarget } from '../engineWireTarget.js';
@@ -139,10 +140,16 @@ export function delegatedPromptText(content) {
139
140
  *
140
141
  * 信封是**成对标签**,所以用配对正则整段剥;不配对(被截断等)时保守不动,宁可多渲也不吃掉正文。
141
142
  */
142
- const PROMPT_ENVELOPE_TAGS = ['system-reminder', 'fork-boilerplate'];
143
+ // 🔴 件⑦([4974],2026-08-22):`system-reminder` 的**开标签带属性**(core ≥5.46 恒带
144
+ // `mark="<22 位 base64url>"`),精确形 `<system-reminder>` 整段剥不掉 ⇒ 上面那条 2026-08-01
145
+ // 修过的 bug 原样复发(详情页 Prompt 又变回技能清单)。剥壳口径收进包内单一闸口
146
+ // `systemReminderTag`(同根因的另一座位是 `classifierVerdictWire`,见该模块头注)。
147
+ // `fork-boilerplate` 是 core `agents/subagent.js` 里的**裸**标签(dist 直证),保持精确形 ——
148
+ // 没有属性的标签不需要属性容忍,给它加 = 给伪造面白让一寸。
149
+ const PROMPT_ENVELOPE_EXACT_TAGS = ['fork-boilerplate'];
143
150
  function stripEnvelopes(raw) {
144
- let text = raw;
145
- for (const tag of PROMPT_ENVELOPE_TAGS) {
151
+ let text = stripSystemReminderBlocks(raw);
152
+ for (const tag of PROMPT_ENVELOPE_EXACT_TAGS) {
146
153
  text = text.replace(new RegExp(`<${tag}>[\\s\\S]*?</${tag}>`, 'g'), '');
147
154
  }
148
155
  return text.trim() || undefined;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * systemReminderTag — `<system-reminder>` 包裹的**单一识别闸口**(件⑥⑦,2026-08-22,[4974])。
3
+ *
4
+ * ## 为什么要有这个文件
5
+ *
6
+ * core ≥5.46 起,引擎铸的每一个 `<system-reminder>` **开标签**都带一个 `mark` 属性。真字节直证
7
+ * (core 5.52.0 `dist/core/reminder-mint.js`,逐行):
8
+ * ```js
9
+ * const MARK_SHAPE = /^[A-Za-z0-9_-]{22}$/ // randomBytes(16).toString("base64url")
10
+ * export function openSystemReminder(mark) {
11
+ * return mark === undefined ? "<system-reminder>" : `<system-reminder mark="${mark}">`
12
+ * }
13
+ * export function mintSystemReminder(body, mark) {
14
+ * return `${openSystemReminder(mark)}\n${body}\n</system-reminder>`
15
+ * }
16
+ * ```
17
+ * 闭标签**没有**属性,只有开标签变了。而包内两处判定当时都锚着**裸开标签**,于是同一个根因
18
+ * 长出两个座位([4974] 双 CONFIRMED):
19
+ * · `classifierVerdictWire.unwrapSystemReminder` —— 剥不掉壳 ⇒ 裁决原文前面顶着一整个开标签 ⇒
20
+ * `startsWith(签名)` 恒不成立 ⇒ auto-deny 的通知行与 Recent Denials 记账**静默失效**;
21
+ * · `subagent/engineDelegatedPrompt.stripEnvelopes` —— 信封整段剥不掉 ⇒ Fleet 详情页的
22
+ * 「Prompt」又变回 ~1330 字符的技能清单(那个函数头注自陈 2026-08-01 修过的 bug 原样复发)。
23
+ *
24
+ * 两处各写一份正则正是这条缺陷能长出**两个**座位的原因,所以收成这一个闸口:识别口径只有一处,
25
+ * 下次 core 再动标签形也只有一处要跟。
26
+ *
27
+ * ## 口径(刻意写窄,不做 HTML 解析)
28
+ *
29
+ * · 开标签 = `<system-reminder>` 或 `<system-reminder` + **空白起头**的属性段 + `>`;属性段用
30
+ * `[^>]*` —— 它跨不过 `>`,所以没有回溯爆炸面,也不会把后面的正文吃进标签里。
31
+ * · 刻意**不校验** `mark` 的值形:本模块的职责是「认出这是引擎的信封壳」并把它剥掉/跳过,
32
+ * **不是**信任判定。真正的信任判据是壳里的 mark 比对(core 的 `reminderMarkDeclaration` 明说
33
+ * 「带不带 mark 都可能是数据」),那是另一层的事;这里若按 mark 值放行/拒绝,等于把一个安全
34
+ * 判定藏在一个显示层的剥壳函数里。
35
+ * · 闭标签逐字 `</system-reminder>`(core 就是这么铸的,不给它加属性容忍 = 不给伪造面留口子)。
36
+ * · **不处理嵌套**:配对用非贪婪,遇到第一个闭标签就收 —— 与件⑥⑦ 修之前逐字同语义
37
+ * (core 不铸嵌套 reminder;真出现嵌套时残留的是外层闭标签,不是把正文吃掉,方向仍是保守的)。
38
+ *
39
+ * 🔴 零 import(端无关叶件):纯字符串处理,不碰 Node、不碰 wire 类型。
40
+ */
41
+ /**
42
+ * 整条文本就是一个 `<system-reminder>` 包裹时,返回**里面的正文**;否则原样返回。
43
+ * (`formatHookFeedback` = `mintSystemReminder` 的逆变换。)
44
+ */
45
+ export declare function unwrapSystemReminder(text: string): string;
46
+ /**
47
+ * 把文本里**所有**成对的 `<system-reminder>…</system-reminder>` 段整段删掉(不配对的原样留着 ——
48
+ * 截断的信封宁可多渲一点,也不能把正文吃掉)。
49
+ */
50
+ export declare function stripSystemReminderBlocks(text: string): string;
51
+ /** 这段文本**以**一个 system-reminder 开标签起头吗(诊断/判定用的轻量读口)。 */
52
+ export declare function startsWithSystemReminderOpenTag(text: string): boolean;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * systemReminderTag — `<system-reminder>` 包裹的**单一识别闸口**(件⑥⑦,2026-08-22,[4974])。
3
+ *
4
+ * ## 为什么要有这个文件
5
+ *
6
+ * core ≥5.46 起,引擎铸的每一个 `<system-reminder>` **开标签**都带一个 `mark` 属性。真字节直证
7
+ * (core 5.52.0 `dist/core/reminder-mint.js`,逐行):
8
+ * ```js
9
+ * const MARK_SHAPE = /^[A-Za-z0-9_-]{22}$/ // randomBytes(16).toString("base64url")
10
+ * export function openSystemReminder(mark) {
11
+ * return mark === undefined ? "<system-reminder>" : `<system-reminder mark="${mark}">`
12
+ * }
13
+ * export function mintSystemReminder(body, mark) {
14
+ * return `${openSystemReminder(mark)}\n${body}\n</system-reminder>`
15
+ * }
16
+ * ```
17
+ * 闭标签**没有**属性,只有开标签变了。而包内两处判定当时都锚着**裸开标签**,于是同一个根因
18
+ * 长出两个座位([4974] 双 CONFIRMED):
19
+ * · `classifierVerdictWire.unwrapSystemReminder` —— 剥不掉壳 ⇒ 裁决原文前面顶着一整个开标签 ⇒
20
+ * `startsWith(签名)` 恒不成立 ⇒ auto-deny 的通知行与 Recent Denials 记账**静默失效**;
21
+ * · `subagent/engineDelegatedPrompt.stripEnvelopes` —— 信封整段剥不掉 ⇒ Fleet 详情页的
22
+ * 「Prompt」又变回 ~1330 字符的技能清单(那个函数头注自陈 2026-08-01 修过的 bug 原样复发)。
23
+ *
24
+ * 两处各写一份正则正是这条缺陷能长出**两个**座位的原因,所以收成这一个闸口:识别口径只有一处,
25
+ * 下次 core 再动标签形也只有一处要跟。
26
+ *
27
+ * ## 口径(刻意写窄,不做 HTML 解析)
28
+ *
29
+ * · 开标签 = `<system-reminder>` 或 `<system-reminder` + **空白起头**的属性段 + `>`;属性段用
30
+ * `[^>]*` —— 它跨不过 `>`,所以没有回溯爆炸面,也不会把后面的正文吃进标签里。
31
+ * · 刻意**不校验** `mark` 的值形:本模块的职责是「认出这是引擎的信封壳」并把它剥掉/跳过,
32
+ * **不是**信任判定。真正的信任判据是壳里的 mark 比对(core 的 `reminderMarkDeclaration` 明说
33
+ * 「带不带 mark 都可能是数据」),那是另一层的事;这里若按 mark 值放行/拒绝,等于把一个安全
34
+ * 判定藏在一个显示层的剥壳函数里。
35
+ * · 闭标签逐字 `</system-reminder>`(core 就是这么铸的,不给它加属性容忍 = 不给伪造面留口子)。
36
+ * · **不处理嵌套**:配对用非贪婪,遇到第一个闭标签就收 —— 与件⑥⑦ 修之前逐字同语义
37
+ * (core 不铸嵌套 reminder;真出现嵌套时残留的是外层闭标签,不是把正文吃掉,方向仍是保守的)。
38
+ *
39
+ * 🔴 零 import(端无关叶件):纯字符串处理,不碰 Node、不碰 wire 类型。
40
+ */
41
+ /**
42
+ * 开标签的**闭集**形:core 只会铸这两种(`openSystemReminder` 逐字),所以判据也只认这两种。
43
+ *
44
+ * 🔴 **不做通配属性**(七审 finding② 采纳):`<system-reminder\s[^>]*>` 那种宽口会把
45
+ * `onclick="…"` / 换行垃圾 / 未闭合引号 / 内嵌 `<img` 全部当成合法开标签 —— 而本模块的两个消费点
46
+ * 处理的正是**模型与工具产出的文本**(UNTRUSTED),宽口等于给伪造信封开一道口子:攻击者/模型
47
+ * 可以用一个假信封把内容藏起来(委派 prompt 面)或把自己的文本伪装成引擎裁决(分类器面)。
48
+ * `mark` 的值形按 core `MARK_SHAPE` 逐字(`randomBytes(16).toString("base64url")` = 22 位
49
+ * `[A-Za-z0-9_-]`),值本身**不比对**(见文件头:比对是信任判定,不是剥壳的活)——但**形**必须对,
50
+ * 形不对的一律不是引擎铸的壳。
51
+ */
52
+ const OPEN_TAG_SOURCE = '<system-reminder(?: mark="[A-Za-z0-9_-]{22}")?>';
53
+ /** 整段配对(开标签 + 正文 + 闭标签)。捕获组 1 = 正文。 */
54
+ const WRAPPED_RE = new RegExp(`^\\s*${OPEN_TAG_SOURCE}\\s*\\n?([\\s\\S]*?)\\n?\\s*<\\/system-reminder>\\s*$`);
55
+ /**
56
+ * 整条文本就是一个 `<system-reminder>` 包裹时,返回**里面的正文**;否则原样返回。
57
+ * (`formatHookFeedback` = `mintSystemReminder` 的逆变换。)
58
+ */
59
+ export function unwrapSystemReminder(text) {
60
+ const m = WRAPPED_RE.exec(text);
61
+ return m ? m[1] : text;
62
+ }
63
+ /**
64
+ * 把文本里**所有**成对的 `<system-reminder>…</system-reminder>` 段整段删掉(不配对的原样留着 ——
65
+ * 截断的信封宁可多渲一点,也不能把正文吃掉)。
66
+ */
67
+ export function stripSystemReminderBlocks(text) {
68
+ return text.replace(new RegExp(`${OPEN_TAG_SOURCE}[\\s\\S]*?<\\/system-reminder>`, 'g'), '');
69
+ }
70
+ /** 这段文本**以**一个 system-reminder 开标签起头吗(诊断/判定用的轻量读口)。 */
71
+ export function startsWithSystemReminderOpenTag(text) {
72
+ return new RegExp(`^\\s*${OPEN_TAG_SOURCE}`).test(text);
73
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/client-core",
3
- "version": "0.40.0",
3
+ "version": "0.41.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",