@sema-agent/client-core 0.19.0 → 0.20.1

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.
@@ -8,6 +8,9 @@ export class HitlSafetyError extends Error {
8
8
  constructor(message,
9
9
  /** A stable code the shell can branch on (REF-CC-036,2026-08-02:闭集联合,不再是开集 string —
10
10
  * 与同族兄弟 `controlRouter.ts` 的 `ControlSafetyError.code` 同形)。 */
11
+ /** 🔴 闭集(FIX② 2026-08-07 加 `empty_answer`,第 5 员)。加员必须**同批**改齐
12
+ * `parkResolver.GATE_FAILURE_CODES` 白名单 —— 那道白名单把不认得的码降成 `undefined`,
13
+ * 一边加一边不改另一边 = 新码在消费端被静默吞掉(比不加还坏:本地拦住了,判词却丢了)。 */
11
14
  code) {
12
15
  super(message);
13
16
  this.code = code;
@@ -185,11 +188,25 @@ export class HitlBridge {
185
188
  * permission gate, but the answer rides `ApprovalDecision.answer` as the worker-validated
186
189
  * `{ answers: [{ header, selected, note? }] }` shape. The D-1 binding STILL binds (it is still a
187
190
  * checkpoint). Each answer's `header` MUST match a question header (the worker 400s on mismatch).
191
+ *
192
+ * 🔴 FIX②(2026-08-07)—— **本地 fail-loud**:此前本方法对 `answers: []` 与「某条 `selected: []`」
193
+ * 零校验,一条注定 400 的请求照样出门。这不是「多一次往返」而已 ——
194
+ * · 用户面只看到「作答失败」,而失败的原因(空作答)在壳侧一个字都没说;
195
+ * · `ToolPermissionDecision.answer` 的头注自己写着「a silently emptied answer is the user's
196
+ * choice being thrown away without anyone noticing」—— 那条纪律此前只写在类型注释里,
197
+ * 没有任何一层真的执行它;
198
+ * · `{answers:[]}` 在 wire 上另有确切含义(question 域 **deny** 的 NO_HUMAN 形),把它当成一次
199
+ * approve 发出去 = 拿 deny 的载荷冒充 approve。
200
+ * 三形一律抛 `HitlSafetyError('empty_answer')`,一次 decide 都不发:空 `answers[]` /
201
+ * 任一条 `selected[]` 为空 / 任一条 `header` 为空串。
188
202
  */
189
203
  async answerQuestion(answers, toolUseID, opts,
190
204
  /** REF-CC-029(2026-08-02):see `decideTool`'s same-named param — the pending row the caller already
191
205
  * fetched via `findPendingForTask`. */
192
206
  preResolvedPending) {
207
+ // 🔴 校验在 findPending **之前**:入参坏了跟 pending 行在不在场无关,而先 list 再拒会把
208
+ // 「你的作答是空的」这条真判词换成「no_pending」那条假判词(行恰好过期时)。
209
+ assertAnswersSendable(answers);
193
210
  const pending = preResolvedPending ?? (await this.findPending(toolUseID, opts));
194
211
  if (!pending) {
195
212
  throw new HitlSafetyError('no pending AskUserQuestion checkpoint to answer — refetch + re-present', 'no_pending');
@@ -304,6 +321,26 @@ export function makeHitlCanUseTool(bridge, prompt) {
304
321
  };
305
322
  }
306
323
  // ── helpers ───────────────────────────────────────────────────────────────────
324
+ /**
325
+ * FIX②(2026-08-07):作答**出门前**的本地闭集校验(fail-closed;详见 `answerQuestion` 头注)。
326
+ * 只判「这批作答在 wire 上有没有意义」,不判 header 与题面对不对得上 —— 后者的真源在 worker
327
+ * (它持有题面),壳侧猜一份题面副本只会造出第二个真源。
328
+ */
329
+ function assertAnswersSendable(answers) {
330
+ if (!Array.isArray(answers) || answers.length === 0) {
331
+ throw new HitlSafetyError('refusing to send an EMPTY AskUserQuestion answer set — `{answers:[]}` is the wire\'s DENY/NO_HUMAN form, ' +
332
+ 'sending it as an approve would throw the human\'s choice away and 400 the worker', 'empty_answer');
333
+ }
334
+ for (const a of answers) {
335
+ if (typeof a?.header !== 'string' || a.header.length === 0) {
336
+ throw new HitlSafetyError('refusing to send an answer with an EMPTY `header` — the worker matches answers to questions BY header (400)', 'empty_answer');
337
+ }
338
+ if (!Array.isArray(a.selected) || a.selected.length === 0) {
339
+ throw new HitlSafetyError(`refusing to send an answer with an EMPTY \`selected\` for header "${a.header}" — ` +
340
+ 'an emptied selection is the human\'s choice silently discarded (the worker 400s on it)', 'empty_answer');
341
+ }
342
+ }
343
+ }
307
344
  /** Marshal one answer into the worker-validated wire entry (contract/04 §4.1). Drops undefined `note`.
308
345
  * REF-CC-038(2026-08-02):return 类型是 `AskAnswer` 本身(唯一源),不再是一份重写的内联匿名形。 */
309
346
  function marshalAnswer(a) {
@@ -37,6 +37,33 @@ export declare function surfaceForCurrentSession(): HitlHostSurface | null;
37
37
  export declare const CANCEL_DENY_BUDGET_MS = 2000;
38
38
  /** warn 行文案(测试锁字面)。 */
39
39
  export declare const CANCEL_DENY_WARN_TEXT = "could not cancel the pending question \u2014 the session may stay locked; the run may need engine-side recovery";
40
+ /** 文案(测试锁字面)。措辞刻意描述**后果**而不是内部机制:用户要知道的是「下次还会问」。 */
41
+ export declare const REMEMBER_NOT_APPLIED_WARN_TEXT = "this session-wide allow was not remembered by the worker \u2014 you will be asked again for this tool";
42
+ /** 文案(测试锁字面)。"original" 是判据词:用户必须读到「跑的是原来那份」。 */
43
+ export declare const EDIT_NOT_FORWARDED_WARN_TEXT = "your edited tool input was NOT forwarded \u2014 the tool is running with the ORIGINAL input you saw before editing";
44
+ /**
45
+ * `allow_session` 决断的 ack 回报 `rememberApplied:false`(server 1.239 起诚实回显 —— 典型成因
46
+ * 是这条 run 没有 sessionId,grant 无处可落)。
47
+ *
48
+ * 🔴 为什么这必须上屏而不是只记 debug:三选卡第 2 项在用户心里签的是「本会话这个工具不再问我」。
49
+ * 记没落店而界面照渲「全放行」,就是**把一个没有发生的授权渲成发生了** —— 下一次弹卡时用户会以为
50
+ * 是 bug,更坏的是他可能因此养成「一路回车」的习惯。缺席(旧 server 不出这个位)= 未知,
51
+ * 按 [honest-absence-not-fabricated-zero] 一律不发通知,绝不把未知渲成失败。
52
+ * 无宿主口(print/headless 没屏)⇒ 静默,与 {@link surfaceCancelDenyWarn} 同语义。
53
+ */
54
+ export declare function surfaceRememberNotApplied(): void;
55
+ /**
56
+ * 「编辑并批准」带了 `updatedInput`,而 ack 回报 `updatedInputForwarded:false`(server 1.241 起
57
+ * 诚实回显)—— 引擎拿到的是**原始**入参。
58
+ *
59
+ * 🔴 这一条比 remember 那条严重一个量级,它是本批 codex 复审判的 [high]:用户在卡上把
60
+ * `rm -rf /tmp/x` 改成 `rm -rf /tmp/x/build` 然后按了批准,而真正执行的是他改之前那条。
61
+ * **事后没有 fail-closed 的余地**(allow 已经送达,工具已经在跑),所以唯一诚实的处置就是立刻
62
+ * 响亮地说出来 —— 静默(此前只写 debug 日志)等于让用户以为自己的编辑生效了。
63
+ * 上游的正位是让 server 在「带了 updatedInput 却转发不了」时 fail-closed 拒批;那属跨仓,
64
+ * 已记进 docs/refactor/README.md 的宿主/上游工单表,本层不做旁路补偿(只做如实告知)。
65
+ */
66
+ export declare function surfaceEditNotForwarded(): void;
40
67
  /**
41
68
  * 中断 deny 的有界观察(壳侧单测 `hitlCancelDeny.test.ts` 的被测面;REF-CC-023 起两条决断腿共用)。
42
69
  * 铁律:不 await 进 abort 返回路径(用户立即拿回控制);这里只管后台 settle 的«观察»:
@@ -75,31 +75,71 @@ const CANCEL_DENY_WARN_TIMEOUT_MS = 10_000;
75
75
  * [paired-mechanisms-must-share-premise]:只给「显示」不给「清」,端各写各的清法必然漂。
76
76
  * 无口(print/headless 没屏可上)⇒ 静默,与壳原文 `if (!store) return` 同语义。 */
77
77
  function surfaceCancelDenyWarn() {
78
+ surfaceSelfClearingWarn(CANCEL_DENY_WARN_KEY, CANCEL_DENY_WARN_TEXT, CANCEL_DENY_WARN_TIMEOUT_MS);
79
+ }
80
+ /**
81
+ * 「上屏一行 warning + 到点自清(仅当仍是本 key)」的**唯一**实现(FIX① 收编:此前只有
82
+ * cancel-by-deny 一处,新增 remember-not-applied 时若各写一遍,两处的自清时序必然漂 ——
83
+ * [paired-mechanisms-must-share-premise];顺带保住 TIMER③ 的裸定时器创建点基线不上涨)。
84
+ * 无宿主口 ⇒ 静默(print/headless 没屏可上,与壳原文 `if (!store) return` 同语义)。
85
+ */
86
+ function surfaceSelfClearingWarn(key, text, timeoutMs) {
78
87
  try {
79
88
  const s = surfaceForCurrentSession();
80
89
  if (!s)
81
90
  return;
82
- s.showNotice({
83
- key: CANCEL_DENY_WARN_KEY,
84
- text: CANCEL_DENY_WARN_TEXT,
85
- color: 'warning',
86
- priority: 'immediate',
87
- timeoutMs: CANCEL_DENY_WARN_TIMEOUT_MS,
88
- });
91
+ s.showNotice({ key, text, color: 'warning', priority: 'immediate', timeoutMs });
89
92
  const timer = setTimeout(() => {
90
93
  try {
91
- s.clearNoticeIfCurrent(CANCEL_DENY_WARN_KEY);
94
+ s.clearNoticeIfCurrent(key);
92
95
  }
93
96
  catch {
94
97
  /* fail-soft */
95
98
  }
96
- }, CANCEL_DENY_WARN_TIMEOUT_MS);
99
+ }, timeoutMs);
97
100
  unrefTimer(timer);
98
101
  }
99
102
  catch {
100
- /* fail-soft:上屏失败绝不再伤害 abort 流 */
103
+ /* fail-soft:上屏失败绝不再伤害它所依附的那条流 */
101
104
  }
102
105
  }
106
+ // ── FIX① (2026-08-07) —— allow_session 的「记住了没有」诚实回执 ────────────────────────────────
107
+ /** 文案(测试锁字面)。措辞刻意描述**后果**而不是内部机制:用户要知道的是「下次还会问」。 */
108
+ export const REMEMBER_NOT_APPLIED_WARN_TEXT = 'this session-wide allow was not remembered by the worker — you will be asked again for this tool';
109
+ const REMEMBER_NOT_APPLIED_KEY = 'hitl-remember-not-applied';
110
+ const REMEMBER_NOT_APPLIED_TIMEOUT_MS = 10_000;
111
+ /** 文案(测试锁字面)。"original" 是判据词:用户必须读到「跑的是原来那份」。 */
112
+ export const EDIT_NOT_FORWARDED_WARN_TEXT = 'your edited tool input was NOT forwarded — the tool is running with the ORIGINAL input you saw before editing';
113
+ const EDIT_NOT_FORWARDED_KEY = 'hitl-edit-not-forwarded';
114
+ /** 比 remember 那条久:它说的是「正在跑的东西和你批的不是同一份」,值得多留一会儿。 */
115
+ const EDIT_NOT_FORWARDED_TIMEOUT_MS = 20_000;
116
+ /**
117
+ * `allow_session` 决断的 ack 回报 `rememberApplied:false`(server 1.239 起诚实回显 —— 典型成因
118
+ * 是这条 run 没有 sessionId,grant 无处可落)。
119
+ *
120
+ * 🔴 为什么这必须上屏而不是只记 debug:三选卡第 2 项在用户心里签的是「本会话这个工具不再问我」。
121
+ * 记没落店而界面照渲「全放行」,就是**把一个没有发生的授权渲成发生了** —— 下一次弹卡时用户会以为
122
+ * 是 bug,更坏的是他可能因此养成「一路回车」的习惯。缺席(旧 server 不出这个位)= 未知,
123
+ * 按 [honest-absence-not-fabricated-zero] 一律不发通知,绝不把未知渲成失败。
124
+ * 无宿主口(print/headless 没屏)⇒ 静默,与 {@link surfaceCancelDenyWarn} 同语义。
125
+ */
126
+ export function surfaceRememberNotApplied() {
127
+ surfaceSelfClearingWarn(REMEMBER_NOT_APPLIED_KEY, REMEMBER_NOT_APPLIED_WARN_TEXT, REMEMBER_NOT_APPLIED_TIMEOUT_MS);
128
+ }
129
+ /**
130
+ * 「编辑并批准」带了 `updatedInput`,而 ack 回报 `updatedInputForwarded:false`(server 1.241 起
131
+ * 诚实回显)—— 引擎拿到的是**原始**入参。
132
+ *
133
+ * 🔴 这一条比 remember 那条严重一个量级,它是本批 codex 复审判的 [high]:用户在卡上把
134
+ * `rm -rf /tmp/x` 改成 `rm -rf /tmp/x/build` 然后按了批准,而真正执行的是他改之前那条。
135
+ * **事后没有 fail-closed 的余地**(allow 已经送达,工具已经在跑),所以唯一诚实的处置就是立刻
136
+ * 响亮地说出来 —— 静默(此前只写 debug 日志)等于让用户以为自己的编辑生效了。
137
+ * 上游的正位是让 server 在「带了 updatedInput 却转发不了」时 fail-closed 拒批;那属跨仓,
138
+ * 已记进 docs/refactor/README.md 的宿主/上游工单表,本层不做旁路补偿(只做如实告知)。
139
+ */
140
+ export function surfaceEditNotForwarded() {
141
+ surfaceSelfClearingWarn(EDIT_NOT_FORWARDED_KEY, EDIT_NOT_FORWARDED_WARN_TEXT, EDIT_NOT_FORWARDED_TIMEOUT_MS);
142
+ }
103
143
  /**
104
144
  * cancel-by-deny 的「良性 no_pending」判据。**结构化 `.code` 为主,`instanceof` 只作加强,绝不替代**:
105
145
  * - `.code` 是 client-core 自家 `HitlSafetyError` 的契约属性(hitlBridge.ts,REF-CC-036 起是闭集
@@ -25,7 +25,14 @@ import { type AskGateWireDeps, type GatePark } from './frameRouter.js';
25
25
  * 开集腐蚀这个新判别位)。消费点只认 `'no_pending'` 为「已解决」,其余码(binding_mismatch 等)
26
26
  * 是真需要重新呈现给人的失败,不能走「继续读流」这条路。
27
27
  */
28
- export type GateFailureCode = 'no_pending' | 'binding_mismatch' | 'wrong_gate' | 'bad_plan_edit';
28
+ /**
29
+ * 白名单的**运行期真源**(FIX② 2026-08-07 由三处散落的 `===` 收成一份元组)。
30
+ * 🔴 与 `hitlBridge.HitlSafetyError.code` 的联合是**同一个闭集的两半**:下方 `_gateFailureCodePins`
31
+ * 双向钉住,任何一边加员而另一边不跟 ⇒ 编译红。此前两边靠人肉同步,而不同步的可观测后果是
32
+ * 「新码被 `isGateFailureCode` 降成 undefined 后静默走文案兜底臂」—— 门里长得跟正常一模一样。
33
+ */
34
+ export declare const GATE_FAILURE_CODES: readonly ["no_pending", "binding_mismatch", "wrong_gate", "bad_plan_edit", "empty_answer"];
35
+ export type GateFailureCode = (typeof GATE_FAILURE_CODES)[number];
29
36
  export type GateOutcome = {
30
37
  kind: 'decided';
31
38
  gatedCallId?: string | undefined;
@@ -6,8 +6,33 @@ import { observeCancelByDeny } from './hitlHostSurface.js';
6
6
  import { isAskTool } from './frameRouter.js';
7
7
  /** 一 turn 内最多循环这么多次 park(防御:引擎/模型病态连环提问时不无限 attach)。 */
8
8
  const MAX_GATE_HOPS = 24;
9
+ /**
10
+ * REF-CC-033:`GateOutcome` 的 `failed` 分支闭集判别码。来源两处:①本文件自己的
11
+ * 「pending 行取不到」早退(`surfaceGateAndDecide` 里 `!pending` 那条);②`HitlBridge` 抛出的
12
+ * `HitlSafetyError.code`(hitlBridge.ts 契约:`binding_mismatch|no_pending|wrong_gate|bad_plan_edit`
13
+ * ——该属性今天仍是开集 `string`,TYPESHAPE-07/REF-CC-036 候后续车锁死;这里落地前用
14
+ * `isGateFailureCode` 按闭集白名单过滤,不认得的值一律降级成 `undefined`,退回文案兜底,不让
15
+ * 开集腐蚀这个新判别位)。消费点只认 `'no_pending'` 为「已解决」,其余码(binding_mismatch 等)
16
+ * 是真需要重新呈现给人的失败,不能走「继续读流」这条路。
17
+ */
18
+ /**
19
+ * 白名单的**运行期真源**(FIX② 2026-08-07 由三处散落的 `===` 收成一份元组)。
20
+ * 🔴 与 `hitlBridge.HitlSafetyError.code` 的联合是**同一个闭集的两半**:下方 `_gateFailureCodePins`
21
+ * 双向钉住,任何一边加员而另一边不跟 ⇒ 编译红。此前两边靠人肉同步,而不同步的可观测后果是
22
+ * 「新码被 `isGateFailureCode` 降成 undefined 后静默走文案兜底臂」—— 门里长得跟正常一模一样。
23
+ */
24
+ export const GATE_FAILURE_CODES = [
25
+ 'no_pending',
26
+ 'binding_mismatch',
27
+ 'wrong_gate',
28
+ 'bad_plan_edit',
29
+ /** FIX②:空作答被本地拦下(空 answers[] / 空 selected[] / 空 header)。 */
30
+ 'empty_answer',
31
+ ];
32
+ const _gateFailureCodePins = [true, true];
33
+ void _gateFailureCodePins;
9
34
  function isGateFailureCode(v) {
10
- return v === 'no_pending' || v === 'binding_mismatch' || v === 'wrong_gate' || v === 'bad_plan_edit';
35
+ return typeof v === 'string' && GATE_FAILURE_CODES.includes(v);
11
36
  }
12
37
  /**
13
38
  * decide 的 `failed` 原因是不是「这个 gate 早就被解决了」(#110 缺陷② c 的判据)。
@@ -62,6 +62,7 @@
62
62
  * 🔴 UNTRUSTED:gate args 为模型作文(service 已 redact),只渲染绝不回喂;决断只带 decision 枚举。
63
63
  */
64
64
  import { type HitlClientLike } from './hitlBridge.js';
65
+ import type { ToolApprovalRespondAck } from '@sema-agent/sdk';
65
66
  /** fs 写权限 gate 判定:未来的一等 kind(tool_approval)或按 toolName(server 桥首批=fs 写三件,
66
67
  * [820] 表)。AskUserQuestion 永不进这里(ask 桥先判)。 */
67
68
  export declare function isFsApprovalGate(gate: {
@@ -127,6 +128,16 @@ export interface ApprovalCardRequest {
127
128
  /** wire 层降级说明(卡 content 上方 dim 行,ToolUseConfirm.wireNote 超集位)——argsOmitted 等
128
129
  * 不可注入 args 的提示走这里([1543]③ 记账的 note 位,2026-07-23 落位)。 */
129
130
  wireNote?: string;
131
+ /**
132
+ * 治理强制位(server ≥7.5.0,2026-08-08 补透传)——原样来自
133
+ * {@link ToolApprovalFrame.governanceForced}。**缺席 ≠ false**:只在为真时在场,缺席 = 无治理来源
134
+ * 的证据。壳应据此把门呈成**表态掀不掉**(而不是引导用户去改 `permissionMode`);呈现形是壳半场,
135
+ * 本包只保证这一位到得了卡口 —— 此前它在帧类型上就不存在,于是宿主无论怎么写都读不到。
136
+ */
137
+ governanceForced?: true;
138
+ /** 委派出处链(core 5.9.0 W1)——原样来自 {@link ToolApprovalFrame.delegation};只读展示增强,
139
+ * `agentName` UNTRUSTED-for-display。子代 ask 才在场。 */
140
+ delegation?: ToolApprovalDelegation;
130
141
  }
131
142
  /**
132
143
  * 🔴 **拆缝口** —— 弹「三选卡」并等人的决断。壳 = vendored CC `PermissionRequest`;
@@ -189,24 +200,91 @@ export interface ToolApprovalFrame {
189
200
  fromSubagent?: boolean;
190
201
  /** core 1.378 RB-39②:展示身份(UNTRUSTED-for-display,server redact 后上帧)——徽章名一手源。 */
191
202
  sourceAgentName?: string;
203
+ /**
204
+ * server ≥7.5.0([2942]/[2943],ADDITIVE;≤7.4.0 恒缺席)——**治理强制**位:此门来自运维治理层
205
+ * (部署侧 `AUTONOMY` / `commandPolicy` / `MANUAL_MODE_SHELL_GATE` / `SENSITIVE_WRITE_PATTERNS`),
206
+ * 不是模型默认门、也不是本次请求的客户端表态。它回答「我都开了 `bypassPermissions` 为什么还在问」。
207
+ *
208
+ * 🔴 **缺席 ≠ `false`**(与风险两轴同族纪律):本键**只在为真时在场**。缺席 = 「没有治理来源的
209
+ * 证据」,它既覆盖真的非治理 ask,也覆盖 server 侧判据够不着的形(治理 shell 门停在 `classify`
210
+ * 档时,一次 shell ask 出自分类器还是别的门无从分辨——server 选择让键缺席而不是猜)。所以缺席
211
+ * **不得**被渲染成「这门可以用表态绕开」。
212
+ *
213
+ * 分工:本包只做**透传**(帧 → {@link ApprovalCardRequest.governanceForced}),徽标呈现是壳半场。
214
+ */
215
+ governanceForced?: true;
216
+ /**
217
+ * core 5.9.0 W1([2535],ADDITIVE):ask 的**委派出处链**(最内层孙代帧胜出)。与
218
+ * {@link fromSubagent} 同门在场(只有子代 ask 带)。只读展示增强 —— 卡可以说清这只 ask 是从哪
219
+ * 一层浮上来的。`agentName` 与 {@link sourceAgentName} 同属 UNTRUSTED-for-display。
220
+ */
221
+ delegation?: ToolApprovalDelegation;
222
+ }
223
+ /** {@link ToolApprovalFrame.delegation} 的形(命名形,不用内联匿名 —— typeshape 门 B4 棘轮口径)。 */
224
+ export interface ToolApprovalDelegation {
225
+ /** 宿主侧那次委派调用的 tool-call id —— 卡的归属锚。 */
226
+ parentToolCallId: string;
227
+ /** 委派深度(宿主直接子代 = 1)。 */
228
+ depth: number;
229
+ /** 子代 agent 名。**UNTRUSTED**:spawning model 的自由文本(server 已脱敏+限长)。 */
230
+ agentName?: string;
192
231
  }
193
232
  /**
194
233
  * 本地值拷贝的**运行时键集镜像**([1950]② 建议形):对账测试拿它与 SDK 0.1.6+ 的同源锚
195
234
  * `TOOL_APPROVAL_FRAME_KEYS` 比对——SDK additive 增键时对账当天红,不再人肉追平。
196
235
  * 下面两个类型钉保证镜像与 interface 本身不可能漂移(少键/多键都是编译错)。
197
236
  */
198
- export declare const TOOL_APPROVAL_FRAME_KEYS_MIRROR: readonly ["type", "approvalId", "toolCallId", "toolName", "sourceTaskId", "fromSubagent", "sourceAgentName", "message", "args", "argsOmitted", "outcome"];
237
+ export declare const TOOL_APPROVAL_FRAME_KEYS_MIRROR: readonly ["type", "approvalId", "toolCallId", "toolName", "sourceTaskId", "fromSubagent", "sourceAgentName", "message", "args", "argsOmitted", "governanceForced", "delegation", "outcome"];
199
238
  /** 子代帧判别:显式键 fromSubagent(core 1.378 RB-39②)优先;缺席退 sourceTaskId 在场性权宜式
200
239
  * (server 1.258 [1549]①3,旧代际兼容)。 */
201
240
  export declare function isFromSubagent(frame: ToolApprovalFrame): boolean;
202
241
  /** 三选卡决断 → respond 端点的 wire 枚举(server parseToolApprovalDecision)。 */
203
242
  export type ToolApprovalRespondDecision = 'allow' | 'allow_session' | 'deny';
204
- /** POST /v1/tool-approvals/:id/respond 的注入面(liveClient 提供;SDK 0.0.46 无此资源,raw fetch)。 */
243
+ /**
244
+ * POST /v1/tool-approvals/:id/respond 的注入面。
245
+ *
246
+ * 🔴 FIX①(2026-08-07):**SDK 6.3.0 起这是一等资源** `client.toolApprovals.respond`,返回
247
+ * `ToolApprovalRespondAck`(`{approvalId, delivery:"applied", decision, rememberApplied?,
248
+ * updatedInputForwarded?}`)。此前本位钉死 `Promise<void>` —— 于是 ack 上那两个**诚实位**在类型层
249
+ * 就不可能被读到:
250
+ * · `rememberApplied:false`(server 1.239)= allow_session 的 grant **没落店**(典型:无 sessionId)
251
+ * ⇒ 壳照渲「本会话全放行」就是把一个没发生的授权渲成发生了;
252
+ * · `updatedInputForwarded`(server 1.241)= 编辑后的 args 是否真被透传给引擎(措辞诚实:
253
+ * forwarded ≠ applied)。
254
+ * 返回型放宽成 `ack | void` 而不是硬钉 ack:**注入面的实现方**(旧 liveClient / mock / 测试桩)
255
+ * 回 void 仍然合法(assignable),消费端按 {@link readToolApprovalRespondAck} 结构化读,缺席=未知。
256
+ */
205
257
  export type RespondToolApprovalFn = (approvalId: string, decision: ToolApprovalRespondDecision, opts?: {
206
258
  signal?: AbortSignal;
207
259
  updatedInput?: unknown;
208
- }) => Promise<void>;
209
- /** 结构性识别流上的 tool_approval 帧(named SSE frame,payload.type === 帧名;非 AgentEvent arm)。 */
260
+ }) => Promise<ToolApprovalRespondAck | void>;
261
+ /**
262
+ * respond 回执的**结构化读口**(wire 是 JSON:注入面可能是旧 liveClient 的 raw fetch,也可能是
263
+ * 比本包新一版的 SDK)。坏形一律降 `undefined` —— 与 `controlRouter.errCodes` 同族纪律:
264
+ * 「拿到一个不好用的值」不如「诚实地什么都没有」。两个可选布尔位非布尔时**单独**降缺席
265
+ * (它们直接决定要不要渲「本会话全放行」,透传一个 `'yes'` 字符串会让 `!== false` 这类读法说反话)。
266
+ *
267
+ * 🔴 `decision` 判到 **wire 三词闭集**而不是「是不是串」(codex 复审二轮):ack 上那两个布尔位会
268
+ * 触发**高危安全声明**(「工具正在用原始入参跑」),所以一个 `decision:'garbage'` 的回执绝不许被
269
+ * 当成合规 ack 放行。**相关性**(「这个 ack 是不是**这一次**审批的回执」)不在本函数判 —— 本函数
270
+ * 只认形,对不对得上由 {@link surfaceToolApprovalFrameAndRespond} 拿着 frame 与刚发出的 decision 核。
271
+ */
272
+ export declare function readToolApprovalRespondAck(v: unknown): ToolApprovalRespondAck | undefined;
273
+ /**
274
+ * 一张 `tool_approval` 帧消费完的产物(FIX①,2026-08-07 **BREAKING**:此前是裸的
275
+ * `ToolApprovalRespondDecision | 'unresolved'` 字符串)。
276
+ * 迁移判据一句话:`const out = await surfaceToolApprovalFrameAndRespond(…); out.decision === 'deny'`。
277
+ */
278
+ export interface ToolApprovalFrameOutcome {
279
+ /** 用户(或 fail-closed)落定的决断;`'unresolved'` = respond 没送达(引擎按 TTL 自决)。 */
280
+ decision: ToolApprovalRespondDecision | 'unresolved';
281
+ /** server 的 200 ack。**缺席 = 未知**(注入面回 void / 旧 server / respond 失败),绝不当成 false。 */
282
+ ack?: ToolApprovalRespondAck;
283
+ }
284
+ /** 结构性识别流上的 tool_approval 帧(named SSE frame,payload.type === 帧名)。
285
+ * ⚠️ 口径更正(2026-08-08):此处原写「非 AgentEvent arm」—— SDK #185a 起这两个帧**是**
286
+ * `AgentEvent` 的臂了(durable 腿也回放),所以结构识别与 union 收窄两条路都成立;本函数仍按
287
+ * 结构读(不依赖类型收窄),因为它同时服务 raw SSE 与 durable 回放两条入口。 */
210
288
  export declare function isToolApprovalFrame(ev: unknown): ev is ToolApprovalFrame;
211
289
  /**
212
290
  * 消费一张 `tool_approval` 帧:弹三选卡 → respond。引擎此刻同步阻塞在本帧上(fail-closed 全腿:
@@ -215,6 +293,6 @@ export declare function isToolApprovalFrame(ev: unknown): ev is ToolApprovalFram
215
293
  * fail-closed:卡面不可用(print 模式无卡口/enqueue 失败)⇒ 立刻 respond deny,不让引擎
216
294
  * 干等 TTL。respond 自身失败(404=已过期/settled)⇒ 'unresolved'(诚实放行后续帧,引擎已自决)。
217
295
  *
218
- * @returns 用户( fail-closed)落定的决断;'unresolved' = respond 没送达。
296
+ * @returns {@link ToolApprovalFrameOutcome} —— 决断 + server ack(ack 缺席 = 未知,不是 false)。
219
297
  */
220
- export declare function surfaceToolApprovalFrameAndRespond(frame: ToolApprovalFrame, respond: RespondToolApprovalFn, streamArgs: unknown | undefined, signal?: AbortSignal): Promise<ToolApprovalRespondDecision | 'unresolved'>;
298
+ export declare function surfaceToolApprovalFrameAndRespond(frame: ToolApprovalFrame, respond: RespondToolApprovalFn, streamArgs: unknown | undefined, signal?: AbortSignal): Promise<ToolApprovalFrameOutcome>;
@@ -65,7 +65,7 @@ import { HitlBridge, HitlSafetyError, findPendingForTask } from './hitlBridge.js
65
65
  import { hostLog } from '../host.js';
66
66
  import { createSessionSlot, DEFAULT_SESSION_KEY } from '../sessionSlot.js';
67
67
  import { readEngineActiveBgTasks } from '../fleet/fleetLedger.js';
68
- import { observeCancelByDeny } from './hitlHostSurface.js';
68
+ import { observeCancelByDeny, surfaceRememberNotApplied, surfaceEditNotForwarded } from './hitlHostSurface.js';
69
69
  /** fs 写权限 gate 判定:未来的一等 kind(tool_approval)或按 toolName(server 桥首批=fs 写三件,
70
70
  * [820] 表)。AskUserQuestion 永不进这里(ask 桥先判)。 */
71
71
  export function isFsApprovalGate(gate) {
@@ -301,10 +301,39 @@ export const TOOL_APPROVAL_FRAME_KEYS_MIRROR = [
301
301
  'message',
302
302
  'args',
303
303
  'argsOmitted',
304
+ // sdk 6.9.0 追平(2026-08-08,#3004 跟修批):server 7.5.0 起真发 `governanceForced`,core 5.9.0 W1
305
+ // 起真发 `delegation` —— 镜像滞后了两代,于是这两键在包内**零消费面**(治理徽标端到端不可达,
306
+ // [1947] 那次 `toolCallId` 滞后的同款形)。本门(run-approval-frame-keys-test.mjs)对 SDK 运行期
307
+ // 锚逐元素相等,所以这次滞后是被门抓到的,不是人肉发现的。
308
+ 'governanceForced',
309
+ 'delegation',
304
310
  'outcome',
305
311
  ];
306
312
  const _mirrorPin = [true, true];
307
313
  void _mirrorPin;
314
+ /**
315
+ * `delegation` 的结构读(wire 是 JSON:注入面/旧 server/比本包新一版的 server 都可能给别的形)。
316
+ * 坏形一律降**缺席** —— 与 `readToolApprovalRespondAck` 同族纪律:「拿到一个不好用的值」不如
317
+ * 「诚实地什么都没有」。`parentToolCallId` 是卡的归属锚、`depth` 决定「第几层」的措辞,两者任一
318
+ * 不成形就整体不透传(半个链比没有链更容易被渲成错话);`agentName` 非串时单独降缺席。
319
+ *
320
+ * **不导出**(2026-08-08 收货修):它是本模块的边界窄化器,唯一消费点就在下面那个卡口调用;导出会
321
+ * 给公面加一个 `unknown` 入参签名(typeshape 门的 unknown-出境棘轮 +1),而它的行为在 pure 门里由
322
+ * `surfaceToolApprovalFrameAndRespond` 的坏形/正形素材端到端覆盖 —— 真消费路径比直调守卫更强。
323
+ */
324
+ function isToolApprovalDelegation(v) {
325
+ if (v === null || typeof v !== 'object')
326
+ return false;
327
+ const d = v;
328
+ // 归属锚:必须是**非空白**串(`' '` 当 tool-call id 用会让卡按一个空锚归属)。
329
+ if (typeof d.parentToolCallId !== 'string' || d.parentToolCallId.trim().length === 0)
330
+ return false;
331
+ // 深度:上游语义是「宿主直接子代 = 1」⇒ **正安全整数**。0 / 负数 / 小数都不是可渲染的层数,
332
+ // 而 `Number.isFinite` 会把它们全放过(codex 复审命中:`depth: 0` / `-1` / `1.5` 曾一律判真)。
333
+ if (typeof d.depth !== 'number' || !Number.isSafeInteger(d.depth) || d.depth < 1)
334
+ return false;
335
+ return d.agentName === undefined || typeof d.agentName === 'string';
336
+ }
308
337
  /** 子代帧判别:显式键 fromSubagent(core 1.378 RB-39②)优先;缺席退 sourceTaskId 在场性权宜式
309
338
  * (server 1.258 [1549]①3,旧代际兼容)。 */
310
339
  export function isFromSubagent(frame) {
@@ -312,7 +341,46 @@ export function isFromSubagent(frame) {
312
341
  return true;
313
342
  return typeof frame.sourceTaskId === 'string' && frame.sourceTaskId.length > 0;
314
343
  }
315
- /** 结构性识别流上的 tool_approval 帧(named SSE frame,payload.type === 帧名;非 AgentEvent arm)。 */
344
+ /** 上面那个闭集的运行期真源(ack 判形要用;词表与类型由下方 `Covers` 双向钉住)。 */
345
+ const RESPOND_DECISIONS = ['allow', 'allow_session', 'deny'];
346
+ const _respondDecisionPins = [true, true];
347
+ void _respondDecisionPins;
348
+ function isRespondDecision(v) {
349
+ return typeof v === 'string' && RESPOND_DECISIONS.includes(v);
350
+ }
351
+ /**
352
+ * respond 回执的**结构化读口**(wire 是 JSON:注入面可能是旧 liveClient 的 raw fetch,也可能是
353
+ * 比本包新一版的 SDK)。坏形一律降 `undefined` —— 与 `controlRouter.errCodes` 同族纪律:
354
+ * 「拿到一个不好用的值」不如「诚实地什么都没有」。两个可选布尔位非布尔时**单独**降缺席
355
+ * (它们直接决定要不要渲「本会话全放行」,透传一个 `'yes'` 字符串会让 `!== false` 这类读法说反话)。
356
+ *
357
+ * 🔴 `decision` 判到 **wire 三词闭集**而不是「是不是串」(codex 复审二轮):ack 上那两个布尔位会
358
+ * 触发**高危安全声明**(「工具正在用原始入参跑」),所以一个 `decision:'garbage'` 的回执绝不许被
359
+ * 当成合规 ack 放行。**相关性**(「这个 ack 是不是**这一次**审批的回执」)不在本函数判 —— 本函数
360
+ * 只认形,对不对得上由 {@link surfaceToolApprovalFrameAndRespond} 拿着 frame 与刚发出的 decision 核。
361
+ */
362
+ export function readToolApprovalRespondAck(v) {
363
+ if (v === null || typeof v !== 'object')
364
+ return undefined;
365
+ const o = v;
366
+ if (o.delivery !== 'applied')
367
+ return undefined;
368
+ if (typeof o.approvalId !== 'string' || o.approvalId.length === 0)
369
+ return undefined;
370
+ if (!isRespondDecision(o.decision))
371
+ return undefined;
372
+ return {
373
+ approvalId: o.approvalId,
374
+ delivery: 'applied',
375
+ decision: o.decision,
376
+ ...(typeof o.rememberApplied === 'boolean' ? { rememberApplied: o.rememberApplied } : {}),
377
+ ...(typeof o.updatedInputForwarded === 'boolean' ? { updatedInputForwarded: o.updatedInputForwarded } : {}),
378
+ };
379
+ }
380
+ /** 结构性识别流上的 tool_approval 帧(named SSE frame,payload.type === 帧名)。
381
+ * ⚠️ 口径更正(2026-08-08):此处原写「非 AgentEvent arm」—— SDK #185a 起这两个帧**是**
382
+ * `AgentEvent` 的臂了(durable 腿也回放),所以结构识别与 union 收窄两条路都成立;本函数仍按
383
+ * 结构读(不依赖类型收窄),因为它同时服务 raw SSE 与 durable 回放两条入口。 */
316
384
  export function isToolApprovalFrame(ev) {
317
385
  const e = ev;
318
386
  return (!!e &&
@@ -365,7 +433,7 @@ function subagentBadgeFor(frame) {
365
433
  * fail-closed:卡面不可用(print 模式无卡口/enqueue 失败)⇒ 立刻 respond deny,不让引擎
366
434
  * 干等 TTL。respond 自身失败(404=已过期/settled)⇒ 'unresolved'(诚实放行后续帧,引擎已自决)。
367
435
  *
368
- * @returns 用户( fail-closed)落定的决断;'unresolved' = respond 没送达。
436
+ * @returns {@link ToolApprovalFrameOutcome} —— 决断 + server ack(ack 缺席 = 未知,不是 false)。
369
437
  */
370
438
  export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamArgs, signal) {
371
439
  const toolName = typeof frame.toolName === 'string' ? frame.toolName : 'Write';
@@ -390,6 +458,9 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
390
458
  // [1535]:子代的写审批浮到宿主卡(server 1.258 broker 腿)——卡头渲归属徽章(`· @name`),
391
459
  // 用户知道在替谁批。判别=fromSubagent 显式键(core 1.378)优先、sourceTaskId 在场性权宜式兜底;
392
460
  // 宿主自身 ask 两键恒缺席=徽章缺席,卡形不变。
461
+ // 🔴 治理位与委派链透传(2026-08-08):两键在帧上**只在为真/在场时**出现,所以这里也按在场性
462
+ // 条件 stamp —— 绝不 `governanceForced: frame.governanceForced === true`(那会把「缺席」折成
463
+ // 显式 `false`,而缺席的语义是「没有治理来源的证据」,不是「这门可以被表态掀掉」)。
393
464
  const card = await surfaceApprovalCard({
394
465
  toolName,
395
466
  args: args,
@@ -397,6 +468,15 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
397
468
  ...(signal ? { signal } : {}),
398
469
  ...(isFromSubagent(frame) ? { workerBadge: subagentBadgeFor(frame) } : {}),
399
470
  ...(wireNote !== undefined ? { wireNote } : {}),
471
+ ...(frame.governanceForced === true ? { governanceForced: true } : {}),
472
+ // 🔴 跨字段不变量(codex 复审命中):`delegation` 只在**子代 ask** 的门上在场(SDK 头注:
473
+ // "Present on the same door as fromSubagent (child asks only)")。只校对象形不校这一条 ⇒
474
+ // 一个**宿主自己**的破坏性工具审批也能带着 delegation 到卡口,被呈成「某层子代发起的」——
475
+ // 把归属这件安全展示位渲错,正是本键存在的反面。`isToolApprovalFrame` 只验 type/approvalId,
476
+ // 所以 raw SSE / durable 回放里的坏形到得了这里,必须在本层挡住。
477
+ ...(isFromSubagent(frame) && isToolApprovalDelegation(frame.delegation)
478
+ ? { delegation: frame.delegation }
479
+ : {}),
400
480
  });
401
481
  const decision = card.kind === 'allow' ? (card.allowSession ? 'allow_session' : 'allow') : 'deny';
402
482
  if (card.kind === 'failed') {
@@ -404,14 +484,40 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
404
484
  }
405
485
  try {
406
486
  // abort 后的 deny 仍要送达(引擎侧同款 cancel-by-deny)——不带已 aborted 的 signal。
407
- await respond(frame.approvalId, decision, {
487
+ const raw = await respond(frame.approvalId, decision, {
408
488
  ...(signal && !signal.aborted ? { signal } : {}),
409
489
  ...(card.kind === 'allow' && card.updatedInput !== undefined ? { updatedInput: card.updatedInput } : {}),
410
490
  });
411
- return decision;
491
+ // 🔴 相关性门(codex 复审二轮):ack 必须是**这一次**审批的回执 —— id 与决断词都要对上。
492
+ // 对不上的 ack(注入面串了别人的响应 / 坏 mock / raw-fetch 包装器复用了连接)如果照样被
493
+ // 消费,一次普通审批就能弹出「你的编辑没生效、工具正在用原始入参跑」这种强安全声明。
494
+ // 不匹配 ⇒ 整个 ack 丢弃(降成「未知」)+ 响亮留痕,绝不据此触发任何用户告警。
495
+ const parsed = readToolApprovalRespondAck(raw);
496
+ let ack = parsed;
497
+ if (parsed !== undefined && (parsed.approvalId !== frame.approvalId || parsed.decision !== decision)) {
498
+ hostLog('error', `liveToolApprovalWire: DISCARDING respond ack for ${frame.approvalId} — it does not correlate ` +
499
+ `(ack.approvalId=${parsed.approvalId} ack.decision=${parsed.decision}, sent decision=${decision}); ` +
500
+ 'treating as "no ack" (unknown) — never surfacing a safety notice off an unrelated receipt');
501
+ ack = undefined;
502
+ }
503
+ // 🔴 FIX①:三选卡第 2 项在用户心里签的是「本会话这个工具不再问我」。server 说没记住时,
504
+ // 界面必须收敛那个承诺 —— 这里只发一条诚实通知(渲不渲「全放行」徽标由宿主按 ack 决定,
505
+ // ack 也随本函数返回值一起交出去)。ack 缺席(旧 server / void 注入面)= 未知,不发。
506
+ if (decision === 'allow_session' && ack?.rememberApplied === false) {
507
+ hostLog('debug', `liveToolApprovalWire: ${frame.approvalId} allow_session ack rememberApplied=false — grant not stored, surfacing honest notice`);
508
+ surfaceRememberNotApplied();
509
+ }
510
+ // 🔴 [high] 同族但更重的一位:用户**编辑过**入参并批准,而 server 说编辑没转发 ⇒ 引擎按
511
+ // 原始入参跑。批的那份 ≠ 跑的那份,对 Write/Edit/Bash 就是破坏性操作的入口。
512
+ // allow 已送达,事后无法 fail-closed ⇒ 唯一诚实的处置是立刻响亮告知(不是 debug 日志)。
513
+ if (card.kind === 'allow' && card.updatedInput !== undefined && ack?.updatedInputForwarded === false) {
514
+ hostLog('debug', `liveToolApprovalWire: ${frame.approvalId} edited args were NOT forwarded (ack.updatedInputForwarded=false) — the tool runs on the ORIGINAL input`);
515
+ surfaceEditNotForwarded();
516
+ }
517
+ return ack !== undefined ? { decision, ack } : { decision };
412
518
  }
413
519
  catch (e) {
414
520
  hostLog('debug', `liveToolApprovalWire: respond(${decision}) failed for ${frame.approvalId}: ${String(e)} — engine self-settles (TTL/abort)`);
415
- return 'unresolved';
521
+ return { decision: 'unresolved' };
416
522
  }
417
523
  }
@@ -6,16 +6,46 @@
6
6
  * retrying → error → '✻ API error · Retrying in Ns'(非终态;187 硬编码 'API error')
7
7
  * rate_limited → error → '✻ Usage limit reached · Retrying in Ns'(rateLimits 真 ⇒ 终态)
8
8
  * circuit_open → error → '✻ <detail|Service temporarily unavailable> · Retrying in Ns'
9
- * 绝不捏造 attempt 计数——只用引擎真给的 phase/detail/retryInSec。
9
+ * recovered → null → 覆盖层摘掉(重试**成功**,不是错误 —— 见下)
10
+ * gave_up → error+terminal → '✻ <detail>'(重试用尽的终态;与 recovered 反向。**打 terminal 位**,
11
+ * 渲染面据此不得再接「· Retrying in Ns」——已经没有下一次了)
12
+ * 绝不捏造 attempt 计数——只用引擎真给的 phase / detail / retryInSec / retryInMs / attempt / maxRetries。
13
+ *
14
+ * 🔴 员数与字段补全(2026-08-08,#3004 跟修批)。此前本文件只列 4 相 + 3 字段,而引擎侧
15
+ * (core `BrainStatusPhase` / `BrainStatus`,dist/core/types.d.ts)是 **6 相 + 6 字段**,server 两腿的
16
+ * `brainStatusEventData`(dist/trace/project.js)把 attempt/maxRetries/retryInMs 全发。两处后果:
17
+ * · `recovered`(引擎重试**成功**的终态)落 `default:` 臂 ⇒ 被渲成 `kind:'error'`,即
18
+ * 「刚刚恢复」被渲成「API error · Retrying」—— 方向完全相反的谎报;
19
+ * · attempt/maxRetries/retryInMs 在本层被剥掉 ⇒ `RetryStatus` 上早就留好的 attempt/maxRetries
20
+ * 两位恒缺席。这不是「不许编造」(那条纪律没错),是**供给方给了却没接**。
21
+ * 反漂移腿:`scripts/run-engine-vocab-floor-test.mjs` G2-b/G2-c 拿下面两张运行期镜像对**实装
22
+ * @sema-agent/core** 的 `BrainStatusPhase` / `BrainStatus` 逐词逐键对账 —— 引擎再加相/加键,那边先红。
10
23
  */
11
- export type RetryStatus = {
24
+ export type RetryStatus =
25
+ /** 等 API 响应(`reconnecting`)。**计数两位在场**:实装 core 的 reconnecting 帧与 retrying/
26
+ * rate_limited 同形,六字段全发(`stream-engine.js` 三处 `sleepAnnouncingRetry` 逐处实测)——
27
+ * 丢掉它们正好是在「用户最需要看重连进度」的那一刻把进度藏起来(2026-08-08 codex 复审命中)。 */
28
+ {
12
29
  kind: 'stalled';
13
30
  deadline: number;
31
+ attempt?: number;
32
+ maxRetries?: number;
14
33
  } | {
15
34
  kind: 'error';
16
35
  deadline: number;
17
36
  attempt?: number;
18
37
  maxRetries?: number;
38
+ /**
39
+ * 🔴 **终态位**(2026-08-08 codex 复审命中):`true` ⇔ 引擎**不会再重试了**(`gave_up` 相)。
40
+ * 缺席 = 仍在重试循环里(retrying / rate_limited / circuit_open)。
41
+ *
42
+ * 为什么必须有这一位:`deadline` 的语义是「距下次重试」,而 gave_up 根本没有下次 —— 实装 core
43
+ * 的 finally 只发 `{phase:'gave_up', detail:'retries exhausted'}`(**不带** attempt/maxRetries,
44
+ * 所以消费方也无法靠「3/3」推出耗尽)。没有这一位,187 那套「<detail> · Retrying in Ns」句式
45
+ * 会把一次**已经放弃**渲成「正在重试,还剩 0 秒」——终帧稍有延迟,这句谎就一直挂着。
46
+ * 渲染措辞是壳半场(本包不渲染),本位只保证那个事实到得了壳。
47
+ */
48
+ terminal?: true;
19
49
  error: {
20
50
  formatted: string;
21
51
  isNetworkDown?: boolean;
@@ -28,9 +58,38 @@ export type RetryStatus = {
28
58
  } | null;
29
59
  };
30
60
  };
61
+ /**
62
+ * core `BrainStatusPhase` 的**运行期值拷贝**(engine-vocab 门 G2-b 拿它对实装 core 对账)。
63
+ * 引擎侧是闭集,**读时按开集处理** —— 一个部署可以在自己的通道上冒出别的相,认得的分支照走,
64
+ * 认不得的落 `default:` 兜底(见 {@link mapBrainStatusToRetry} 末臂)。
65
+ */
66
+ export declare const BRAIN_STATUS_PHASES: readonly ["rate_limited", "retrying", "reconnecting", "circuit_open", "recovered", "gave_up"];
67
+ export type BrainStatusPhase = (typeof BRAIN_STATUS_PHASES)[number];
68
+ /** wire 上 `status` 臂的载荷(= core `BrainStatus`;server 两腿白名单原样转发这 6 键)。 */
31
69
  export interface BrainStatusPayload {
32
- phase: string;
70
+ /** 闭集 + `(string & {})`:未知相仍可携带(开集读),不必先改类型再解析。 */
71
+ phase: BrainStatusPhase | (string & {});
72
+ /** 中性人话提示(无 provider/HTTP 细节;server 已脱敏 + 限 300 字)。 */
33
73
  detail?: string;
74
+ /** 距下次重试的秒数(= `ceil(retryInMs/1000)`,core circuit-breaker 与 ms 位同发)。 */
34
75
  retryInSec?: number;
76
+ /** 距下次重试的毫秒数 —— **更精的同一个量**,在场时优先于 `retryInSec`。 */
77
+ retryInMs?: number;
78
+ /** 引擎真实的第几次尝试(1-based)。缺席 = 引擎没给,绝不折成 0。 */
79
+ attempt?: number;
80
+ /** 引擎这一轮的重试上限。与 `attempt` 一起才能渲「2/5」。 */
81
+ maxRetries?: number;
35
82
  }
36
- export declare function mapBrainStatusToRetry(p: BrainStatusPayload, nowMs: number): RetryStatus;
83
+ /**
84
+ * 上面那个 interface 的**运行期键镜像**(照 `TOOL_APPROVAL_FRAME_KEYS_MIRROR` 先例):
85
+ * engine-vocab 门 G2-c 拿它与 core `BrainStatus` 的键集逐元素比 ⇒ 引擎 additive 增键当天红。
86
+ * 下面两个类型钉保证镜像与 interface 之间不可能漂移(少键/多键都是编译错)。
87
+ */
88
+ export declare const BRAIN_STATUS_PAYLOAD_KEYS: readonly ["phase", "detail", "retryInSec", "retryInMs", "attempt", "maxRetries"];
89
+ /**
90
+ * BrainStatus 载荷 → spinner 行状态。
91
+ *
92
+ * 返回 **`null` = 没有覆盖层**(不是「不知道」):今天只有 `recovered` 走这条 —— 引擎明说这次重试
93
+ * 已经成功,行就该摘掉。此前本函数返回型钉死非空,于是 `recovered` 只能被塞进 error 臂。
94
+ */
95
+ export declare function mapBrainStatusToRetry(p: BrainStatusPayload, nowMs: number): RetryStatus | null;