@sema-agent/client-core 0.43.1 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +528 -0
  2. package/README.md +1 -1
  3. package/dist/adapt/arms.js +33 -0
  4. package/dist/adapt.d.ts +1 -1
  5. package/dist/adapt.js +2 -0
  6. package/dist/adapter/downstream/eventToSdkMessage.js +82 -0
  7. package/dist/engineCapsCache.d.ts +14 -0
  8. package/dist/engineCapsCache.js +21 -0
  9. package/dist/engineSessionParam.d.ts +27 -1
  10. package/dist/engineSessionParam.js +37 -6
  11. package/dist/fleet/fleetLedger.d.ts +174 -18
  12. package/dist/fleet/fleetLedger.js +321 -26
  13. package/dist/hitl/planReviewWire.d.ts +1 -1
  14. package/dist/hitl/planReviewWire.js +44 -1
  15. package/dist/hitl/toolApprovalWire.d.ts +58 -11
  16. package/dist/hitl/toolApprovalWire.js +138 -6
  17. package/dist/hooksWireCaps.d.ts +37 -0
  18. package/dist/hooksWireCaps.js +49 -0
  19. package/dist/notifications.d.ts +62 -1
  20. package/dist/notifications.js +68 -0
  21. package/dist/seam.d.ts +41 -1
  22. package/dist/seam.js +4 -0
  23. package/dist/subagent/engineCompactWire.d.ts +32 -5
  24. package/dist/subagent/engineCompactWire.js +129 -48
  25. package/dist/subagent/engineDelegatedPrompt.js +9 -4
  26. package/dist/subagent/engineRowStopGate.d.ts +10 -3
  27. package/dist/subagent/engineRowStopGate.js +15 -6
  28. package/dist/subagent/engineSubagentOutput.js +12 -6
  29. package/dist/subagent/engineSubagentResume.d.ts +53 -3
  30. package/dist/subagent/engineSubagentResume.js +57 -14
  31. package/dist/subagent/engineSubagentSteer.js +20 -11
  32. package/dist/subagent/engineSubagentTail.js +18 -6
  33. package/dist/subagent/engineTaskHandleWire.js +33 -10
  34. package/dist/subagentContentStore.d.ts +57 -4
  35. package/dist/subagentContentStore.js +152 -13
  36. package/docs/INTEGRATION-CLIENTS.md +156 -25
  37. package/package.json +1 -1
@@ -295,6 +295,12 @@ parkGatedCallId) {
295
295
  ...(pending.governanceForced === true ? { governanceForced: true } : {}),
296
296
  ...(ruleOffersReadOnly !== undefined ? { ruleOffersReadOnly } : {}),
297
297
  ...(isWireRecordCarrier(durableProbeCause) ? { probeCause: durableProbeCause } : {}),
298
+ // #348(0.44.0)durable 腿的对偶:行上**本来就存着**这个值(server `parked-decide.ts`;SDK
299
+ // `PendingCheckpoint.toolCallId?: string | null` 直证)。与活卡帧腿同一个卡位、同一条缺席纪律 ——
300
+ // `null`(SDK 声明的第二种缺席形)与空串一并降键缺席,绝不把 `null` 折成串。
301
+ ...(typeof pending.toolCallId === 'string' && pending.toolCallId !== ''
302
+ ? { toolCallId: pending.toolCallId }
303
+ : {}),
298
304
  });
