@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.
@@ -5,8 +5,30 @@ import { surfaceFsApprovalAndDecide } from './toolApprovalWire.js';
5
5
  import { observeCancelByDeny } from './hitlHostSurface.js';
6
6
  import { flushHeldWithInterruptRewrite, isAskTool } from './frameRouter.js';
7
7
  import { approvalCallKey, askGateQuestionId } from './gateIdentity.js';
8
- /** 一 turn 内最多循环这么多次 park(防御:引擎/模型病态连环提问时不无限 attach)。 */
9
- const MAX_GATE_HOPS = 24;
8
+ /**
9
+ * L-80(2026-09-03,[6215];#357 复发):**连续非进展轮**上限 —— 数的是「壳再附着之后那段流里一个
10
+ * host 推进帧都没有」的轮次,**不是** park 次数。修前这里是 `24` 且按每次 park 递增
11
+ * (askGateWire `hops++` 无条件),于是一个 turn 里模型对同一失败 Edit 重试 24 次、每次真被门真被决,
12
+ * 也会在第 25 次撞成 `gate hop limit (24) exceeded`——一句与真因无关的预算话术。CC 没有这条预算:
13
+ * 模型问 N 次就答 N 次。本闸只留作最后防线:连续 N 轮零进展(坐标失配空转 / reopen 类 re-park /
14
+ * decide 出站恒断)才收场,收场前再呈一次收场卡、终帧带真因与出路。
15
+ */
16
+ export const MAX_GATE_HOPS = 2;
17
+ /** 纯函数:上一轮预算 + 本轮判决 ⇒ 新预算(不就地改入参;`undefined` = 本 turn 第一轮)。 */
18
+ export function nextHopBudget(prev, round) {
19
+ const total = (prev?.total ?? 0) + 1;
20
+ return round === 'progress' ? { stalled: 0, total } : { stalled: (prev?.stalled ?? 0) + 1, total };
21
+ }
22
+ /** decide 出站瞬断耗尽臂(retryExhausted)的同因连续上限:第 3 次同因即诚实收场(修前该臂**无闸**)。 */
23
+ const MAX_TRANSPORT_REATTACHES = 2;
24
+ /**
25
+ * 硬兜底(对抗复审 r1 [high]①,r6 [high] 收窄):一 turn 内**解析不出身份**的 park 数上限。连续非进展计数靠
26
+ * 「解析到的 pending 行身份 + host 推进帧」判进展;解析不到行(tool-less park / approvals 恒空)时身份判不出 ⇒
27
+ * 只能靠推进帧,一个每轮先吐一帧文本再原样 park 的病态引擎在那一形下仍可能无限。**只数这一类**:每轮都解析到
28
+ * 新 call 且真推进的门(模型重试同一失败调用 N 次)不吃这格 —— CC 没有此闸,问 65 次就答 65 次。
29
+ * 命中即收场、终帧如实说「本 turn 有 N 次 park 解析不出身份」。
30
+ */
31
+ export const MAX_TOTAL_PARKS = 64;
10
32
  /**
11
33
  * 「已解决 ⇒ reattach」臂的**同因连续命中上限**(#357,现网 P0;[5522] 定谳 / [5525] server 答复)。
12
34
  * 同一判据源连续第 `N+1` 次命中即**不再** reattach。
@@ -140,7 +162,7 @@ export function toAnsweredOutput(questions, answer) {
140
162
  async function surfaceGateAndDecide(deps, taskId, askArgsByCall, signal,
141
163
  /** 这张 park 的待批 call 身份(server ≥7.41.0;缺席是常态)。语义与不 fail-closed 的理由
142
164
  * 与 fs 腿同一条,见 `toolApprovalWire.surfaceFsApprovalAndDecide` 的同名参数头注。 */
143
- parkGatedCallId) {
165
+ parkGatedCallId, onPresented) {
144
166
  if (!hasQuestionOverlay()) {
145
167
  return { kind: 'failed', reason: 'no question overlay mounted (print/non-REPL mode)' };
146
168
  }
@@ -197,6 +219,7 @@ parkGatedCallId) {
197
219
  return;
198
220
  }
199
221
  signal?.addEventListener('abort', onAbort, { once: true });
222
+ onPresented?.(); // L-80:ask 腿的呈现回执与 fs 腿同一枚旗(对抗复审 r2 [high]②:此前只数卡口)
200
223
  publishQuestionFrame({ type: 'question', questionId, questions });
201
224
  });
202
225
  const bridge = new HitlBridge(deps.client, taskId);
