@sema-agent/client-core 0.50.0 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -40,6 +40,102 @@
40
40
  > 门侧窄豁免同批登记(`KNOWN_HEADING_ERRATA` `version: '0.48.0', releasedAt: '4fae01b'`),
41
41
  > 与本段互钉,均为永久记录。
42
42
 
43
+ ## 0.52.0(2026-09-04)
44
+
45
+ ### hitl:分类器拒绝行带 `ctx`(toolCallId + tool_start args)(L-69⑨;DEBTS-cli)
46
+
47
+ - **病**:`frameRouter` 的 `classifier-deny` 臂只把 `toolName` 交给宿主 ⇒ 端的 /permissions Recent Denials
48
+ 三行同名(三条被拒的 `Bash` 长一个样),retry 粒度只能落到「整个工具」。拿不到 args 的根因在台账:
49
+ `gateLedger` **只在 gated tool_start** 留 args(`gatedStartArgsByCall`),而分类器拒绝的工具通常
50
+ **没被 gate**(引擎侧分类器直接 block,一张 `tool_approval` 帧都不出)⇒ 拒绝当拍 args 已不可得。
51
+ - **改**:
52
+ - `GateLedger` 加三个动词 `noteStart` / `startArgs` / `dropStartArgs`:**每一张** `tool_start` 都留一份
53
+ args 快照,**有界 FIFO**(`START_ARGS_MAX_ENTRIES = 256`,超容量丢最早),`tool_end` 收口当拍释放。
54
+ 🔴 与 `gatedStartArgs` **刻意分两张表**:「被 gate 过」是另一个集合,不许拿「表里有 args」当身份位;
55
+ 两表的生命周期与容量纪律也不同(gated 表整个 turn 不删、条目数天然受限)。
56
+ - `HitlHostSurface.surfaceClassifierDeny(toolName, verdict, ctx?)` 加 **additive 可选**第三参
57
+ `ClassifierDenyContext { toolCallId: string; args?: unknown }`;deny 臂传 `{toolCallId: callId, args}`。
58
+ 老宿主(两参实现)**零改动照跑**;台账缺席 ⇒ `args` 键不铸(诚实缺席,不是 `args: undefined`)。
59
+ - 新导出纯函数 `classifierDenyDisplay(toolName, args)`(三端同形的**行文案**判定):`Bash` ⇒ `command`,
60
+ `Read`/`Write`/`Edit`/`MultiEdit` ⇒ `file_path`,`NotebookEdit` ⇒ **`notebook_path`**(工具入参真形),
61
+ 表外工具 / 非串 / 空串 / 缺席一律回落工具名(空工具名 ⇒ `tool`);长度上界
62
+ `CLASSIFIER_DENY_DISPLAY_MAX = 200`。
63
+ - **异源对抗复审 R1 三条 finding 采纳**:①「有界」的措辞订正成**条目数有界**(存的是引用不是拷贝,同一份
64
+ 入参在这一拍照常进转录面;单条载荷无字节预算 = 成文的在册取舍)· ② `noteStart` 挪到 `markStarted`
65
+ **渲染去重闸之前**(排在闸后 ⇒ durable 重放的刷新在生产路径上永远走不到,被淘汰项再也拿不回快照),
66
+ 并给 `tool_end` 的 `isEnded` 早退臂补上释放 · ③ 截断改**代理对安全**(边界跨 emoji 时少切一个码元,
67
+ 绝不把代理对切成半只),并把「上界量的是原文长度、端消毒后会变长、端要再兜一次底」写进常量头注与 §15c。
68
+ - **R2 采纳的措辞订正(非行为改动)**:`bounded` 的契约从「绝不交出孤代理项」收紧成「截断**不制造**畸形」——
69
+ **输入里本来就有的**孤代理项(`JSON.parse('"\ud800"')` 合法)原样透出,修好它属展示消毒,单源是本包
70
+ 已导出的 `escapeDisplayControlChars`(其头注逐字点名「孤代理项:上游可能本来就送半只,或被列宽截断劈开」);
71
+ 在库里再消一遍 = 第二份字符集。同批把四条分工线钉成判据(含消毒单源真会转义孤代理项的正控)。
72
+ - 🔴 **端必读**:`ctx.args` 是 **UNTRUSTED** wire 值,本包**原样**交出 —— 不渲、不截、不消毒。展示消毒是端的事
73
+ (cli `cleanUntrustedForDisplay`),库里消一遍端再消一遍两份字符集必漂。retry 粒度(重试哪一只调用、
74
+ 按什么规则铸)同样**不在包内**,身份锚 = `ctx.toolCallId`。接入形见
75
+ `docs/INTEGRATION-CLIENTS.md` §15a/§15c。
76
+
77
+ ### hooks wire:第三道 managed 治理门 `strictPluginOnlyCustomization`(L-67④)
78
+
79
+ - **病**:`hooksForWire()` 过了 `disableAllHooks` / `allowManagedHooksOnly` 两道 managed 门,**没过**
80
+ `strictPluginOnlyCustomization`(CC 语义:值为 `true` 或数组含 `"hooks"` ⇒ user/project/local 三源的
81
+ hooks 全屏蔽,只放行 `policySettings.hooks`)。而 `hooksWireCaps.ts` 头注自己写着「the wire must honor
82
+ the SAME gates the local executor honors, or a fleet-managed policy is silently bypassed by the engine
83
+ leg」—— 管理侧锁了 hooks 面之后,壳的本地执行器不再跑用户态 hooks,**引擎腿照投照跑**:禁令只在一半的
84
+ 执行面上成立,而这一半恰好是工具真正执行的那一半。
85
+ - **改**:`hooksForWire()` 读 `policySettings.strictPluginOnlyCustomization`,命中 ⇒ `sources=['policySettings']`
86
+ 且 `/goal` 用户态 overlay 一并禁(与 `allowManagedHooksOnly` **同处置**),`hostLog('debug')` 留痕。
87
+ 判据与 cli 本地执行器 `isRestrictedToPluginOnly('hooks')` **逐条对照、差异为零**:`=== true` 锁 /
88
+ 数组含 `"hooks"` 锁 / 数组不含则不锁 / 其余一切值形当未设(**不** fail-closed 整条腿 —— 相邻两道门的
89
+ fail-closed 守的是「读取抛出」这一未知态,本条守的是「读到了但值是坏形」,两件事别混)。
90
+ `disableAllHooks` 与信任门仍排在本门之前、仍恒赢。
91
+ - **端影响**:纯治理面收紧,不改任何公面签名。只有**管理侧真的设了**这个字段的部署会看到差异(那些部署
92
+ 今天本来就在被绕过)。语义见 `docs/INTEGRATION-CLIENTS.md` §15b。
93
+
94
+ ### 在册缺口(本批新登记,**未修**)
95
+
96
+ - cli 本地执行器还有第四条腿:`disableAllHooks` 出现在**非** managed 来源时降级成「只跑 managed hooks」。
97
+ 本包今天只读 `policySettings.disableAllHooks` ⇒ 该形在引擎腿上不成立。**不做单边近似**的理由:cli 那条腿
98
+ 读的是**合并后**的标量(四源后写覆盖前写),而本包 `SettingsPort` 只有 per-source 读口,「任一来源为 true」
99
+ 会在「user 写 true、local 写 false」上判反;忠实复刻要给 `SettingsPort` 加合并读口 = 公面改动,属另一批。
100
+ 登记在 `src/hooksWireCaps.ts` 头注。
101
+
102
+ ## 0.51.0(2026-09-03)
103
+
104
+ ### hitl:park 再附着的 hop 预算改「连续非进展轮」计数(L-80;cli [6215]/[6217];#357 复发根治)
105
+
106
+ - **病**(1.0.96 现网,auto 模式):一个 turn 里模型对同一失败 Edit 反复重试、每次真被门真被决,第 25 次 park 撞
107
+ `gate hop limit (24) exceeded`,Edit 报 `Operation aborted`,run 引擎侧仍 parked;下一条消息被 activeRunSelfHeal 判
108
+ `ask-reopen-failed` 拒发(「Your message was NOT sent」)。五根同族:`askGateWire` 对**每次 park** 无条件 `hops++`;
109
+ rescan 找到行即无条件复位 #357 的同因限次;`retryExhausted` 臂无次数闸;24 轮只 debug;耗尽后 selfHeal 对
110
+ 「无卡但链已直决成功」仍判失败。
111
+ - **改**:
112
+ - `MAX_GATE_HOPS`(现导出,值 `2`)语义换成**连续非进展轮上限**;`nextHopBudget(prev, 'progress'|'stalled')`
113
+ 纯函数导出;驱动侧按「重附着后那段流有没有 `isHostProgressFrame` 帧」判进展 —— 有进展就清零,决断成功但
114
+ 引擎原样 park 回来(坐标失配 / reopen 类 re-park `resume.tool_unavailable`)判非进展。
115
+ - 同因限次复位挪到驱动侧的进展观察点;`resolvePark` 内两处无条件复位删除。已解决臂限次保持 1;`retryExhausted`
116
+ 臂纳入同一本账、同因连续 2 次即收场。
117
+ - `AskGateWireDeps.onParkReattach?: (e: ParkReattachNotice) => void`(additive):每一轮非进展 reattach 调一次
118
+ `{attempt, max, reason}`,端渲状态行(cli 接 Spinner);缺席只留 debug。
119
+ - 触顶收场:上一轮没呈过卡 ⇒ 先现读队列再呈一次收场卡;呈过 ⇒ 不重复问;终帧 `hitl_unanswered` 的
120
+ `errorMessage` 改为真因(「stayed parked across N consecutive re-attach rounds … (last: …)」/ 传输连断 N 次)
121
+ + 出路句「decide it on the card when it is shown again, or cancel the run」。**不再出现** `gate hop limit` 字样。
122
+ - `ReopenCardVerdict` 新位 `{ reopened:false, decidedWithoutCard:true }` ⇒ `SelfHealOutcome` 新 kind
123
+ `ask-decided-without-card`(disposition `resending`),文案「…your permission rules decided on their own … sema is
124
+ re-sending your message」;裸 `{reopened:false}` 语义零变。
125
+ - **门**:`scripts/run-park-hop-progress-test.mjs`(39 checks,红先绿后);`run-hitl-gate-honesty-test.mjs` F15-f 夹具
126
+ 按新语义补一帧 text_delta(两只正当的门之间本来就有模型推进;修前零推进帧也能过,正是复位挂错位置的证据)。
127
+ - **对抗复审 r1 三条采纳**:①进展**同时**要求 park 身份推进(同 `gatedCallId` 原样回来,中间吐一帧文本也不算)
128
+ + `MAX_TOTAL_PARKS=64` 硬兜底(tool-less park 无身份只看帧,防病态引擎无限);②`decidedWithoutCard` 只证明「决断受理」,
129
+ 库先有界等 claim 释放(与 cancel 释放窗同源 10s,`runs.get`):释放 ⇒ `ask-decided-without-card{released:true}`
130
+ (resending);在跑 ⇒ 既有 running 臂(三选卡 / 等它跑完),**绝不盲发**;仍 parked / 读不出 / 无 `runs.get` ⇒ 新结局 `ask-decided-release-unknown`(不重发、不谎报失败);
131
+ ③「上一轮呈过卡」改用**每次调用的真回执**(`FsApprovalWireDeps.onPresented` + ask 腿同一枚旗;r2 又抓出进程级计数跨会话串扰、ask 呈现漏计两条),规则直决记 false ⇒ 触顶时收场卡照呈;④(r2)进展判决挪到 resolve 之后,身份取自真解析到的 pending 行(durable `suspended` 帧常无 toolCallId,缺席不覆盖已知身份)。
132
+ - **对抗复审 r3 两条采纳**:①预算最终在 resolve 后按权威身份一次提交,帧上的 park 身份不再参与预判(重放帧携旧 call A、rescan 解析到新 call B 的形不再误触顶;红门 L10);②`ask-decided-release-unknown` 的处置 = `not-delivered`(r4 撤回 r3 的 held-for-release:没有释放驱动的重投机制就不承诺投递;闭集不加值)。
133
+ - **对抗复审 r6 两条采纳**:①`MAX_TOTAL_PARKS` 只数解析不出身份的 park(70 只真新门全过;L16);②提交后收场先按解析到的身份摘掉已批那只 call 的扣留帧,再排水(L15③)。
134
+ - **对抗复审 r5 三条采纳**:①空读豁免只授予被确认为进展的决断(同 call 可见/不可见交替形不再无限;L13);②self-heal 的 `runs.get` 保留 `this`(真 SDK RunsResource 形;L14);③提交后收场且 decide 已成功 ⇒ 新终帧码 `hitl_stalled`,不再说「could not be answered / still parked」(L15)。
135
+ - **对抗复审 r4 三条采纳**:①有推进帧的轮次不在解析前触顶(先呈先决,提交后仍同 call 才收场;L11);②`resolution.gatedCallId` 只认 pending 行、不回填帧身份,`progress:false` 一票否决(L12);③见上。
136
+ - **端义务**(INTEGRATION-CLIENTS §14):装 `onParkReattach`;消费 `ask-decided-without-card`(自动重发接线
137
+ 按 disposition 或按 kind 显式列举 —— cli 现按 kind 等值判,需同批加);终帧文案不再 grep `hop limit`。
138
+
43
139
  ## 0.50.0(2026-09-03)