299
305
  switch (card.kind) {
300
306
  case 'failed':
@@ -540,6 +546,11 @@ export function readToolApprovalRespondAck(v) {
540
546
  // 🔴 **整只判形**(不逐条丢):合取批的回执是一份清单,漏掉一条就是对人少报了一次授权 ——
541
547
  // 非数组 / 空 / 含非串或空串成员 ⇒ 整只降缺席(诚实「不知道」优先于半份清单)。
542
548
  ...(isNonEmptyStringArray(o.persistedRules) ? { persistedRules: o.persistedRules } : {}),
549
+ // P-39 归属锚(server ≥7.48.0):同样**整只判形**,理由见该位注。
550
+ ...(() => {
551
+ const anchors = readPersistedRuleAnchors(o.persistedRuleAnchors);
552
+ return anchors !== undefined ? { persistedRuleAnchors: anchors } : {};
553
+ })(),
543
554
  };
544
555
  // 🔴 **两位规范文本回显的「同份 ack 内矛盾形」**(异源对抗复审 [high] 采纳,0.43.0)——
545
556
  // 这与本函数不做的**相关性**判定(「这个 ack 是不是这一次审批的回执」,归编排层)是两件事:
@@ -551,13 +562,35 @@ export function readToolApprovalRespondAck(v) {
551
562
  // 而 `rulePersisted` 那一位同时在说没存上。
552
563
  // 两形都**只丢这两位**(不牵连整份 ack):`rememberApplied`/`updatedInputForwarded` 是更重的
553
564
  // 安全告知位,不该被一条规则回显的坏形连坐掉。
554
- if (out.persistedRule !== undefined && out.persistedRules !== undefined) {
565
+ // 🔴 归属锚随它所归属的那份清单一起判:单数回显是**编辑臂**的产物,批臂的归属锚与它同场 =
566
+ // 这份回执在说两次不同的授权(与下面①同一条理由,只是多一位要一起丢)。
567
+ if (out.persistedRule !== undefined && (out.persistedRules !== undefined || out.persistedRuleAnchors !== undefined)) {
555
568
  delete out.persistedRule;
556
569
  delete out.persistedRules;
570
+ delete out.persistedRuleAnchors;
557
571
  }
558
- else if ((out.persistedRule !== undefined || out.persistedRules !== undefined) && out.rulePersisted !== true) {
572
+ else if ((out.persistedRule !== undefined || out.persistedRules !== undefined || out.persistedRuleAnchors !== undefined) &&
573
+ out.rulePersisted !== true) {
559
574
  delete out.persistedRule;
560
575
  delete out.persistedRules;
576
+ delete out.persistedRuleAnchors;
577
+ }
578
+ // 🔴 **跨位自洽**(P-39,0.44.0):锚是 `persistedRules` 的逐成员分解 —— server 两处铸点都在同一个
579
+ // `persisted` 结果上取 `rules` 与 `members`,所以条数恒等。不等 = 这份回执对「到底存了什么」
580
+ // 自相矛盾。**只丢锚、保留 `persistedRules`**:additive 位到货绝不回头削弱既有位的现行为
581
+ // (老 server 上本来就没有锚,那条路仍按基数门走)。
582
+ // 🔴 **逐位置同文本**(异源对抗复审二轮 [high] 采纳):server 侧两位是**同一个数组**的两种投影 ——
583
+ // dist `rules-consent.js` 逐字 `{ rules: anchored.map(a => a.rule), members: anchored }`,且两者
584
+ // 同按 `memberIndex` 排序 ⇒ `persistedRules[i] === anchors[i].rule` 是**铸造期保证**,不等只可能
585
+ // 来自被改过/串了台的回执。
586
+ // ⚠️ 与本文件「**不复判文本**」那条纪律不冲突:那条禁的是拿回执文本去比 **offer 上的**文本
587
+ // (落盘的是规范化后的字节,与 offer 按设计可以不同 —— 比它就是装第二个判官);这里比的是
588
+ // **同一份回执内部**两个位互相说的话,属「这个对象自己说不通」的形的范畴。
589
+ if (out.persistedRuleAnchors !== undefined &&
590
+ (out.persistedRules === undefined ||
591
+ out.persistedRules.length !== out.persistedRuleAnchors.length ||
592
+ out.persistedRuleAnchors.some((a, i) => a.rule !== out.persistedRules?.[i]))) {
593
+ delete out.persistedRuleAnchors;
561
594
  }
562
595
  return out;
563
596
  }
@@ -565,6 +598,61 @@ export function readToolApprovalRespondAck(v) {
565
598
  function isNonEmptyStringArray(v) {
566
599
  return Array.isArray(v) && v.length > 0 && v.every(x => typeof x === 'string' && x !== '');
567
600
  }
601
+ /**
602
+ * {@link ToolApprovalRespondAckView.persistedRuleAnchors} 的**整只**判形(P-39,server ≥7.48.0)。
603
+ *
604
+ * 逐条要求三键齐、`offerIndex`/`memberIndex` 是**非负安全整数**、`rule` 非空串;再加两条**同只回执内
605
+ * 自洽**:
606
+ * · `offerIndex` **整表同值** —— server 的 map 闭包捕获的是同一个 `persistRule.batchOfferIndex`
607
+ * (dist 直证),混合值的表不可能由合规 server 铸出;
608
+ * · `memberIndex` **不重复** —— 两条锚认领同一个成员,那份归属表就不是它自称的逐成员分解。
609
+ * 任一条不满足 ⇒ **整只降缺席**(不逐条丢):半份归属表会让相关性门拿着残表放行,比没有锚更坏。
610
+ *
611
+ * 🔴 下标**绝不折算**(不 `|0`、不 `Math.trunc`):一个被折过的下标指向的是**另一条** offer /
612
+ * 另一个成员,那正是本位要证伪的东西。
613
+ */
614
+ function readPersistedRuleAnchors(v) {
615
+ if (!Array.isArray(v) || v.length === 0)
616
+ return undefined;
617
+ const out = [];
618
+ const seenMembers = new Set();
619
+ let firstOfferIndex;
620
+ for (const raw of v) {
621
+ if (raw === null || typeof raw !== 'object')
622
+ return undefined;
623
+ const o = raw;
624
+ const { offerIndex, memberIndex, rule } = o;
625
+ if (!isNonNegativeSafeInt(offerIndex) || !isNonNegativeSafeInt(memberIndex))
626
+ return undefined;
627
+ if (typeof rule !== 'string' || rule === '')
628
+ return undefined;
629
+ if (firstOfferIndex === undefined)
630
+ firstOfferIndex = offerIndex;
631
+ else if (offerIndex !== firstOfferIndex)
632
+ return undefined;
633
+ if (seenMembers.has(memberIndex))
634
+ return undefined;
635
+ seenMembers.add(memberIndex);
636
+ out.push({ offerIndex, memberIndex, rule });
637
+ }
638
+ // 🔴 **座位必须恰好铺满 `0..n-1`**(异源对抗复审二轮 [high] 采纳)。这不是本包发明的严格,是
639
+ // **server 自己的不变量**(dist `rules-consent.js` 的 `anchorBatchMembers`,逐字):
640
+ // · `anchored.length !== displayed.rules.length || distinctSeats !== anchored.length`
641
+ // ⇒ **整个不发 members**(锚要么是与人看见的那只 offer **一一对应的满覆盖**,要么缺席);
642
+ // · 发之前还 `anchored.sort((a,b) => a.memberIndex - b.memberIndex)`。
643
+ // ⇒ 合规锚表的 memberIndex 集合恒 = `{0..n-1}`。越界座位是 server 铸不出的形。
644
+ // ⚠️ 只校**集合**不校**顺序**:排序是 server 今天的实现细节,而集合是它成文的不变量
645
+ // ([verdict-must-accept-stronger-form]:别把一个合法的更强形判成回归)。
646
+ // ⚠️ 「7.48 引擎 + 锚缺席」是**合法形**(上面两条 warn 分支就是它)⇒ 缺席绝不可读作可疑。
647
+ for (const seat of seenMembers)
648
+ if (seat >= out.length)
649
+ return undefined;
650
+ return out;
651
+ }
652
+ /** 非负安全整数(下标位共用;`Number.isSafeInteger` 已排除 NaN/Infinity/小数)。 */
653
+ function isNonNegativeSafeInt(v) {
654
+ return typeof v === 'number' && Number.isSafeInteger(v) && v >= 0;
655
+ }
568
656
  /**
569
657
  * 从 respond 抛出来的东西上读 {@link ToolApprovalRespondRefusal}。**永不抛、永不造**。
570
658
  *
@@ -900,6 +988,12 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
900
988
  args: args,
901
989
  // A-028.3:live 帧腿的卡身份键经 gateIdentity 唯一铸口(帧上无 gatedCallId,以 approvalId 铸)。
902
990
  callKey: liveFrameCallKey(frame.approvalId),
991
+ // #348(0.44.0):帧上的 core tool-call id 透传到卡口 —— 卡与 transcript 里 `tool_use` 块之间唯一的
992
+ // wire 事实锚。**非空串才 stamp**(空串是坏值不是「没有调用」,同文件既有窄读同族纪律);
993
+ // 缺席 ⇒ 键缺席,宿主按缺席降级(绝不拿 approvalId 冒充,那正是 #348 的病形)。
994
+ ...(typeof frame.toolCallId === 'string' && frame.toolCallId !== ''
995
+ ? { toolCallId: frame.toolCallId }
996
+ : {}),
903
997
  ...(signal ? { signal } : {}),
904
998
  ...(isFromSubagent(frame) ? { workerBadge: subagentBadgeFor(frame) } : {}),
905
999
  ...(wireNote !== undefined ? { wireNote } : {}),
@@ -1123,7 +1217,8 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
1123
1217
  // 🔴 **只丢这两位、不牵连整份 ack**(与上面那道 id/decision 相关性门刻意不同姿势):那道门管的是
1124
1218
  // 「整份回执根本不是我的」,这道门管的是「回执是我的,但其中一格与我发出的臂矛盾」——
1125
1219
  // 整份丢会把 `rememberApplied`/`updatedInputForwarded` 这两个更重的安全告知位一起连坐掉。
1126
- if (ack !== undefined && (ack.persistedRule !== undefined || ack.persistedRules !== undefined)) {
1220
+ if (ack !== undefined &&
1221
+ (ack.persistedRule !== undefined || ack.persistedRules !== undefined || ack.persistedRuleAnchors !== undefined)) {
1127
1222
  // 🔴 单数回显**只属编辑臂**(异源对抗复审二轮 [medium] 采纳,server `editedArmEcho` 逐字:
1128
1223
  // `persistRule.kind !== 'text' || !persistRule.edited || …` ⇒ 返 `{}`)——候选臂成功时
1129
1224
  // server **不铸**这一位,所以候选臂上收到它同样是「一次没发生过的授权」。
@@ -1135,27 +1230,64 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
1135
1230
  // ⚠️ 只做**基数**相关性,**不复判文本**:规范化是引擎的活(`Bash(adb *)` → `Bash(adb:*)`),
1136
1231
  // 在这里比对文本就是装第二个判官。
1137
1232
  const expectedBatchCount = batchArmTarget?.rules.length;
1233
+ // 🔴 **P-39 归属锚门**(server ≥7.48.0,0.44.0):锚在场时,相关性从「基数」升级到「身份」——
1234
+ // `offerIndex` 必须等于**这一次真发出去**的 `persistRuleBatchOfferIndex`,`memberIndex` 必须
1235
+ // 落在所选 offer 的成员表范围内。这正是 P-39 登记里基数门**抓不到**的那一形:
1236
+ // 「条数对,但这份回执说的是另一只 offer 的规则」。
1237
+ // ⚠️ 锚**缺席**(≤7.47 引擎)⇒ 本门整条让位,回落既有基数门 —— additive 位不许把老引擎判红。
1238
+ // 🔴 **absent / valid / invalid 必须是三态,不是两态**(异源对抗复审 [high] 采纳,本批):
1239
+ // 读器把「在场但坏形」也降成 `undefined`,若这里只看 `ack.persistedRuleAnchors === undefined`
1240
+ // 就把它与「老引擎真没这个键」混成一格 ⇒ 让位基数门。于是**「另一只 offer + 规则条数恰好相同
1241
+ // + 故意发一份坏锚」这条回执可以精确绕过本门刚加的身份校验** —— 攻击面正是本门要封的那一形。
1242
+ // ⇒ 判据取**wire 上键在不在**(raw),不取窄化产物在不在:
1243
+ // · 键真缺席 ⇒ 让位基数门(老引擎兼容);
1244
+ // · 键在场 ∧ 合形 ⇒ 走身份门;
1245
+ // · 键在场 ∧ 坏形 ⇒ **不许让位**,连同 `persistedRules` 一起丢(一份自称带归属却给不出
1246
+ // 合法归属的回执,不配驱动任何「已存了什么」的强断言)。
1247
+ // (结构视图读:SDK 的 respond 返回型上没有这个 additive 键,与同文件 `pending.ruleOffers`
1248
+ // / `riskDescriptor.probeCause` 同款姿势 —— 不引入新的宽 cast。)
1249
+ // 🔴 判据是**键在不在**(`hasOwnProperty`),**不是**「值是不是 `undefined`」(异源对抗复审
1250
+ // 二轮 [high] 采纳):注入面是**宿主给的 JS 函数**,不是 `JSON.parse` 的产物 —— 一个自铸
1251
+ // `{persistedRuleAnchors: undefined}` 的回执在 JS 里**键是在场的**,用值判会把它读成
1252
+ // 「老引擎真没这个键」而让位基数门,恰好绕过身份门。同仓 `engineCapState` 用 `in` 而不用
1253
+ // `!== undefined` 的成文理由与此同源(那处头注:「本表也接宿主注入的对象」)。
1254
+ const anchorKeyOnWire = typeof raw === 'object' &&
1255
+ raw !== null &&
1256
+ Object.prototype.hasOwnProperty.call(raw, 'persistedRuleAnchors');
1257
+ const anchors = ack.persistedRuleAnchors;
1258
+ const anchorsOk = anchors !== undefined
1259
+ ? persistRuleBatchOfferIndex !== undefined &&
1260
+ expectedBatchCount !== undefined &&
1261
+ anchors.every(a => a.offerIndex === persistRuleBatchOfferIndex && a.memberIndex < expectedBatchCount)
1262
+ : !anchorKeyOnWire;
1138
1263
  const batchEchoOk = ack.persistedRules !== undefined &&
1139
1264
  expectedBatchCount !== undefined &&
1140
- ack.persistedRules.length === expectedBatchCount;
1265
+ ack.persistedRules.length === expectedBatchCount &&
1266
+ anchorsOk;
1141
1267
  const strayEchoes = [];
1142
1268
  if (ack.persistedRule !== undefined && !sentEditedArm)
1143
1269
  strayEchoes.push('persistedRule');
1144
1270
  if (ack.persistedRules !== undefined && !batchEchoOk)
1145
1271
  strayEchoes.push('persistedRules');
1272
+ if (anchors !== undefined && !(anchorsOk && batchEchoOk))
1273
+ strayEchoes.push('persistedRuleAnchors');
1146
1274
  if (strayEchoes.length > 0) {
1147
1275
  hostLog('error', `liveToolApprovalWire: DISCARDING persisted-rule echo(es) [${strayEchoes.join(', ')}] on the ack for ` +
1148
1276
  `${frame.approvalId} — they do not correlate with the persistence arm actually sent ` +
1149
1277
  `(editedArm=${String(sentEditedArm)} batchArmMembers=${String(expectedBatchCount ?? 'none')} ` +
1150
- `echoedRules=${String(ack.persistedRules?.length ?? 'none')}); never telling the user a rule ` +
1278
+ `sentOfferIndex=${String(persistRuleBatchOfferIndex ?? 'none')} ` +
1279
+ `echoedRules=${String(ack.persistedRules?.length ?? 'none')} ` +
1280
+ `echoedAnchorOffer=${String(anchors?.[0]?.offerIndex ?? 'none')}); never telling the user a rule ` +
1151
1281
  'was stored off a receipt that does not line up with what this client actually sent');
1152
- const { persistedRule: _pr, persistedRules: _prs, ...rest } = ack;
1282
+ const { persistedRule: _pr, persistedRules: _prs, persistedRuleAnchors: _pra, ...rest } = ack;
1153
1283
  void _pr;
1154
1284
  void _prs;
1285
+ void _pra;
1155
1286
  ack = {
1156
1287
  ...rest,
1157
1288
  ...(ack.persistedRule !== undefined && sentEditedArm ? { persistedRule: ack.persistedRule } : {}),
1158
1289
  ...(ack.persistedRules !== undefined && batchEchoOk ? { persistedRules: ack.persistedRules } : {}),
1290
+ ...(anchors !== undefined && anchorsOk && batchEchoOk ? { persistedRuleAnchors: anchors } : {}),
1159
1291
  };
1160
1292
  }
1161
1293
  }
@@ -1,3 +1,4 @@
1
+ import { type HookFailureNotice } from './notifications.js';
1
2
  import type { WireHooksConfig } from './finalVerifyWire.js';
2
3
  export { GOAL_STOP_HOOK_WIRE_ENV, CC_STOP_SEMANTICS_MIN_SERVER, ccStopSemanticsFromVersion, engineCcStopSemantics, isGoalStopHookWireArmed, setWireSessionStopHook, getWireSessionStopHook, getWireSessionStopHookCcSemantics, buildGoalStopHookPrompt, } from './goalStopHook.js';
3
4
  export type { WireHooksConfig };
@@ -8,3 +9,39 @@ export type { WireHooksConfig };
8
9
  export declare function hooksForWire(): WireHooksConfig | undefined;
9
10
  /** 测试钩:清 `/goal` 锁存(壳里靠进程边界隔离;包内同进程多组断言必须能清)。 */
10
11
  export declare function __resetHooksWireCapsForTests(): void;
12
+ /**
13
+ * 这台引擎会不会发 `hook_non_blocking_failure` 观测帧(hook 自己坏了的那一族)。
14
+ *
15
+ * 🔴 **能力位嵌在 `fleet` 对象里**,不是顶层键 —— server `dist/http/routes/capabilities.js:52`:
16
+ * `fleet: { stream, sessionScope, observe, resume, bgNotifyFailClosed, steerPriority,
17
+ * hookFailureNotice: true }`。拿平铺口 `engineCapTrue(base,'hookFailureNotice')` 去读**恒 false**
18
+ * (白读),而白读的后果不是报错、是安静地把新车道判成没有。
19
+ * 🔴 未判/父键缺席/父键为 `false`(整族不供)/子键缺席 ⇒ **false** = fail-closed。
20
+ *
21
+ * 🔴 **它是 affordance 判据,不是上屏闸**:一帧真到货的故障通知**不该**被本位闸掉 —— 帧在手里
22
+ * 就是最强证据,而能力位在探测落地前恒 false,拿它闸会把早到的真实故障静默掉(方向与 #281 相反)。
23
+ * 本位的正当用途 = 「要不要在设置/状态面声称有这项监测」。判定口见 `notifications.ts`
24
+ * 的 `classifyHookFailureFrame`(那里刻意不读本位)。
25
+ */
26
+ export declare function fleetHookFailureNoticeCapable(baseUrl: string | undefined): boolean;
27
+ /** footer 横幅文案前缀(测试锁字面;与 `HOOK_NOTICE_WARN_PREFIX` 同族风格)。 */
28
+ export declare const HOOK_FAILURE_WARN_PREFIX = "Hook error";
29
+ /**
30
+ * 「这个 hook 自己坏了」的**单行**横幅文案(hook 名 + 故障形/退出码 + stderr 摘要)。
31
+ *
32
+ * 🔴 **为什么在本文件而不是 `notifications.ts`**:文案口要过展示消毒单源 `collapseLabel`
33
+ * (在 `fleetTaskDesc.ts`),而 `notifications.ts` 在 **A 层闭包**内(portability 门只降棘轮)——
34
+ * 从那里 import 会让闭包涨一格,手抄第二份字符集又是 `fleetTaskDesc` 顶注点名禁止的形。
35
+ * ⇒ 判定留 `notifications.ts`(它只交出**原文**),展示归本层。
36
+ *
37
+ * 🔴 **绝不编造退出码**:`timeout` / `spawn_failed` 上没有 `exitCode`,那两形只报故障词
38
+ * (写 `exit 0` 会让人去查一个根本不存在的退出码)。
39
+ * 🔴 **两位都是 UNTRUSTED-for-display**(异源对抗复审二轮 [medium] 采纳):`hookName` 的一手源是
40
+ * **用户 settings 里自己写的** `statusMessage` / command / url / prompt;`stderr` 是**坏 hook 自己吐的
41
+ * 流**。server 只做脱敏 + 截断、**不做控制符消毒**(与 `workerBadge.name` / fleet 行标签同一条分工)。
42
+ * 不消毒的后果不是难看:一个带 `\n` / ESC / RLO 的 hook 名能把「**哪个**安全 hook 失效了」这条
43
+ * **唯一**告警重排、截断或整段藏掉,而这条横幅存在的全部理由就是让那件事看得见。
44
+ * 🔴 **单行**:渲染面是 `wrap="truncate"` 的一行;`collapseLabel` 同时做 `\s+` 折平与控制符/双向符/
45
+ * 孤代理项可见化转义,两件事一把做完(不在这里手抄第二份字符集)。
46
+ */
47
+ export declare function hookFailureNoticeText(n: HookFailureNotice): string;
@@ -62,7 +62,10 @@
62
62
  * and never inherit engine secrets; user-defined variables must ride `settings.env` — nothing for the
63
63
  * shell to scrub here (commands are the user's own text).
64
64
  */
65
+ import { engineCapNestedTrue } from './engineCapsCache.js';
66
+ import { collapseLabel } from './fleetTaskDesc.js';
65
67
  import { hostLog, hostSettings } from './host.js';
68
+ import { MAX_HOOK_NOTICE_TEXT_CHARS } from './notifications.js';
66
69
  // REF-CC-156(split):`/goal` 版本判档 + prompt 组装 + 模块状态搬到 goalStopHook.ts。
67
70
  // `goalStopHookMatcher`/`__resetGoalStopHookForTests` 是两文件间的私有接口(不 re-export);
68
71
  // 其余七个名字原样 re-export,外部 import 路径(index 的 `export *`)零改动。
@@ -164,3 +167,49 @@ export function hooksForWire() {
164
167
  export function __resetHooksWireCapsForTests() {
165
168
  __resetGoalStopHookForTests();
166
169
  }
170
+ // ── hook 观测面的能力位(#281 / server ≥7.48.0)──────────────────────────────────────────────
171
+ /**
172
+ * 这台引擎会不会发 `hook_non_blocking_failure` 观测帧(hook 自己坏了的那一族)。
173
+ *
174
+ * 🔴 **能力位嵌在 `fleet` 对象里**,不是顶层键 —— server `dist/http/routes/capabilities.js:52`:
175
+ * `fleet: { stream, sessionScope, observe, resume, bgNotifyFailClosed, steerPriority,
176
+ * hookFailureNotice: true }`。拿平铺口 `engineCapTrue(base,'hookFailureNotice')` 去读**恒 false**
177
+ * (白读),而白读的后果不是报错、是安静地把新车道判成没有。
178
+ * 🔴 未判/父键缺席/父键为 `false`(整族不供)/子键缺席 ⇒ **false** = fail-closed。
179
+ *
180
+ * 🔴 **它是 affordance 判据,不是上屏闸**:一帧真到货的故障通知**不该**被本位闸掉 —— 帧在手里
181
+ * 就是最强证据,而能力位在探测落地前恒 false,拿它闸会把早到的真实故障静默掉(方向与 #281 相反)。
182
+ * 本位的正当用途 = 「要不要在设置/状态面声称有这项监测」。判定口见 `notifications.ts`
183
+ * 的 `classifyHookFailureFrame`(那里刻意不读本位)。
184
+ */
185
+ export function fleetHookFailureNoticeCapable(baseUrl) {
186
+ return engineCapNestedTrue(baseUrl, 'fleet', 'hookFailureNotice');
187
+ }
188
+ /** footer 横幅文案前缀(测试锁字面;与 `HOOK_NOTICE_WARN_PREFIX` 同族风格)。 */
189
+ export const HOOK_FAILURE_WARN_PREFIX = 'Hook error';
190
+ /**
191
+ * 「这个 hook 自己坏了」的**单行**横幅文案(hook 名 + 故障形/退出码 + stderr 摘要)。
192
+ *
193
+ * 🔴 **为什么在本文件而不是 `notifications.ts`**:文案口要过展示消毒单源 `collapseLabel`
194
+ * (在 `fleetTaskDesc.ts`),而 `notifications.ts` 在 **A 层闭包**内(portability 门只降棘轮)——
195
+ * 从那里 import 会让闭包涨一格,手抄第二份字符集又是 `fleetTaskDesc` 顶注点名禁止的形。
196
+ * ⇒ 判定留 `notifications.ts`(它只交出**原文**),展示归本层。
197
+ *
198
+ * 🔴 **绝不编造退出码**:`timeout` / `spawn_failed` 上没有 `exitCode`,那两形只报故障词
199
+ * (写 `exit 0` 会让人去查一个根本不存在的退出码)。
200
+ * 🔴 **两位都是 UNTRUSTED-for-display**(异源对抗复审二轮 [medium] 采纳):`hookName` 的一手源是
201
+ * **用户 settings 里自己写的** `statusMessage` / command / url / prompt;`stderr` 是**坏 hook 自己吐的
202
+ * 流**。server 只做脱敏 + 截断、**不做控制符消毒**(与 `workerBadge.name` / fleet 行标签同一条分工)。
203
+ * 不消毒的后果不是难看:一个带 `\n` / ESC / RLO 的 hook 名能把「**哪个**安全 hook 失效了」这条
204
+ * **唯一**告警重排、截断或整段藏掉,而这条横幅存在的全部理由就是让那件事看得见。
205
+ * 🔴 **单行**:渲染面是 `wrap="truncate"` 的一行;`collapseLabel` 同时做 `\s+` 折平与控制符/双向符/
206
+ * 孤代理项可见化转义,两件事一把做完(不在这里手抄第二份字符集)。
207
+ */
208
+ export function hookFailureNoticeText(n) {
209
+ const code = n.exitCode !== undefined ? `exit ${n.exitCode}` : n.reason;
210
+ const name = collapseLabel(n.hookName);
211
+ const tail = n.stderr !== undefined ? `: ${collapseLabel(n.stderr)}` : '';
212
+ const line = `${HOOK_FAILURE_WARN_PREFIX}: ${name} (${code})${tail}`;
213
+ // 有界兜底(与铸点同上限;消毒在前、截断在后 —— 反过来会把一个转义序列切成半截)。
214
+ return line.length <= MAX_HOOK_NOTICE_TEXT_CHARS ? line : line.slice(0, MAX_HOOK_NOTICE_TEXT_CHARS);
215
+ }
@@ -291,7 +291,13 @@ export declare function splitApiErrorSupplement(text: string): {
291
291
  main: string;
292
292
  supplement?: string;
293
293
  };
294
- /** SSE 平铺 wire 形(server 1.280.0 http/server.js:6347-6355 直证;ownerScope/ownerSessionId 上 wire 前剥掉)。 */
294
+ /** SSE 平铺 wire 形(server 1.280.0 http/server.js:6347-6355 直证;ownerScope/ownerSessionId 上 wire 前剥掉)。
295
+ * 🔴 平铺形在 7.48.0 仍逐字成立:`dist/http/routes/fleet.js:146` =
296
+ * `send("hook_notice", { type:"hook_notice", ...hookNoticeWire(hn), ts })`(spread,不是 `notice:` 嵌套)。
297
+ * 🔴 本形是**第一族**(`hook_decision_unavailable`)的平铺视图,**刻意不为第二族加宽**:
298
+ * 两族只共用 `type`/`kind`/`event`/`ts`,而 `reason` 的**值域各自闭集**(同名不同义)。
299
+ * 第二族的专有键走本文件内部视图 `HookFailureWireView`(不出包边界)—— 把两族的键混进同一个
300
+ * 导出形,等于对外承诺一个「哪一族都不完全对」的形,消费端读哪一位都得再猜一次 kind。 */
295
301
  export interface HookNoticeWire {
296
302
  type?: unknown;
297
303
  kind?: unknown;
@@ -338,4 +344,59 @@ export declare function isHookNoticeFrame(frame: unknown): boolean;
338
344
  export declare function hookNoticeLastCheckText(n: GoalHookNotice): string;
339
345
  /** footer 通知条文案前缀(测试锁字面;宿主拼 ` (${reason})`)。 */
340
346
  export declare const HOOK_NOTICE_WARN_PREFIX = "Goal check: couldn't evaluate this turn \u2014 proceeded";
347
+ /** 机器可判的故障形(dist 逐字闭集)。 */
348
+ export type HookFailureReason = 'exit_nonzero' | 'timeout' | 'spawn_failed' | 'bad_json';
349
+ /** 条目类型 —— 没有它,`hookName` 是命令还是 URL 还是提示词全靠猜(dist 逐字闭集)。 */
350
+ export type HookEntryType = 'command' | 'http' | 'prompt' | 'agent';
351
+ /** 宿主写 store / 渲横幅的形(与 {@link GoalHookNotice} 平级,但**不是**同一件事)。 */
352
+ export interface HookFailureNotice {
353
+ /** 哪个 hook 事件(`PreToolUse` / `Stop` / …)。开集:server 不闭这一位。 */
354
+ event: string;
355
+ reason: HookFailureReason;
356
+ /** hook 身份(server 侧 `statusMessage` 优先,否则 command / url / prompt),已脱敏 + 有界。 */
357
+ hookName: string;
358
+ entryType: HookEntryType;
359
+ /** 🔴 **超时 / spawn 失败没有退出码 ⇒ 缺席**(绝不铸 0 那种看着合法的假读数)。 */
360
+ exitCode?: number;
361
+ /** 工具事件才有(PreToolUse/PostToolUse/PostToolUseFailure);非工具事件缺席。 */
362
+ toolName?: string;
363
+ /** hook 的 stderr 摘要(server 已脱敏 + 截断;本包边界再兜底一次)。空 ⇒ 缺席。 */
364
+ stderr?: string;
365
+ detail?: string;
366
+ at: number;
367
+ }
368
+ /**
369
+ * 观测帧自由文本上限 —— 与 server `MAX_HOOK_NOTICE_TEXT_CHARS`(`fleet/fleet-bus.d.ts`)同值。
370
+ * 🔴 server 已截过一遍(那一遍拿得到原文,是**承重**的);本包这一遍是**边界兜底**,挡住忘了截断的
371
+ * 未来生产者把一条无界流灌进单行横幅。幂等:输出恒 ≤ 上限。
372
+ */
373
+ export declare const MAX_HOOK_NOTICE_TEXT_CHARS = 512;
374
+ /** 第二族的判定结果。drop 原因供宿主打 SEMA_DEBUG 档。 */
375
+ export type HookFailureVerdict = {
376
+ ok: true;
377
+ failure: HookFailureNotice;
378
+ } | {
379
+ ok: false;
380
+ drop: 'not-hook-notice' | 'unknown-kind' | 'not-session-scoped' | 'unknown-reason' | 'unknown-entry-type' | 'no-hook-name';
381
+ kind?: string;
382
+ };
383
+ /**
384
+ * `hook_non_blocking_failure` 帧的判定本体(#281 的客户端半场)。
385
+ *
386
+ * 🔴 **与 {@link classifyHookNoticeFrame} 是两个口,不是一个口的两条臂**:两族的载荷形不同
387
+ * (那族出 {@link GoalHookNotice},本族出 {@link HookFailureNotice}),把它们塞进同一个返回联合会
388
+ * **破坏既有消费端**(现在 `v.ok` 之后直接读 `v.notice` 的写法要全部改窄化)。分口 ⇒ 姊妹口的行为
389
+ * **逐字节不变**(它对本族仍报 `unknown-kind`,常驻套对这一格有钉),宿主按 `kind` 分派一次即可。
390
+ *
391
+ * 🔴 **闭集位一律「不认就丢」,绝不折成某一档**:与姊妹族「表外 reason 归一到 `skipped`」刻意不同 ——
392
+ * 那一族的 `skipped` 有真兜底语义(「跳过了」),本族**没有**「兜底故障形」这种东西,把一个没见过的
393
+ * 词折成 `exit_nonzero` 就是替引擎编一个它没说过的故障。
394
+ *
395
+ * 🔴 `hookName` 缺席即丢:一条「某个 hook 坏了、但不知道是哪个」的横幅,用户拿它什么都做不了,
396
+ * 只会制造焦虑 —— 而 server 侧这一位是**必填**,缺席意味着这不是一帧合规的本族帧。
397
+ */
398
+ export declare function classifyHookFailureFrame(frame: unknown, opts: {
399
+ sessionScoped: boolean;
400
+ now?: number;
401
+ }): HookFailureVerdict;
341
402
  export {};
@@ -1090,3 +1090,71 @@ export function hookNoticeLastCheckText(n) {
1090
1090
  }
1091
1091
  /** footer 通知条文案前缀(测试锁字面;宿主拼 ` (${reason})`)。 */
1092
1092
  export const HOOK_NOTICE_WARN_PREFIX = 'Goal check: couldn\'t evaluate this turn — proceeded';
1093
+ const KNOWN_FAILURE_REASONS = new Set(['exit_nonzero', 'timeout', 'spawn_failed', 'bad_json']);
1094
+ const KNOWN_ENTRY_TYPES = new Set(['command', 'http', 'prompt', 'agent']);
1095
+ /**
1096
+ * 观测帧自由文本上限 —— 与 server `MAX_HOOK_NOTICE_TEXT_CHARS`(`fleet/fleet-bus.d.ts`)同值。
1097
+ * 🔴 server 已截过一遍(那一遍拿得到原文,是**承重**的);本包这一遍是**边界兜底**,挡住忘了截断的
1098
+ * 未来生产者把一条无界流灌进单行横幅。幂等:输出恒 ≤ 上限。
1099
+ */
1100
+ export const MAX_HOOK_NOTICE_TEXT_CHARS = 512;
1101
+ function boundedNoticeText(s) {
1102
+ return s.length <= MAX_HOOK_NOTICE_TEXT_CHARS ? s : s.slice(0, MAX_HOOK_NOTICE_TEXT_CHARS);
1103
+ }
1104
+ /**
1105
+ * `hook_non_blocking_failure` 帧的判定本体(#281 的客户端半场)。
1106
+ *
1107
+ * 🔴 **与 {@link classifyHookNoticeFrame} 是两个口,不是一个口的两条臂**:两族的载荷形不同
1108
+ * (那族出 {@link GoalHookNotice},本族出 {@link HookFailureNotice}),把它们塞进同一个返回联合会
1109
+ * **破坏既有消费端**(现在 `v.ok` 之后直接读 `v.notice` 的写法要全部改窄化)。分口 ⇒ 姊妹口的行为
1110
+ * **逐字节不变**(它对本族仍报 `unknown-kind`,常驻套对这一格有钉),宿主按 `kind` 分派一次即可。
1111
+ *
1112
+ * 🔴 **闭集位一律「不认就丢」,绝不折成某一档**:与姊妹族「表外 reason 归一到 `skipped`」刻意不同 ——
1113
+ * 那一族的 `skipped` 有真兜底语义(「跳过了」),本族**没有**「兜底故障形」这种东西,把一个没见过的
1114
+ * 词折成 `exit_nonzero` 就是替引擎编一个它没说过的故障。
1115
+ *
1116
+ * 🔴 `hookName` 缺席即丢:一条「某个 hook 坏了、但不知道是哪个」的横幅,用户拿它什么都做不了,
1117
+ * 只会制造焦虑 —— 而 server 侧这一位是**必填**,缺席意味着这不是一帧合规的本族帧。
1118
+ */
1119
+ export function classifyHookFailureFrame(frame, opts) {
1120
+ const f = frame;
1121
+ if (f?.type !== 'hook_notice')
1122
+ return { ok: false, drop: 'not-hook-notice' };
1123
+ if (f.kind !== 'hook_non_blocking_failure')
1124
+ return { ok: false, drop: 'unknown-kind', kind: String(f.kind) };
1125
+ // 🔴 归属镜像(与姊妹族同一条 fail-CLOSED):server 只对 scoped 连接过滤。
1126
+ if (!opts.sessionScoped)
1127
+ return { ok: false, drop: 'not-session-scoped' };
1128
+ if (typeof f.reason !== 'string' || !KNOWN_FAILURE_REASONS.has(f.reason))
1129
+ return { ok: false, drop: 'unknown-reason' };
1130
+ if (typeof f.entryType !== 'string' || !KNOWN_ENTRY_TYPES.has(f.entryType))
1131
+ return { ok: false, drop: 'unknown-entry-type' };
1132
+ if (typeof f.hookName !== 'string' || f.hookName === '')
1133
+ return { ok: false, drop: 'no-hook-name' };
1134
+ return {
1135
+ ok: true,
1136
+ failure: {
1137
+ event: typeof f.event === 'string' ? f.event : '',
1138
+ reason: f.reason,
1139
+ hookName: boundedNoticeText(f.hookName),
1140
+ entryType: f.entryType,
1141
+ // 🔴 非整数/非数一律降缺席 —— 退出码是给人照着查的,一个 1.5 或 'x' 比没有更坏。
1142
+ ...(typeof f.exitCode === 'number' && Number.isSafeInteger(f.exitCode) ? { exitCode: f.exitCode } : {}),
1143
+ ...(typeof f.toolName === 'string' && f.toolName ? { toolName: boundedNoticeText(f.toolName) } : {}),
1144
+ ...(typeof f.stderr === 'string' && f.stderr ? { stderr: boundedNoticeText(f.stderr) } : {}),
1145
+ ...(typeof f.detail === 'string' && f.detail ? { detail: boundedNoticeText(f.detail) } : {}),
1146
+ at: typeof f.ts === 'number' ? f.ts : (opts.now ?? Date.now()),
1147
+ },
1148
+ };
1149
+ }
1150
+ // 🔴 本族的**横幅文案口** `hookFailureNoticeText` / `HOOK_FAILURE_WARN_PREFIX` 与能力位读口一样,
1151
+ // 刻意**不在本文件**,而在 `hooksWireCaps.ts` —— 理由同 `fleetHookFailureNoticeCapable`:本文件在
1152
+ // **A 层闭包**内(portability 门 ①b,只降棘轮),而文案口必须过展示消毒单源 `collapseLabel`
1153
+ // (在 `fleetTaskDesc.ts`),把那个文件拉进闭包会让棘轮当场涨一格;在本文件手抄第二份字符集
1154
+ // 则正是 `fleetTaskDesc` 顶注点名禁止的形(「改一处必须改另一处」)。
1155
+ // ⇒ **判定留本文件、展示归那一层**,与包内既有分工一致(本文件只交出**原文** `hookName`/`stderr`,
1156
+ // UNTRUSTED-for-display 的消毒责任在渲染侧)。
1157
+ // 🔴 本族的**能力位读口** `fleetHookFailureNoticeCapable` 刻意**不放在本文件**,而在
1158
+ // `hooksWireCaps.ts` —— 本文件在 **A 层闭包**内(portability 门 ①b,只降棘轮),而能力位要读
1159
+ // `engineCapsCache`,把那个文件拉进闭包会让棘轮当场涨一格。能力位本就属 `*WireCaps` 那一层
1160
+ // (与 `hooksForWire` 同域),放那里是归位不是绕道。
package/dist/seam.d.ts CHANGED
@@ -389,7 +389,47 @@ export type ChromeEvent = {
389
389
  * `actor.hostAsserted` 是消费端唯一能判「这个署名可信吗」的位:渲署名而不渲这个位 = 把一个
390
390
  * 未经验证的名字渲成可信的(core 自己的 `[from …]` 渲染就是靠它决定加不加 `(unverified)`)。
391
391
  */
392
- | HumanInputChromeEvent | EngineNoticeChromeEvent;
392
+ | HumanInputChromeEvent | EngineNoticeChromeEvent | TextSegmentEndChromeEvent;
393
+ /**
394
+ * {@link ChromeEvent} 的 `text_segment_end` 臂(#323 / core #447,core ≥5.63 / server ≥7.50)——
395
+ * 「**assistant 的这一段散文写完了**」,由引擎明说,不是由本包猜。
396
+ *
397
+ * ── 它替代的是什么 ──────────────────────────────────────────────────────────────────────────
398
+ * 本包的 `adapt/textStream.ts` 至今用**启发式**猜段边界:静默 ≥1.5s + 段尾是句末/段末字符 +
399
+ * 每段至多一刀(`takeAnswerSegmentOnIdle`,#323 症状① 的止血件)。那是猜 —— 慢模型的一次卡顿会
400
+ * 被当成段结束,一轮回复被切成 N 条 assistant 消息。本臂是**引擎报的真边界**:模型关掉了那个
401
+ * text content block。CC 对位:CC 在 provider 的 `content_block_stop` 上把每个写完的块当作一条
402
+ * 独立 assistant 消息 —— 块结束**就是**分段信号,本臂把同一个边界搬到了 wire 上。
403
+ *
404
+ * 🔴 **宿主消费义务**(全部可选、fail-soft;本臂存在的第一价值 = 边界信号不再落 `unknown_arm`):
405
+ * ① **它是信号,不是内容**。`content` = 那一段的**权威全文**,而这些字节**已经**以 `stream_delta`
406
+ * 活体增量流过、并且会由 transcript 平面的 assistant 消息给出 committed 形。
407
+ * 🔴 拿 `content` 再渲一行 = **同一段文字上屏两遍**。它的正当用途是**对账**(用引擎的权威全文
408
+ * 校/换掉自己缝合出来的那一段)与**定界**(现在可以提交这一段了)。
409
+ * ② **诚实缺席,不可反推**(core 臂注逐字):只有会报块结束的 Brain 才发它 —— 三个一方 brain 都发,
410
+ * 自定义 brain 可能整条流一帧都没有。⇒ 缺席 = 「**没报**」,**永远不等于**「这一段没结束」。
411
+ * 消费方按**每条流**判「这条流带不带边界帧」,只在整条流一帧都没有时才回落自己的启发式;
412
+ * 按**单帧**缺席就回落 = 把「这一拍还没到边界」误当「引擎不支持」。
413
+ * ③ **空段无帧**:引擎只为**有字节**的段发帧(空 text block 静默关闭)—— 所以宿主永远不会收到
414
+ * 空 `content`,收到了也不该提交一个幻影段(本包投影层已按 `empty_payload` 拦下)。
415
+ * ④ 🔴 **子流让位**:带 `parentToolCallId` 的段边界属于子代/编排流,拿它去提交 leader 的段就是
416
+ * 跨 lane 状态破坏(与 `stream_delta` 的 thinking 半场、`retry_status` 同一条纪律)。所以本臂
417
+ * 在库内**已经断闸** —— 子流的段边界**根本不产出本 chrome 事件**,本臂因此没有
418
+ * `parentToolCallId` 位(有位才是可疑的:那意味着 leader 面上会出现别人的边界)。
419
+ * 要消费子流边界的宿主读 SDKMessage 平面的 `text_end` 内部臂,那一层原样带 `parentToolCallId`。
420
+ *
421
+ * ⚠️ **UNTRUSTED、仅展示**:`content` 是模型输出,契约与活体增量同 —— 渲染,绝不回喂模型。
422
+ * 📋 如实留白:本包的 `takeAnswerSegmentOnIdle` 启发式**本批不撤**(撤它要按 ② 做一条 per-stream
423
+ * 的状态化策略,属行为面改动,按宪法三问单独走)。本批只把边界送到宿主手上。
424
+ */
425
+ export interface TextSegmentEndChromeEvent {
426
+ kind: 'text_segment_end';
427
+ laneProof: LaneProof;
428
+ /** 该段的**权威全文**(与那一段活体增量的拼接逐字节相等)。🔴 对账/定界用,别拿它再渲一行。 */
429
+ content: string;
430
+ /** core 铸的事件身份(uuidv7 形);wire 未必带 ⇒ 缺席时本键不在场。 */
431
+ eventId?: string;
432
+ }
393
433
  /**
394
434
  * {@link ChromeEvent} 的 `engine_notice` 臂(#310 / #318 件①,server ≥7.36;契约 = server
395
435
  * `ASSISTANT-WIRE-CONTRACT` 附录 D)——「**引擎想让这条会话的人知道一件事**」。
package/dist/seam.js CHANGED
@@ -48,6 +48,10 @@ const CHROME_ARM_TABLE = {
48
48
  required: false,
49
49
  duty: '可选:渲引擎通告(按 code+detail,message 仅 fallback;未知 code 也必须渲不许丢;durable 重放按 eventId 幂等;harvest 的 moved/escalated 并列呈现绝不相减)',
50
50
  },
51
+ text_segment_end: {
52
+ required: false,
53
+ duty: '可选:引擎明报的 assistant 散文段边界(#323/core #447)。🔴 content 是对账/定界用的权威全文,拿它再渲一行 = 同一段上屏两遍;缺席只表示「没报」,绝不等于「段没结束」——要退回自家启发式必须按整条流判、不按单帧判',
54
+ },
51
55
  };
52
56
  /**
53
57
  * chrome 臂**覆盖率断言出口**(§8-1 裁决的一半:client-core 定接口不定实现)。
@@ -1,12 +1,39 @@
1
- /** Test/introspection hook. */
2
- export declare function isEngineCompactPending(): boolean;
3
- /** Test hook: reset the cached capability (fresh engine / respawn). */
1
+ /** Test/introspection hook(缺省 = 默认槽,与 0.44.0 逐字同)。 */
2
+ export declare function isEngineCompactPending(sessionKey?: string): boolean;
3
+ /**
4
+ * Test hook: reset the cached capability (fresh engine / respawn)。
5
+ *
6
+ * 🔴 **只清能力面,绝不动 arm 台账**(异源对抗复审第四轮 [medium] 采纳,修回归):本口是**生产口**
7
+ * (引擎重启 / respawn 的能力失效),不是「取消用户的 /compact」。一度顺手加的 `pendingCompactKeys.clear()`
8
+ * 会让「idle 时 `/compact` 回过 armed → 引擎 respawn → 下一条 run 不再压缩」——用户的命令无声蒸发,
9
+ * 而且那是**默认槽**上的行为改变,与本批「单会话宿主逐字节零变化」的声明直接矛盾。
10
+ * 要清 arm 走 {@link __resetEngineCompactArmForTests}(测试钩,生产面没有这个动作)。
11
+ */
4
12
  export declare function resetManualCompactCapability(): void;
13
+ /** 测试钩:清 arm 台账(module 级单例,同进程多组断言必须能清)。生产面**没有**这个动作。 */
14
+ export declare function __resetEngineCompactArmForTests(): void;
5
15
  /**
6
16
  * The /compact command's live half. Mid-turn: fire now. Idle: arm for the next run bind.
7
17
  * Returns what it did (for the command's display line / tests). No-op ('offline') without a live
8
18
  * wire; 'unsupported' when the engine's capabilities said manualCompact:false ([488] TOC-local) —
9
19
  * the client-side projection compaction still runs, but the ENGINE session context does not shrink.
20
+ *
21
+ * 🔴 design/285 批 3(D7b):本族三处零参 `engineWireTarget()` 全部换成 `engineWireTargetFor(sessionKey)`。
22
+ * /compact 打的是**这个会话此刻在飞的那条 run**,不是某一行子代 —— owner 台账里没有它,所以槽键
23
+ * 只能由调用方给(多会话宿主每个 REPL 面板一个槽)。缺省 `DEFAULT_SESSION_KEY` ⇒ 与 0.44.0 逐字同。
24
+ * 📋 **带反证的驳回半场(异源对抗复审第三轮 [high] 的另一半,如实记)**:复审还要求把快照提前到
25
+ * **request(arm)那一拍**,理由是「arm 与 bind 之间槽可能被重绑,意图属于 A 却在 B 上执行」。
26
+ * **不采**,反证:arm 那一拍**根本没有 taskId** —— `/compact` 在 idle 下的语义是成文的
27
+ * 「**这个槽的下一条 run**」(见本文件头注的 arm-then-fire 段),槽的身份就是 key 本身。
28
+ * 槽真被重绑到另一台引擎时,「下一条 run」就在那台引擎上,压它才是意图;把 request 那一刻的
29
+ * target 冻下来反而会去压一台**已经不再跑这条会话**的引擎。⇒ 冻结的正确起点是**拿到 taskId 的
30
+ * 那一拍**(bind),那之后的每一次延迟与重试都必须用同一份 —— 那正是上面这一条。
31
+ * 现状由常驻钉 `B3-P31/R6i` 钉住(arm 之后换槽装配 ⇒ 发射打的是**换后**那台,且带换后的会话)。
32
+ * 🔴 **一条动词的槽键必须一以贯之**(异源对抗复审 [high] 采纳):目标、`?session=`、**run id**
33
+ * (`activeEngineRunIdFor`)、**能力位**(per-baseUrl 分桶)、**arm 状态**(per-key 分桶)——
34
+ * 五件全按同一个 key。只换其中几件就是**半 keyed 形**:那比换锚之前更坏(之前五件一致地错在同一个
35
+ * 槽上,一眼可见;半 keyed 是「A 的 run id 发到 B 的服务器」「B 的 arm 被 A 的 bind 吃掉」这类只在
36
+ * 多会话下现形的错)。这条正是 plan 决策链本批**豁免**的同一条理由 —— 那边整条链搬不动,这边搬得动。
10
37
  */
11
- export declare function requestEngineCompact(): 'fired' | 'armed' | 'offline' | 'unsupported';
12
- export declare function onEngineTaskBound(taskId: string): void;
38
+ export declare function requestEngineCompact(sessionKey?: string): 'fired' | 'armed' | 'offline' | 'unsupported';
39
+ export declare function onEngineTaskBound(taskId: string, sessionKey?: string): void;