@@ -304,11 +327,11 @@ function isFetchStepNoPending(outcome) {
304
327
  * @param gatedCallId 交给取件腿的**身份**(wire 给的 park 身份;缺席是常态)。首击与坐标重探
305
328
  * 传的是**同一个值** —— 重探要的是读数新鲜,不是判据放宽,理由见调用点的红线注。
306
329
  */
307
- async function surfaceParkGate(park, ctx, gatedCallId) {
330
+ async function surfaceParkGate(park, ctx, gatedCallId, onPresented) {
308
331
  const { deps, led, taskId } = ctx;
309
332
  if (park.gate === 'fs') {
310
333
  // [816] 放宽腿:fs 写权限 gate → CC 三选卡(vendored PermissionRequest)→ decide。
311
- const outcome = await surfaceFsApprovalAndDecide({ client: deps.client }, taskId, argsByCallOf(led), ctx.signal, gatedCallId);
334
+ const outcome = await surfaceFsApprovalAndDecide({ client: deps.client, ...(onPresented ? { onPresented } : {}) }, taskId, argsByCallOf(led), ctx.signal, gatedCallId);
312
335
  // #51: `outcome`'s declared type is the wider `GateOutcome |
313
336
  // FsApprovalOutcome`; both unions share a 'decided' kind with
314
337
  // different optional fields (`answered` vs `denied`), so a plain
@@ -321,11 +344,11 @@ async function surfaceParkGate(park, ctx, gatedCallId) {
321
344
  }
322
345
  return outcome;
323
346
  }
324
- return surfaceGateAndDecide(deps, taskId, argsByCallOf(led), ctx.signal, gatedCallId);
347
+ return surfaceGateAndDecide(deps, taskId, argsByCallOf(led), ctx.signal, gatedCallId, onPresented);
325
348
  }
326
349
  export async function resolvePark(park, ctx) {
327
350
  // `deps` 不在本函数直读:呈卡/决断两族腿统一经 `surfaceParkGate`(#357 单源化,见其头注)。
328
- const { led, taskId, hops } = ctx;
351
+ const { led, taskId } = ctx;
329
352
  let outcome;
330
353
  // REF-CC-034:这次 park 的候选 callId(仅 fs 分支填,已决断身份匹配用)——ask 分支不填,
331
354
  // 因为 `surfaceGateAndDecide` 的 `!pending` 早退已经自带 `code:'no_pending'`(REF-CC-033),
@@ -333,13 +356,44 @@ export async function resolvePark(park, ctx) {
333
356
  // re-attach 重放的 `suspended` park 没有配套的新 tool_start/tool_end,那个栈在这次重放之前
334
357
  // 早被 tool_end 的 drop 清空了(见台账声明处注),取不到候选。
335
358
  let candidateGatedCallId;
336
- if (hops > MAX_GATE_HOPS) {
337
- outcome = { kind: 'failed', reason: `gate hop limit (${MAX_GATE_HOPS}) exceeded` };
359
+ const stalledRounds = ctx.stalledRounds ?? 0;
360
+ let closingCardTried = false;
361
+ // 呈现回执(对抗复审 r2 [medium]②③):每次 resolvePark 各自一枚旗,fs 腿经 deps.onPresented、ask 腿经
362
+ // publishQuestionFrame 前的同一枚旗置位;并发会话互不串扰,规则直决/取件失败恒 false。
363
+ let presentedThisRound = false;
364
+ const witness = () => {
365
+ presentedThisRound = true;
366
+ };
367
+ if ((ctx.unverifiableParks ?? 0) > MAX_TOTAL_PARKS) {
368
+ // 硬兜底:见 MAX_TOTAL_PARKS 头注(只数解析不出身份的 park)。不呈收场卡,终帧如实。
369
+ closingCardTried = true;
370
+ outcome = {
371
+ kind: 'failed',
372
+ reason: `${ctx.unverifiableParks ?? 0} parks in this single turn resolved to no identifiable approval (hard cap ${MAX_TOTAL_PARKS}; last: ${ctx.lastStallReason ?? 'the engine kept parking'})`,
373
+ };
374
+ }
375
+ else if (stalledRounds > MAX_GATE_HOPS) {
376
+ // 最后防线:连续 N 轮零进展。收场前**再呈一次收场卡**(现读队列的可决行,用户面能看见能答),
377
+ // 真呈出来且决了就按决断走;呈不出/仍失败才 failsoft,终帧带真因与出路(不再是预算话术)。
378
+ closingCardTried = true;
379
+ const stalledReason = `the run stayed parked across ${stalledRounds} consecutive re-attach rounds without any host progress ` +
380
+ `(last: ${ctx.lastStallReason ?? 'unknown'})`;
381
+ if (ctx.lastRoundPresentedCard === true) {
382
+ // 上一轮已经把卡呈给用户、用户也答了、引擎还是原样 park 回来 —— 再呈一次只是把同一张失效卡再问一遍。
383
+ outcome = { kind: 'failed', reason: stalledReason };
384
+ }
385
+ else {
386
+ const closing = await surfaceParkGate(park, ctx, park.gatedCallId, witness);
387
+ outcome =
388
+ closing.kind === 'decided' || closing.kind === 'aborted'
389
+ ? closing
390
+ : { kind: 'failed', reason: `${stalledReason}; closing re-read: ${closing.reason}` };
391
+ }
338
392
  }
339
393
  else {
340
394
  if (park.gate === 'fs')
341
395
  candidateGatedCallId = led.lastFsOrShellGatedCallId();
342
- outcome = await surfaceParkGate(park, ctx, park.gatedCallId);
396
+ outcome = await surfaceParkGate(park, ctx, park.gatedCallId, witness);
343
397
  }
344
398
  // #110 缺陷② b/c —— **重放的、早已决断过的 park 不是失败**。
345
399
  // durable re-attach 必然会把 park 帧再送一遍(`lastEventId` 再准也只能精确到帧,park 就在
@@ -371,7 +425,8 @@ export async function resolvePark(park, ctx) {
371
425
  const alreadyDecidedById = outcome.kind === 'failed' &&
372
426
  candidateGatedCallId !== undefined &&
373
427
  led.takeDecided(candidateGatedCallId);
374
- if (outcome.kind === 'failed' && (isAlreadyResolvedFailure(outcome) || alreadyDecidedById)) {
428
+ // L-80:触顶收场的合成失败**不进**「已解决」臂(它的文案判据会把 `(last: no pending )` 认成同族再续一轮)
429
+ if (!closingCardTried && outcome.kind === 'failed' && (isAlreadyResolvedFailure(outcome) || alreadyDecidedById)) {
375
430
  const firstReason = outcome.reason;
376
431
  // 键按语义分(见 `alreadyResolvedStreakKey` 头注);命中的那个文案词只进诊断串。
377
432
  const streakKey = alreadyResolvedStreakKey(!isAlreadyResolvedFailure(outcome) && alreadyDecidedById);
@@ -386,8 +441,8 @@ export async function resolvePark(park, ctx) {
386
441
  // 的那把闸。抹掉它就等于把 F10-j 钉住的那条绕道兜底重新打开 —— 身份指着一行反族行、队列里
387
442
  // 又没有别的行时,「任意行」兜底会把那行反族行捞回来呈卡 + decide(卡面全错、决断打在另一个
388
443
  // checkpoint 上)。重探要的是**读数新鲜**,不是**判据放宽**。
389
- const rescan = isFetchStepNoPending(outcome) && hops <= MAX_GATE_HOPS
390
- ? await surfaceParkGate(park, ctx, park.gatedCallId)
444
+ const rescan = isFetchStepNoPending(outcome) && stalledRounds <= MAX_GATE_HOPS // L-80:按连续非进展轮判,不按 park 总数
445
+ ? await surfaceParkGate(park, ctx, park.gatedCallId, witness)
391
446
  : undefined;
392
447
  // 🔴 只有**真进展**才采信重探的结果:决断成功 / 用户 Esc / 瞬断耗尽(那一形有自己的重呈臂)。
393
448
  // 重探自己失败(网络类、或队列确实没有可决行)⇒ **不采信**,走限次闸兜底 —— 采信的话,
@@ -397,7 +452,8 @@ export async function resolvePark(park, ctx) {
397
452
  (rescan.kind === 'decided' ||
398
453
  rescan.kind === 'aborted' ||
399
454
  (rescan.kind === 'failed' && rescan.retryExhausted === true))) {
400
- led.resetAlreadyResolvedGate();
455
+ // L-80:**不在这里**复位同因计数 —— 复位只在驱动侧观察到 host 进展帧时发生(#357 的闸曾被这一行
456
+ // 每轮绕空:坐标失配序下每轮都「找到新行→决成功→引擎原样重放」,计数永远回不到 2)。
401
457
  hostLog('debug', `liveHitlAskWire: gate reported already-resolved (${firstReason}) but a fresh approvals re-read for run ` +
402
458
  `${taskId} surfaced a decidable row under the current coordinates — re-presented it (rescan: ${rescan.kind}) ` +
403
459
  `instead of re-attaching on the stale park identity`);
@@ -407,10 +463,11 @@ export async function resolvePark(park, ctx) {
407
463
  const streak = led.noteAlreadyResolvedGate(streakKey);
408
464
  if (streak <= MAX_ALREADY_RESOLVED_REATTACHES) {
409
465
  const seq = led.lastSeq();
466
+ notifyParkReattach(ctx, streak, MAX_ALREADY_RESOLVED_REATTACHES, reasonToken ?? streakKey);
410
467
  hostLog('debug', `liveHitlAskWire: gate already resolved (${firstReason}) — replayed park, re-attaching runs.events(${taskId})${seq ? ` from seq ${seq}` : ''} instead of failing the turn` +
411
468
  (alreadyDecidedById ? ` [decidedGates id match: ${candidateGatedCallId}]` : '') +
412
469
  (led.decidedCount() > 0 ? ` [decided so far: ${led.decidedCount()}]` : ''));
413
- return { kind: 'reattach' };
470
+ return { kind: 'reattach', progress: false, reason: reasonToken ?? streakKey, presented: presentedThisRound, gatedCallId: outcome.gatedCallId };
414
471
  }
415
472
  // ── 兜底:同因连续第 2 次 ⇒ 不再空转,诚实收场 ──────────────────────────────────────
416
473
  // 件B(可见告知):真因走**终帧的 errorMessage**(下面 fail-soft 汇流合成的那一条),不是只落
@@ -437,42 +494,27 @@ export async function resolvePark(park, ctx) {
437
494
  // 用户**(重呈的呈现就是卡本身,归端零新 UI);hop 预算照吃(每轮都要人再答一次,不会空转)。
438
495
  // 引擎真死时失败也尽快显形:下一轮的 approvals.list / runs.events 对死引擎当场失败,walks 既有
439
496
  // 诚实红(reason 是 approvals.list failed,不带 retryExhausted ⇒ 不再进本臂)。
440
- if (outcome.kind === 'failed' && outcome.retryExhausted === true) {
441
- const seq = led.lastSeq();
442
- hostLog('debug', `liveHitlAskWire: decide transport retries exhausted (${outcome.reason}) — re-presenting the gate via ` +
443
- `re-attach runs.events(${taskId})${seq ? ` from seq ${seq}` : ''} instead of failing the turn (the run is still parked and the pending row is still decidable)`);
444
- return { kind: 'reattach' };
497
+ if (outcome.kind === 'failed' && outcome.retryExhausted === true && !closingCardTried) {
498
+ // L-80:该臂修前**无次数闸**(头注自认「approvals.list 网络失败这类真失败会连吃 24 个 hop」)——
499
+ // 现进同一本同因账:连续第 3 次即收场(下方 failsoft),终帧带真因。
500
+ const token = transportReasonToken(outcome.reason);
501
+ const streak = led.noteAlreadyResolvedGate(`transport:${token}`);
502
+ if (streak <= MAX_TRANSPORT_REATTACHES) {
503
+ const seq = led.lastSeq();
504
+ notifyParkReattach(ctx, streak, MAX_TRANSPORT_REATTACHES, `decide transport failed: ${token}`);
505
+ hostLog('debug', `liveHitlAskWire: decide transport retries exhausted (${outcome.reason}) — re-presenting the gate via ` +
506
+ `re-attach runs.events(${taskId})${seq ? ` from seq ${seq}` : ''} instead of failing the turn (the run is still parked and the pending row is still decidable) [${streak}/${MAX_TRANSPORT_REATTACHES}]`);
507
+ return { kind: 'reattach', progress: false, reason: `decide transport failed: ${token}`, presented: presentedThisRound, gatedCallId: outcome.gatedCallId };
508
+ }
509
+ outcome = {
510
+ kind: 'failed',
511
+ ...(outcome.gatedCallId !== undefined ? { gatedCallId: outcome.gatedCallId } : {}),
512
+ reason: `the decide call failed on transport ${streak} times in a row while the run stayed parked (${outcome.reason})`,
513
+ };
445
514
  }
446
515
  if (outcome.kind !== 'decided') {
447
516
  hostLog('debug', `liveHitlAskWire: gate not decided (${outcome.kind}${'reason' in outcome ? `: ${outcome.reason}` : ''}) — fail-soft to suspended terminal`);
448
- // 回退:毒化帧照旧渲染(= 修复前的诚实红)
449
- // 件 B(异源复审 finding 采纳):**五个排水出口一律走同一个中断感知出口** —— 本出口今天恒是
450
- // park 批(走到这里的前提就是有一张 park),零-park 硬门必挡,所以是**语义等价的 no-op**;
451
- // 写成统一形是为了「新开一个出口就绕过改写」这条病形从此在结构上不成立(常驻门:hitl F13-h
452
- // 钉住 src 下 `flushHeld()` 的调用点恰好一处)。
453
- const events = [
454
- ...flushHeldWithInterruptRewrite(led, {
455
- terminal: park.pendingDone,
456
- signal: ctx.signal,
457
- // 模 B:`aborted` = 用户在门卡上按了 Esc(该 outcome 在包内的语义就是逐字这一条)。
458
- gateAbortedByUser: outcome.kind === 'aborted',
459
- }),
460
- ];
461
- if (park.pendingDone) {
462
- events.push(park.pendingDone);
463
- }
464
- else if (outcome.kind === 'failed') {
465
- // 记案(#87 评审② minor,不修):durable re-attach 的第二问(park.pendingDone 无)走 `aborted`
466
- // 时(仅用户主动 Esc/Ctrl+C 中断触发)不吐终帧——下游正在拆流,合成终帧也没人渲;flushHeld
467
- // 已把毒化帧诚实吐出。真正的 failed 才合成下面的可见终帧。
468
- // durable-leg park 无 done 可回吐 —— 合成 failed 让用户看得见为什么停了
469
- events.push({
470
- type: 'failed',
471
- errorCode: 'hitl_unanswered',
472
- errorMessage: `${park.gate === 'fs' ? 'Tool approval' : 'AskUserQuestion'} gate could not be answered: ${outcome.reason}`,
473
- });
474
- }
475
- return { kind: 'failsoft', events };
517
+ return { kind: 'failsoft', events: failsoftEvents(park, ctx, outcome.kind === 'aborted', outcome.kind === 'failed' ? outcome.reason : undefined) };
476
518
  }
477
519
  // decide 成功:丢弃该 call 的毒化 HOLD(续流重放会带 isError:false 的解答帧收口卡片),
478
520
  // 并记下真实答案供该解答帧 stamp `structured`(否则卡片渲成结果不可用)。
@@ -486,7 +528,8 @@ export async function resolvePark(park, ctx) {
486
528
  // 行给没给出 callId 无关(park 有主角,decide 也成功了)。
487
529
  // #357:决断成功 = 判据链上的**真进展**,「已解决」的同因连续计数归零(否则一个 turn 里两只门
488
530
  // 各带一次正当重放,第二只的重放会被第一只的计数顶成「第 2 次」而被闸掉)。
489
- led.resetAlreadyResolvedGate();
531
+ // L-80:决断成功**不在这里**复位同因计数 —— 「决了」不等于「引擎动了」(reopen 类 re-park / 坐标
532
+ // 失配都是决了又原样 park 回来);复位归驱动侧的进展观察点。
490
533
  led.dropHeldForDecidedPark(outcome.gatedCallId, park.gatedCallId);
491
534
  if (outcome.gatedCallId) {
492
535
  // 🔴 #324:**刻意不把 pending 行的 callId 当成连坐判别的主角身份**。durable `checkpointGate`
@@ -507,5 +550,79 @@ export async function resolvePark(park, ctx) {
507
550
  }
508
551
  const seq = led.lastSeq();
509
552
  hostLog('debug', `liveHitlAskWire: gate decided (call ${outcome.gatedCallId ?? '?'}) — attaching runs.events(${taskId})${seq ? ` from seq ${seq}` : ''}`);
510
- return { kind: 'reattach' };
553
+ return { kind: 'reattach', progress: true, presented: presentedThisRound, gatedCallId: outcome.gatedCallId };
554
+ }
555
+ /**
556
+ * fail-soft 收场的事件序列:扣留帧一律走中断感知出口(件 B:五个排水出口同一形,零-park 硬门必挡,语义等价 no-op;
557
+ * 常驻门 hitl F13-h 钉 src 下 `flushHeld()` 调用点恰一处)+ sync 终帧原样回吐 / durable 腿合成 `hitl_unanswered` 真因终帧
558
+ * (#87 评审② minor 记案:durable 第二问走 `aborted` 时不吐终帧 —— 下游正在拆流,合成了也没人渲)。
559
+ */
560
+ function failsoftEvents(park, ctx, gateAbortedByUser, failedReason, terminal) {
561
+ const events = [
562
+ ...flushHeldWithInterruptRewrite(ctx.led, {
563
+ terminal: park.pendingDone,
564
+ signal: ctx.signal,
565
+ gateAbortedByUser,
566
+ }),
567
+ ];
568
+ if (park.pendingDone) {
569
+ events.push(park.pendingDone);
570
+ }
571
+ else if (terminal !== undefined) {
572
+ events.push({ type: 'failed', ...terminal });
573
+ }
574
+ else if (failedReason !== undefined) {
575
+ events.push({
576
+ type: 'failed',
577
+ errorCode: 'hitl_unanswered',
578
+ // 真因 + 出路(L-80:修前的终帧只有一句预算话术,用户拿不到「run 还 parked、卡还能决」这件事)。
579
+ errorMessage: `${park.gate === 'fs' ? 'Tool approval' : 'AskUserQuestion'} gate could not be answered: ${failedReason}` +
580
+ ' — the run is still parked on this approval: decide it on the card when it is shown again, or cancel the run.',
581
+ });
582
+ }
583
+ return events;
584
+ }
585
+ /**
586
+ * 驱动侧在**预算提交之后**判定触顶时的收场(对抗复审 r4 [high]①:有推进帧的轮次触顶必须等权威身份解析完 ——
587
+ * 先呈 B、决 B,提交后仍是同一只 call 才收场;这一轮已经呈过卡,不再呈收场卡)。
588
+ */
589
+ export function stalledTerminal(park, ctx, stalledRounds, lastStallReason,
590
+ /** 这一轮的决断有没有落地(r5 [medium]③):落地了就**不许**说「could not be answered」「still parked」—— 那是谎报。 */
591
+ decidedThisRound,
592
+ /** 这一轮决断落在哪只 call(解析到的行身份):收场排水前先把**它**的扣留帧摘掉(r6 [medium]:两只同族 call 都在
593
+ * 扣留、park 帧无可信身份时,决断臂刻意保守不摘,排水会把刚批准的那只当 abort 吐出)。 */
594
+ decidedGatedCallId) {
595
+ const gate = park.gate === 'fs' ? 'Tool approval' : 'AskUserQuestion';
596
+ if (decidedThisRound) {
597
+ // r7 [high]:第二参必须是 **park 帧上的**可信身份(GateLedger 契约),不能拿候选身份自证 —— 无可信身份且同族
598
+ // 不唯一时保守不摘、由排水按中断语义吐出(一帧不丢),与决断臂同一条纪律。
599
+ if (decidedGatedCallId !== undefined)
600
+ ctx.led.dropHeldForDecidedPark(decidedGatedCallId, park.gatedCallId);
601
+ const errorMessage = `${gate} was decided, but the engine parked the same call again after each of the last ${stalledRounds} decisions, so sema ` +
602
+ `stopped re-attaching. Your last decision was accepted; sema did not observe the run move on after it — check the ` +
603
+ `transcript, decide the call again if the card comes back, or cancel the run.`;
604
+ hostLog('debug', `liveHitlAskWire: ${errorMessage}`);
605
+ return { kind: 'failsoft', events: failsoftEvents(park, ctx, false, undefined, { errorCode: 'hitl_stalled', errorMessage }) };
606
+ }
607
+ const reason = `the run stayed parked across ${stalledRounds} consecutive re-attach rounds without any host progress ` +
608
+ `(last: ${lastStallReason ?? 'the engine kept parking'})`;
609
+ hostLog('debug', `liveHitlAskWire: ${reason} — fail-soft after the round committed`);
610
+ return { kind: 'failsoft', events: failsoftEvents(park, ctx, false, reason) };
611
+ }
612
+ /** 非进展 reattach 的用户面告知(deps 口缺席 = 只留 debug;口抛错不许炸续流)。 */
613
+ function notifyParkReattach(ctx, attempt, max, reason) {
614
+ const cb = ctx.deps.onParkReattach;
615
+ if (typeof cb !== 'function')
616
+ return;
617
+ try {
618
+ cb({ attempt, max, reason });
619
+ }
620
+ catch (e) {
621
+ hostLog('debug', `liveHitlAskWire: onParkReattach threw (${String(e)}) — ignored`);
622
+ }
623
+ }
624
+ /** 传输失败的同因键:只留错误类词(同一类连断才算同因;数字/id 每次都变,不能进键)。 */
625
+ function transportReasonToken(reason) {
626
+ const m = /(ECONNREFUSED|ECONNRESET|ETIMEDOUT|EPIPE|EAI_AGAIN|TimeoutError|AbortError|fetch failed|network)/i.exec(reason);
627
+ return (m?.[1] ?? reason.split(/\s+/).slice(0, 4).join(' ')).toLowerCase();
511
628
  }
@@ -132,6 +132,9 @@ export type FsApprovalOutcome = {
132
132
  /** 本桥消费的 wire 面(liveHitlAskWire 的 AskGateWireDeps 同形切片,mock 可注入)。 */
133
133
  export interface FsApprovalWireDeps {
134
134
  client: HitlClientLike;
135
+ /** L-80(对抗复审 r2 [medium]):**这一次调用**真把卡交给卡口时回调一次 —— 每次调用各自的回执,
136
+ * 不是进程级计数(那会把并发会话 A 的呈卡算到 B 头上)。缺席 = 不关心。 */
137
+ onPresented?: () => void;
135
138
  }
136
139
  /** 结构等值(键序无关深比较)——updatedInput「真编辑过」判定用。zod parse 会产新引用与重排,
137
140
  * 引用比较/JSON.stringify 串比较都会假报「编辑过」。 */
@@ -289,6 +289,7 @@ parkGatedCallId) {
289
289
  // (run-durable-card-display-keys-test.mjs 的 NOT_PROJECTED 账),上游补位后按 probeCause 的
290
290
  // 双源合流形跟批。
291
291
  const durableProbeCause = pending.riskDescriptor?.probeCause;
292
+ deps.onPresented?.(); // L-80:真要交给卡口了才算呈过(规则直决 / 取件失败都不走到这一行)
292
293
  const card = await surfaceApprovalCard({
293
294
  toolName,
294
295
  args,
@@ -45,7 +45,24 @@
45
45
  * - workspace trust: interactive sessions with the trust dialog unaccepted ship NO hooks
46
46
  * (shouldSkipHookDueToTrust — the rc.36 "hooks 全被 trust 门禁" invariant, engine leg included);
47
47
  * - `disableAllHooks` (managed/policy settings): ships NO hooks;
48
- * - `allowManagedHooksOnly`: ONLY the policy-settings hooks ship; user/project/local are blocked.
48
+ * - `allowManagedHooksOnly`: ONLY the policy-settings hooks ship; user/project/local are blocked;
49
+ * - `strictPluginOnlyCustomization` covering the "hooks" surface (L-67④, 0.52.0): same disposition as
50
+ * `allowManagedHooksOnly` — ONLY the policy-settings hooks ship. Verbatim parity with the local
51
+ * executor's `isRestrictedToPluginOnly('hooks')` is argued clause-by-clause on
52
+ * {@link hooksLockedToPluginOnly}.
53
+ *
54
+ * 🔴 **在册缺口(同形族扫所得,本批刻意未修 —— 别把它读成「已经守住了」)**:cli 本地执行器
55
+ * (`src/utils/hooks/hooksConfigSnapshot.ts:getHooksFromAllowedSources`)还有**第四条**腿 ——
56
+ * `disableAllHooks` 出现在**非** managed 的来源(user/project/local)时,按 CC 语义降级成
57
+ * 「只跑 managed hooks」(非 managed 设置不能禁掉 managed hooks,但能禁掉自己)。本文件今天
58
+ * **只读 `policySettings.disableAllHooks`** ⇒ 这一形在引擎腿上不成立(用户把自己的 hooks 关了,
59
+ * 引擎照投照跑)。**为什么本批不修**:cli 那条腿读的是**合并后**的标量
60
+ * (`getSettings_DEPRECATED().disableAllHooks`,四源按 policy→user→project→local 后写覆盖前写),
61
+ * 而本包的 `SettingsPort` 只有 per-source 读口 —— 拿「任一来源为 true」去近似合并结果会在
62
+ * 「user 写 true、local 写 false」这一形上判反(cli 那边是**不**限制)。忠实复刻需要给
63
+ * `SettingsPort` 加一个合并读口 = **公面改动**,属另一批(端要跟车实现)。
64
+ * ⇒ 登记在此,不做单边近似([honest-absence-not-fabricated-zero]:守不住就说守不住,
65
+ * 别用一个会判反的近似冒充守住了)。
49
66
  *
50
67
  * Projection semantics (CC settings merge, verbatim shapes):
51
68
  * - Sources: policy → user → project → local settings files, per-event arrays CONCATENATED in that
@@ -72,6 +89,41 @@ import { MAX_HOOK_NOTICE_TEXT_CHARS } from './notifications.js';
72
89
  import { goalStopHookMatcher, __resetGoalStopHookForTests } from './goalStopHook.js';
73
90
  export { GOAL_STOP_HOOK_WIRE_ENV, CC_STOP_SEMANTICS_MIN_SERVER, ccStopSemanticsFromVersion, engineCcStopSemantics, isGoalStopHookWireArmed, setWireSessionStopHook, getWireSessionStopHook, getWireSessionStopHookCcSemantics, buildGoalStopHookPrompt, } from './goalStopHook.js';
74
91
  const EDITABLE_HOOK_SOURCES = ['userSettings', 'projectSettings', 'localSettings'];
92
+ /**
93
+ * managed `strictPluginOnlyCustomization` 对 **hooks 面**的锁判定(L-67④,0.52.0)。
94
+ *
95
+ * 🔴 病(修前):`hooksForWire()` 过了 `disableAllHooks` / `allowManagedHooksOnly` 两道 managed 治理门,
96
+ * **没过**这一道 —— 而本文件头注自己写着「the wire must honor the SAME gates the local executor
97
+ * honors, or a fleet-managed policy is silently bypassed by the engine leg」。后果:管理侧把 hooks
98
+ * 面锁成 plugin-only 之后,壳的**本地执行器**不再跑 user/project/local 的 hooks,而**引擎腿**照投
99
+ * 照跑 —— 禁令只在一半的执行面上成立,而这一半恰好是工具真正执行的那一半。
100
+ *
101
+ * 🔴 与 cli 本地执行器 `src/utils/settings/pluginOnlyPolicy.ts:isRestrictedToPluginOnly('hooks')`
102
+ * **逐条对照,差异为零**:
103
+ * · `=== true`(布尔真)⇒ 锁(cli 原文 `if (policy === true) return true`:`true` 锁**全部四个面**,
104
+ * hooks 在内)。⚠️ 本条是必须实现的一形 —— 只认数组形会把「一个字就锁全部」的管理写法整个放过,
105
+ * 而那正是本件要堵的洞;
106
+ * · 数组且含 `'hooks'` ⇒ 锁;数组不含 ⇒ **不锁**(锁的是别的面:skills/agents/mcp,与 hooks 无关);
107
+ * · 其余一切值形(缺席 / `false` / 串 / 对象 / 数字)⇒ **不锁**(当未设),**不** fail-closed 整条腿。
108
+ *
109
+ * 🔴 「非数组非 true ⇒ 当未设」为什么与 cli 差异为零(而不是本包自己另立一条宽口):cli 侧读到这个字段
110
+ * 之前先过 `SettingsSchema`,该字段的 `.preprocess(...).catch(undefined)`(cli `settings/types.ts`
111
+ * 逐字注释:「Non-array invalid values ("skills" string, {object}) … .catch drops the field to
112
+ * undefined instead. Degrades to unlocked-for-this-field, never to everything-broken.」)已经把脏值形
113
+ * 丢成 `undefined` ⇒ 到达 `isRestrictedToPluginOnly` 的值形只有 boolean / 字符串数组两种,本函数对这
114
+ * 两种与 cli **逐条同判**;而对**没过 schema** 的脏值形(别的宿主可能把盘上原文直接交上来),本函数
115
+ * 的处置方向与 cli 那条 schema 腿**同向**(degrade to unlocked-for-this-field)。
116
+ * 🔴 与相邻两道门的 fail-closed 纪律**不矛盾**:那两条 fail-closed 守的是「读取**抛出**」(治理策略
117
+ * 读不出来 = 当作有限制);本条守的是「读到了、但值是个坏形」—— 值在手里,方向由字段属主(schema)
118
+ * 定,不是未知态。两件事别混。
119
+ */
120
+ function hooksLockedToPluginOnly(policyValue) {
121
+ if (policyValue === true)
122
+ return true;
123
+ if (Array.isArray(policyValue))
124
+ return policyValue.includes('hooks');
125
+ return false;
126
+ }
75
127
  /**
76
128
  * REF-CC-155(midband-03,fix-outright):此前这里是静默 `catch { return null }` —— 零 hostLog、零
77
129
  * 注释说明「为什么这次失败可以死」。settings 读取失败(格式损坏 / 端内部错误)会被悄悄当成
@@ -136,7 +188,19 @@ export function hooksForWire() {
136
188
  hostLog('debug', 'hooksWireCaps: disableAllHooks (managed) — no hooks projected to the engine');
137
189
  return undefined;
138
190
  }
139
- const managedOnly = policy?.allowManagedHooksOnly === true;
191
+ // L-67④(0.52.0):第三道 managed 治理门 —— `strictPluginOnlyCustomization` 含 hooks 面。
192
+ // 🔴 **顺序与 `allowManagedHooksOnly` 无关**(两者同时在场时无论谁先判,结论都是同一个
193
+ // `['policySettings']`,处置逐字相同 —— 它们是**同一个动作**的两个理由,不是两级策略);
194
+ // 真正有顺序的只有 `disableAllHooks`:它在上面**先**判且**恒赢**(连 policy 自己的都不投),
195
+ // 本门只在它没命中时才有机会求值。
196
+ const pluginOnlyHooks = hooksLockedToPluginOnly(policy?.strictPluginOnlyCustomization);
197
+ if (pluginOnlyHooks) {
198
+ hostLog('debug', 'hooksWireCaps: strictPluginOnlyCustomization locks the hooks surface (managed) — only policySettings hooks projected to the engine');
199
+ }
200
+ // 🔴 `managedOnly` 这个名字下面还被 `/goal` overlay 的分道复用(用户态 goal 钩子必须与
201
+ // EDITABLE_HOOK_SOURCES 同进退)——所以本门**合流进同一个量**,而不是只改 `sources`:
202
+ // 只改 sources 会让 plugin-only 锁下 user 态的 goal 钩子照投,那是同一个洞换了个入口。
203
+ const managedOnly = policy?.allowManagedHooksOnly === true || pluginOnlyHooks;
140
204
  const sources = managedOnly
141
205
  ? ['policySettings']
142
206
  : ['policySettings', ...EDITABLE_HOOK_SOURCES];