@sema-agent/client-core 0.41.0 → 0.43.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.
@@ -11,6 +11,26 @@ 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
+ /**
15
+ * CC `utils/messages.ts` 的 `INTERRUPT_MESSAGE_FOR_TOOL_USE` **逐字**(件 B,#323 症状②,2026-08-25)。
16
+ *
17
+ * 🔴 CC 语料直证的两条事实(壳侧勘察,cli `src/utils/messages.ts:216-217` 逐字节同):
18
+ * ① `"Operation aborted"` 在 CC 只在 transport 层 throw,**从不上屏** —— 它是我们这条引擎链
19
+ * 特有的产物,渲成红 `Error: Operation aborted` 是 CC 里根本不存在的形;
20
+ * ② CC 的**唯一**中断呈现形就是本串:壳的 `UserToolErrorMessage` 对
21
+ * `content.includes(INTERRUPT_MESSAGE_FOR_TOOL_USE)` 渲 dim `Interrupted`(CC 2.1.223 逐字判据)。
22
+ * ⇒ 用户中断批的 tool_result 文案在**呈现向**归一到本串,三端就天然拿到 CC 的 Interrupted 形,
23
+ * 不必各自再抄一份「Operation aborted 也算中断」的手工判据(那正是漂移温床)。
24
+ * 🔴 **呈现向,不是模型面**:本包这条链是转录/渲染链;喂回模型的那一份由引擎自己的 wire 承载,
25
+ * 这里一个字节都不碰它。
26
+ */
27
+ export const HITL_INTERRUPT_MESSAGE_FOR_TOOL_USE = '[Request interrupted by user for tool use]';
28
+ /**
29
+ * run 被**取消**的终态短码(server cancel settle 契约:`runs.cancel` → run SETTLES to `failed` +
30
+ * `errorCode:"cancelled"`;成文见本包 `controlRouter.ts` cancel 段与 `docs/INTEGRATION-CLIENTS.md`
31
+ * 的 cancel/deny 对照行 ——「ack 带 `errorCode:"cancelled"`」)。
32
+ */
33
+ const RUN_CANCELLED_ERROR_CODE = 'cancelled';
14
34
  /**
15
35
  * core 对**被 gate park 的那个 call**(gate 主角)铸的 tool_end 载体(逐字;desktop session-host
16
36
  * 真引擎实测同款)。HOLD 谓词锚它做**精确等值** —— 普通工具错的输出是各自的错误文案,永不进 HOLD。
@@ -247,6 +267,87 @@ export function isHostProgressFrame(ev) {
247
267
  return ((ev.type === 'text_delta' || ev.type === 'reasoning_delta' || ev.type === 'tool_start') &&
248
268
  ev.parentToolCallId === undefined);
249
269
  }
270
+ // ── 件 B(#323 症状②):用户中断批的呈现向文案归一 ────────────────────────────────────────────
271
+ /**
272
+ * 这次 flush 的**出口**带不带「用户要停」的证据。两条腿各自是**结构事实**,都不是猜:
273
+ * ① **wire 终帧腿**:`failed.errorCode === "cancelled"`(server cancel settle 契约)/ durable 腿
274
+ * 同码骑在 `done.result.errorCode` 上(终态投影表 `terminalToSdkResult` 头注的同一格)。
275
+ * 壳侧实证:Esc 的默认臂之外还有一发 best-effort `POST /v1/runs/:id/cancel`,run 就是这么
276
+ * 结算的 —— 所以「这一批 abort 帧属于一次取消」在 wire 上有据可查。
277
+ * ② **宿主腿**:`ctx.signal.aborted`。这个 signal 是宿主的 turn 中断信号,壳侧唯一 abort 源是
278
+ * 用户按 Esc(`abortController.abort('user-cancel')`),web/desktop 同位。它是**宿主事实**
279
+ * 而非帧事实,所以只在**收口出口**读(源流已经走完/终帧已到),不在流中途读。
280
+ * 🔴 两腿都不命中 ⇒ 没有中断证据 ⇒ 文案一个字节不改(诚实缺席,不靠猜)。
281
+ */
282
+ function isUserInterruptTerminalOrSignal(ev, signal) {
283
+ if (signal?.aborted === true)
284
+ return true;
285
+ if (ev === undefined)
286
+ return false;
287
+ if (ev.type === 'failed') {
288
+ return ev.errorCode === RUN_CANCELLED_ERROR_CODE;
289
+ }
290
+ if (ev.type === 'done') {
291
+ const r = ev.result;
292
+ return !!r && r.errorCode === RUN_CANCELLED_ERROR_CODE;
293
+ }
294
+ return false;
295
+ }
296
+ /**
297
+ * 呈现向改写的**帧级**准入(与出口级证据合取)。三条,全是不放宽的精确判据:
298
+ * ① 文案**精确等于** {@link ENGINE_ABORT_TOOL_RESULT}(6.0.0 的块数组形先经 `toolEndOutputText`
299
+ * 归一)—— `"operation aborted before execution"`(连坐旁观者:从未执行)与
300
+ * `interrupted_never_started` 族**刻意不进本臂**:core 显式拒绝合并两串向([4973]),
301
+ * 两串各承真语义,它们各自的文案/折叠语义已在别处成立,并进来 = 把两个语义压成一个。
302
+ * ② `errorCode !== "gate.parked"`:机读位在场时,**门语义帧一票否决** —— 那是「门把这次调用
303
+ * park 了」,不是「用户按了 Esc」。⚠️ 这一票**只在 ①/② 两腿证据下投**:见
304
+ * `allowGateParked` 与 {@link flushHeldWithInterruptRewrite} 头注的两模判词。
305
+ * ③ `isError === true`:呈现向的中断形长在报错卡那一路(壳 `UserToolErrorMessage` 的判据位)。
306
+ */
307
+ function isUserInterruptRewritable(ev, allowGateParked) {
308
+ if (ev.isError !== true)
309
+ return false;
310
+ if (!allowGateParked && ev.errorCode === ENGINE_GATE_PARKED_ERROR_CODE) {
311
+ return false;
312
+ }
313
+ return toolEndOutputText(ev.output) === ENGINE_ABORT_TOOL_RESULT;
314
+ }
315
+ /**
316
+ * `led.flushHeld()` 的**唯一**排水出口包装:用户中断批把毒化帧的 `output` 归一成 CC 的中断形
317
+ * ({@link HITL_INTERRUPT_MESSAGE_FOR_TOOL_USE}),其余一切原样。
318
+ *
319
+ * ── 判词(两模,别合并;方向一律 fail-safe:证据不足就走原路)──────────────────────────────
320
+ * · **模 A —— 纯用户中断批**(证据 = cancel 终帧码 / 宿主 `signal.aborted`):要求 **本批零 park
321
+ * 登记**(`hasBatchPark() === false`),且帧上 `errorCode !== "gate.parked"`。两道门都是同一件事
322
+ * 的两个面 ——「这一批背后没有任何门」才谈得上纯中断;有门而我们只看见 abort 帧 = 判不出,不改。
323
+ * · **模 B —— 门被用户中断**(证据 = {@link HeldFlushInterruptEvidence.gateAbortedByUser}):
324
+ * park 在场是**前提**而不是障碍,所以模 A 的两道门在这一模下**都让位** —— 包括
325
+ * `gate.parked` 那一票。理由:该码回答的是「这次调用为什么中止 =被门 park 了」,回答不了
326
+ * 「这一批为什么收场 = 用户按了 Esc」,两者正交;拿它做否决会把这一格里**最该显形的那张
327
+ * 主角卡**留成红 `Error: Operation aborted`(真机复现:审批卡挂着按 Esc,主角与连坐帧同文案)。
328
+ * 🔴 **gate deny 语义零变**:deny 走的是 `decided` 分支(→ 续流重放 → `denied-call`/`deny-stamp-next`
329
+ * 两臂 stamp CC REJECT 文案),那两臂排在 hold-poison **之前**、帧根本不进扣留表,本包装够不着。
330
+ * fail-soft 的其余原因(no_pending / 传输失败 / hop 用尽)三腿全不命中 ⇒ 诚实红照旧。
331
+ *
332
+ * 🔴 **只改 `output` 一个键**:`isError` / `errorCode` / `toolCallId` / `toolName` / `settledBy` /
333
+ * `approver` / `resolution` / `structured` 以及 `flushHeld` 自己盖的
334
+ * `_sema_collateral_abort` 机读位全部原样过境(展开赋值只覆盖 `output`)—— 端侧的连坐折叠锚的是
335
+ * 机读位不是文案,所以折叠语义不受影响,只是被折那一行的正文换成了 CC 中断形。
336
+ */
337
+ export function* flushHeldWithInterruptRewrite(led, evidence) {
338
+ // 🔴 读位必须在 flushHeld **之前**求值:那一拍尾部 `retireBatchState()` 会把批级计数清零。
339
+ const gateAborted = evidence.gateAbortedByUser === true;
340
+ const rewrite = gateAborted
341
+ ? true
342
+ : !led.hasBatchPark() && isUserInterruptTerminalOrSignal(evidence.terminal, evidence.signal);
343
+ for (const ev of led.flushHeld()) {
344
+ if (!rewrite || ev.type !== 'tool_end' || !isUserInterruptRewritable(ev, gateAborted)) {
345
+ yield ev;
346
+ continue;
347
+ }
348
+ yield { ...ev, output: HITL_INTERRUPT_MESSAGE_FOR_TOOL_USE };
349
+ }
350
+ }
250
351
  /**
251
352
  * 🔴 **有序**(承重):分类器批3([907])的注释原文 = 「结构性识别(core 机器签名)优先于 fs
252
353
  * HOLD/REJECT 分支(签名比"fs 写报错"更特定;分类器 deny 从不产 tool_approval 帧,denied /
@@ -496,7 +597,8 @@ function routeDone(ev, ctx) {
496
597
  led.noteParkGate({ fsOrShellFamily: isFsOrShellToolName(ev.result.checkpointGate?.toolName) });
497
598
  return { kind: 'park', gate: 'fs', pendingDone: ev, ...(gatedCallId !== undefined ? { gatedCallId } : {}) };
498
599
  }
499
- const events = [...led.flushHeld()]; // 真错(非 gate)的 Ask tool_end 此刻诚实渲染
600
+ // 真错(非 gate)的 Ask tool_end 此刻诚实渲染;件 B:纯用户中断批在这个出口把文案归一成 CC 中断形。
601
+ const events = [...flushHeldWithInterruptRewrite(led, { terminal: ev, signal: ctx.signal })];
500
602
  // durable 终帧的 result.taskId === sessionId(引擎 durable quirk)会让 captureSessionId 跳过
501
603
  // rewind 绑定 —— 用 sync leg 捕的真 handle 修正回去。
502
604
  const r = ev.result;
@@ -532,7 +634,9 @@ export async function routeFrame(ev, ctx) {
532
634
  return routeSuspended(ev, ctx);
533
635
  if (ev.type === 'done')
534
636
  return routeDone(ev, ctx);
535
- if (ev.type === 'failed')
536
- return { kind: 'end', events: [...led.flushHeld(), ev] };
637
+ // B:cancel settle 的终帧(`failed{errorCode:"cancelled"}`)就是「用户要停」的 wire 证据。
638
+ if (ev.type === 'failed') {
639
+ return { kind: 'end', events: [...flushHeldWithInterruptRewrite(led, { terminal: ev, signal: ctx.signal }), ev] };
640
+ }
537
641
  return { kind: 'yield', events: [ev] };
538
642
  }
@@ -173,6 +173,15 @@ export interface GateLedger {
173
173
  noteParkGate(evidence: {
174
174
  fsOrShellFamily: boolean;
175
175
  }): void;
176
+ /**
177
+ * 「**本批**有没有登记过 park」——件 B(#323 症状②,2026-08-25)的硬门读位。
178
+ *
179
+ * 🔴 与 {@link flushHeld} 的主角判别是**两件事**,别合并:那条要的是「恰好一张」(归属可证),
180
+ * 本条要的是「**零张**」(这批 abort 帧背后没有任何 gate ⇒ 才谈得上「纯用户中断批」)。
181
+ * 判词方向同样 fail-safe:有 park = 门语义,呈现向一个字节都不改。
182
+ * 🔴 读位必须在 `flushHeld` 之前取(那一拍尾部会 `retireBatchState()` 把计数清零)。
183
+ */
184
+ hasBatchPark(): boolean;
176
185
  /** 续流重放的解答 tool_end 无 output,不补就会渲成结果不可用;这里记下真实答案供该帧 stamp
177
186
  * `structured` 让卡片渲真实选择。 */
