@sema-agent/client-core 0.60.0 → 0.62.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.
@@ -67,6 +67,57 @@ export function resolveWireAuth(baseUrl, token) {
67
67
  export function wireAuthTokenFor(baseUrl, token) {
68
68
  return resolveWireAuth(baseUrl, token);
69
69
  }
70
+ /** 出站头的**归一读写**(SDK 的传输层给的是普通对象,但入参形状是 `HeadersInit` 三形之一)。 */
71
+ function headersToRecord(init) {
72
+ const out = {};
73
+ if (init === undefined)
74
+ return out;
75
+ if (Array.isArray(init)) {
76
+ for (const [k, v] of init)
77
+ out[String(k).toLowerCase()] = String(v);
78
+ return out;
79
+ }
80
+ const maybe = init;
81
+ if (typeof maybe.forEach === 'function') {
82
+ ;
83
+ init.forEach((v, k) => {
84
+ out[String(k).toLowerCase()] = String(v);
85
+ });
86
+ return out;
87
+ }
88
+ for (const [k, v] of Object.entries(init))
89
+ out[String(k).toLowerCase()] = String(v);
90
+ return out;
91
+ }
92
+ /**
93
+ * 把一个 `fetch` 包一层:**每一发出站请求**重读凭证并就地改写 Authorization 头。
94
+ *
95
+ * 🔴 为什么是包 `fetch` 而不是「构造时解析一次」:底层客户端把凭证捕获进构造期闭包,构造时解析
96
+ * 等于把快照换了个位置放 —— 换代之后照样发旧凭证。请求那一刻才是**决定结果的时刻**。
97
+ * 🔴 三态在这一层逐格重演(与 {@link resolveWireAuth} 同一只):串 ⇒ `Bearer <串>`;
98
+ * `{mode:'loopback-unauthed'}` ⇒ **删**掉 Authorization 头(绝不伪造一个)。
99
+ * 🔴 取值函数抛错 ⇒ 当「这一刻没有凭证」处置(fail-soft):一个坏掉的取值口不该让整条 wire 不可用,
100
+ * 而且异常里可能夹带凭证材料,原样上抛就是又一条外溢面。
101
+ */
102
+ function withPerRequestAuth(baseUrl, read, inner) {
103
+ return async (input, init) => {
104
+ let token;
105
+ try {
106
+ token = read();
107
+ }
108
+ catch {
109
+ // fail-soft:取不到就按无 token 走三态(理由见本函数头注第三条)。
110
+ token = undefined;
111
+ }
112
+ const resolved = resolveWireAuth(baseUrl, token);
113
+ const headers = headersToRecord(init?.headers);
114
+ if (typeof resolved === 'string')
115
+ headers.authorization = `Bearer ${resolved}`;
116
+ else
117
+ delete headers.authorization;
118
+ return inner(input, { ...init, headers });
119
+ };
120
+ }
70
121
  /**
71
122
  * 构造一个引擎 wire 用 AgentClient。null = 构造失败(SDK fail-closed 拒绝——不该发生:
72
123
  * wireAuthTokenFor 恒给合法形;防御性 fail-soft,调用方按各自离线/探测失败路径处理)。
@@ -74,6 +125,9 @@ export function wireAuthTokenFor(baseUrl, token) {
74
125
  export function makeEngineWireClient(cfg) {
75
126
  try {
76
127
  const principal = normalizeWirePrincipal(cfg.principal);
128
+ // 取值函数形:出站腿包一层「每发一次读一次」;构造期**一次都不读**(读一次就是一次快照)。
129
+ const source = typeof cfg.token === 'function' ? cfg.token : null;
130
+ const innerFetch = cfg.fetchImpl ?? fetch;
77
131
  const base = {
78
132
  baseUrl: cfg.baseUrl,
79
133
  // F-011 停发:缺席/空串=不给键(SDK 6.11 缺席=不发 x-agent-principal 头,owner-null)。
@@ -81,13 +135,26 @@ export function makeEngineWireClient(cfg) {
81
135
  ...(principal !== undefined ? { principal } : {}),
82
136
  ...(cfg.timeoutMs !== undefined ? { timeoutMs: cfg.timeoutMs } : {}),
83
137
  maxRetries: cfg.maxRetries ?? 0,
84
- ...(cfg.fetchImpl ? { fetch: cfg.fetchImpl } : {}),
138
+ ...(source !== null
139
+ ? { fetch: withPerRequestAuth(cfg.baseUrl, source, innerFetch) }
140
+ : cfg.fetchImpl
141
+ ? { fetch: cfg.fetchImpl }
142
+ : {}),
85
143
  };
86
144
  // relay 形直传(显式声明,不过三态解析);串/缺席走 resolveWireAuth 三态,语义与 0.28.x 字节
87
145
  // 不变。分支构造而非三元合流:SDK AgentClientConfig 按 authToken 判别联合,联合值不可直赋。
88
- return typeof cfg.token === 'object'
89
- ? new AgentClient({ ...base, authToken: cfg.token })
90
- : new AgentClient({ ...base, authToken: resolveWireAuth(cfg.baseUrl, cfg.token) });
146
+ if (typeof cfg.token === 'object')
147
+ return new AgentClient({ ...base, authToken: cfg.token });
148
+ if (source !== null) {
149
+ // 🔴 构造期给的是**不读取值函数**的三态形(回环 ⇒ loopback-unauthed;非回环 ⇒ 匿名身份)。
150
+ // 它只用来过 SDK 的构造期形校验与浏览器守卫 —— 真正上 wire 的那一份由上面那层每发重写,
151
+ // 所以这里绝不能顺手调一次取值函数:调了就多一次「与任何请求都不对应」的读取,
152
+ // 而「读取次数 = 请求次数」正是本形的可验证承诺(见 run-wire-auth-source-test.mjs G2)。
153
+ return new AgentClient({ ...base, authToken: resolveWireAuth(cfg.baseUrl, undefined) });
154
+ }
155
+ // 到这里 `cfg.token` 只可能是串或缺席(对象形与函数形都已在上面分流),类型面收窄靠一次显式判。
156
+ const literal = typeof cfg.token === 'string' ? cfg.token : undefined;
157
+ return new AgentClient({ ...base, authToken: resolveWireAuth(cfg.baseUrl, literal) });
91
158
  }
92
159
  catch {
93
160
  return null;
@@ -6,6 +6,25 @@
6
6
  * 供单测直取——fleetClient 拖 SDK/appState/notification 重图,无法轻量 bundle;行为与提出前逐字同源)。
7
7
  */
8
8
  export declare function escapeDisplayControlChars(s: string): string;
9
+ /**
10
+ * UNTRUSTED-for-display 呈前处理的**共用铸点**:**先转义、后按转义结果封长**。
11
+ *
12
+ * 🔴 顺序反过来会让承诺的显示预算失守 —— {@link escapeDisplayControlChars} 把每个不可见字符
13
+ * 改写成 6 字符的 `\uXXXX`,先按原文截长再转义,会把一段纯控制字符的 `max` 个原始字符送成
14
+ * `6 × max` 个显示字符。屏上的列宽预算是按**显示字符**给的,所以封长必须量转义后的那一份。
15
+ * 🔴 相反方向的顾虑(「先转义后截会把一枚转义序列拦腰截断」)由下面两条避让解掉,不构成
16
+ * 改回先截后转义的理由:
17
+ * · 截点落在一枚 `\uXXXX` token 中间 ⇒ 回退到该 token 之前(留半截 `\u00` 比整枚不渲更坏,
18
+ * 它看起来像一个完整答案的开头);
19
+ * · 截点落在一对合法代理对(如 emoji)中间 ⇒ 回退一位。`DISPLAY_UNSAFE` 只转义**孤**代理项,
20
+ * 合法对被放行原样透传,在这里截断等于人为造一枚裸高位代理项留在末尾。
21
+ *
22
+ * 语义:返回值恒是**已消毒**的串,长度 ≤ `max`(可能因避让短于 `max`)。`max <= 0` ⇒ 空串。
23
+ *
24
+ * ⇄ 本函数是本包该族的**唯一**实现(0.62.0 起):operator 面的 READ 档位读器、写保护姿态行、
25
+ * SQL 姿态行四处此前各写一份,其中三份是先截后转义 —— 同形存量清剿后一律接这一只。
26
+ */
27
+ export declare function capForDisplay(raw: string, max: number): string;
9
28
  /** collapse a wire label to a single trimmed line (drops embedded newlines/runs). 187 row labels are always
10
29
  * one short line; the <FleetTree/> column (Math.min(28,…) + wrap="truncate") does the visible ellipsis, so we
11
30
  * only normalize here — no double-ellipsis, faithful to the 187 render.
@@ -39,6 +39,40 @@ const DISPLAY_UNSAFE = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028\u202
39
39
  export function escapeDisplayControlChars(s) {
40
40
  return s.replace(DISPLAY_UNSAFE, ch => `\\u${(ch.codePointAt(0) ?? 0).toString(16).toUpperCase().padStart(4, '0')}`);
41
41
  }
42
+ /**
43
+ * UNTRUSTED-for-display 呈前处理的**共用铸点**:**先转义、后按转义结果封长**。
44
+ *
45
+ * 🔴 顺序反过来会让承诺的显示预算失守 —— {@link escapeDisplayControlChars} 把每个不可见字符
46
+ * 改写成 6 字符的 `\uXXXX`,先按原文截长再转义,会把一段纯控制字符的 `max` 个原始字符送成
47
+ * `6 × max` 个显示字符。屏上的列宽预算是按**显示字符**给的,所以封长必须量转义后的那一份。
48
+ * 🔴 相反方向的顾虑(「先转义后截会把一枚转义序列拦腰截断」)由下面两条避让解掉,不构成
49
+ * 改回先截后转义的理由:
50
+ * · 截点落在一枚 `\uXXXX` token 中间 ⇒ 回退到该 token 之前(留半截 `\u00` 比整枚不渲更坏,
51
+ * 它看起来像一个完整答案的开头);
52
+ * · 截点落在一对合法代理对(如 emoji)中间 ⇒ 回退一位。`DISPLAY_UNSAFE` 只转义**孤**代理项,
53
+ * 合法对被放行原样透传,在这里截断等于人为造一枚裸高位代理项留在末尾。
54
+ *
55
+ * 语义:返回值恒是**已消毒**的串,长度 ≤ `max`(可能因避让短于 `max`)。`max <= 0` ⇒ 空串。
56
+ *
57
+ * ⇄ 本函数是本包该族的**唯一**实现(0.62.0 起):operator 面的 READ 档位读器、写保护姿态行、
58
+ * SQL 姿态行四处此前各写一份,其中三份是先截后转义 —— 同形存量清剿后一律接这一只。
59
+ */
60
+ export function capForDisplay(raw, max) {
61
+ const escaped = escapeDisplayControlChars(raw);
62
+ if (escaped.length <= max)
63
+ return escaped;
64
+ let cut = max;
65
+ const tokenStart = escaped.lastIndexOf('\\', cut - 1);
66
+ if (tokenStart >= 0 && tokenStart + 6 > cut && /^\\u[0-9A-F]{4}$/.test(escaped.slice(tokenStart, tokenStart + 6))) {
67
+ cut = tokenStart;
68
+ }
69
+ if (cut > 0) {
70
+ const code = escaped.charCodeAt(cut - 1);
71
+ if (code >= 0xd800 && code <= 0xdbff)
72
+ cut -= 1;
73
+ }
74
+ return cut > 0 ? escaped.slice(0, cut) : '';
75
+ }
42
76
  /** collapse a wire label to a single trimmed line (drops embedded newlines/runs). 187 row labels are always
43
77
  * one short line; the <FleetTree/> column (Math.min(28,…) + wrap="truncate") does the visible ellipsis, so we
44
78
  * only normalize here — no double-ellipsis, faithful to the 187 render.
package/dist/index.d.ts CHANGED
@@ -151,6 +151,7 @@ export * from './engineCapsCache.js';
151
151
  export * from './sqlEngineCapability.js';
152
152
  export * from './writeProtectionCapability.js';
153
153
  export * from './runTerminal.js';
154
+ export * from './readFacePosture.js';
154
155
  export * from './gateOutcome.js';
155
156
  export * from './engineToolLabelStore.js';
156
157
  export * from './fleetTaskDesc.js';
package/dist/index.js CHANGED
@@ -168,6 +168,11 @@ export * from './sqlEngineCapability.js';
168
168
  export * from './writeProtectionCapability.js';
169
169
  // 0.60.0(engine ≥7.64.0 / sdk 8.4.0):终局因由与门记录两只**判定归包**的读器。
170
170
  export * from './runTerminal.js';
171
+ // 0.61.0(engine ≥7.65.0 / sdk 8.5.0 / S-167):READ 容纳面档位 + 来源的 operator 面窄读器
172
+ // (`WiringDiagnostics.readFace`)。与 `writeProtectionCapability` / `gateOutcome` 同构同纪律
173
+ // (防御读 / 唯一措辞铸点 / UNTRUSTED-for-display);与租户面 `capabilities.readFace` 刻意不合流,
174
+ // 只带一个纯比较函数,渲染归端。
175
+ export * from './readFacePosture.js';
171
176
  export * from './gateOutcome.js';
172
177
  export * from './engineToolLabelStore.js';
173
178
  export * from './fleetTaskDesc.js';
@@ -2,9 +2,10 @@
2
2
  * interactiveHalt — 交互 **Esc** 的停止判定层(#363 件③,0.47.0;三端公共判定上收)。
3
3
  *
4
4
  * ── 收的是哪一件事 ────────────────────────────────────────────────────────────────────────
5
- * 「用户按 Esc ⇒ 先发 **turn 级 halt**;升级成 **run 级 cancel** 恰有**两格**:
5
+ * 「用户按 Esc ⇒ 先发 **turn 级 halt**;升级成 **run 级 cancel** 恰有**三格**:
6
6
  * ① 引擎自己回了升级闭集里的 **409**(它在说「这里没有在飞 turn 可切,run 级停止请用 cancel」);
7
- * ② 这一发**连判决都没拿到**(传输失败/超时/未武装)**且**屏上确实挂着审批卡(`parked`)
7
+ * ② 这一发**连判决都没拿到**(传输失败/超时/未武装)**且**屏上确实挂着审批卡(`parked`);
8
+ * ③ 这一发连判决都没拿到 **且** 这条 run 武装了 **detach**(断连不再收 run,0.62.0 新格)。
8
9
  * 其余一律不升级。」—— 这条判定 TUI / desktop / web **三端都要**(三端都会 Esc、都会撞
9
10
  * 同一个 parked 格),此前整条住在 cli 壳里(`src/sema/seamQuery.ts` 的 `bestEffortInteractiveHalt`
10
11
  * + `src/sema/interruptWire.ts` 的 `needsRunLevelStop`)。本模块把**判据**搬进来;
@@ -96,6 +97,20 @@ export interface InteractiveHaltInput {
96
97
  * 🔴 缺席 / 非 `true` 一律按**非 parked** 处理(fail-closed:证不出 parked 就不给升级资格)。
97
98
  */
98
99
  readonly parked?: boolean | undefined;
100
+ /**
101
+ * **这条 run 武装了 detach 吗**(壳自己独立知道的第二个事实:出站时给了「断连不收 run」的那一位)。
102
+ *
103
+ * 🔴 为什么它会改变判决(0.62.0 新格):撕 SSE 的**后果**因它而变。老形态下交互车道零 detach ——
104
+ * 撕流 = server 按断连语义当场把那条 run 收尾,所以「interrupt 那一发没落地」还有一层兜底
105
+ * (用户按 Esc、壳撕流,run 也就停了)。detach 武装之后,撕流只断开**观看**,run 会一路跑到
106
+ * turn 结束:此时 interrupt 自身失败(传输失败/超时/未武装)= 用户按下的 Esc **一点效果都没有**,
107
+ * 而屏上没有任何东西会告诉他这件事。这一格就是那层兜底消失后留下的洞。
108
+ * 🔴 它**只在「连判决都没拿到」那一格**被读 —— 与 {@link InteractiveHaltInput.parked} 同一条纪律:
109
+ * 引擎给了判决时判决说了算,壳自己知道的事实不许骑到引擎的结构化答复上。
110
+ * 🔴 **缺席 / 非 `true` 一律按未武装处理**(fail-closed,与 `parked` 同尺):证不出 detach 就不给
111
+ * 升级资格。⇒ 不传这个键的宿主行为与上一版**逐格相同**(门里有全格穷举的零差断言)。
112
+ */
113
+ readonly detachArmed?: boolean | undefined;
99
114
  /**
100
115
  * 首发 interrupt 的结局。**缺席 = 这一发还没打** ⇒ 本函数判 `interrupt`(首发恒行,无条件)。
101
116
  * 在场 ⇒ 本函数回答的是「接着还要不要升级」。
@@ -114,7 +129,9 @@ export type InteractiveHaltReason =
114
129
  | 'engine-says-run-level'
115
130
  /** 连判决都没拿到(传输失败/超时/未武装),但壳自证屏上挂着审批卡 ⇒ 这一格本来就没有在飞 turn。 */
116
131
  | 'no-verdict-on-parked-card'
117
- /** 连判决都没拿到且**非** parked那里真可能有在飞工具,凭一次超时拆整条 run 正是要消除的病。 */
132
+ /** 连判决都没拿到、也没有审批卡,但这条 run 武装了 detach 撕流不会收它,不升级 = Esc 零效果。 */
133
+ | 'no-verdict-detach-armed'
134
+ /** 连判决都没拿到、且两个壳侧事实都不成立 ⇒ 那里真可能有在飞工具,凭一次超时拆整条 run 正是要消除的病。 */
118
135
  | 'no-verdict-not-parked'
119
136
  /** 引擎给了判决,但不在升级闭集里(404 / 400 / 非 409 / `steering.not_running` / 码读不出)。 */
120
137
  | 'refused-no-escalation'
@@ -129,22 +146,28 @@ export interface InteractiveHaltPlan {
129
146
  * Esc 停止弧的**唯一判定口**。
130
147
  *
131
148
  * ── 分支表(逐条 = 一条行为承诺)──────────────────────────────────────────────────────────
132
- * | `interruptOutcome` | `parked` | 判决 | reason |
133
- * |--------------------------------------------|----------|-------------------|--------|
134
- * | 缺席(还没打) | 任意 | `interrupt` | `first-shot` |
135
- * | `halted` | 任意 | `none` | `halted` |
136
- * | `refused` + 409 + 升级闭集码 | 任意 | `escalate-cancel` | `engine-says-run-level` |
137
- * | `refused`(其余:非 409 / 码不在闭集 / 码缺席)| 任意 | `none` | `refused-no-escalation` |
138
- * | `transport` / `unarmed` | `true` | `escalate-cancel` | `no-verdict-on-parked-card` |
139
- * | `transport` / `unarmed` | 其余 | `none` | `no-verdict-not-parked` |
140
- * | 认不得的形 | 任意 | `none` | `unknown-outcome` |
149
+ * | `interruptOutcome` | `parked` | `detachArmed` | 判决 | reason |
150
+ * |--------------------------------------------|----------|---------------|-------------------|--------|
151
+ * | 缺席(还没打) | 任意 | 任意 | `interrupt` | `first-shot` |
152
+ * | `halted` | 任意 | 任意 | `none` | `halted` |
153
+ * | `refused` + 409 + 升级闭集码 | 任意 | 任意 | `escalate-cancel` | `engine-says-run-level` |
154
+ * | `refused`(其余:非 409 / 码不在闭集 / 码缺席)| 任意 | 任意 | `none` | `refused-no-escalation` |
155
+ * | `transport` / `unarmed` | `true` | 任意 | `escalate-cancel` | `no-verdict-on-parked-card` |
156
+ * | `transport` / `unarmed` | 其余 | `true` | `escalate-cancel` | `no-verdict-detach-armed` |
157
+ * | `transport` / `unarmed` | 其余 | 其余 | `none` | `no-verdict-not-parked` |
158
+ * | 认不得的形 | 任意 | 任意 | `none` | `unknown-outcome` |
141
159
  *
142
- * 🔴 **`parked` 只在「没有判决」那两格被读**:引擎给了判决时,判决说了算 —— 壳的 UI 状态不许覆盖
143
- * 引擎的结构化答复(反过来也一样:引擎说 parked 时,`parked=false` 不阻止升级)。
160
+ * 🔴 **`parked` 与 `detachArmed` 只在「没有判决」那两格被读**:引擎给了判决时,判决说了算 ——
161
+ * 壳的 UI 状态不许覆盖引擎的结构化答复(反过来也一样:引擎说 parked 时,`parked=false` 不阻止升级)。
162
+ * 🔴 **两个壳侧事实的先后是刻意的**:`parked` 先判,所以老宿主(只传 `parked`)的 reason 逐字不变;
163
+ * 两者同时为 `true` 时报 `no-verdict-on-parked-card` —— 那一格的处置与文案本来就成立,新键不改口。
164
+ * 🔴 **`detachArmed` 缺席 = 老语义零差**:不传这个键时整张表与上一版逐格相同(门里有全格穷举断言)。
144
165
  * 🔴 **首发无条件**:`first-shot` 那一格刻意不看 `parked`。审批卡挂着时首发 interrupt 会吃一个
145
166
  * 409,那正是升级闸要的**判决**;为了省一次往返而直接跳到 cancel,等于把判据从引擎搬回壳里猜。
146
- * 🔴 **顺序契约(端必读,不是本函数能保证的那半)**:这一发必须排在「撕 SSE」**之前**。交互车道零
147
- * `x-detach-on-disconnect`,先撕流 = server 按断连语义当场收尾那条 run,随后落地的 interrupt
148
- * 只会拿到 409 `steering.not_running`(cli L-11 真机实测:同步撕流形每一轮都是它)。
167
+ * 🔴 **顺序契约(端必读,不是本函数能保证的那半)**:这一发必须排在「撕 SSE」**之前**。
168
+ * · **未武装 detach** 的车道上,先撕流 = server 按断连语义当场收尾那条 run,随后落地的 interrupt
169
+ * 只会拿到 409 `steering.not_running`(真机实测:同步撕流形每一轮都是它)。
170
+ * · **武装了 detach** 的车道上,撕流只断开观看、run 照跑 —— 顺序不再影响 interrupt 拿到什么,
171
+ * 但它让「interrupt 没落地」变成一个**没有兜底**的结局,那正是 `detachArmed` 这一格要接的洞。
149
172
  */
150
173
  export declare function planInteractiveHalt(input: InteractiveHaltInput): InteractiveHaltPlan;
@@ -2,9 +2,10 @@
2
2
  * interactiveHalt — 交互 **Esc** 的停止判定层(#363 件③,0.47.0;三端公共判定上收)。
3
3
  *
4
4
  * ── 收的是哪一件事 ────────────────────────────────────────────────────────────────────────
5
- * 「用户按 Esc ⇒ 先发 **turn 级 halt**;升级成 **run 级 cancel** 恰有**两格**:
5
+ * 「用户按 Esc ⇒ 先发 **turn 级 halt**;升级成 **run 级 cancel** 恰有**三格**:
6
6
  * ① 引擎自己回了升级闭集里的 **409**(它在说「这里没有在飞 turn 可切,run 级停止请用 cancel」);
7
- * ② 这一发**连判决都没拿到**(传输失败/超时/未武装)**且**屏上确实挂着审批卡(`parked`)
7
+ * ② 这一发**连判决都没拿到**(传输失败/超时/未武装)**且**屏上确实挂着审批卡(`parked`);
8
+ * ③ 这一发连判决都没拿到 **且** 这条 run 武装了 **detach**(断连不再收 run,0.62.0 新格)。
8
9
  * 其余一律不升级。」—— 这条判定 TUI / desktop / web **三端都要**(三端都会 Esc、都会撞
9
10
  * 同一个 parked 格),此前整条住在 cli 壳里(`src/sema/seamQuery.ts` 的 `bestEffortInteractiveHalt`
10
11
  * + `src/sema/interruptWire.ts` 的 `needsRunLevelStop`)。本模块把**判据**搬进来;
@@ -85,23 +86,29 @@ function enginePointsToRunLevelStop(outcome) {
85
86
  * Esc 停止弧的**唯一判定口**。
86
87
  *
87
88
  * ── 分支表(逐条 = 一条行为承诺)──────────────────────────────────────────────────────────
88
- * | `interruptOutcome` | `parked` | 判决 | reason |
89
- * |--------------------------------------------|----------|-------------------|--------|
90
- * | 缺席(还没打) | 任意 | `interrupt` | `first-shot` |
91
- * | `halted` | 任意 | `none` | `halted` |
92
- * | `refused` + 409 + 升级闭集码 | 任意 | `escalate-cancel` | `engine-says-run-level` |
93
- * | `refused`(其余:非 409 / 码不在闭集 / 码缺席)| 任意 | `none` | `refused-no-escalation` |
94
- * | `transport` / `unarmed` | `true` | `escalate-cancel` | `no-verdict-on-parked-card` |
95
- * | `transport` / `unarmed` | 其余 | `none` | `no-verdict-not-parked` |
96
- * | 认不得的形 | 任意 | `none` | `unknown-outcome` |
89
+ * | `interruptOutcome` | `parked` | `detachArmed` | 判决 | reason |
90
+ * |--------------------------------------------|----------|---------------|-------------------|--------|
91
+ * | 缺席(还没打) | 任意 | 任意 | `interrupt` | `first-shot` |
92
+ * | `halted` | 任意 | 任意 | `none` | `halted` |
93
+ * | `refused` + 409 + 升级闭集码 | 任意 | 任意 | `escalate-cancel` | `engine-says-run-level` |
94
+ * | `refused`(其余:非 409 / 码不在闭集 / 码缺席)| 任意 | 任意 | `none` | `refused-no-escalation` |
95
+ * | `transport` / `unarmed` | `true` | 任意 | `escalate-cancel` | `no-verdict-on-parked-card` |
96
+ * | `transport` / `unarmed` | 其余 | `true` | `escalate-cancel` | `no-verdict-detach-armed` |
97
+ * | `transport` / `unarmed` | 其余 | 其余 | `none` | `no-verdict-not-parked` |
98
+ * | 认不得的形 | 任意 | 任意 | `none` | `unknown-outcome` |
97
99
  *
98
- * 🔴 **`parked` 只在「没有判决」那两格被读**:引擎给了判决时,判决说了算 —— 壳的 UI 状态不许覆盖
99
- * 引擎的结构化答复(反过来也一样:引擎说 parked 时,`parked=false` 不阻止升级)。
100
+ * 🔴 **`parked` 与 `detachArmed` 只在「没有判决」那两格被读**:引擎给了判决时,判决说了算 ——
101
+ * 壳的 UI 状态不许覆盖引擎的结构化答复(反过来也一样:引擎说 parked 时,`parked=false` 不阻止升级)。
102
+ * 🔴 **两个壳侧事实的先后是刻意的**:`parked` 先判,所以老宿主(只传 `parked`)的 reason 逐字不变;
103
+ * 两者同时为 `true` 时报 `no-verdict-on-parked-card` —— 那一格的处置与文案本来就成立,新键不改口。
104
+ * 🔴 **`detachArmed` 缺席 = 老语义零差**:不传这个键时整张表与上一版逐格相同(门里有全格穷举断言)。
100
105
  * 🔴 **首发无条件**:`first-shot` 那一格刻意不看 `parked`。审批卡挂着时首发 interrupt 会吃一个
101
106
  * 409,那正是升级闸要的**判决**;为了省一次往返而直接跳到 cancel,等于把判据从引擎搬回壳里猜。
102
- * 🔴 **顺序契约(端必读,不是本函数能保证的那半)**:这一发必须排在「撕 SSE」**之前**。交互车道零
103
- * `x-detach-on-disconnect`,先撕流 = server 按断连语义当场收尾那条 run,随后落地的 interrupt
104
- * 只会拿到 409 `steering.not_running`(cli L-11 真机实测:同步撕流形每一轮都是它)。
107
+ * 🔴 **顺序契约(端必读,不是本函数能保证的那半)**:这一发必须排在「撕 SSE」**之前**。
108
+ * · **未武装 detach** 的车道上,先撕流 = server 按断连语义当场收尾那条 run,随后落地的 interrupt
109
+ * 只会拿到 409 `steering.not_running`(真机实测:同步撕流形每一轮都是它)。
110
+ * · **武装了 detach** 的车道上,撕流只断开观看、run 照跑 —— 顺序不再影响 interrupt 拿到什么,
111
+ * 但它让「interrupt 没落地」变成一个**没有兜底**的结局,那正是 `detachArmed` 这一格要接的洞。
105
112
  */
106
113
  export function planInteractiveHalt(input) {
107
114
  const outcome = input.interruptOutcome;
@@ -121,10 +128,15 @@ export function planInteractiveHalt(input) {
121
128
  : { action: 'none', reason: 'refused-no-escalation' };
122
129
  }
123
130
  if (kind === 'transport' || kind === 'unarmed') {
124
- // 没拿到判决。只有壳能**独立证明** parked 的那一格才升级(理由见顶注的不对称段)。
125
- return input.parked === true
126
- ? { action: 'escalate-cancel', reason: 'no-verdict-on-parked-card' }
127
- : { action: 'none', reason: 'no-verdict-not-parked' };
131
+ // 没拿到判决。只有壳能**独立证明**的两个事实之一成立才升级(理由见顶注的不对称段)。
132
+ // 🔴 `parked` 先判:老宿主只传它,先判保证那一格的 reason 逐字不漂。
133
+ if (input.parked === true)
134
+ return { action: 'escalate-cancel', reason: 'no-verdict-on-parked-card' };
135
+ // 🔴 detach 武装 ⇒ 撕流不再收 run,不升级 = 用户按下的 Esc 一点效果都没有。
136
+ // 严格 `=== true`(与 parked 同尺):证不出来就不给升级资格。
137
+ if (input.detachArmed === true)
138
+ return { action: 'escalate-cancel', reason: 'no-verdict-detach-armed' };
139
+ return { action: 'none', reason: 'no-verdict-not-parked' };
128
140
  }
129
141
  // 宿主传了本模块认不得的形(新 kind / 坏对象)。fail-closed:不对一个读不懂的结局动手。
130
142
  return { action: 'none', reason: 'unknown-outcome' };
@@ -0,0 +1,53 @@
1
+ /**
2
+ * operator 面读数(sdk `ReadFacePosture` 的防御读视图;结构逐字同源,见文件尾编译期对账钉)。
3
+ */
4
+ export interface ReadFacePostureView {
5
+ /** 这台部署此刻的生效档。`null` = 没钉,引擎默认接管——与租户面同一份产物、同一句「没钉」。 */
6
+ face: 'open' | 'roots' | null;
7
+ /** 这一档是谁定的。server 闭四词(`env` / `center` / `posture` / `engine-default`),**按开集读**
8
+ * ——server 闭集之外的值原样透传,不窄读成枚举(那会在 server 加词当天把一个合法读数判没)。 */
9
+ source: string;
10
+ /** 给运维的指路句。UNTRUSTED-for-display:呈前消毒 + 封长,见 {@link readFacePostureDetail}。 */
11
+ note: string;
12
+ }
13
+ /**
14
+ * `wiring.readFace` → 读数;**畸形一律 `undefined`**,绝不抛出。
15
+ *
16
+ * 🔴 整键缺席(老 worker,server <7.65.0)与在场但形坏,消费端拿到的都是同一个 `undefined` ——
17
+ * 两者对端说的是同一句话(「这一面这一次答不出来」)。「为什么答不出来」不是这一位该回答的,
18
+ * 是调用方自己知道的事(它有没有拿到 operator 响应;见 {@link readFacePostureDetail} 的
19
+ * `opts.reachable` 参数,那条信息只能由调用方——不是本读器——提供)。
20
+ * ⚠️ `face` 只认三态字面量(闭集,坏词一律判畸形);`source` 非空串即收(开集,理由见上);`note`
21
+ * 允许空串(它是自由文本,空文本本身也是一句读数,不是「读不出」)。
22
+ */
23
+ export declare function projectReadFacePosture(wiring: unknown): ReadFacePostureView | undefined;
24
+ /**
25
+ * 三态措辞的**唯一铸点**(三端共用一句话;别在各端的行装配里另写一遍——与
26
+ * `writeProtectionDoctorDetail` / `sqlEngineDoctorDetail` 同一条纪律)。
27
+ *
28
+ * 🔴 三句刻意逐字互异(黑盒锚):
29
+ * ① `view` 在场 —— `read face <open|roots|not pinned> (source: <source> — <note>)`;
30
+ * ② `opts.reachable === false` —— 「未观测」:这次进程没读到 operator 响应,与「这台部署没有
31
+ * READ 档」是两件事,消费端不许把它读成后者;
32
+ * ③ `reachable:true` 但 `view` 仍缺席 —— 「不报」:响应读到了,只是这一位读不出来。
33
+ * 🔴 **不武断咎为版本**:`projectReadFacePosture` 把「整键缺席(老引擎,<7.65.0)」与
34
+ * 「在场但形坏(≥7.65.0 的引擎送出一个本读器解不出来的响应)」折成同一个 `undefined`
35
+ * (见该函数顶注),句子因此**不能**替其中一种情形撒谎——「需要更新引擎」对第二种情形是一句
36
+ * 误导故障排查的假话。措辞同时点出两种成因,不擅自替调用方选一种。
37
+ */
38
+ export declare function readFacePostureDetail(view: ReadFacePostureView | undefined, opts: {
39
+ reachable: boolean;
40
+ }): string;
41
+ /**
42
+ * 租户面 `capabilities.readFace` 与本面 `posture.face` 分歧时的一句话;**只答分歧,不答一致**
43
+ * ——一致不是新闻(两位的在场时点天然不同源,「相符」证不了什么也不该被渲成保证),本函数只在
44
+ * 两者**确实不同**时才开口。
45
+ *
46
+ * 🔴 两个入参**任一**答不出来 ⇒ `undefined`(诚实缺席,不是「没有分歧」——见不到两位就判不了):
47
+ * `posture === undefined`(operator 面这一次读不到,见 {@link projectReadFacePosture})或
48
+ * `capFace` 既不是三态字面量也不是任意字符串(租户面那一位整键缺席时调用方会传 `undefined`,
49
+ * 这里按同一条闸处理,不单独分支)。
50
+ * 🔴 `capFace` 按**开集**读,与租户面型面同宽:除 `open`/`roots`/`null` 外的任意字符串仍当一个
51
+ * **能判别的词**收(不是「读不懂」),因为 sdk 的租户面型面本就留了同一个 `(string & {})` 逃生口。
52
+ */
53
+ export declare function readFaceDisagreement(capFace: unknown, posture: ReadFacePostureView | undefined): string | undefined;
@@ -0,0 +1,93 @@
1
+ import { capForDisplay } from './fleetTaskDesc.js';
2
+ /** `note` 上屏前的封长(UTF-16 单元,按**转义后**的字节数算——见 {@link capForDisplay})。它是
3
+ * 一句人话不是一个词,给得比 source/face 宽。 */
4
+ const READ_FACE_NOTE_MAX = 256;
5
+ /** `source` / 分歧句里词形的封长(与本包其余 detail 铸点同值同理由;同样按转义后字节数算)。 */
6
+ const READ_FACE_WORD_MAX = 40;
7
+ /**
8
+ * `wiring.readFace` → 读数;**畸形一律 `undefined`**,绝不抛出。
9
+ *
10
+ * 🔴 整键缺席(老 worker,server <7.65.0)与在场但形坏,消费端拿到的都是同一个 `undefined` ——
11
+ * 两者对端说的是同一句话(「这一面这一次答不出来」)。「为什么答不出来」不是这一位该回答的,
12
+ * 是调用方自己知道的事(它有没有拿到 operator 响应;见 {@link readFacePostureDetail} 的
13
+ * `opts.reachable` 参数,那条信息只能由调用方——不是本读器——提供)。
14
+ * ⚠️ `face` 只认三态字面量(闭集,坏词一律判畸形);`source` 非空串即收(开集,理由见上);`note`
15
+ * 允许空串(它是自由文本,空文本本身也是一句读数,不是「读不出」)。
16
+ */
17
+ export function projectReadFacePosture(wiring) {
18
+ if (typeof wiring !== 'object' || wiring === null || Array.isArray(wiring))
19
+ return undefined;
20
+ if (!('readFace' in wiring))
21
+ return undefined;
22
+ const rf = wiring.readFace;
23
+ if (typeof rf !== 'object' || rf === null || Array.isArray(rf))
24
+ return undefined;
25
+ const r = rf;
26
+ if (r.face !== 'open' && r.face !== 'roots' && r.face !== null)
27
+ return undefined;
28
+ if (typeof r.source !== 'string' || r.source.length === 0)
29
+ return undefined;
30
+ if (typeof r.note !== 'string')
31
+ return undefined;
32
+ return { face: r.face, source: r.source, note: r.note };
33
+ }
34
+ /**
35
+ * 三态措辞的**唯一铸点**(三端共用一句话;别在各端的行装配里另写一遍——与
36
+ * `writeProtectionDoctorDetail` / `sqlEngineDoctorDetail` 同一条纪律)。
37
+ *
38
+ * 🔴 三句刻意逐字互异(黑盒锚):
39
+ * ① `view` 在场 —— `read face <open|roots|not pinned> (source: <source> — <note>)`;
40
+ * ② `opts.reachable === false` —— 「未观测」:这次进程没读到 operator 响应,与「这台部署没有
41
+ * READ 档」是两件事,消费端不许把它读成后者;
42
+ * ③ `reachable:true` 但 `view` 仍缺席 —— 「不报」:响应读到了,只是这一位读不出来。
43
+ * 🔴 **不武断咎为版本**:`projectReadFacePosture` 把「整键缺席(老引擎,<7.65.0)」与
44
+ * 「在场但形坏(≥7.65.0 的引擎送出一个本读器解不出来的响应)」折成同一个 `undefined`
45
+ * (见该函数顶注),句子因此**不能**替其中一种情形撒谎——「需要更新引擎」对第二种情形是一句
46
+ * 误导故障排查的假话。措辞同时点出两种成因,不擅自替调用方选一种。
47
+ */
48
+ export function readFacePostureDetail(view, opts) {
49
+ if (view !== undefined) {
50
+ const faceWord = view.face === null ? 'not pinned' : view.face;
51
+ const source = capForDisplay(view.source, READ_FACE_WORD_MAX);
52
+ const note = capForDisplay(view.note, READ_FACE_NOTE_MAX);
53
+ return note.length > 0
54
+ ? `read face ${faceWord} (source: ${source} — ${note})`
55
+ : `read face ${faceWord} (source: ${source})`;
56
+ }
57
+ if (!opts.reachable) {
58
+ return "read face not observed (this end could not read the engine's diagnostics)";
59
+ }
60
+ return 'read face not reported by this engine (an engine below 7.65.0, or a response this end could not parse)';
61
+ }
62
+ /**
63
+ * 租户面 `capabilities.readFace` 与本面 `posture.face` 分歧时的一句话;**只答分歧,不答一致**
64
+ * ——一致不是新闻(两位的在场时点天然不同源,「相符」证不了什么也不该被渲成保证),本函数只在
65
+ * 两者**确实不同**时才开口。
66
+ *
67
+ * 🔴 两个入参**任一**答不出来 ⇒ `undefined`(诚实缺席,不是「没有分歧」——见不到两位就判不了):
68
+ * `posture === undefined`(operator 面这一次读不到,见 {@link projectReadFacePosture})或
69
+ * `capFace` 既不是三态字面量也不是任意字符串(租户面那一位整键缺席时调用方会传 `undefined`,
70
+ * 这里按同一条闸处理,不单独分支)。
71
+ * 🔴 `capFace` 按**开集**读,与租户面型面同宽:除 `open`/`roots`/`null` 外的任意字符串仍当一个
72
+ * **能判别的词**收(不是「读不懂」),因为 sdk 的租户面型面本就留了同一个 `(string & {})` 逃生口。
73
+ */
74
+ export function readFaceDisagreement(capFace, posture) {
75
+ if (posture === undefined)
76
+ return undefined;
77
+ if (capFace !== null && typeof capFace !== 'string')
78
+ return undefined;
79
+ if (capFace === posture.face)
80
+ return undefined;
81
+ const capWord = capFace === null ? 'not pinned' : capForDisplay(capFace, READ_FACE_WORD_MAX);
82
+ const wireWord = posture.face === null ? 'not pinned' : posture.face;
83
+ return `capabilities says ${capWord}, wiring says ${wireWord}`;
84
+ }
85
+ /**
86
+ * **编译期对账钉**(不出公面):sdk 的 `ReadFacePosture` 必须能赋给本视图 —— 这是本包 sdk 地板抬到
87
+ * 8.5.0 的直接理由:<8.5.0 上这个名字根本不存在,`tsc` 报「没有导出成员」。
88
+ *
89
+ * 🔴 反向**刻意不钉**(本视图不必能赋给 sdk 形):`source` 在本视图上放宽成任意 `string`(开集读,
90
+ * 见字段注),那是**故意比铸点宽**——窄读域只许等于或宽于铸点域,钉反向会把这条纪律反过来判成错。
91
+ */
92
+ const _readFacePostureShapePin = (w) => w;
93
+ void _readFacePostureShapePin;
package/dist/seam.d.ts CHANGED
@@ -390,7 +390,7 @@ export type ChromeEvent = {
390
390
  * `actor.hostAsserted` 是消费端唯一能判「这个署名可信吗」的位:渲署名而不渲这个位 = 把一个
391
391
  * 未经验证的名字渲成可信的(core 自己的 `[from …]` 渲染就是靠它决定加不加 `(unverified)`)。
392
392
  */
393
- | HumanInputChromeEvent | EngineNoticeChromeEvent | TextSegmentEndChromeEvent | WiringManifestChromeEvent;
393
+ | HumanInputChromeEvent | EngineNoticeChromeEvent | TextSegmentEndChromeEvent | WiringManifestChromeEvent | ResultTextDivergedChromeEvent;
394
394
  /**
395
395
  * {@link ChromeEvent} 的 `wiring_manifest` 臂(core #524 + core 147③,server ≥7.58.0)——
396
396
  * 引擎接线自述里**三段面向终端用户的事实**(S-124 起 `mcp[]` 是第三段),其余每一段仍不投影(射程见 eventToSdkMessage 的
@@ -480,6 +480,29 @@ export interface TextSegmentEndChromeEvent {
480
480
  /** core 铸的事件身份(uuidv7 形);wire 未必带 ⇒ 缺席时本键不在场。 */
481
481
  eventId?: string;
482
482
  }
483
+ /**
484
+ * {@link ChromeEvent} 的 `result_text_diverged` 臂(0.62.0)——「**终帧正文与已上屏的正文对不上**」。
485
+ *
486
+ * 什么时候会有:一个 turn 的正文正常是**流着上屏**的,终帧带的那份是同一段话的全文,两者恒相等
487
+ * (或终帧那份是已上屏的**延长**——断线期间模型接着说完了,那一截由库补吐一发)。本臂只在第三种
488
+ * 情形发出:两份**互相都不是对方的前缀** —— 即终帧说的话与屏上那段**从某处起就分了岔**。
489
+ *
490
+ * 🔴 **库刻意什么都不补吐**:两份对不上时,谁才算数**只有引擎说得清**;把终帧那份接在屏上已有
491
+ * 那段后面,会拼出一段**谁都没说过**的话(前半是流式的、后半是另一版的),那比缺一截更坏。
492
+ * 🔴 **宿主消费义务**(可选、fail-soft):把这一拍的转录行**改口** —— 一句诚实的「这一轮的最终
493
+ * 答复与上面流式显示的内容不一致」,而不是让用户以为屏上那段就是最终答复。不接本臂 = 用户
494
+ * 看不到这条披露(不是报错),但**绝不许**把它渲成「已完成」的正面确认。
495
+ * 🔴 两个数是**长度**不是内容:本臂不复述任何一份正文(屏上那份宿主自己有,终帧那份在 result 帧上),
496
+ * 复述一遍只会给渲染端第三个版本去挑。
497
+ */
498
+ export interface ResultTextDivergedChromeEvent {
499
+ kind: 'result_text_diverged';
500
+ laneProof: LaneProof;
501
+ /** 本 turn 已经**提交上屏**的 assistant 正文长度(UTF-16 单元)。 */
502
+ committedLength: number;
503
+ /** 终帧 `result` 那份正文的长度(UTF-16 单元)。 */
504
+ resultLength: number;
505
+ }
483
506
  /**
484
507
  * {@link ChromeEvent} 的 `engine_notice` 臂(#310 / #318 件①,server ≥7.36;契约 = server
485
508
  * `ASSISTANT-WIRE-CONTRACT` 附录 D)——「**引擎想让这条会话的人知道一件事**」。
package/dist/seam.js CHANGED
@@ -56,6 +56,13 @@ const CHROME_ARM_TABLE = {
56
56
  'mcp 的空数组是「一台都没申报」这句正面事实、**不是**缺席(判在场写 mcp !== undefined,' +
57
57
  '别写 mcp?.length),errorCode 按开集分支必带 default;durable 重放按 run/leg 幂等',
58
58
  },
59
+ result_text_diverged: {
60
+ required: false,
61
+ duty: '可选:终帧正文与已上屏正文**互相都不是对方前缀**时的诚实披露。🔴 处置是把这一拍的转录行**改口**' +
62
+ '(「最终答复与上面流式显示的内容不一致」),不是补渲一段 —— 两份对不上时谁算数只有引擎说得清,' +
63
+ '把终帧那份接在屏上那段后面会拼出一段谁都没说过的话。不接 = 这条披露看不见(不是报错),' +
64
+ '但绝不许把它渲成「已完成」的正面确认;两个位是长度不是内容,别拿它们当正文来源',
65
+ },
59
66
  text_segment_end: {
60
67
  required: false,
61
68
  duty: '可选:引擎明报的 assistant 散文段边界(#323/core #447)。🔴 content 是对账/定界用的权威全文,拿它再渲一行 = 同一段上屏两遍;缺席只表示「没报」,绝不等于「段没结束」——要退回自家启发式必须按整条流判、不按单帧判',