@sema-agent/client-core 0.30.2 → 0.30.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -16,6 +16,37 @@
16
16
  > 🔴 **互链**(web [C166]⑦):各版「已知局限」段只记**该版新增**;接入面已知局限的完整台账在
17
17
  > `docs/INTEGRATION-CLIENTS.md` §6e/§7 —— **只读其一会漏**,两处都过。
18
18
 
19
+ ## 0.30.3
20
+
21
+ - **#244 F3 wire/会话判定上收 · 包半场(A-028.9/.10/.11/.12/.13/.15 族E;新三件
22
+ `principalWire.ts` / `wireErrorTriage.ts` / `sessionMap.ts`,+14 公面运行期导出)**(2026-08-15;
23
+ 常驻钉 = `run-client-core-pure-test.mjs` F3E 段 58 checks):
24
+ - 🔴 **BEHAVIOR CHANGE(A-028.10,cli 主会话裁定)**:`engineWireTargetFor()` 的 principal
25
+ 在场性判定从裸 truthy 收编为 **trim 判**(新原语 `normalizeWirePrincipal`)——
26
+ **全空白** `SEMA_LIVE_PRINCIPAL`(env 臂)与全空白 `installed.principal`(显式装配臂)
27
+ 从「发出全空白 `x-agent-principal` 头」改为「键缺席=不发头(owner-null)」。
28
+ 理由:F-011 停发纪律([3279])的语义是「缺席=不发头,绝不铸哨兵值」,全空白值是垃圾值
29
+ 伪装在场 —— 壳 `livePrincipal.ts` 早已按 trim 判(裁定=壳语义为正),包侧对齐消除两侧
30
+ 判定相反的分脑。同批 `makeEngineWireClient` 的 `!== ''` 判升级为同一把 trim 尺
31
+ (全空白 principal 同罪归缺席)。非空白实值(含空白包围形如 `' alice '`)一字不动。
32
+ - **`wireErrorTriage.ts`(A-028.11/.13 新件)**:turn 错误分型判定半场
33
+ (`classifyTurnWireError` 四臂:http / stream-ended-without-terminal / transport / internal;
34
+ `isWireTransportError` / `isPreStreamDrainingReject` / `isResumeAtRejection` /
35
+ `drainingRetryDelayMs` / `WIRE_NETWORK_ERROR_PATTERN`)+ scenario 拒绝判型
36
+ (`scenarioDenyFromError`,allowlist 防御过滤单源)。语义 = cli `seamQuery` /
37
+ `engineTarget.isEngineTransportError` / `scenarioNotAllowedCopy` 逐字,人话文案与渲染归端;
38
+ web `turn-error-classify.ts` 第二实现与逐 token 跨仓 parity 门的退役半场归 web(发布帖点名)。
39
+ - **`sessionMap.ts`(A-028.12 新件)**:「客户端会话 id ↔ 引擎会话 id」映射的单一键形
40
+ (`SessionMapRecord` / `EngineSessionEntry`,壳形为正)+ `engineNamespaceKeyFor` +
41
+ 两个纯 merge 判定(`mergeSessionMapRecord` / `mergeEngineEntry`,拒写带 reason 出境)+
42
+ `SessionMapStorePort`(存储归端:cli 文件锁/原子写,web localStorage)。
43
+ - **`engineErrorCodes.ts` +3 常量**:`DRAINING_ERROR_CODE` / `SCENARIO_NOT_ALLOWED_ERROR_CODE` /
44
+ `RESUME_AT_ERROR_CODE_PREFIX`(`REWIND_ERROR_CODE_PREFIXES[0]` 改引同源;resume_at 窄形
45
+ 刻意不并入 rewind 宽形 —— 自动重发安全性论证只对 resume_at 族成立)。
46
+ - **`applyLiveRequestDefaults` 补 `additionalReadDirectories` 位(A-028.9)**:壳独有真行为
47
+ (#257 配置目录+tmp 族只读宽根)上收 —— 值(广度闸/realpath 判决)归端算,包做
48
+ 「缺席时补位」;`LIVE_DEFAULT_FIELDS` 九件→十件,`unregisteredRequestKeys` 门随表跟上。
49
+
19
50
  ## 0.30.2
20
51
 
21
52
  - **#244 F2 HITL 规则侧上收 · 包半场(A-028.14 + parseLocalAllowRule 硬排期件;hitl/ 新两件,
package/README.md CHANGED
@@ -35,7 +35,7 @@ Renamed from **`@sema-agent/wire-cc-adapter`** (0.1.x, deprecated — see *Migra
35
35
 
36
36
  ## Scope
37
37
 
38
- **Version:** 0.30.2
38
+ **Version:** 0.30.3
39
39
 
40
40
  - **Today** — the adapter seam, the whole `adapt()` pipeline (all 14 A-layer arms plus the
41
41
  B/D/E tool-card layers), the notification/caps/model families, the adapter kernel (stream driver
@@ -88,6 +88,13 @@ export declare const STOP_PARKED = "stop.parked";
88
88
  export declare const STOP_CONFLICT_CODES: readonly ["stop.park_arbiter_unreachable", "stop.park_resume_won", "stop.not_landed", "stop.not_local", "stop.parked"];
89
89
  /** `outputSchema` 任务在重试上限内没能产出合法结构化输出(语义字面就是 CC 那个 subtype 的话)。 */
90
90
  export declare const OUTPUT_INVALID = "output.invalid";
91
+ /**
92
+ * `resume_at.*` 单族前缀(A-028.11 单源化,#244 族E):壳 `isResumeAtRejection`(Esc 杀锚后的
93
+ * 一次性去锚自动重发判型)此前持裸字面 `'resume_at.'` —— 收编到本表。🔴 它**刻意窄于**
94
+ * {@link REWIND_ERROR_CODE_PREFIXES}(不含 `rewind_snapshot.`):自动重发的安全性论证只对
95
+ * resume_at 族做过(pre-stream 零副作用),扩到全 rewind 族属行为变更,须另立项。
96
+ */
97
+ export declare const RESUME_AT_ERROR_CODE_PREFIX = "resume_at.";
91
98
  /**
92
99
  * 用户**可自解**的操作性错误的码前缀(选错回退目标 / 回退过根 / 快照缺失)。
93
100
  * 消费点把 code 附在 message 后便于对账 —— 这一族是「你的操作有问题」,不是「引擎坏了」。
@@ -96,6 +103,16 @@ export declare const OUTPUT_INVALID = "output.invalid";
96
103
  export declare const REWIND_ERROR_CODE_PREFIXES: readonly ["resume_at.", "rewind_snapshot."];
97
104
  /** 该码是否属 rewind/resume 可自解族。缺席 ⇒ false。 */
98
105
  export declare function isRewindFamilyCode(code: string | undefined): boolean;
106
+ /**
107
+ * server 温切 drain 门的 pre-stream 拒收码(503 + `errorCode:"draining"`;server 侧
108
+ * `error:"draining"` 是冻结契约,SDK toApiError 盖成 `errorCode`)。此前壳/包注释各持裸字面。
109
+ */
110
+ export declare const DRAINING_ERROR_CODE = "draining";
111
+ /**
112
+ * 场景执法拒绝码(service gateScenarioRequest 的 400;SDK `ScenarioNotAllowedError`)。
113
+ * 判型半场见 wireErrorTriage.scenarioDenyFromError;allowlist 渲染归各端。
114
+ */
115
+ export declare const SCENARIO_NOT_ALLOWED_ERROR_CODE = "scenario_not_allowed";
99
116
  /**
100
117
  * 15 分钟流帽(server `src/http/sse-log.ts`)。
101
118
  * 🔴 **到达 ≠ run 死了** —— 帧自己就说「run 仍然活着」。正确处置 = 按 `Last-Event-ID` 重连续读
@@ -121,16 +121,34 @@ export const STOP_CONFLICT_CODES = [
121
121
  /** `outputSchema` 任务在重试上限内没能产出合法结构化输出(语义字面就是 CC 那个 subtype 的话)。 */
122
122
  export const OUTPUT_INVALID = 'output.invalid';
123
123
  // ── rewind / resume 族(core 1.292 [833])────────────────────────────────────────────────────
124
+ /**
125
+ * `resume_at.*` 单族前缀(A-028.11 单源化,#244 族E):壳 `isResumeAtRejection`(Esc 杀锚后的
126
+ * 一次性去锚自动重发判型)此前持裸字面 `'resume_at.'` —— 收编到本表。🔴 它**刻意窄于**
127
+ * {@link REWIND_ERROR_CODE_PREFIXES}(不含 `rewind_snapshot.`):自动重发的安全性论证只对
128
+ * resume_at 族做过(pre-stream 零副作用),扩到全 rewind 族属行为变更,须另立项。
129
+ */
130
+ export const RESUME_AT_ERROR_CODE_PREFIX = 'resume_at.';
124
131
  /**
125
132
  * 用户**可自解**的操作性错误的码前缀(选错回退目标 / 回退过根 / 快照缺失)。
126
133
  * 消费点把 code 附在 message 后便于对账 —— 这一族是「你的操作有问题」,不是「引擎坏了」。
127
134
  * 前缀形(不是成员形)= 开集:这一族里每加一个新码,判别自动跟上。
128
135
  */
129
- export const REWIND_ERROR_CODE_PREFIXES = ['resume_at.', 'rewind_snapshot.'];
136
+ export const REWIND_ERROR_CODE_PREFIXES = [RESUME_AT_ERROR_CODE_PREFIX, 'rewind_snapshot.'];
130
137
  /** 该码是否属 rewind/resume 可自解族。缺席 ⇒ false。 */
131
138
  export function isRewindFamilyCode(code) {
132
139
  return typeof code === 'string' && REWIND_ERROR_CODE_PREFIXES.some((p) => code.startsWith(p));
133
140
  }
141
+ // ── drain / 场景执法族(A-028.11/.13 单源化,#244 族E,2026-08-15)────────────────────────────
142
+ /**
143
+ * server 温切 drain 门的 pre-stream 拒收码(503 + `errorCode:"draining"`;server 侧
144
+ * `error:"draining"` 是冻结契约,SDK toApiError 盖成 `errorCode`)。此前壳/包注释各持裸字面。
145
+ */
146
+ export const DRAINING_ERROR_CODE = 'draining';
147
+ /**
148
+ * 场景执法拒绝码(service gateScenarioRequest 的 400;SDK `ScenarioNotAllowedError`)。
149
+ * 判型半场见 wireErrorTriage.scenarioDenyFromError;allowlist 渲染归各端。
150
+ */
151
+ export const SCENARIO_NOT_ALLOWED_ERROR_CODE = 'scenario_not_allowed';
134
152
  // ── 流控族(SDK 6.2.0 CB-1/TR-6 的 `error` 臂)───────────────────────────────────────────────
135
153
  /**
136
154
  * 15 分钟流帽(server `src/http/sse-log.ts`)。
@@ -22,6 +22,7 @@
22
22
  * principal 时省略该头,迁移后恒出示,对 requirePrincipal 部署是 fail-open 改善,已记账。
23
23
  */
24
24
  import { AgentClient } from '@sema-agent/sdk';
25
+ import { normalizeWirePrincipal } from './principalWire.js';
25
26
  /** loopback 判定(engineTarget.isLoopbackEngineUrl 逐字镜像;解析失败=非 loopback)。 */
26
27
  export function isLoopbackWireUrl(url) {
27
28
  try {
@@ -72,10 +73,12 @@ export function wireAuthTokenFor(baseUrl, token) {
72
73
  */
73
74
  export function makeEngineWireClient(cfg) {
74
75
  try {
76
+ const principal = normalizeWirePrincipal(cfg.principal);
75
77
  const base = {
76
78
  baseUrl: cfg.baseUrl,
77
79
  // F-011 停发:缺席/空串=不给键(SDK 6.11 缺席=不发 x-agent-principal 头,owner-null)。
78
- ...(cfg.principal !== undefined && cfg.principal !== '' ? { principal: cfg.principal } : {}),
80
+ // A-028.10(0.30.3):空串判升级为 trim 判(principalWire 同尺)——全空白值同罪,归缺席。
81
+ ...(principal !== undefined ? { principal } : {}),
79
82
  ...(cfg.timeoutMs !== undefined ? { timeoutMs: cfg.timeoutMs } : {}),
80
83
  maxRetries: cfg.maxRetries ?? 0,
81
84
  ...(cfg.fetchImpl ? { fetch: cfg.fetchImpl } : {}),
@@ -1,7 +1,9 @@
1
1
  export interface EngineWireTarget {
2
2
  baseUrl: string;
3
3
  token?: string;
4
- /** 缺席=不发 x-agent-principal 头(owner-null,F-011 停发);显式装配/env 值恒赢。 */
4
+ /** 缺席=不发 x-agent-principal 头(owner-null,F-011 停发);显式装配/env 值恒赢。
5
+ * A-028.10(0.30.3):在场性按 trim 判(principalWire 原语)——全空白值在**读出口**归缺席,
6
+ * 绝不发全空白头(裁定=壳 livePrincipal trim 语义为正)。 */
5
7
  principal?: string;
6
8
  }
7
9
  /** 装/卸引擎 wire 目标。传 null 卸回 env 派生。返回还原函数。 */
@@ -19,6 +19,7 @@
19
19
  */
20
20
  import { hostEnv } from './hostEnv.js';
21
21
  import { createSessionSlot, DEFAULT_SESSION_KEY } from './sessionSlot.js';
22
+ import { normalizeWirePrincipal } from './principalWire.js';
22
23
  /** 显式装配(非 Node 宿主唯一的入口;装了就**优先于** env,便于桌面/web 一页多引擎)。
23
24
  * W1(design/161):sessionKey → 注册表;零参 API = DEFAULT_SESSION_KEY 兼容层。 */
24
25
  const installedByKey = createSessionSlot();
@@ -47,19 +48,29 @@ export function engineWireTarget() {
47
48
  */
48
49
  export function engineWireTargetFor(sessionKey) {
49
50
  const installed = installedByKey.get(sessionKey) ?? null;
50
- if (installed !== null)
51
- return installed;
51
+ // A-028.10 显式装配臂(0.30.3 BEHAVIOR CHANGE):装配值里全空白 principal 在读出口归缺席
52
+ // (键不出现)——与 env 臂同一把 trim 尺;其余键原样。非空白值(含带空白包围的实值)一字不动。
53
+ if (installed !== null) {
54
+ const p = normalizeWirePrincipal(installed.principal);
55
+ if (p === installed.principal)
56
+ return installed;
57
+ const { principal: _dropped, ...rest } = installed;
58
+ return rest;
59
+ }
52
60
  const env = hostEnv();
53
61
  const baseUrl = env.SEMA_LIVE_BASEURL;
54
62
  if (!baseUrl)
55
63
  return null;
64
+ // principal must match the run's owner (stamped by the live stream) — the owner-gated routes answer
65
+ // 404 for non-owners (no existence oracle). F-011 停发([3279]):缺席=不发头,owner-null 两侧
66
+ // 对齐(server 7.8.1 读写面窄互认盖存量);显式 SEMA_LIVE_PRINCIPAL 恒赢,绝不铸哨兵值。
67
+ // A-028.10(0.30.3 BEHAVIOR CHANGE):裸 truthy → trim 判定 —— 全空白 env 值此前会发出
68
+ // 全空白 x-agent-principal 头(垃圾值伪装在场),现归缺席(键不出现),与壳 livePrincipal 同尺。
69
+ const envPrincipal = normalizeWirePrincipal(env.SEMA_LIVE_PRINCIPAL);
56
70
  return {
57
71
  baseUrl,
58
- // principal must match the run's owner (stamped by the live stream) — the owner-gated routes answer
59
- // 404 for non-owners (no existence oracle). F-011 停发([3279]):缺席=不发头,owner-null 两侧
60
- // 对齐(server 7.8.1 读写面窄互认盖存量);显式 SEMA_LIVE_PRINCIPAL 恒赢,绝不铸哨兵值。
61
72
  ...(env.SEMA_LIVE_TOKEN ? { token: env.SEMA_LIVE_TOKEN } : {}),
62
- ...(env.SEMA_LIVE_PRINCIPAL ? { principal: env.SEMA_LIVE_PRINCIPAL } : {}),
73
+ ...(envPrincipal !== undefined ? { principal: envPrincipal } : {}),
63
74
  };
64
75
  }
65
76
  /** 诊断开关(壳侧 `process.env.SEMA_DEBUG` 的等价读;本包零 process)。 */
package/dist/index.d.ts CHANGED
@@ -208,6 +208,9 @@ export * from './subagent/engineCompactWire.js';
208
208
  export * from './subagent/engineSubagentTail.js';
209
209
  export * from './engineSessionParam.js';
210
210
  export * from './engineWireTarget.js';
211
+ export * from './principalWire.js';
212
+ export * from './wireErrorTriage.js';
213
+ export * from './sessionMap.js';
211
214
  export * from './detachWire.js';
212
215
  export * from './workflowMonitor.js';
213
216
  export * from './workflowClient.js';
package/dist/index.js CHANGED
@@ -269,6 +269,16 @@ export * from './subagent/engineCompactWire.js';
269
269
  export * from './subagent/engineSubagentTail.js';
270
270
  export * from './engineSessionParam.js';
271
271
  export * from './engineWireTarget.js';
272
+ // ── A-028 族E(#244 F3,2026-08-15)────────────────────────────────────────────────────────────
273
+ // · principalWire:principal 在场性 trim 原语(A-028.10 裁定=壳语义为正;engineWireTarget 两臂 +
274
+ // makeEngineWireClient 同尺,壳 livePrincipal 闸口消费同一原语)。
275
+ export * from './principalWire.js';
276
+ // · wireErrorTriage:turn 错误分型判定半场(A-028.11;文案/渲染归端)+ scenario 拒绝判型
277
+ // (A-028.13;web 逐字节同形过滤行的正主)。码字面引 engineErrorCodes。
278
+ export * from './wireErrorTriage.js';
279
+ // · sessionMap:「客户端会话 id ↔ 引擎会话 id」映射单一键形 + merge 判定(A-028.12;存储经
280
+ // SessionMapStorePort 归端 —— cli 文件锁/原子写,web localStorage)。
281
+ export * from './sessionMap.js';
272
282
  // B6 余项①:headless detach wire(**拆**:判定+cancel-arm 台账进包,信号路径裸 fetch 发射留宿主
273
283
  // —— 设计稿 §3 表脚注「`:221` 裸 cancel = TUI 留」;宿主取件口 = `detachCancelArm()`)。
274
284
  export * from './detachWire.js';
@@ -0,0 +1,18 @@
1
+ /**
2
+ * principalWire.ts — principal 在场性判定的**唯一原语**(A-028.10,#244 族E,2026-08-15)。
3
+ *
4
+ * 裁定背景(cli 主会话裁,census top-07 §2):壳 `livePrincipal.ts` 与本包
5
+ * `engineWireTarget.engineWireTargetFor()` 各持一条 principal 解析,且**语义相反**:壳判
6
+ * `v && v.trim() !== ''`(全空白=缺席),包侧裸 truthy(全空白=在场)⇒ `SEMA_LIVE_PRINCIPAL=' '`
7
+ * 会发出一个全空白的 `x-agent-principal` 头 —— 垃圾值伪装在场,破坏 F-011 停发纪律
8
+ * ([3279]:缺席=不发头,owner-null;绝不铸哨兵值)。裁定=壳 trim 语义为正,包侧两臂
9
+ * (env 派生臂 + 显式装配臂)都收编本原语。
10
+ *
11
+ * 🔴 语义逐字(壳 `resolveLivePrincipal` 的判定半场):
12
+ * · undefined / '' / 全空白 ⇒ undefined(键缺席,不发头);
13
+ * · 其余 ⇒ **原值原样返回**(不 trim 改写 —— ` alice ` 照发 ` alice `,在场性判定与值改写
14
+ * 是两件事,本原语只做前者)。
15
+ * env 的读取方式留在端上(壳读 `process.env`,包内经 `hostEnv()`)——本原语零 IO 零 env。
16
+ */
17
+ /** principal 在场性判定:全空白=缺席(undefined);实值原样返回(绝不改写)。 */
18
+ export declare function normalizeWirePrincipal(v: string | undefined): string | undefined;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * principalWire.ts — principal 在场性判定的**唯一原语**(A-028.10,#244 族E,2026-08-15)。
3
+ *
4
+ * 裁定背景(cli 主会话裁,census top-07 §2):壳 `livePrincipal.ts` 与本包
5
+ * `engineWireTarget.engineWireTargetFor()` 各持一条 principal 解析,且**语义相反**:壳判
6
+ * `v && v.trim() !== ''`(全空白=缺席),包侧裸 truthy(全空白=在场)⇒ `SEMA_LIVE_PRINCIPAL=' '`
7
+ * 会发出一个全空白的 `x-agent-principal` 头 —— 垃圾值伪装在场,破坏 F-011 停发纪律
8
+ * ([3279]:缺席=不发头,owner-null;绝不铸哨兵值)。裁定=壳 trim 语义为正,包侧两臂
9
+ * (env 派生臂 + 显式装配臂)都收编本原语。
10
+ *
11
+ * 🔴 语义逐字(壳 `resolveLivePrincipal` 的判定半场):
12
+ * · undefined / '' / 全空白 ⇒ undefined(键缺席,不发头);
13
+ * · 其余 ⇒ **原值原样返回**(不 trim 改写 —— ` alice ` 照发 ` alice `,在场性判定与值改写
14
+ * 是两件事,本原语只做前者)。
15
+ * env 的读取方式留在端上(壳读 `process.env`,包内经 `hostEnv()`)——本原语零 IO 零 env。
16
+ */
17
+ /** principal 在场性判定:全空白=缺席(undefined);实值原样返回(绝不改写)。 */
18
+ export function normalizeWirePrincipal(v) {
19
+ return v !== undefined && v.trim() !== '' ? v : undefined;
20
+ }
@@ -52,7 +52,7 @@ export interface RequestFieldSpec {
52
52
  */
53
53
  export declare const REQUEST_FIELD_MATRIX: readonly RequestFieldSpec[];
54
54
  /** live 兜底层(`toLiveRequest`)追加的字段 —— 两条车道**都**经过,故不进上表。 */
55
- export declare const LIVE_DEFAULT_FIELDS: readonly ["cwd", "additionalDirectories", "forwardSubagentEvents", "retainSubagentSessions", "agents", "appendSystemPrompt|settings.outputStyle", "suggestNextPrompts", "compactionModel", "sessionId(三态解析)"];
55
+ export declare const LIVE_DEFAULT_FIELDS: readonly ["cwd", "additionalDirectories", "additionalReadDirectories", "forwardSubagentEvents", "retainSubagentSessions", "agents", "appendSystemPrompt|settings.outputStyle", "suggestNextPrompts", "compactionModel", "sessionId(三态解析)"];
56
56
  /** 端解析好的输入 —— 每一项都是**值**,不是取值方式(取值方式属端)。 */
57
57
  export interface TaskRequestInput {
58
58
  /** 本 turn 的模型面输入(已含 slash-skill 正文 / 注入式 meta / 历史种子等端侧组装)。 */
@@ -121,6 +121,10 @@ export declare function buildTaskRequest(input: TaskRequestInput, lane: RequestL
121
121
  export interface LiveDefaultsInput {
122
122
  cwd?: string;
123
123
  additionalDirectories?: readonly string[];
124
+ /** 只读面宽根(A-028.9 补位,壳 #257 真行为上收):配置目录 + tmp 族等**主机自身姿态**的
125
+ * read-boundary 根。值(广度闸/realpath 判决)归端算,包只做「缺席时补位」。
126
+ * 🔴 只宽读不宽写:与 `additionalDirectories`(/add-dir 用户显式写授权)是分开的两个口。 */
127
+ additionalReadDirectories?: readonly string[];
124
128
  agents?: unknown;
125
129
  /** 引擎 caps 判真 ⇒ 走 `appendSystemPrompt` 一等位;否则借道 `settings.outputStyle`。 */
126
130
  appendSystemPromptCapable: boolean;
@@ -60,6 +60,7 @@ export const REQUEST_FIELD_MATRIX = [
60
60
  export const LIVE_DEFAULT_FIELDS = [
61
61
  'cwd',
62
62
  'additionalDirectories',
63
+ 'additionalReadDirectories',
63
64
  'forwardSubagentEvents',
64
65
  'retainSubagentSessions',
65
66
  'agents',
@@ -166,6 +167,11 @@ export function applyLiveRequestDefaults(req, host) {
166
167
  if (out.additionalDirectories === undefined && (host.additionalDirectories?.length ?? 0) > 0) {
167
168
  out.additionalDirectories = [...(host.additionalDirectories ?? [])];
168
169
  }
170
+ // A-028.9:只读面宽根(壳 cli 逐字:`if (roots.length > 0) out.additionalReadDirectories = roots`,
171
+ // 「已带值不覆盖」的守卫同其余各条)。
172
+ if (out.additionalReadDirectories === undefined && (host.additionalReadDirectories?.length ?? 0) > 0) {
173
+ out.additionalReadDirectories = [...(host.additionalReadDirectories ?? [])];
174
+ }
169
175
  // C1 / design/144:交互与 headless 都常开 —— 不开则引擎不建 SubagentRetainLedger,
170
176
  // 后台子代 SendMessage 唤醒直接 "session was not retained" 拒绝。老引擎按 body→spec 白名单
171
177
  // 忽略未知字段,version-safe。
@@ -0,0 +1,95 @@
1
+ /**
2
+ * sessionMap.ts — 「客户端会话 id ↔ 引擎会话 id」映射的**单一键形与 merge 判定**
3
+ * (A-028.12,#244 族E,2026-08-15)。
4
+ *
5
+ * ## 收编前的形(census top-10 §2)
6
+ * 同一概念两端各持一份、**键名零重合**:
7
+ * · 壳 `sessionIdMapping.ts`:`{shellSessionId, engineSessionId, lastEngineTaskId, engines{}}`,
8
+ * `.session-map.json` 侧车(lock + temp→fsync→rename 原子写);
9
+ * · web `engine-history-wire.ts`:`{uiId:{s:engineId,t:activeTaskId}}`,localStorage。
10
+ * 与 B18 seatContract 修的「同一契约两份声明、编译器永不告警」同形。本件定**单一键形**
11
+ * (壳形为正:显式键名 + per-engine 命名空间,web 缩写形迁移归 web 半场)+ 两个纯 merge 判定;
12
+ * 存储经 {@link SessionMapStorePort} 归端(cli = lock+原子写两进程纪律,web = localStorage)。
13
+ *
14
+ * 🔴 merge 语义 = 壳 `persist`/`persistEngineEntry` 传给 lockedReadMergeWrite 的那两个闭包**逐字**:
15
+ * `partial ?? prior` 逐字段让位、`engines[key]` 命名空间不互相覆盖、身份键缺席=拒写(skip 判定
16
+ * 带 reason 出境,落日志的措辞归端)。
17
+ */
18
+ /**
19
+ * ONE engine's continuity record, keyed inside {@link SessionMapRecord.engines} by the engine
20
+ * NAMESPACE key ({@link engineNamespaceKeyFor}): task ids / sync watermarks minted by DIFFERENT
21
+ * engines must never overwrite each other (a cloud taskId fed to the local engine on resume =
22
+ * the id-collision this namespace exists to prevent). `instanceId` is the engine's /health
23
+ * identity stamp — a redeployed engine at the same origin stays distinguishable.
24
+ */
25
+ export interface EngineSessionEntry {
26
+ /** Engine session id ON THAT ENGINE(single-namespace 裁定下与 shell id 同值,仍显式持久)。 */
27
+ engineSessionId: string;
28
+ /** Engine /health `instanceId` at record time (precise engine identity, not just the origin). */
29
+ instanceId?: string;
30
+ /** Last `done.result.taskId` observed FROM THIS ENGINE. */
31
+ lastEngineTaskId?: string;
32
+ /** The session leafId at the last successful sync push/pull against this engine (sync watermark). */
33
+ lastSyncedLeafId?: string;
34
+ updatedAt: string;
35
+ }
36
+ export interface SessionMapRecord {
37
+ /** Shell/client (transcript) sessionId — the primary key / namespace anchor. */
38
+ shellSessionId: string;
39
+ /** Engine session id(LEGACY/current-engine view:写入时活跃引擎的值;跨引擎消费读 engines)。 */
40
+ engineSessionId?: string;
41
+ /** Last `done.result.taskId` observed for this session(rewind/resume continuity;同上注意)。 */
42
+ lastEngineTaskId?: string;
43
+ /** Per-engine continuity, keyed by {@link engineNamespaceKeyFor}(baseUrl)('local' | origin). */
44
+ engines?: Record<string, EngineSessionEntry>;
45
+ /** auto-sync per-session 覆盖:true/false 覆盖项目开关,undefined = 跟随。 */
46
+ autoSync?: boolean;
47
+ /** 冲突 pause:fork/stale 后停 auto 转手动;手动 push 成功 / sync on 清除。 */
48
+ autoSyncPaused?: {
49
+ reason: string;
50
+ at: string;
51
+ };
52
+ updatedAt: string;
53
+ }
54
+ /**
55
+ * The engine NAMESPACE key for a CONNECTED engine target's base url: the lowercased origin
56
+ * (scheme+host+port — TOFU 同粒度). ⚠️ ROLE decides the namespace, not the url shape: the
57
+ * SELF-SPAWNED engine is always keyed `'local'` (callers pass no url — its port can drift across
58
+ * boots, one logical engine), while a `connect`ed target is keyed by origin EVEN when loopback
59
+ * (a second local engine on another port is a DIFFERENT engine with a different data root).
60
+ * Pure; never throws. A malformed non-empty url falls back to the raw lowercased string
61
+ * (honest separation beats aliasing it onto another namespace).
62
+ */
63
+ export declare function engineNamespaceKeyFor(baseUrl: string | undefined): string;
64
+ /** merge 判定的出境形:拒写带 reason(措辞/日志归端),成写带整份下一记录。 */
65
+ export type SessionMapMergeVerdict = {
66
+ ok: true;
67
+ record: SessionMapRecord;
68
+ } | {
69
+ ok: false;
70
+ reason: 'no-shell-session-id' | 'no-engine-session-id';
71
+ };
72
+ /**
73
+ * 顶层记录 merge(壳 `persist` 闭包逐字):`shellSessionId` 取 `partial ?? prior`,两处都缺 =
74
+ * 拒写;成写 = `{...prior, ...partial, shellSessionId, updatedAt: now()}`。
75
+ */
76
+ export declare function mergeSessionMapRecord(prior: SessionMapRecord | undefined, partial: Partial<SessionMapRecord> & {
77
+ shellSessionId?: string;
78
+ }, now?: () => string): SessionMapMergeVerdict;
79
+ /**
80
+ * per-engine entry merge(壳 `persistEngineEntry` 闭包逐字):`engines[key]` 内
81
+ * `partial ?? priorEntry` 让位;`prior.engines` 作 spread 基底 —— 未被本次改动的其它 engine key
82
+ * 原样保留(两个不同 key 的并发写互不覆盖的**判定半场**;「基底必须是锁内新鲜读」的进程纪律
83
+ * 归端的存储实现)。拒写序:先 engineSessionId 后 shellSessionId(与壳日志序一致)。
84
+ */
85
+ export declare function mergeEngineEntry(prior: SessionMapRecord | undefined, key: string, partial: Partial<EngineSessionEntry>, shellSessionId?: string, now?: () => string): SessionMapMergeVerdict;
86
+ /**
87
+ * 存储端口(端注入):cli = per-session 文件 + proper-lockfile + temp→fsync→rename(REPL 单例与
88
+ * one-shot CLI 两个 OS 进程共写,`readMergeWrite` 的 prior 必须是**临界区内**的新鲜读);
89
+ * web = localStorage(单线程,直读直写即满足契约)。`build` 返回 undefined = 本次没有可写的
90
+ * 记录(身份键缺席)—— 实现方跳过写并返回 undefined。
91
+ */
92
+ export interface SessionMapStorePort {
93
+ read(): SessionMapRecord | undefined;
94
+ readMergeWrite(build: (prior: SessionMapRecord | undefined) => SessionMapRecord | undefined): SessionMapRecord | undefined;
95
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * sessionMap.ts — 「客户端会话 id ↔ 引擎会话 id」映射的**单一键形与 merge 判定**
3
+ * (A-028.12,#244 族E,2026-08-15)。
4
+ *
5
+ * ## 收编前的形(census top-10 §2)
6
+ * 同一概念两端各持一份、**键名零重合**:
7
+ * · 壳 `sessionIdMapping.ts`:`{shellSessionId, engineSessionId, lastEngineTaskId, engines{}}`,
8
+ * `.session-map.json` 侧车(lock + temp→fsync→rename 原子写);
9
+ * · web `engine-history-wire.ts`:`{uiId:{s:engineId,t:activeTaskId}}`,localStorage。
10
+ * 与 B18 seatContract 修的「同一契约两份声明、编译器永不告警」同形。本件定**单一键形**
11
+ * (壳形为正:显式键名 + per-engine 命名空间,web 缩写形迁移归 web 半场)+ 两个纯 merge 判定;
12
+ * 存储经 {@link SessionMapStorePort} 归端(cli = lock+原子写两进程纪律,web = localStorage)。
13
+ *
14
+ * 🔴 merge 语义 = 壳 `persist`/`persistEngineEntry` 传给 lockedReadMergeWrite 的那两个闭包**逐字**:
15
+ * `partial ?? prior` 逐字段让位、`engines[key]` 命名空间不互相覆盖、身份键缺席=拒写(skip 判定
16
+ * 带 reason 出境,落日志的措辞归端)。
17
+ */
18
+ /**
19
+ * The engine NAMESPACE key for a CONNECTED engine target's base url: the lowercased origin
20
+ * (scheme+host+port — TOFU 同粒度). ⚠️ ROLE decides the namespace, not the url shape: the
21
+ * SELF-SPAWNED engine is always keyed `'local'` (callers pass no url — its port can drift across
22
+ * boots, one logical engine), while a `connect`ed target is keyed by origin EVEN when loopback
23
+ * (a second local engine on another port is a DIFFERENT engine with a different data root).
24
+ * Pure; never throws. A malformed non-empty url falls back to the raw lowercased string
25
+ * (honest separation beats aliasing it onto another namespace).
26
+ */
27
+ export function engineNamespaceKeyFor(baseUrl) {
28
+ if (!baseUrl)
29
+ return 'local';
30
+ try {
31
+ return new URL(baseUrl).origin.toLowerCase();
32
+ }
33
+ catch {
34
+ return baseUrl.toLowerCase();
35
+ }
36
+ }
37
+ /** 缺省时钟(可注入 —— 测试与「同一批双写同刻」的端语义都经它)。 */
38
+ const isoNow = () => new Date().toISOString();
39
+ /**
40
+ * 顶层记录 merge(壳 `persist` 闭包逐字):`shellSessionId` 取 `partial ?? prior`,两处都缺 =
41
+ * 拒写;成写 = `{...prior, ...partial, shellSessionId, updatedAt: now()}`。
42
+ */
43
+ export function mergeSessionMapRecord(prior, partial, now = isoNow) {
44
+ const shellSessionId = partial.shellSessionId ?? prior?.shellSessionId;
45
+ if (!shellSessionId)
46
+ return { ok: false, reason: 'no-shell-session-id' };
47
+ return { ok: true, record: { ...prior, ...partial, shellSessionId, updatedAt: now() } };
48
+ }
49
+ /**
50
+ * per-engine entry merge(壳 `persistEngineEntry` 闭包逐字):`engines[key]` 内
51
+ * `partial ?? priorEntry` 让位;`prior.engines` 作 spread 基底 —— 未被本次改动的其它 engine key
52
+ * 原样保留(两个不同 key 的并发写互不覆盖的**判定半场**;「基底必须是锁内新鲜读」的进程纪律
53
+ * 归端的存储实现)。拒写序:先 engineSessionId 后 shellSessionId(与壳日志序一致)。
54
+ */
55
+ export function mergeEngineEntry(prior, key, partial, shellSessionId, now = isoNow) {
56
+ const priorEntry = prior?.engines?.[key];
57
+ const engineSessionId = partial.engineSessionId ?? priorEntry?.engineSessionId;
58
+ if (!engineSessionId)
59
+ return { ok: false, reason: 'no-engine-session-id' };
60
+ const sid = shellSessionId ?? prior?.shellSessionId;
61
+ if (!sid)
62
+ return { ok: false, reason: 'no-shell-session-id' };
63
+ const entry = { ...priorEntry, ...partial, engineSessionId, updatedAt: now() };
64
+ return {
65
+ ok: true,
66
+ record: {
67
+ ...prior,
68
+ shellSessionId: sid,
69
+ engines: { ...prior?.engines, [key]: entry },
70
+ updatedAt: now(),
71
+ },
72
+ };
73
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * 网络/传输层失败的词面基表(壳 seamQuery「件2c transport 收窄」的那条正则逐字)。
3
+ * 判据变更义务:加词=各端跟批;删词/改形=先与消费端对表(web 叠加宿主词的半场见其
4
+ * turn-error-classify 头注)。
5
+ */
6
+ export declare const WIRE_NETWORK_ERROR_PATTERN: RegExp;
7
+ /** turn 错误的结构化分型判决(渲染/文案归端;门与遥测按 kind 对账)。 */
8
+ export type TurnWireErrorVerdict =
9
+ /** 引擎应答了非 2xx(reachable)。`errorCode` 只认活键([2055]);缺席=不带。 */
10
+ {
11
+ kind: 'http';
12
+ status: number;
13
+ errorCode?: string;
14
+ message: string;
15
+ }
16
+ /** SDK typed park 信号:流结束但 run 未达终态(多半 suspended 候人决断)—— 不是故障。 */
17
+ | {
18
+ kind: 'stream-ended-without-terminal';
19
+ }
20
+ /** 真网络/传输层失败(引擎压根没应答)。detail = cause 原话 > message > String(err)。 */
21
+ | {
22
+ kind: 'transport';
23
+ detail: string;
24
+ }
25
+ /** 分类不明(多半是客户端自己的 bug)—— 如实报,绝不指去查引擎/网络。 */
26
+ | {
27
+ kind: 'internal';
28
+ name: string;
29
+ detail: string;
30
+ };
31
+ /**
32
+ * turn 错误分型(模块头②节的四臂;臂序=壳 `semaApiErrorContent` 逐字:http → park 信号 →
33
+ * transport/internal)。纯判定,永不抛。
34
+ */
35
+ export declare function classifyTurnWireError(err: unknown): TurnWireErrorVerdict;
36
+ /**
37
+ * 真·网络/传输层失败判(壳 `engineTarget.isEngineTransportError` 逐字语义):typed HTTP error
38
+ * (numeric `status` 在场 = 引擎应答了)恒 false —— 死端点分诊绝不误挂在活引擎的业务错误上。
39
+ * 消费面:headless `-p` 死 pin 提示 / 审批提交腿的重试判型(approvalSubmitRetry)。
40
+ */
41
+ export declare function isWireTransportError(err: unknown): boolean;
42
+ /**
43
+ * 判型:server drain 门的 pre-stream 拒收(503 + `errorCode:"draining"`)。结构判读不
44
+ * instanceof(mock/包装错误同判);机器码单腿(sdk 4.0.0 起 message 腿退役)。
45
+ * 消费面的重试铁律(严格限定 pre-stream、有界)归壳 seamQuery 的重试环。
46
+ */
47
+ export declare function isPreStreamDrainingReject(err: unknown): boolean;
48
+ /**
49
+ * #166 — `resume_at.*` 错误码族:提交带的 resumeAt 锚服务端不认(Esc 杀锚未持久化的典型形)。
50
+ * 🔴 刻意**不**用 `isRewindFamilyCode`(那是含 `rewind_snapshot.` 的宽形):去锚自动重发的安全性
51
+ * 论证只对 resume_at 族做过,放宽属行为变更(RESUME_AT_ERROR_CODE_PREFIX 头注)。
52
+ */
53
+ export declare function isResumeAtRejection(err: unknown): boolean;
54
+ /**
55
+ * 第 `attempt`(0-based)次 draining 重试前的等待:`err.retryAfterMs`(SDK 若带)封顶优先
56
+ * (上限 15s = server drain 门现值,防把一个 turn park 到分钟级),缺席走退避梯
57
+ * (默认 1s/2s/4s = 壳 S4-P0 实测值;超出梯长取末档)。`opts.ladder` = 端的测试钩子注入口
58
+ * (壳 `setDrainingBackoffForTest` 经此透传)。纯函数,零模块状态(singleton 门口径)。
59
+ */
60
+ export declare function drainingRetryDelayMs(err: unknown, attempt: number, opts?: {
61
+ ladder?: readonly number[];
62
+ retryAfterCapMs?: number;
63
+ }): number;
64
+ /** 结构化拒绝字段(与 SDK `ScenarioNotAllowedError` 同形;判定层不依赖 SDK 类型面)。 */
65
+ export interface ScenarioDenyDetail {
66
+ /** The principal's allowed scenario names (defensively filtered; may be empty). */
67
+ allowlist: string[];
68
+ }
69
+ /**
70
+ * 从一个被 catch 的错误判定「场景不在指派列表」并提取结构化 detail。
71
+ * duck-typed(不 instanceof — bundle 下跨包类标识可能双实例):`status===400 &&
72
+ * errorCode===SCENARIO_NOT_ALLOWED_ERROR_CODE` 即命中;allowlist 缺席/非数组/畸形项 → 逐项
73
+ * 防御过滤成空数组(兜底文案契约归端)。非该 code 的 400 / 其他错误 → null(绝不误吃普通 400)。
74
+ * 🔴 键位([2055] 死键纪律):只认 `errorCode`,退役 `code` 键不做兼容(clean-cut)。
75
+ */
76
+ export declare function scenarioDenyFromError(err: unknown): ScenarioDenyDetail | null;
@@ -0,0 +1,130 @@
1
+ /**
2
+ * wireErrorTriage.ts — turn 错误分型的**判定半场**(A-028.11/.13,#244 族E,2026-08-15)。
3
+ *
4
+ * ## 收编前的形(census top-10 §1/§3)
5
+ * 同一套「turn 失败 → 分型 → 处置类」判定跑着**两份半**:
6
+ * · 壳 `seamQuery.semaApiErrorContent` 的判型内联(typed HTTP / 网络词面 / internal 三分支)
7
+ * + `isPreStreamDrainingReject` / `isResumeAtRejection` / `drainingRetryDelayMs`;
8
+ * · 壳 `engineTarget.isEngineTransportError`(注释自陈「两处判据变更须同步」的第二份 inline);
9
+ * · web `turn-error-classify.ts`(580 行第二实现,靠跨仓逐 token 读壳源码的 parity 门硬撑)。
10
+ * 本件把**纯判定**收成一份:错误对象 → 结构化分型判决。人话文案与渲染(Ink 行 / web 卡)
11
+ * 按 §8-5 归各端 —— 本件绝不铸一句面向用户的话。
12
+ *
13
+ * ## 判定语义(壳侧逐字,行为面零变化)
14
+ * ① `typeof err.status === 'number'` ⇒ **http**(引擎真的应答了):机器码只认 `errorCode`
15
+ * ([2055] 死键纪律:退役 `code` 槽不作兼容);
16
+ * ② `err.name === 'StreamEndedWithoutTerminalError'`(#156/[2669] SDK typed park 信号,name 判
17
+ * 别 —— 跨包 instanceof 不可靠)⇒ **stream-ended-without-terminal**(run 多半 suspended
18
+ * 候人决断,绝不谎报网络/壳 bug);
19
+ * ③ 网络词面命中或 undici `cause` 在场 ⇒ **transport**(真网络层;detail 取 cause 原话优先);
20
+ * ④ 其余 ⇒ **internal**(分类不明如实说,不编造网络原因 —— 编一个具体原因比说不知道坏得多)。
21
+ *
22
+ * ## 词面表的跨端义务
23
+ * {@link WIRE_NETWORK_ERROR_PATTERN} 是壳/web 共用的**基表**(web 侧另有浏览器 fetch 的四种说法
24
+ * `failed to fetch`/`networkerror`/`network error`/`load failed`,那是宿主词,由 web 在自己那半场
25
+ * 叠加)。web 的 run-error-table-parity-test(逐 token 读壳源码)随本件退役为「共用同一 import」。
26
+ */
27
+ import { DRAINING_ERROR_CODE, RESUME_AT_ERROR_CODE_PREFIX, SCENARIO_NOT_ALLOWED_ERROR_CODE, } from './engineErrorCodes.js';
28
+ /**
29
+ * 网络/传输层失败的词面基表(壳 seamQuery「件2c transport 收窄」的那条正则逐字)。
30
+ * 判据变更义务:加词=各端跟批;删词/改形=先与消费端对表(web 叠加宿主词的半场见其
31
+ * turn-error-classify 头注)。
32
+ */
33
+ export const WIRE_NETWORK_ERROR_PATTERN = /fetch failed|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EAI_AGAIN|ENOTFOUND|EHOSTUNREACH|ENETUNREACH|EPIPE|socket hang up|UND_ERR|terminated|other side closed/i;
34
+ /** 传输失败 detail 的取值序(undici `.cause` 的精确 host:port 优先)。包内共用,不出公面。 */
35
+ function wireErrorDetail(err) {
36
+ const e = err;
37
+ const cause = e?.cause;
38
+ return ((typeof cause?.message === 'string' && cause.message.length > 0 && cause.message) ||
39
+ (typeof e?.message === 'string' && e.message.length > 0 && e.message) ||
40
+ String(err));
41
+ }
42
+ /**
43
+ * turn 错误分型(模块头②节的四臂;臂序=壳 `semaApiErrorContent` 逐字:http → park 信号 →
44
+ * transport/internal)。纯判定,永不抛。
45
+ */
46
+ export function classifyTurnWireError(err) {
47
+ const e = err;
48
+ if (typeof e?.status === 'number') {
49
+ const errorCode = typeof e.errorCode === 'string' && e.errorCode.length > 0 ? e.errorCode : undefined;
50
+ const message = typeof e.message === 'string' && e.message.length > 0 ? e.message : String(err);
51
+ return { kind: 'http', status: e.status, ...(errorCode !== undefined ? { errorCode } : {}), message };
52
+ }
53
+ // park 判别按 `name` **结构读**,不附加 instanceof Error(codex F3 [medium] 真病修):壳源形的
54
+ // `err instanceof Error &&` 合取在单进程壳里恒真无害,但本件是三端共用面 —— desktop IPC 序列化 /
55
+ // web 跨 bundle 的同名错误是 plain object,instanceof 合取会把「候人决断」误诊成「客户端 bug +
56
+ // 建议重跑」(重跑=重复 turn,且掩盖真待决态)。壳注释自陈的意图本就是「name 判别(跨包
57
+ // instanceof 不可靠)」—— 这里把实现对齐到意图(更强形,park 臂只宽不窄)。
58
+ const errName = typeof err === 'object' && err !== null && typeof err.name === 'string'
59
+ ? err.name
60
+ : undefined;
61
+ if (errName === 'StreamEndedWithoutTerminalError') {
62
+ return { kind: 'stream-ended-without-terminal' };
63
+ }
64
+ const detail = wireErrorDetail(err);
65
+ const isNetwork = e?.cause !== undefined || WIRE_NETWORK_ERROR_PATTERN.test(detail);
66
+ if (isNetwork)
67
+ return { kind: 'transport', detail };
68
+ return { kind: 'internal', name: err instanceof Error ? err.name : typeof err, detail };
69
+ }
70
+ /**
71
+ * 真·网络/传输层失败判(壳 `engineTarget.isEngineTransportError` 逐字语义):typed HTTP error
72
+ * (numeric `status` 在场 = 引擎应答了)恒 false —— 死端点分诊绝不误挂在活引擎的业务错误上。
73
+ * 消费面:headless `-p` 死 pin 提示 / 审批提交腿的重试判型(approvalSubmitRetry)。
74
+ */
75
+ export function isWireTransportError(err) {
76
+ const e = err;
77
+ if (typeof e?.status === 'number')
78
+ return false;
79
+ return e?.cause !== undefined || WIRE_NETWORK_ERROR_PATTERN.test(wireErrorDetail(err));
80
+ }
81
+ /**
82
+ * 判型:server drain 门的 pre-stream 拒收(503 + `errorCode:"draining"`)。结构判读不
83
+ * instanceof(mock/包装错误同判);机器码单腿(sdk 4.0.0 起 message 腿退役)。
84
+ * 消费面的重试铁律(严格限定 pre-stream、有界)归壳 seamQuery 的重试环。
85
+ */
86
+ export function isPreStreamDrainingReject(err) {
87
+ const e = err;
88
+ return typeof e?.status === 'number' && e.status === 503 && e.errorCode === DRAINING_ERROR_CODE;
89
+ }
90
+ /**
91
+ * #166 — `resume_at.*` 错误码族:提交带的 resumeAt 锚服务端不认(Esc 杀锚未持久化的典型形)。
92
+ * 🔴 刻意**不**用 `isRewindFamilyCode`(那是含 `rewind_snapshot.` 的宽形):去锚自动重发的安全性
93
+ * 论证只对 resume_at 族做过,放宽属行为变更(RESUME_AT_ERROR_CODE_PREFIX 头注)。
94
+ */
95
+ export function isResumeAtRejection(err) {
96
+ const e = err;
97
+ return typeof e?.errorCode === 'string' && e.errorCode.startsWith(RESUME_AT_ERROR_CODE_PREFIX);
98
+ }
99
+ /**
100
+ * 第 `attempt`(0-based)次 draining 重试前的等待:`err.retryAfterMs`(SDK 若带)封顶优先
101
+ * (上限 15s = server drain 门现值,防把一个 turn park 到分钟级),缺席走退避梯
102
+ * (默认 1s/2s/4s = 壳 S4-P0 实测值;超出梯长取末档)。`opts.ladder` = 端的测试钩子注入口
103
+ * (壳 `setDrainingBackoffForTest` 经此透传)。纯函数,零模块状态(singleton 门口径)。
104
+ */
105
+ export function drainingRetryDelayMs(err, attempt, opts) {
106
+ const ra = err?.retryAfterMs;
107
+ if (typeof ra === 'number' && Number.isFinite(ra) && ra >= 0) {
108
+ return Math.min(ra, opts?.retryAfterCapMs ?? 15_000);
109
+ }
110
+ const ladder = opts?.ladder && opts.ladder.length > 0 ? opts.ladder : [1_000, 2_000, 4_000];
111
+ return ladder[Math.min(attempt, ladder.length - 1)] ?? 1_000;
112
+ }
113
+ /**
114
+ * 从一个被 catch 的错误判定「场景不在指派列表」并提取结构化 detail。
115
+ * duck-typed(不 instanceof — bundle 下跨包类标识可能双实例):`status===400 &&
116
+ * errorCode===SCENARIO_NOT_ALLOWED_ERROR_CODE` 即命中;allowlist 缺席/非数组/畸形项 → 逐项
117
+ * 防御过滤成空数组(兜底文案契约归端)。非该 code 的 400 / 其他错误 → null(绝不误吃普通 400)。
118
+ * 🔴 键位([2055] 死键纪律):只认 `errorCode`,退役 `code` 键不做兼容(clean-cut)。
119
+ */
120
+ export function scenarioDenyFromError(err) {
121
+ if (typeof err !== 'object' || err === null)
122
+ return null;
123
+ const e = err;
124
+ if (e.status !== 400 || e.errorCode !== SCENARIO_NOT_ALLOWED_ERROR_CODE)
125
+ return null;
126
+ const allowlist = Array.isArray(e.allowlist)
127
+ ? e.allowlist.filter((x) => typeof x === 'string' && x.length > 0)
128
+ : [];
129
+ return { allowlist };
130
+ }
@@ -23,7 +23,7 @@
23
23
  | peer:wire 契约 | `@sema-agent/sdk` **>=6.17.2**(value-level,非 type-only) | `package.json` `peerDependencies` |
24
24
  | peer:会话词汇表 | `@sema-agent/agent-types` **>=0.2.0**(type-only,零运行时) | 同上 |
25
25
  | runtime dep | `diff` ^9.0.0(**唯一**一条;portability 门按**等值**钉死) | `package.json` `dependencies` |
26
- | 公开导出面 | **692** 个运行期符号(+ 33 个测试钩) | `scripts/public-export-baseline.json` 的 `count` / `testHookCount` —— **别手抄进别处,以该文件为准** |
26
+ | 公开导出面 | **706** 个运行期符号(+ 33 个测试钩) | `scripts/public-export-baseline.json` 的 `count` / `testHookCount` —— **别手抄进别处,以该文件为准** |
27
27
  | 常驻门 | 以 `scripts/gates-manifest.json` 的 `suites` 长度为准(**本档不抄这个数**) | `scripts/gates-manifest.json`;`npm test` 的名单等值门与它逐名对账 |
28
28
  | 沿革档 | 0.29.0 起建 `CHANGELOG.md`;更早批次记账在 `src/index.ts` 文件头 + `docs/REFACTOR-LEDGER.md` | — |
29
29
 
@@ -99,7 +99,7 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
99
99
 
100
100
  ## §2 公共导出面地图(按域)
101
101
 
102
- > 全集真源 = `scripts/public-export-baseline.json` 的 `names`(**692** 项)。
102
+ > 全集真源 = `scripts/public-export-baseline.json` 的 `names`(**706** 项)。
103
103
  > 本节**不逐名抄**,只给「域 → 承重导出 → 用途 → 实现锚」。承重导出 = 一个端为了让这个域干活
104
104
  > **必须**直接调到的那几个符号;其余是它们的类型、变体与辅助位。
105
105
  > 单一入口:`import { … } from '@sema-agent/client-core'`(`exports` 只有 `.` 一个;
@@ -109,21 +109,21 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
109
109
 
110
110
  `public-export-baseline.json` 由 **`dist/index.js` 的运行期导出**生成(生成口径自述见
111
111
  `scripts/run-client-core-typeshape-test.mjs`,双向精确集合门在 `scripts/run-public-surface-test.mjs`)。
112
- 实测:692 项 **100% 是运行期导出,零 type-only**。
112
+ 实测:706 项 **100% 是运行期导出,零 type-only**。
113
113
 
114
114
  **推论(端必须知道)**:
115
- - barrel 导出的**类型**面比 692 大得多,且**不被这道门看守** —— `AdapterContext` / `SeamEvent` /
115
+ - barrel 导出的**类型**面比 706 大得多,且**不被这道门看守** —— `AdapterContext` / `SeamEvent` /
116
116
  `ChromeEvent` / `HostPorts` / `ApprovalCardPort` / `HitlHostSurface` / `ClientSliceLike` /
117
117
  `LocalSessionEvent` / `SeatMethodName` / `ModelCatalog` 全在公面上、全**不在**基线里。
118
118
  端依赖这些类型是合法的,但**不要**拿基线 diff 当"类型面没变"的证据。
119
119
  - `src/agentSession/contract.ts` 对基线贡献 **0** 项(纯类型模块,`export *` 在 dist 里是空转发)。
120
120
 
121
- 692 项的内部构成(帮助端估读表大小):**206** 项是 `SCREAMING_SNAKE` 常量数据表/词汇表
121
+ 706 项的内部构成(帮助端估读表大小):**210** 项是 `SCREAMING_SNAKE` 常量数据表/词汇表
122
122
  (矩阵、键集、env 名、锚串)而非可调用物;**4** 项是 PascalCase 运行期值
123
123
  (`ControlRouter` / `ControlSafetyError` / `HitlBridge` / `HitlSafetyError`);
124
- **38** 项是 `*For(sessionKey, …)` 的 per-session 变体(§6)。
124
+ **39** 项是 `*For(sessionKey, …)` 的 per-session 变体(§6;其中 `engineNamespaceKeyFor` 是命名巧合 —— 参数是 baseUrl 不是 sessionKey,见域 14)。
125
125
 
126
- ### 2b. 域图(16 域,逐域计数之和 = 692)
126
+ ### 2b. 域图(16 域,逐域计数之和 = 706)
127
127
 
128
128
  | # | 域 | 名数 | 承重导出 | 用途 | 实现锚 |
129
129
  |---|---|---|---|---|---|
@@ -140,9 +140,9 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
140
140
  | 11 | **模型目录与预算** | 52 | `resolveModelCatalog` · `loadCatalogWithSources` · `PROVIDER_PRESETS` / `MODEL_FAMILIES` · `defaultMaxTokensFor` · `getLiveModelCatalog` / `setLiveModelCatalogRefresher` · `providerAuthMethods` / `beginDeviceCodeAuth` | 三层 provider 目录解析(线上 URL → 包内预设 → 用户覆盖)+ per-model `maxTokens` 封顶。线上腿需注入 `CatalogFetchJson`,缺席 ⇒ 整条不启用(`online.reason='no-fetch-port'`);缓存落盘经 `CatalogCachePort` | `src/model/{catalog,catalogLoader,providerAuth,providerPresets}.ts`、`src/liveModelCatalog.ts`、`src/modelBudgetRule.ts`、`src/sessionModelLatch.ts`、`src/effortWire.ts` |
141
141
  | 12 | **workflow 与后台工作视图** | 15 | `projectWorkflowRun` · `createLiveWorkflowSource` · `createBackgroundView` · `projectBackgroundView` · `recordWorkflowAgentTaskId` · `agentDisplayStatus` | 活过一个 turn 的长任务读面:workflow run + 跨 session 后台任务归一表(`assistant.tasks` 与 fleet SSE **两源独立降级**) | `src/workflow.ts`、`src/workflowClient.ts`、`src/workflowMonitor.ts`、`src/agentSession/backgroundView.ts`(+ 纯类型 `src/agentSession/contract.ts`) |
142
142
  | 13 | **座位 IPC 契约** | 33 | `LOCAL_SESSIONS_SPEC` · `SEAT_METHOD_NAMES` · `SEAT_EVENT_TYPES` · `isLocalSessionEvent` · `isToolPermissionRequest` · `toolPermissionRequestId` · `SEAT_VALIDATOR_KEY_COVERAGE` | desktop↔web 座位 IPC 契约的**单一真源**(此前两边各一份、名字零重合 ⇒ 编译器永远不会告诉你它们漂了)。🔴 加 verb 忘了加 `LOCAL_SESSIONS_SPEC` **不报错**:preload 不注册 channel、渲染端读到 `undefined` | `src/seatContract.ts`(**零 import**,纯类型 + 常量 + 纯谓词) |
143
- | 14 | **宿主端口与会话槽** | 18 | `installHost` · `installHostFor` · `hostPortMisses(For)` · `DEFAULT_SESSION_KEY` · `hostEnv` · `unrefTimer` | 进程/端级装配层(settings/fs/queue/timers/session/log/probe),与 per-turn 的 `AdapterContext` **分层**。头注的判定规则:**这个能力每 turn 都会变吗?** 会 ⇒ `ctx`;不会 ⇒ `installHost` | `src/host.ts`、`src/hostEnv.ts`、`src/sessionSlot.ts`、`src/unrefTimer.ts`、`src/env/localeGeo.ts` |
144
- | 15 | **控制面与传输** | 60 | `ControlRouter`(+ `ControlSafetyError`)· `makeEngineWireClient` + `resolveWireAuth` · `installEngineWireTarget` · `diagnoseSseIdleTear` / `isSseIdleError` · `attemptActiveRunSelfHeal` + `activeRunBusySignal` + `activeRunSelfHealRow` / `activeRunBusyHeadlessRow` · `kickEngineCapsProbe` / `engineCapTrue` · `mapBrainStatusToRetry` · `waitForClaimRelease` + `CLAIM_RELEASED_STATES` / `CLAIM_HELD_STATES` · `atMostOnceFailureClass` / `readSteerDelivery` · `INTERACTIVE_WAY_OUT`(默认出路串单源) | 上行通道的**监管**半场(submit / steer / kill / 队列命令定序)+ 传输构造、caps 探测、SSE 断流分诊、**409 active-run 自愈** | `src/controlRouter.ts`、`steering.ts`、`sseIdleTriage.ts`、`retryStatus.ts`、`diagnostics.ts`、`engineWireSdk.ts`、`engineWireTarget.ts`、`engineSessionParam.ts`、`engineCapsCache.ts`、`liveInitToolFace.ts`、`adapter/activeRunSelfHeal.ts` |
145
- | 16 | **引擎词汇表与包自检** | 36 | `CONFIG_REFUSAL_CODES` / `isConfigRefusalCode` · `STOP_CONFLICT_CODES` · `isInterruptedToolEndCode` · `isRewindFamilyCode` · `CLIENT_VERBS` · `compensationSplitViolations` | 三端分臂共用的**去字面化** `errorCode` 词表(病根正是三端各抄一份字面);编译期 verb 闭合门;搬迁补偿登记表 | `src/engineErrorCodes.ts`(29 )、`src/classifierVerdictWire.ts`、`src/compensations.ts`、`src/clientSlice.ts` |
143
+ | 14 | **宿主端口与会话槽** | 21 | `installHost` · `installHostFor` · `hostPortMisses(For)` · `DEFAULT_SESSION_KEY` · `hostEnv` · `unrefTimer` · `engineNamespaceKeyFor` / `mergeSessionMapRecord` / `mergeEngineEntry`(A-028.12:会话 id 映射单一键形 + merge 判定;存储经 `SessionMapStorePort` 归端 —— cli 文件锁/原子写,web localStorage)| 进程/端级装配层(settings/fs/queue/timers/session/log/probe),与 per-turn 的 `AdapterContext` **分层**。头注的判定规则:**这个能力每 turn 都会变吗?** 会 ⇒ `ctx`;不会 ⇒ `installHost` | `src/host.ts`、`src/hostEnv.ts`、`src/sessionSlot.ts`、`src/unrefTimer.ts`、`src/env/localeGeo.ts`、`src/sessionMap.ts` |
144
+ | 15 | **控制面与传输** | 68 | `ControlRouter`(+ `ControlSafetyError`)· `makeEngineWireClient` + `resolveWireAuth` · `installEngineWireTarget` · `diagnoseSseIdleTear` / `isSseIdleError` · `attemptActiveRunSelfHeal` + `activeRunBusySignal` + `activeRunSelfHealRow` / `activeRunBusyHeadlessRow` · `kickEngineCapsProbe` / `engineCapTrue` · `mapBrainStatusToRetry` · `waitForClaimRelease` + `CLAIM_RELEASED_STATES` / `CLAIM_HELD_STATES` · `atMostOnceFailureClass` / `readSteerDelivery` · `INTERACTIVE_WAY_OUT`(默认出路串单源)· `normalizeWirePrincipal`(A-028.10:principal 在场性 trim 原语 —— 全空白=缺席不发头,engineWireTarget 两臂/makeEngineWireClient/壳 livePrincipal 同尺)· `classifyTurnWireError` / `isWireTransportError` / `isPreStreamDrainingReject` / `isResumeAtRejection` / `drainingRetryDelayMs` / `scenarioDenyFromError` + `WIRE_NETWORK_ERROR_PATTERN`(A-028.11/.13:turn 错误分型判定半场,人话文案与渲染归端) | 上行通道的**监管**半场(submit / steer / kill / 队列命令定序)+ 传输构造、caps 探测、SSE 断流分诊、**409 active-run 自愈** | `src/controlRouter.ts`、`steering.ts`、`sseIdleTriage.ts`、`retryStatus.ts`、`diagnostics.ts`、`engineWireSdk.ts`、`engineWireTarget.ts`、`src/principalWire.ts`、`src/wireErrorTriage.ts`、`engineSessionParam.ts`、`engineCapsCache.ts`、`liveInitToolFace.ts`、`adapter/activeRunSelfHeal.ts` |
145
+ | 16 | **引擎词汇表与包自检** | 39 | `CONFIG_REFUSAL_CODES` / `isConfigRefusalCode` · `STOP_CONFLICT_CODES` · `isInterruptedToolEndCode` · `isRewindFamilyCode` · `CLIENT_VERBS` · `compensationSplitViolations` | 三端分臂共用的**去字面化** `errorCode` 词表(病根正是三端各抄一份字面);编译期 verb 闭合门;搬迁补偿登记表 | `src/engineErrorCodes.ts`(32 项;A-028.11/.13 补 `DRAINING_ERROR_CODE`/`SCENARIO_NOT_ALLOWED_ERROR_CODE`/`RESUME_AT_ERROR_CODE_PREFIX`)、`src/classifierVerdictWire.ts`、`src/compensations.ts`、`src/clientSlice.ts` |
146
146
 
147
147
  🔴 **`engineErrorCodes` 的开集纪律**(该文件头注逐字):这些 `ReadonlySet` / 前缀谓词一律是**识别表**,
148
148
  回答的是「我认不认得这个码」,**绝不是**「合法码只有这些」。消费点 `switch` **必须留 `default`**,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/client-core",
3
- "version": "0.30.2",
3
+ "version": "0.30.3",
4
4
  "description": "Client-side session runtime shared by every sema human client (TUI / web / desktop): sema wire frames (AgentEvent) -> CC session vocabulary (SDKMessage) with dual-plane output (transcript/chrome), deterministic transcript ids, lane discipline as a type, and the notification/dedup ledgers. Every CC-skin shape is collected here so the wire itself stays neutral. Blackboard [1832] design axioms; [1651]/[1652]/[1653] signed seam design. Renamed from @sema-agent/wire-cc-adapter (0.1.x).",
5
5
  "license": "MIT",
6
6
  "type": "module",