178
187
  rememberAnswer(callId: string, answered: AskAnsweredOutput): void;
@@ -245,6 +245,9 @@ export function createGateLedger() {
245
245
  fsOrShellFamilyGate = evidence.fsOrShellFamily;
246
246
  heldSinceLastPark = false;
247
247
  },
248
+ hasBatchPark() {
249
+ return batchParkCount > 0;
250
+ },
248
251
  rememberAnswer(callId, answered) {
249
252
  resolvedAnswers.set(callId, answered);
250
253
  },
@@ -308,9 +308,23 @@ export declare class HitlBridge {
308
308
  *
309
309
  * - `approve` → `{decision:"approve", boundCallId, boundInputHash}`; `updatedInput` rides along for
310
310
  * approve-with-edit (applied AFTER the binding check — the hash still binds the ORIGINAL input).
311
- * - `deny` → `{decision:"deny", reason}`. CANCEL a suspended run by DENYING, never by `runs.cancel`
312
- * (which 409s on a suspended run — contract/04 §2.4); the deny-and-abort `interrupt:true` EFFECT is
313
- * "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 断言当理由的散文。
314
328
  *
315
329
  * The resumed run continues its SAME durable stream. A binding mismatch (409) is re-raised as a
316
330
  * `HitlSafetyError('binding_mismatch')` — the caller re-presents, NEVER auto-retries.
@@ -415,9 +415,23 @@ export class HitlBridge {
415
415
  *
416
416
  * - `approve` → `{decision:"approve", boundCallId, boundInputHash}`; `updatedInput` rides along for
417
417
  * approve-with-edit (applied AFTER the binding check — the hash still binds the ORIGINAL input).
418
- * - `deny` → `{decision:"deny", reason}`. CANCEL a suspended run by DENYING, never by `runs.cancel`
419
- * (which 409s on a suspended run — contract/04 §2.4); the deny-and-abort `interrupt:true` EFFECT is
420
- * "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 断言当理由的散文。
421
435
  *
422
436
  * The resumed run continues its SAME durable stream. A binding mismatch (409) is re-raised as a
423
437
  * `HitlSafetyError('binding_mismatch')` — the caller re-presents, NEVER auto-retries.
@@ -621,7 +635,9 @@ export function makeHitlCanUseTool(bridge, prompt) {
621
635
  const decision = forceDecision ??
622
636
  (await prompt({ toolName: tool.name, input, toolUseID, gate }));
623
637
  if (decision.behavior === 'deny') {
624
- // 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`.
625
641
  // 0.28.0 发版扫描 F1(P2):message 逐字嵌原始命令(壳侧 bashPermissions 无上限)——必须与
626
642
  // 卡腿同门经窄化器截到 4096,否则 server 413 丢的是整次 deny(run 留 suspended)。
627
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";
@@ -64,8 +87,65 @@ export declare function surfaceRememberNotApplied(): void;
64
87
  * 已记进 docs/refactor/README.md 的宿主/上游工单表,本层不做旁路补偿(只做如实告知)。
65
88
  */
66
89
  export declare function surfaceEditNotForwarded(): void;
90
+ /**
91
+ * 文案(测试锁字面)。措辞刻意描述**后果**,并且**只说证得出的话**(异源对抗复审四轮 [medium] 修):
92
+ * · 不写「这台引擎不支持」——能力位缺席有三种同形成因(宿主没接这个字段 / 探测还没回来 / 探测失败),
93
+ * 其中只有明确的 `false` 才勉强算「引擎说了不」。写成断言就是替引擎宣布一件没证据的事
94
+ * ([honest-absence-not-fabricated-zero]);改成 `could not be confirmed` 的未知口径。
95
+ * · 「审批本身过了」这句**只在 respond 真成功之后**才成立 —— 所以本通知的**发出时机**被移到
96
+ * `await respond(...)` 成功之后(见 `toolApprovalWire` 的调用点顶注),而不是丢键那一刻。
97
+ */
98
+ export declare const RULE_NOT_SENT_WARN_TEXT = "your \"don't ask again\" choice was NOT sent to this engine \u2014 support for that rule form could not be confirmed. The approval itself went through, but you will be asked again";
99
+ /**
100
+ * 卡上选中了「不再询问」(编辑臂的自由文本 / 批臂的合取批),而**这台引擎的能力位没有确认**
101
+ * 那条兑付通道 ⇒ 编排层把持久臂整条丢掉、只送决断。
102
+ *
103
+ * 🔴 为什么这必须上屏而不是只记 debug(异源对抗复审三轮 [high] 采纳,0.43.0):它与
104
+ * {@link surfaceRememberNotApplied} 是**同一个病**——用户按下了一个明确的意图,系统把它静默
105
+ * 丢掉,然后下一次照旧弹卡。用户能得出的唯一结论是「这个功能坏了」或「我按错了」。卡上那一格
106
+ * 是**真 affordance**(offer 确实在场、确实可以走本地落规则那条路),被丢的只是**这条 wire
107
+ * 兑付通道** —— 所以正解不是把选项藏起来,是**如实说出来**。
108
+ * 🔴 **一条通知服务两条臂**(同形清剿):编辑臂(`respondFreeFormRules` 位缺席)与批臂
109
+ * (`respondBatchRuleOffers` 位缺席)此前都只写 debug —— 同一个病形两处存量,同批一起改。
110
+ * 🔴 决断本身**不受影响**(照常送达),所以文案第二句说清「审批过了,只是规则没存」——
111
+ * ⚠️ 正因为文案里有那句话,**调用时机必须在 `respond` 真成功之后**(异源对抗复审四轮 [medium]:
112
+ * 丢键那一刻就发,而随后 respond 抛错 ⇒ 决断其实**没有**落定,用户却已经读到「审批过了」,
113
+ * 可能按「工具已经在跑」继续操作)。调用点的时序契约见 `toolApprovalWire` 那一处。
114
+ * 🔴 文案**不说「引擎不支持」**:能力位缺席三种同形成因(宿主没接字段 / 探测未回 / 探测失败),
115
+ * 断言引擎的能力是没有证据的话 —— 用 `could not be confirmed` 的未知口径。
116
+ * 无宿主口(print/headless 没屏)⇒ 静默,与本文件其余 surface 同语义。
117
+ */
118
+ export declare function surfaceRuleArmNotSent(): void;
119
+ /**
120
+ * 文案(测试锁字面)。与 {@link RULE_NOT_SENT_WARN_TEXT} **刻意分两条**:两者对用户的下一步建议不同 ——
121
+ * 那条说「这台引擎能不能收还不知道」(换台引擎/等探测就好了),本条说「你这次的选择**本身**没过
122
+ * 客户端那道核对」(卡口实现有问题或同时给了两个互斥选择,换台引擎也不会变)。折成一条会让任一方
123
+ * 谎报原因。措辞同样只说证得出的话:不猜是哪一种坏法,把两种都摆出来。
124
+ */
125
+ export declare const RULE_NOT_SENT_REJECTED_WARN_TEXT = "your \"don't ask again\" choice was NOT sent \u2014 it did not pass the client-side check (the card returned a rule the engine never offered, an index that is not a batch offer, or two conflicting choices at once). The approval itself went through, but you will be asked again";
126
+ /**
127
+ * 卡上选中了「不再询问」,而那次选择**没过包内那道表核 / 互斥核**(报了引擎没 offer 过的文本、
128
+ * 下标不指向 batch offer、或文本臂与批臂同场)⇒ 编排层把持久臂整条丢掉、只送决断。
129
+ *
130
+ * 🔴 为什么这也必须上屏(异源对抗复审十轮 [medium] 采纳,0.43.0):它与
131
+ * {@link surfaceRuleArmNotSent} 是**同一个诚实性问题**,只是原因换了一个 —— 用户按下的明确意图
132
+ * 被系统丢掉,审批照常成功,下次照旧弹卡。此前这三条丢键路径**只写 `hostLog('error')`**:
133
+ * 那是给开发者看的诊断,**不是**给按下按钮的那个人的答复。
134
+ * 🔴 **时序契约与那条一致**:文案里有「审批本身过了」⇒ 只在 `await respond(...)` 真成功之后发。
135
+ * 🔴 **成因通常是卡口实现的 bug**(表外文本/坏下标/两臂同场都是宿主侧构造出来的),所以 `hostLog`
136
+ * 的那条 `error` 留痕**照旧保留** —— 两个受众,两条通道,谁都不顶替谁。
137
+ * 无宿主口(print/headless 没屏)⇒ 静默,与本文件其余 surface 同语义。
138
+ */
139
+ export declare function surfaceRuleArmRejected(): void;
67
140
  /**
68
141
  * 中断 deny 的有界观察(壳侧单测 `hitlCancelDeny.test.ts` 的被测面;REF-CC-023 起两条决断腿共用)。
142
+ *
143
+ * 🔴 **命名撤稿(0.42.0)**:函数名与日志里的「cancel-by-deny」是**历史词**,保留只为不打断下游
144
+ * 按名锚的用例。它描述的动作在现行契约(server `ASSISTANT-WIRE-CONTRACT.md` §4a)下的准确说法是
145
+ * 「**中断时把这只挂着的 ask 用一个 TOOL 级 deny 结算掉**,好让 run 不停在 suspended 上」——
146
+ * **不是**「用 deny 去 cancel 一条 run」。方向也别读反:这里是把**用户的中断**结算成一次 deny,
147
+ * 而 §4a 禁的是反向的那件事(把**用户的拒绝**译成 cancel),两者不是同一件事。
148
+ *
69
149
  * 铁律:不 await 进 abort 返回路径(用户立即拿回控制);这里只管后台 settle 的«观察»:
70
150
  * - 2s 内 settle 成功 ⇒ 零上屏(SEMA_DEBUG 记成功);
71
151
  * - 失败/超时 ⇒ 上屏一行 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';
@@ -140,6 +163,65 @@ export function surfaceRememberNotApplied() {
140
163
  export function surfaceEditNotForwarded() {
141
164
  surfaceSelfClearingWarn(EDIT_NOT_FORWARDED_KEY, EDIT_NOT_FORWARDED_WARN_TEXT, EDIT_NOT_FORWARDED_TIMEOUT_MS);
142
165
  }
166
+ /**
167
+ * 文案(测试锁字面)。措辞刻意描述**后果**,并且**只说证得出的话**(异源对抗复审四轮 [medium] 修):
168
+ * · 不写「这台引擎不支持」——能力位缺席有三种同形成因(宿主没接这个字段 / 探测还没回来 / 探测失败),
169
+ * 其中只有明确的 `false` 才勉强算「引擎说了不」。写成断言就是替引擎宣布一件没证据的事
170
+ * ([honest-absence-not-fabricated-zero]);改成 `could not be confirmed` 的未知口径。
171
+ * · 「审批本身过了」这句**只在 respond 真成功之后**才成立 —— 所以本通知的**发出时机**被移到
172
+ * `await respond(...)` 成功之后(见 `toolApprovalWire` 的调用点顶注),而不是丢键那一刻。
173
+ */
174
+ export const RULE_NOT_SENT_WARN_TEXT = 'your "don\'t ask again" choice was NOT sent to this engine — support for that rule form could not be confirmed. The approval itself went through, but you will be asked again';
175
+ const RULE_NOT_SENT_KEY = 'hitl-rule-arm-not-sent';
176
+ /** 与 remember 那条同量级(都是「你以为记住了、其实没有」),取同一个时长。 */
177
+ const RULE_NOT_SENT_TIMEOUT_MS = 10_000;
178
+ /**
179
+ * 卡上选中了「不再询问」(编辑臂的自由文本 / 批臂的合取批),而**这台引擎的能力位没有确认**
180
+ * 那条兑付通道 ⇒ 编排层把持久臂整条丢掉、只送决断。
181
+ *
182
+ * 🔴 为什么这必须上屏而不是只记 debug(异源对抗复审三轮 [high] 采纳,0.43.0):它与
183
+ * {@link surfaceRememberNotApplied} 是**同一个病**——用户按下了一个明确的意图,系统把它静默
184
+ * 丢掉,然后下一次照旧弹卡。用户能得出的唯一结论是「这个功能坏了」或「我按错了」。卡上那一格
185
+ * 是**真 affordance**(offer 确实在场、确实可以走本地落规则那条路),被丢的只是**这条 wire
186
+ * 兑付通道** —— 所以正解不是把选项藏起来,是**如实说出来**。
187
+ * 🔴 **一条通知服务两条臂**(同形清剿):编辑臂(`respondFreeFormRules` 位缺席)与批臂
188
+ * (`respondBatchRuleOffers` 位缺席)此前都只写 debug —— 同一个病形两处存量,同批一起改。
189
+ * 🔴 决断本身**不受影响**(照常送达),所以文案第二句说清「审批过了,只是规则没存」——
190
+ * ⚠️ 正因为文案里有那句话,**调用时机必须在 `respond` 真成功之后**(异源对抗复审四轮 [medium]:
191
+ * 丢键那一刻就发,而随后 respond 抛错 ⇒ 决断其实**没有**落定,用户却已经读到「审批过了」,
192
+ * 可能按「工具已经在跑」继续操作)。调用点的时序契约见 `toolApprovalWire` 那一处。
193
+ * 🔴 文案**不说「引擎不支持」**:能力位缺席三种同形成因(宿主没接字段 / 探测未回 / 探测失败),
194
+ * 断言引擎的能力是没有证据的话 —— 用 `could not be confirmed` 的未知口径。
195
+ * 无宿主口(print/headless 没屏)⇒ 静默,与本文件其余 surface 同语义。
196
+ */
197
+ export function surfaceRuleArmNotSent() {
198
+ surfaceSelfClearingWarn(RULE_NOT_SENT_KEY, RULE_NOT_SENT_WARN_TEXT, RULE_NOT_SENT_TIMEOUT_MS);
199
+ }
200
+ /**
201
+ * 文案(测试锁字面)。与 {@link RULE_NOT_SENT_WARN_TEXT} **刻意分两条**:两者对用户的下一步建议不同 ——
202
+ * 那条说「这台引擎能不能收还不知道」(换台引擎/等探测就好了),本条说「你这次的选择**本身**没过
203
+ * 客户端那道核对」(卡口实现有问题或同时给了两个互斥选择,换台引擎也不会变)。折成一条会让任一方
204
+ * 谎报原因。措辞同样只说证得出的话:不猜是哪一种坏法,把两种都摆出来。
205
+ */
206
+ export const RULE_NOT_SENT_REJECTED_WARN_TEXT = 'your "don\'t ask again" choice was NOT sent — it did not pass the client-side check (the card returned a rule the engine never offered, an index that is not a batch offer, or two conflicting choices at once). The approval itself went through, but you will be asked again';
207
+ const RULE_NOT_SENT_REJECTED_KEY = 'hitl-rule-arm-rejected';
208
+ const RULE_NOT_SENT_REJECTED_TIMEOUT_MS = 10_000;
209
+ /**
210
+ * 卡上选中了「不再询问」,而那次选择**没过包内那道表核 / 互斥核**(报了引擎没 offer 过的文本、
211
+ * 下标不指向 batch offer、或文本臂与批臂同场)⇒ 编排层把持久臂整条丢掉、只送决断。
212
+ *
213
+ * 🔴 为什么这也必须上屏(异源对抗复审十轮 [medium] 采纳,0.43.0):它与
214
+ * {@link surfaceRuleArmNotSent} 是**同一个诚实性问题**,只是原因换了一个 —— 用户按下的明确意图
215
+ * 被系统丢掉,审批照常成功,下次照旧弹卡。此前这三条丢键路径**只写 `hostLog('error')`**:
216
+ * 那是给开发者看的诊断,**不是**给按下按钮的那个人的答复。
217
+ * 🔴 **时序契约与那条一致**:文案里有「审批本身过了」⇒ 只在 `await respond(...)` 真成功之后发。
218
+ * 🔴 **成因通常是卡口实现的 bug**(表外文本/坏下标/两臂同场都是宿主侧构造出来的),所以 `hostLog`
219
+ * 的那条 `error` 留痕**照旧保留** —— 两个受众,两条通道,谁都不顶替谁。
220
+ * 无宿主口(print/headless 没屏)⇒ 静默,与本文件其余 surface 同语义。
221
+ */
222
+ export function surfaceRuleArmRejected() {
223
+ surfaceSelfClearingWarn(RULE_NOT_SENT_REJECTED_KEY, RULE_NOT_SENT_REJECTED_WARN_TEXT, RULE_NOT_SENT_REJECTED_TIMEOUT_MS);
224
+ }
143
225
  /**
144
226
  * cancel-by-deny 的「良性 no_pending」判据。**结构化 `.code` 为主,`instanceof` 只作加强,绝不替代**:
145
227
  * - `.code` 是 client-core 自家 `HitlSafetyError` 的契约属性(hitlBridge.ts,REF-CC-036 起是闭集
@@ -161,6 +243,13 @@ function isNoPendingError(e) {
161
243
  }
162
244
  /**
163
245
  * 中断 deny 的有界观察(壳侧单测 `hitlCancelDeny.test.ts` 的被测面;REF-CC-023 起两条决断腿共用)。
246
+ *
247
+ * 🔴 **命名撤稿(0.42.0)**:函数名与日志里的「cancel-by-deny」是**历史词**,保留只为不打断下游
248
+ * 按名锚的用例。它描述的动作在现行契约(server `ASSISTANT-WIRE-CONTRACT.md` §4a)下的准确说法是
249
+ * 「**中断时把这只挂着的 ask 用一个 TOOL 级 deny 结算掉**,好让 run 不停在 suspended 上」——
250
+ * **不是**「用 deny 去 cancel 一条 run」。方向也别读反:这里是把**用户的中断**结算成一次 deny,
251
+ * 而 §4a 禁的是反向的那件事(把**用户的拒绝**译成 cancel),两者不是同一件事。
252
+ *
164
253
  * 铁律:不 await 进 abort 返回路径(用户立即拿回控制);这里只管后台 settle 的«观察»:
165
254
  * - 2s 内 settle 成功 ⇒ 零上屏(SEMA_DEBUG 记成功);
166
255
  * - 失败/超时 ⇒ 上屏一行 warn + SEMA_DEBUG 记原因(deny 丢失 = run 卡 suspended,下一条消息
@@ -3,7 +3,7 @@ import { publishQuestionFrame, registerLocalQuestionResponder, hasQuestionOverla
3
3
  import { hostLog } from '../host.js';
4
4
  import { surfaceFsApprovalAndDecide } from './toolApprovalWire.js';
5
5
  import { observeCancelByDeny } from './hitlHostSurface.js';
6
- import { isAskTool } from './frameRouter.js';
6
+ import { flushHeldWithInterruptRewrite, isAskTool } from './frameRouter.js';
7
7
  import { approvalCallKey, askGateQuestionId } from './gateIdentity.js';
8
8
  /** 一 turn 内最多循环这么多次 park(防御:引擎/模型病态连环提问时不无限 attach)。 */
9
9
  const MAX_GATE_HOPS = 24;
@@ -154,12 +154,24 @@ parkGatedCallId) {
154
154
  });
155
155
  const bridge = new HitlBridge(deps.client, taskId);
156
156
  if (answer === null) {
157
- // 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 是这一步的正解」,不再是权宜。
158
165
  // 件3(中断事故修复批 G,2026-07-15,症状1 壳侧配套):此前 .catch(()=>{}) 全吞 = deny 丢失时
159
166
  // run 永卡 suspended,session 锁死,用户下一条消息撞 409「active run」还全无线索。改为有界观察
160
167
  // (observeCancelByDeny,2s 预算):abort 仍立即返回用户控制(不 await,交互时序不变),后台
161
- // settle 失败/超时上屏一行 warn + SEMA_DEBUG 记失败原因。引擎侧解锁腿([866] server:cancel
162
- // 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 撤稿件)。
163
175
  observeCancelByDeny(bridge.decideTool({ decision: 'deny', reason: 'Interrupted by user' }, gatedCallId, undefined, pending), taskId);
164
176
  return { kind: 'aborted', gatedCallId };
165
177
  }
@@ -274,7 +286,19 @@ export async function resolvePark(park, ctx) {
274
286
  }
275
287
  if (outcome.kind !== 'decided') {
276
288
  hostLog('debug', `liveHitlAskWire: gate not decided (${outcome.kind}${'reason' in outcome ? `: ${outcome.reason}` : ''}) — fail-soft to suspended terminal`);
277
- const events = [...led.flushHeld()]; // 回退:毒化帧照旧渲染(= 修复前的诚实红)
289
+ // 回退:毒化帧照旧渲染(= 修复前的诚实红)
290
+ // 件 B(异源复审 finding 采纳):**五个排水出口一律走同一个中断感知出口** —— 本出口今天恒是
291
+ // park 批(走到这里的前提就是有一张 park),零-park 硬门必挡,所以是**语义等价的 no-op**;
292
+ // 写成统一形是为了「新开一个出口就绕过改写」这条病形从此在结构上不成立(常驻门:hitl F13-h
293
+ // 钉住 src 下 `flushHeld()` 的调用点恰好一处)。
294
+ const events = [
295
+ ...flushHeldWithInterruptRewrite(led, {
296
+ terminal: park.pendingDone,
297
+ signal: ctx.signal,
298
+ // 模 B:`aborted` = 用户在门卡上按了 Esc(该 outcome 在包内的语义就是逐字这一条)。
299
+ gateAbortedByUser: outcome.kind === 'aborted',
300
+ }),
301
+ ];
278
302
  if (park.pendingDone) {
279
303
  events.push(park.pendingDone);
280
304
  }