44
140
 
45
141
  ### 新增:S-81 selfOrchestration 拒绝的三端公共判定(server ≥7.57.0)
package/README.md CHANGED
@@ -35,7 +35,7 @@ Renamed from **`@sema-agent/wire-cc-adapter`** (0.1.x, deprecated — see *Migra
35
35
 
36
36
  ## Scope
37
37
 
38
- **Version:** 0.50.0
38
+ **Version:** 0.52.0
39
39
 
40
40
  - **Today** — the adapter seam, the whole `adapt()` pipeline (all 14 A-layer arms plus the
41
41
  B/D/E tool-card layers), the notification/caps/model families, the adapter kernel (stream driver
@@ -241,6 +241,7 @@ public-surface guard checks that last one).
241
241
  | `scripts/run-plan-review-decide-verify-test.mjs` | `decidePlanReview`'s post-decide honesty ([2315]/[2316], engine RB-471 family): a 2xx from the decide endpoint is **not** a terminal — the wire re-pulls the task status and words the outcome by the real shape (still-locked / legal new gate / genuinely left park / unverified), never claiming success it hasn't earned. Driven against a real fake-engine HTTP server through the shipped dist |
242
242
  | `scripts/run-shell-gate-durable-allow-test.mjs` | #110: the durable approval leg for **shell** gates. The tool_end HOLD/REJECT predicate must cover Bash the same way park detection already does (otherwise the park poison frame `Operation aborted` hits the transcript, `endedCalls` swallows the real replayed result, and the user who pressed Yes watches a command that really ran be reported as aborted); a replayed, already-decided park must resume reading the stream instead of being reported as a failed turn; `lastEventId` must track numeric `seq` too. Mutation-proven: each of the three fixes reverted turns the gate red |
243
243
  | `scripts/run-hitl-gate-honesty-test.mjs` | [2393] the four HITL disciplines that a passing type-check cannot see. (1) The park predicate and the `tool_end` predicate must cover the **same** set — the park side admits a first-class `kind:'tool_approval'` gate for *any* tool name, and a `tool_end` frame carries no `kind`, so the frame-level judge falls back to the engine's exact abort marker; otherwise the poison frame hits the transcript and `markEnded` swallows the real replayed result (the #110 disease, reopened on kind-only gates). (2) The already-decided identity criterion is **one-shot**: its two inputs are monotonic, so without consumption one successful decide makes every later park failure — including a real `approvals.list` outage — read as "already resolved" until the 24-hop budget runs out and reports a cause that has nothing to do with what happened. (3) A `plan_review` card dismissed without an answer must be re-presentable: the idempotent re-arm short-circuit re-publishes the still-armed card, and a stale armed id (responder gone) re-arms from scratch rather than presenting a card nobody can answer. (4) `HitlSafetyError` is a safety signal — the `remember` fallback arm must re-raise it instead of auto-retrying the decide, while a plain unknown-key 400 still falls back. (5) The polling leg reschedules after an escaping throw and flips `mode()` to `idle` once it consistently fails, so the honesty surface stops reporting a dead feed as live |
244
+ | `scripts/run-park-hop-progress-test.mjs` | L-80: the park re-attach loop budgets **stalled** rounds, not parks. A turn where the model keeps hitting gates and every one of them is really decided (a card was answered, the engine really moved on) must never be cut off by the hop budget — the budget counts consecutive rounds that produced no progress, and "the engine revived and immediately parked again on the same coordinates" is not progress. The three non-progress arms (already-resolved, decide-transport-exhausted, and a re-scan that was adopted but led nowhere) share one same-cause limit instead of one arm having a limit and the others having none, and every non-progress re-attach is announced once through the host callback rather than only to the debug log. When the limit is spent the resolver reads the approval queue once more and puts whatever is decidable in front of the user before it gives up; only when there is genuinely nothing to show does it fail soft, and the terminal message then carries the real cause and a real way out instead of a sentence about a budget. On the self-heal side, a reopen verdict that reports `decidedWithoutCard` — the chain settled the gate by rule, so there was no card to present — is progress, not a reopen failure, and the user is not told their message was NOT sent. Negative control: a genuinely empty queue with a run that never moves still fails soft |
244
245
  | `scripts/run-notif-fleet-honesty-test.mjs` | [2393] the five notification/fleet disciplines a green type-check cannot see, each proven by reverting the fix. (1) The workflow-side dedup `return` keeps a count and a trace — without it "suppressed by design" and "a real completion swallowed because the runId minting changed" are the same observation. (2) `seq` normalisation has exactly one mint point, so a 0-based or fractional wire `seq` cannot make the watcher lane and the frame lane key the same completion differently (which would feed the model twice). (3) The TTL sweep defers to a probe arm that is still inside its own deadline — an entry recorded as "abandoned" must not be delivered a moment later — while an arm that has outlived its deadline never blocks the sweep, so the headless exit gate keeps its liveness. (4) The reset hook really clears every ledger it claims to (the sticky `prompt` ledger leaked across cases). (5) The fleet ledger counts all three drop paths (malformed / unknown frame type / isolation drop), and the panel projection's settled recycling is anchored on the settle instant and skips still-present rows, so the dedup token is never carried off with the entry (which would re-emit `end`) |
245
246
  | `scripts/run-public-surface-test.mjs` | The outward promises: the npm export surface baseline (an **exact set**, both directions — a new export that never entered the baseline is one nobody watched leave, and deleting it later would not be red), the peer floor witness, and this README's claims |
246
247
  | `scripts/run-client-core-message-branching-test.mjs` | §B8 (branching on error **text**) and §B10 (truthiness standing in for existence when the value can be `0`). AST + type-checker census over `src/`, a named ALLOW list carrying owner and expiry, a known-site floor, and two fixed corpora with a known verdict judged by the same classifier on every run |
@@ -129,8 +129,12 @@ export interface DurableRunCallOpts {
129
129
  * 断言不成立 —— 按重开失败臂说话(说「已重开」而宿主明知没渲 = 谎报);true/缺席不降级。
130
130
  * 回执机制的实现留宿主端,包只成文这个契约位。
131
131
  */
132
- export type ReopenCardVerdict = {
132
+ export type ReopenCardVerdict =
133
+ /** L-80:`decidedWithoutCard` = 重开链**没呈卡但已成功决断**(规则直决 / sync-allow)—— 这是进展,
134
+ * 不是失败;端不许把它渲成「could not reopen … NOT sent」。缺席 = 真失败形(语义零变)。 */
135
+ {
133
136
  reopened: false;
137
+ decidedWithoutCard?: true;
134
138
  } | {
135
139
  reopened: true;
136
140
  firstSight: boolean;
@@ -280,6 +284,18 @@ export type SelfHealOutcome =
280
284
  taskId: string;
281
285
  decidePath: string | null;
282
286
  }
287
+ /** L-80:重开链无卡直决成功 ⇒ run 已恢复;消息按 `resending` 处置(端接自动重发,见 disposition)。 */
288
+ | {
289
+ kind: 'ask-decided-without-card';
290
+ taskId: string;
291
+ released: true;
292
+ }
293
+ /** L-80:直决成功但 run 释放与否**不知道**(无 `runs.get` / 读不出 status / 窗内没释放):不重发、不谎报失败。 */
294
+ | {
295
+ kind: 'ask-decided-release-unknown';
296
+ taskId: string;
297
+ lastStatus: string | null;
298
+ }
283
299
  /**
284
300
  * 三选卡①:用户选了 steer,消息交给了那条持锁的 run。`delivery` = SDK `SteerReceipt.delivery`
285
301
  * (`applied` / `queued` / `parked_for_wake`,读不出即 null)—— 三种投递语义**完全不同**,文案
@@ -138,7 +138,12 @@ export function selfHealSubmissionDisposition(outcome) {
138
138
  // reopen-failed 那一臂)⇒ 用户手上确有把手。
139
139
  return 'held-for-decision';
140
140
  case 'running-cancelled':
141
+ case 'ask-decided-without-card':
141
142
  return 'resending';
143
+ case 'ask-decided-release-unknown':
144
+ // L-80(对抗复审 r4 [high]③):决断成功、释放未确认。**没有**释放驱动的重投机制就不许承诺「稍后投递」——
145
+ // 注入件按既有 not-delivered 处置(端:归因上屏 + 问一次待决队列,不回灌不盲发);用户消息文案说「再发一次」。
146
+ return 'not-delivered';
142
147
  case 'running-steered':
143
148
  return 'handed-off';
144
149
  default:
@@ -421,7 +426,7 @@ export async function attemptActiveRunSelfHeal(signal, runs, deps) {
421
426
  if (gateKind !== null && PLAN_REVIEW_GATE_KINDS.includes(gateKind))
422
427
  return planReviewArm(taskId, signal, deps);
423
428
  if (gateKind !== null && ASK_PARK_GATE_KINDS.includes(gateKind))
424
- return askParkArm(taskId, signal, deps);
429
+ return askParkArm(taskId, signal, runs, deps);
425
430
  // ── 状态真源两级:wire 直供 > 自己查一趟 runs.get(kind 缺席/表外 kind 的诚实陈述都要它)──
426
431
  let status = signal.activeTaskStatus;
427
432
  if (status === null) {
@@ -448,7 +453,7 @@ export async function attemptActiveRunSelfHeal(signal, runs, deps) {
448
453
  if (PLAN_REVIEW_STATES.includes(status))
449
454
  return planReviewArm(taskId, signal, deps);
450
455
  if (ASK_PARK_STATES.includes(status))
451
- return askParkArm(taskId, signal, deps);
456
+ return askParkArm(taskId, signal, runs, deps);
452
457
  // 真在跑 ⇒ 把引擎给着的两条路(steer / cancel)+ 现状做成三选卡交给用户;表外的其它状态
453
458
  // (含今天还不存在的)照旧「不动它 + 如实说」—— 语义不明的状态不配递把手(见 RUNNING_STATES 注)。
454
459
  // Inkglow-1085 P0b②:`statusFromWire` = 这个 running 是 409 终帧直供的投影(best-effort,可陈旧
@@ -486,11 +491,36 @@ async function planReviewArm(taskId, signal, deps) {
486
491
  : { kind: 'plan-review-reopen-failed', taskId, decidePath: signal.pendingGate?.decidePath ?? null };
487
492
  }
488
493
  /** 审批/提问门:重开卡。cancel 在这里的语义 = 替用户否掉待决项,且实证是循环病根 —— 臂已退役。 */
489
- async function askParkArm(taskId, signal, deps) {
494
+ async function askParkArm(taskId, signal, runs, deps) {
490
495
  const verdict = await askVerdict(taskId, deps);
491
- return reopenDelivered(verdict)
492
- ? { kind: 'ask-reopened', taskId, firstSight: verdict.firstSight === true }
493
- : { kind: 'ask-reopen-failed', taskId, decidePath: signal.pendingGate?.decidePath ?? null };
496
+ if (reopenDelivered(verdict))
497
+ return { kind: 'ask-reopened', taskId, firstSight: verdict.firstSight === true };
498
+ // L-80:无卡但链已成功决断(规则直决)—— 它只证明「决断受理」,不证明「run 交出了会话」。三分:
499
+ // · 有界等到 run 释放 ⇒ `ask-decided-without-card`(resending,与 running-cancelled 同一条重发腿的前提相同);
500
+ // · 没释放但 run 在跑 ⇒ 走既有 running 三选卡(steer / cancel / wait),绝不盲发;
501
+ // · 没释放且 run 仍 parked / 读不到 ⇒ 如实 reopen-failed(旧文案;plan 腿无此形,规则不决 plan)。
502
+ if (verdict.reopened === false && verdict.decidedWithoutCard === true) {
503
+ // 决断成功是正面事实,**不许**再落 reopen-failed(那句「could not reopen … NOT sent」会掩盖它)。
504
+ if (typeof runs?.get !== 'function')
505
+ return { kind: 'ask-decided-release-unknown', taskId, lastStatus: null };
506
+ // r5 [high]②:真 SDK 的 RunsResource 方法依赖 `this`(与 cancel 路径 `boundGet` 同款保留接收者;裸函数会恒 TypeError
507
+ // 被轮询器吞掉、10s 后错落 release-unknown)。
508
+ const durableGet = runs.get;
509
+ const get = (id, opts) => durableGet.call(runs, id, opts);
510
+ const release = await waitForClaimRelease(taskId, {
511
+ get,
512
+ budgetMs: deps?.cancelReleaseWaitMs ?? CANCEL_RELEASE_WAIT_MS, // 与 cancel 释放窗同源(10s),慢恢复不被 4s 误判
513
+ ...(deps?.signal !== undefined ? { signal: deps.signal } : {}),
514
+ ...sessionOpts(deps),
515
+ });
516
+ if (release.released)
517
+ return { kind: 'ask-decided-without-card', taskId, released: true };
518
+ if (release.lastStatus !== null && RUNNING_STATES.includes(release.lastStatus)) {
519
+ return runningChoiceArm(taskId, release.lastStatus, signal, false, runs, deps);
520
+ }
521
+ return { kind: 'ask-decided-release-unknown', taskId, lastStatus: release.lastStatus };
522
+ }
523
+ return { kind: 'ask-reopen-failed', taskId, decidePath: signal.pendingGate?.decidePath ?? null };
494
524
  }
495
525
  /**
496
526
  * `running` 形:呈三选卡 → 按用户的决定动手。零决定 = 零动作(`not-parked` 现状行)。
@@ -561,7 +591,7 @@ async function runningChoiceArm(taskId, status, busy, statusFromWire, runs, deps
561
591
  if (PLAN_REVIEW_STATES.includes(fresh))
562
592
  return planReviewArm(taskId, busy, deps);
563
593
  if (ASK_PARK_STATES.includes(fresh))
564
- return askParkArm(taskId, busy, deps);
594
+ return askParkArm(taskId, busy, runs, deps);
565
595
  // (对抗复审 [high] 采纳)终态词 ⇒ 专属结局:not-parked 的「wait for it to finish」对一条
566
596
  // 已终结的 run 是永远等不到的假话。表外的未知词仍落 not-parked 如实说(不替引擎断言终结)。
567
597
  if (CLAIM_RELEASED_STATES.includes(fresh))
@@ -844,6 +874,14 @@ function activeRunSelfHealBaseRow(outcome, signal, wayOut) {
844
874
  `why nothing new could start — that approval card was closed without being answered, so sema ` +
845
875
  `reopened it. Answer it and send your message again. (sema did NOT cancel the run: cancelling ` +
846
876
  `would have thrown the plan away on your behalf.)`);
877
+ case 'ask-decided-without-card':
878
+ return (`The previous turn was parked on an approval that your permission rules decided on their own (no card was ` +
879
+ `needed); run ${outcome.taskId} resumed and has since finished — sema is re-sending your message.`);
880
+ case 'ask-decided-release-unknown':
881
+ return (`The previous turn was parked on an approval that your permission rules decided on their own (no card was ` +
882
+ `needed), so run ${outcome.taskId} should be resuming` +
883
+ (outcome.lastStatus ? ` (last engine status: ${outcome.lastStatus})` : '') +
884
+ `. sema could not yet confirm it finished, so your message was not sent — wait a moment and send it again.`);
847
885
  case 'ask-reopen-failed': {
848
886
  const viaEngine = outcome.decidePath
849
887
  ? ` You can also decide it on the engine directly: POST ${outcome.decidePath}.`
@@ -24,5 +24,44 @@ export declare function classifierDenyFromToolEnd(ev: {
24
24
  isError?: unknown;
25
25
  output?: unknown;
26
26
  }): ClassifierDenyVerdict | undefined;
27
+ /**
28
+ * {@link classifierDenyDisplay} 的返回上界(字符数,含末尾省略号)。
29
+ *
30
+ * 🔴 为什么必须有界:入参 `args` 是**模型产出**的工具入参(UNTRUSTED),`Bash.command` 与
31
+ * `Write.file_path` 都可以是任意长度的串。呈现面(cli 的 /permissions Recent Denials 是一行、
32
+ * `wrap="truncate"`)自己也会截,但**库不许交出一个没有上限的串** —— 端把它塞进别的容器
33
+ * (通知行、日志、剪贴板)时那道呈现截断就不在了。
34
+ * 🔴 **本上界量的是「交出去的原文长度」,不是端渲出来那一行的最终宽度**(异源对抗复审 R1
35
+ * finding③ 采纳的措辞订正):控制符 / 双向符 / 换行的可见化是**展示消毒**,归端
36
+ * (cli `cleanUntrustedForDisplay`),而消毒会**变长** —— 200 个 ESC 在这里长度是 200,端把它们
37
+ * 逐个转成 `\u001B` 之后是 1200。⇒ **端在自己消毒之后必须再按自己的行宽兜一次底**,别把本常量
38
+ * 读成「拿到手就一定不超过 200 个显示格」。
39
+ * 🔴 那为什么库不干脆先消毒再截(那样最终宽度就守得住)?因为消毒的字符集只能有**一份**:库里消
40
+ * 一遍、端再消一遍,两份字符集必然漂([machine-readable-signal-not-visual-anchor] 同族),而
41
+ * 端那一份还要管别的载体(hook 名 / fleet 行标签)。分工是刻意的:库守「原文不无限长」,
42
+ * 端守「渲出来那一行不超宽」。
43
+ */
44
+ export declare const CLASSIFIER_DENY_DISPLAY_MAX = 200;
45
+ /**
46
+ * 一条分类器拒绝行的**显示名**:三端同形的「这一行说的是哪条命令 / 哪个文件」判定。
47
+ *
48
+ * 为什么在库里(而不是各端自己拼):端要渲的是同一件事(Recent Denials 行 / 通知行 / retry 提示),
49
+ * 判据(读哪个入参键、什么算「在场」、上界多少)三端各写一遍必然各错一遍;而这是**纯判定**、零 IO、
50
+ * 零展示——正合本包的归层。
51
+ *
52
+ * 判据(逐条):
53
+ * · `Bash` ⇒ `args.command`;`Read`/`Write`/`Edit`/`MultiEdit` ⇒ `args.file_path`;
54
+ * `NotebookEdit` ⇒ `args.notebook_path`(见 {@link DISPLAY_ARG_KEY_BY_TOOL});表外工具 ⇒ 工具名;
55
+ * · 取到的值**必须是非空串**(trim 后非空)——非串 / 缺席 / 空串 / 纯空白一律回落工具名。
56
+ * 🔴 回落**不是**「渲一个空行」:一个空的显示名会让那一行看起来像坏了,而工具名至少是真的;
57
+ * · 工具名自身为空 ⇒ `'tool'`(与同文件 {@link classifierDenyNoticeText} 的兜底逐字同款);
58
+ * · 结果长度上界 {@link CLASSIFIER_DENY_DISPLAY_MAX}(超出 ⇒ 截到上界、末位换 `…`)。
59
+ *
60
+ * 🔴 **绝不**在这里做展示消毒(控制符/双向符/换行)——那是端的事,理由见
61
+ * {@link CLASSIFIER_DENY_DISPLAY_MAX} 头注。返回值仍然是 **UNTRUSTED-for-display**。
62
+ * 🔴 本函数**不**回答 retry 粒度:重试哪一只调用、按什么规则铸,是端侧规则面的事
63
+ * (身份锚 = `ClassifierDenyContext.toolCallId`)。
64
+ */
65
+ export declare function classifierDenyDisplay(toolName: string, args: unknown): string;
27
66
  /** 通知行文案(exported for tests;CC 207 三段 text 化)。 */
28
67
  export declare function classifierDenyNoticeText(toolName: string, reason: string | undefined): string;
@@ -94,6 +94,117 @@ export function classifierDenyFromToolEnd(ev) {
94
94
  const reason = tail.startsWith(': ') ? tail.slice(2).trim() : undefined;
95
95
  return { message, ...(reason !== undefined && reason.length > 0 ? { reason } : {}) };
96
96
  }
97
+ // ── L-69⑨(0.52.0):被拒行的**行文案判定**(三端同形)────────────────────────────────────────
98
+ /**
99
+ * {@link classifierDenyDisplay} 的返回上界(字符数,含末尾省略号)。
100
+ *
101
+ * 🔴 为什么必须有界:入参 `args` 是**模型产出**的工具入参(UNTRUSTED),`Bash.command` 与
102
+ * `Write.file_path` 都可以是任意长度的串。呈现面(cli 的 /permissions Recent Denials 是一行、
103
+ * `wrap="truncate"`)自己也会截,但**库不许交出一个没有上限的串** —— 端把它塞进别的容器
104
+ * (通知行、日志、剪贴板)时那道呈现截断就不在了。
105
+ * 🔴 **本上界量的是「交出去的原文长度」,不是端渲出来那一行的最终宽度**(异源对抗复审 R1
106
+ * finding③ 采纳的措辞订正):控制符 / 双向符 / 换行的可见化是**展示消毒**,归端
107
+ * (cli `cleanUntrustedForDisplay`),而消毒会**变长** —— 200 个 ESC 在这里长度是 200,端把它们
108
+ * 逐个转成 `\u001B` 之后是 1200。⇒ **端在自己消毒之后必须再按自己的行宽兜一次底**,别把本常量
109
+ * 读成「拿到手就一定不超过 200 个显示格」。
110
+ * 🔴 那为什么库不干脆先消毒再截(那样最终宽度就守得住)?因为消毒的字符集只能有**一份**:库里消
111
+ * 一遍、端再消一遍,两份字符集必然漂([machine-readable-signal-not-visual-anchor] 同族),而
112
+ * 端那一份还要管别的载体(hook 名 / fleet 行标签)。分工是刻意的:库守「原文不无限长」,
113
+ * 端守「渲出来那一行不超宽」。
114
+ */
115
+ export const CLASSIFIER_DENY_DISPLAY_MAX = 200;
116
+ /**
117
+ * 每只工具「拿哪个入参当行文案」的表(逐字锚 CC/sema 的工具入参键名,实测直证 sema-cli
118
+ * `src/tools/*`:`BashTool` = `command`、`FileReadTool`/`FileWriteTool`/`FileEditTool` = `file_path`、
119
+ * `NotebookEditTool` = **`notebook_path`**)。
120
+ *
121
+ * 🔴 `NotebookEdit` 的键**不是** `file_path`(它是 `notebook_path`)—— 写成 `file_path` 不会报错,
122
+ * 只会让这只工具**永远**落回工具名兜底,而那正好和「没实现」长得一模一样(静默无效)。
123
+ * 🔴 键名归一按 `toolNameKey`(去空白/下划线/连字符 + 小写),与本仓 `toolNameIsFsWrite` /
124
+ * `toolNameIsShellExec` 的归一姿势逐字同款 —— 引擎侧工具名的书写形不由本包决定。
125
+ * 🔴 表**外**的工具一律回落工具名:这是一张**识别表**,不是「合法工具只有这些」
126
+ * (`engineErrorCodes` 的开集纪律同族)。
127
+ */
128
+ const DISPLAY_ARG_KEY_BY_TOOL = new Map([
129
+ ['bash', 'command'],
130
+ ['read', 'file_path'],
131
+ ['fileread', 'file_path'],
132
+ ['write', 'file_path'],
133
+ ['filewrite', 'file_path'],
134
+ ['edit', 'file_path'],
135
+ ['fileedit', 'file_path'],
136
+ ['multiedit', 'file_path'],
137
+ ['notebookedit', 'notebook_path'],
138
+ ]);
139
+ /** 工具名归一(与 `hitl/toolApprovalWire.ts` 的两个谓词逐字同款)。 */
140
+ function toolNameKey(name) {
141
+ return name.replace(/[\s_-]+/g, '').toLowerCase();
142
+ }
143
+ /**
144
+ * 一条分类器拒绝行的**显示名**:三端同形的「这一行说的是哪条命令 / 哪个文件」判定。
145
+ *
146
+ * 为什么在库里(而不是各端自己拼):端要渲的是同一件事(Recent Denials 行 / 通知行 / retry 提示),
147
+ * 判据(读哪个入参键、什么算「在场」、上界多少)三端各写一遍必然各错一遍;而这是**纯判定**、零 IO、
148
+ * 零展示——正合本包的归层。
149
+ *
150
+ * 判据(逐条):
151
+ * · `Bash` ⇒ `args.command`;`Read`/`Write`/`Edit`/`MultiEdit` ⇒ `args.file_path`;
152
+ * `NotebookEdit` ⇒ `args.notebook_path`(见 {@link DISPLAY_ARG_KEY_BY_TOOL});表外工具 ⇒ 工具名;
153
+ * · 取到的值**必须是非空串**(trim 后非空)——非串 / 缺席 / 空串 / 纯空白一律回落工具名。
154
+ * 🔴 回落**不是**「渲一个空行」:一个空的显示名会让那一行看起来像坏了,而工具名至少是真的;
155
+ * · 工具名自身为空 ⇒ `'tool'`(与同文件 {@link classifierDenyNoticeText} 的兜底逐字同款);
156
+ * · 结果长度上界 {@link CLASSIFIER_DENY_DISPLAY_MAX}(超出 ⇒ 截到上界、末位换 `…`)。
157
+ *
158
+ * 🔴 **绝不**在这里做展示消毒(控制符/双向符/换行)——那是端的事,理由见
159
+ * {@link CLASSIFIER_DENY_DISPLAY_MAX} 头注。返回值仍然是 **UNTRUSTED-for-display**。
160
+ * 🔴 本函数**不**回答 retry 粒度:重试哪一只调用、按什么规则铸,是端侧规则面的事
161
+ * (身份锚 = `ClassifierDenyContext.toolCallId`)。
162
+ */
163
+ export function classifierDenyDisplay(toolName, args) {
164
+ const fallback = toolName.length > 0 ? toolName : 'tool';
165
+ const key = DISPLAY_ARG_KEY_BY_TOOL.get(toolNameKey(toolName));
166
+ if (key === undefined)
167
+ return bounded(fallback);
168
+ if (args === null || typeof args !== 'object')
169
+ return bounded(fallback);
170
+ const raw = args[key];
171
+ if (typeof raw !== 'string' || raw.trim().length === 0)
172
+ return bounded(fallback);
173
+ return bounded(raw);
174
+ }
175
+ /**
176
+ * 上界裁剪(超限 ⇒ 截到上界、末位换 `…`;与 {@link classifierDenyNoticeText} 的截法同形)。
177
+ *
178
+ * 🔴 **本函数守的是「截断不制造畸形」,不是「输出一定 well-formed」**(措辞由异源对抗复审 R2
179
+ * finding 订正 —— 上一版写成「绝不交出孤代理项」是**过度声称**)。两件事必须分开:
180
+ * · **截断制造的**孤高代理项 = 本函数的责任。`slice` 按 UTF-16 码元切,边界正好落在一个 emoji /
181
+ * 星平面字符的**代理对中间**时,交出去的是一个本来不存在的畸形串 —— 那是本层自己弄坏的,
182
+ * 必须自己不弄坏(少切一个码元、结果短一格)。
183
+ * · **输入里本来就有的**孤代理项(高或低)= **原样透出**,与本文件其余 UNTRUSTED 位一个待遇。
184
+ * `JSON.parse('"\ud800"')` 完全合法,所以这种入参是真实存在的;而把它「修好」就是**展示消毒**
185
+ * —— 那一份在本包里已有唯一真源:{@link escapeDisplayControlChars}(`fleetTaskDesc.ts`,公面导出),
186
+ * 它的头注逐字点名这一族:「孤代理项:上游可能本来就送半只,或被列宽截断劈开」。在这里再消一遍
187
+ * = 第二份字符集,正是那条单源纪律要防的事。
188
+ * 🔴 因此只回退**末位的高代理项**这一形:那是本函数唯一可能自己造出来的畸形(切的永远是前缀,
189
+ * 所以断口只在尾部)。末位是低代理项 ⇒ 要么它的高位还在串里(配对完整),要么它在输入里本来就是
190
+ * 孤的(上一条,原样透出)。
191
+ * 🔴 组合记号 / 变体选择符 / ZWJ 序列被切开只是「显示成两个字」,仍是合法 Unicode —— 本函数不做
192
+ * 字素簇分段(那要一整份 grapheme 表,且属于**渲染**面,与本文件的分工线一致)。
193
+ */
194
+ function bounded(s) {
195
+ if (s.length <= CLASSIFIER_DENY_DISPLAY_MAX)
196
+ return s;
197
+ const keep = CLASSIFIER_DENY_DISPLAY_MAX - 1;
198
+ let cut = s.slice(0, keep);
199
+ const last = cut.charCodeAt(cut.length - 1);
200
+ // 🔴 只有「末位是高代理项 **且** 原串紧接着的那个码元是低代理项」才是被截断劈开的合法代理对(本层
201
+ // 自己造的畸形,少切一格);末位高代理项后面跟的不是低代理项 ⇒ 它在输入里本来就是孤的 ⇒ 原样透出
202
+ // (对抗复审 r3 [medium]:无条件回退会静默删掉输入自带的孤高代理项,违反上面的分工线)。
203
+ const next = s.charCodeAt(keep);
204
+ if (last >= 0xd800 && last <= 0xdbff && next >= 0xdc00 && next <= 0xdfff)
205
+ cut = cut.slice(0, -1);
206
+ return `${cut}\u2026`;
207
+ }
97
208
  /** 通知行文案(exported for tests;CC 207 三段 text 化)。 */
98
209
  export function classifierDenyNoticeText(toolName, reason) {
99
210
  let r = reason ?? '';
@@ -74,10 +74,11 @@ import type { AgentEvent } from '@sema-agent/sdk';
74
74
  import { type AskGateWireDeps } from './frameRouter.js';
75
75
  export { HITL_REJECT_MESSAGE, ENGINE_ABORT_TOOL_RESULT } from './frameRouter.js';
76
76
  export { HITL_INTERRUPT_MESSAGE_FOR_TOOL_USE } from './frameRouter.js';
77
- export type { AskGateWireDeps } from './frameRouter.js';
77
+ export type { AskGateWireDeps, ParkReattachNotice } from './frameRouter.js';
78
78
  export type { AskAnsweredOutput } from './gateLedger.js';
79
79
  export { SEMA_COLLATERAL_ABORT_KEY } from './gateLedger.js';
80
- export { toAnsweredOutput, isAlreadyResolvedGateReason, GATE_FAILURE_CODES } from './parkResolver.js';
80
+ export { toAnsweredOutput, isAlreadyResolvedGateReason, GATE_FAILURE_CODES, nextHopBudget, MAX_GATE_HOPS, MAX_TOTAL_PARKS } from './parkResolver.js';
81
+ export type { HopBudget, HopRound } from './parkResolver.js';
81
82
  export type { GateOutcome, GateFailureCode } from './parkResolver.js';
82
83
  /**
83
84
  * 包一层 AgentEvent 流:把 AskUserQuestion 的 suspended park 变成「对话框 → decide → 续流」闭环。
@@ -1,6 +1,6 @@
1
1
  import { createGateLedger } from './gateLedger.js';
2
2
  import { flushHeldWithInterruptRewrite, isHostProgressFrame, routeFrame, } from './frameRouter.js';
3
- import { resolvePark } from './parkResolver.js';
3
+ import { resolvePark, nextHopBudget, stalledTerminal, MAX_GATE_HOPS } from './parkResolver.js';
4
4
  // ── 宿主面口(搬迁差分 3)────────────────────────────────────────────────────────────────────
5
5
  //
6
6
  // 壳里这三件都是 `getAppStateStoreRef()` 直写 `AppState` + `require('../utils/autoModeDenials.js')`。
@@ -28,7 +28,7 @@ export { SEMA_COLLATERAL_ABORT_KEY } from './gateLedger.js';
28
28
  // 🔴 `HoldOrigin` 随 #324 换锚删除(2026-08-22):出身不再在入表当拍冻结成两值枚举,改在
29
29
  // `flushHeld` 当拍按已登记的 gate 主角身份求值 —— 那个枚举没有对外语义了。干净切:它只在
30
30
  // 未发布的 0.40.0 窗里存在过,零已发布消费者([clean-cut-no-legacy-compat])。
31
- export { toAnsweredOutput, isAlreadyResolvedGateReason, GATE_FAILURE_CODES } from './parkResolver.js';
31
+ export { toAnsweredOutput, isAlreadyResolvedGateReason, GATE_FAILURE_CODES, nextHopBudget, MAX_GATE_HOPS, MAX_TOTAL_PARKS } from './parkResolver.js';
32
32
  /**
33
33
  * 包一层 AgentEvent 流:把 AskUserQuestion 的 suspended park 变成「对话框 → decide → 续流」闭环。
34
34
  * 其它事件原样透传;非 AskUserQuestion 的 gate 保持现状。fail-soft:任何桥内失败回退为
@@ -53,12 +53,32 @@ export async function* bridgeAskUserQuestionGates(source, deps, opts) {
53
53
  ...(opts?.signal ? { signal: opts.signal } : {}),
54
54
  };
55
55
  let stream = source;
56
- let hops = 0;
56
+ // ── L-80 hop 预算(2026-09-03):数「连续非进展轮」,不数 park 次数 ─────────────────────────────
57
+ // 一轮的进展 = 上一次 reattach 打开的那段流里出现过 host 推进帧(`isHostProgressFrame`:
58
+ // text/reasoning/tool_start 且非孙代)。真 park 的帧序 tool_end→message_committed→turn_end→
59
+ // done{suspended} 一个推进帧都不带([2084]① 收窄过的集合),所以「引擎 revive 后立刻原样 re-park」
60
+ // (坐标失配 / reopen 类)天然判非进展;「模型重试同一失败 Edit 每次真被门」每轮都有推进帧 ⇒
61
+ // 判进展、不限次(CC 同款:问 N 次答 N 次)。首段是 sync leg,自带 gated tool_start ⇒ 恒判进展。
62
+ let budget;
63
+ let progressedSinceReattach = false;
64
+ let lastStallReason;
65
+ let lastRoundPresentedCard = false;
66
+ // 对抗复审 r1 [high]①:进展**同时**要求 park 身份推进 —— 同一个 gatedCallId 原样回来,哪怕中间吐了一帧文本
67
+ // (「retrying…」)也不算进展;新 toolCallId(模型重试/下一只门)+ 推进帧才算。tool-less park 无身份,只看帧
68
+ // + MAX_TOTAL_PARKS 硬兜底。
69
+ let lastResolvedGatedCallId;
70
+ /** 上一轮有没有真决断落地(progress:true)。有 ⇒ 这一轮的「首读空」是取件失败不是空转,推进帧照算进展
71
+ * (F15-f:门1 决完引擎真推进了,门2 首击读空);没有(从没解析到过行)⇒ no_pending 一票否决(L12)。 */
72
+ let lastRoundDecided = false;
73
+ /** 本 turn 内解析不出身份的 park 数(MAX_TOTAL_PARKS 只数这一类,r6 [high]:真推进的新门不吃硬兜底)。 */
74
+ let unverifiableParks = 0;
57
75
  while (true) {
58
76
  /** park 请求:遇 AskUserQuestion / 工具审批 gate 时置位后 break 内环。 */
59
77
  let park = null;
60
78
  for await (const ev of stream) {
61
79
  led.noteSeq(ev);
80
+ if (isHostProgressFrame(ev))
81
+ progressedSinceReattach = true;
62
82
  // HOLD 只护 park 窗口:host lane 的模型推进帧一到就放行扣留帧(触发集的来龙去脉见
63
83
  // `frameRouter.isHostProgressFrame` 上方长注)。
64
84
  // 件 B(异源复审 finding 采纳):这个中途出口**也**要走中断感知的那一个。病形 = 用户按了
@@ -97,19 +117,75 @@ export async function* bridgeAskUserQuestionGates(source, deps, opts) {
97
117
  yield* flushHeldWithInterruptRewrite(led, { signal: opts?.signal });
98
118
  return;
99
119
  }
100
- hops++;
101
- const resolution = await resolvePark(park, {
120
+ // 进展判决分两半(对抗复审 r2 [high]①):
121
+ // · **帧**半场在 resolve 之前判:上一段流里有推进帧 ⇒ 先按进展记(同因计数复位、预算清零);
122
+ // · **身份**半场在 resolve 之后追认:这一轮真解析到的 pending 行(`resolution.gatedCallId`)与上一轮
123
+ // **同一只 call** ⇒ 把这一轮追改成非进展(哪怕中间吐了一帧文本)。durable `suspended` 帧常不带
124
+ // toolCallId,所以身份只认解析到的行,帧上缺席不算「变了」,也**不覆盖**上一次已知身份。
125
+ // 帧半场先判是为了不让触顶晚一拍(cap 用的是**本轮之前**的连续非进展数)。
126
+ // 帧半场(resolve 前):只看上一段流有没有 host 推进帧。有 ⇒ 同因账先复位(下一只门的首击要从 1 数起,
127
+ // F15-f),预算**不在这里提交**;触顶输入 = 上一轮累计(有帧时沿用,没帧时 +1)—— 帧上的 park 身份
128
+ // **不参与**预判(对抗复审 r3 [high]①:重放帧可能仍携旧 call A,而 rescan 真解析到的是新 call B)。
129
+ const framesProgressed = progressedSinceReattach;
130
+ const prevStalled = budget?.stalled ?? 0;
131
+ if (framesProgressed) {
132
+ led.resetAlreadyResolvedGate();
133
+ lastStallReason = undefined;
134
+ }
135
+ // 触顶(对抗复审 r4 [high]①):有推进帧时**不在解析前**触顶 —— 先解析、先呈、先决,提交预算后仍是同一只
136
+ // call 才收场(见下);没有推进帧时上一轮累计 +1 已足够判定,在 resolvePark 内按 stalledRounds 收场。
137
+ const ctxBase = {
102
138
  deps,
103
139
  led,
104
140
  taskId: taskId.current,
105
- hops,
141
+ hops: (budget?.total ?? 0) + 1,
142
+ lastStallReason,
143
+ lastRoundPresentedCard,
106
144
  ...(opts?.signal ? { signal: opts.signal } : {}),
145
+ };
146
+ const resolution = await resolvePark(park, {
147
+ ...ctxBase,
148
+ stalledRounds: framesProgressed ? 0 : prevStalled + 1,
149
+ unverifiableParks,
107
150
  });
108
151
  if (resolution.kind === 'failsoft') {
109
152
  for (const out of resolution.events)
110
153
  yield out;
111
154
  return;
112
155
  }
156
+ // 身份半场(resolve 后,权威):只认这一轮真解析到的 pending 行(`resolution.gatedCallId` **不**回填帧上身份,
157
+ // 对抗复审 r4 [high]②)。同一只 call 原样回来 ⇒ 非进展(哪怕有推进帧);没解析到行(no_pending / 传输败)
158
+ // 的轮次 `progress:false` 一票否决。预算在这里一次提交;提交后超上限 ⇒ 立即收场(这一轮已呈过卡,不再呈)。
159
+ const resolvedId = resolution.gatedCallId;
160
+ if (resolvedId === undefined)
161
+ unverifiableParks++;
162
+ const sameCall = resolvedId !== undefined && resolvedId === lastResolvedGatedCallId;
163
+ const progressed = framesProgressed && !sameCall && (resolution.progress !== false || lastRoundDecided);
164
+ budget = nextHopBudget(budget, progressed ? 'progress' : 'stalled');
165
+ if (!progressed && budget.stalled > MAX_GATE_HOPS) {
166
+ const terminal = stalledTerminal(park, { ...ctxBase, stalledRounds: budget.stalled }, budget.stalled, resolution.progress === false ? resolution.reason : (sameCall ? 'the approval was decided but the engine parked the same call again' : lastStallReason), resolution.progress === true, resolvedId);
167
+ if (terminal.kind === 'failsoft') {
168
+ for (const out of terminal.events)
169
+ yield out;
170
+ return;
171
+ }
172
+ }
173
+ if (progressed) {
174
+ lastStallReason = undefined;
175
+ }
176
+ else if (resolution.progress === false) {
177
+ lastStallReason = resolution.reason;
178
+ }
179
+ else {
180
+ lastStallReason = 'the approval was decided but the engine parked the same call again';
181
+ }
182
+ if (resolvedId !== undefined)
183
+ lastResolvedGatedCallId = resolvedId;
184
+ // r5 [high]①:豁免只授予**新 call** 上落地的决断(与上一轮身份不同)—— 同一只 call 原样回来再决一次不授予,
185
+ // 否则「可见并决 / 暂不可见」交替可无限;而 F15-f 那形(门1 首次决完、门2 首击读空)仍能拿到豁免。
186
+ lastRoundDecided = resolution.progress === true && !sameCall;
187
+ lastRoundPresentedCard = resolution.presented; // 每次调用的真回执:规则直决 / 取件失败 = false ⇒ 触顶时收场卡照呈
188
+ progressedSinceReattach = false;
113
189
  const seq = led.lastSeq();
114
190
  stream = deps.runsEvents(taskId.current, {
115
191
  ...(seq !== undefined ? { lastEventId: seq } : {}),
@@ -61,6 +61,15 @@ export declare const ENGINE_ABORT_TOOL_RESULT = "Operation aborted";
61
61
  */
62
62
  export declare function toolEndOutputText(output: unknown): string | undefined;
63
63
  /** 本桥消费的 wire 面(@sema-agent/sdk AgentClient 的结构切片,mock 可注入)。 */
64
+ /** L-80:非进展 reattach 的一次用户面告知(命名形,typeshape B4 棘轮:导出签名不带内联匿名对象)。 */
65
+ export interface ParkReattachNotice {
66
+ /** 该臂同因连续第几次。 */
67
+ attempt: number;
68
+ /** 该臂上限(到了就诚实收场)。 */
69
+ max: number;
70
+ /** 真因 token(已解决码 / 传输错类词)。 */
71
+ reason: string;
72
+ }
64
73
  export interface AskGateWireDeps {
65
74
  /** approvals.list/decide + assistant(HitlBridge 的 client 切片)。 */
66
75
  client: HitlClientLike;
@@ -72,6 +81,10 @@ export interface AskGateWireDeps {
72
81
  /** POST /v1/tool-approvals/:id/respond(server 1.191 同步帧腿,[830]①)。缺省=不消费
73
82
  * tool_approval 帧(帧被吞、引擎按自身 fail-closed TTL 自决)——mock/旧引擎路径零影响。 */
74
83
  respondToolApproval?: RespondToolApprovalFn;
84
+ /** L-80(2026-09-03,[6215]):**非进展** reattach 的用户面告知口 —— 每一轮「引擎重放了同一张 park /
85
+ * decide 出站瞬断耗尽」而壳只能再附着一次时调一次(`attempt`=该臂同因连续第几次,`max`=该臂上限,
86
+ * `reason`=真因 token)。缺席 = 只留 debug 行(修前形:24 轮空转用户零告知)。进展轮**不**调。 */
87
+ onParkReattach?: (e: ParkReattachNotice) => void;
75
88
  /** #229 respond-note 供给链(0.29.0 发包扫描门修,2026-08-12):帧腿车道参数(能力位读数),
76
89
  * 宿主从自己的 caps 缓存供给。缺席 = note 门 fail-closed 到「不发」侧(决断照常,现状字节
77
90
  * 不变)—— 此前包内唯一生产调用点(routeFrame 的 tool_approval 臂)无此位,note 恒不发。