@sema-agent/client-core 0.30.2 → 0.30.4

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,69 @@
16
16
  > 🔴 **互链**(web [C166]⑦):各版「已知局限」段只记**该版新增**;接入面已知局限的完整台账在
17
17
  > `docs/INTEGRATION-CLIENTS.md` §6e/§7 —— **只读其一会漏**,两处都过。
18
18
 
19
+ ## 0.30.4
20
+
21
+ - **#280 移交两件:probeCause 随卡透传 + tail meta 帧发布口(additive,零 BREAKING)**(2026-08-15;
22
+ 红先绿后,常驻钉 = durable-card-display-keys ⑧ 段 10 checks + pure B7 probeCause 八钉 / B6
23
+ tail-meta 六钉(含 fail-soft 两钉:sink 同步抛错不撕裂 tail / async 拒绝有界观察零
24
+ unhandledRejection —— codex 复审 [high] 采纳);壳 #280 围栏 T6 现状锁候本批包透传落地
25
+ 翻红接线):
26
+ - **件1 `ApprovalCardRequest.probeCause` 透传**(server ≥7.21.0-rc #253 的结构化探针因由,
27
+ `{code, roots:{shown,total}, further?}`):0.30.3 的两处「帧→卡入参」显式挑键把上游键丢弃 ——
28
+ 活帧腿(`surfaceToolApprovalFrameAndRespond` 的卡入参)与 durable 行腿
29
+ (`surfaceFsApprovalAndDecide`,载体 = `PendingCheckpoint.riskDescriptor.probeCause`,server 对
30
+ riskDescriptor 整体透传与活帧同值)。本批两腿共用一把**载体形**判(`isProbeCauseCarrier`:
31
+ 非 null 非数组对象)+ 原样搬运;🔴 内部结构(code/roots/further)**刻意不校** —— 结构校验归
32
+ 呈卡端窄读器(壳 `probeCauseNote.readProbeCauseKey`),包只搬运,unknown 开集类型
33
+ (`ToolApprovalFrame.probeCause` / `ApprovalCardRequest.probeCause` 两位,agent-types 无现成形)。
34
+ 帧键镜像 15→16 键;`probeCause` **领先** sdk 6.17.2 运行期锚一代 ⇒ 对账门 AHEAD_OF_ANCHOR
35
+ 带退出条件登记(#144 persistedRuleShadowed 同形先例)。超集台账 `docs/type-superset.json`
36
+ 补 `cc-counterpart-addition` 一条(CC 近邻 = `CanUseTool` options 的 `decisionReason`/
37
+ `blockedPath` 散文/标量位,差在结构化)。
38
+ - **件2 tail `event: meta` 帧发布口**(`engineSubagentTail.ts`):0.30.3 消费循环
39
+ `ev.event !== 'forward'` continue 把 meta 帧结构性 skip ⇒ `contentFrames` 供给形判别位
40
+ (server e439a6c,闭集 {on, progress_only, unknown} ——「内容永远不会来 vs 还没来」的唯一
41
+ 诚实依据)到不了端。本批循环改帧分类闸(switch):meta 经新可装口
42
+ `installSubagentTailMetaSink`(载荷 `SubagentTailMeta = {taskId, meta}`,meta 为帧开集原文,
43
+ 判别位窄读归消费端)发布后仍不进内容面;🔴 开闸范围**只放 meta**,heartbeat/未来非 forward
44
+ 帧维持 skip。不装 sink = 0.30.3 现状字节不变。公面 +3 名
45
+ (`installSubagentTailMetaSink` 运行期导出 + `SubagentTailMeta`/`SubagentTailMetaSink` 类型),
46
+ baseline 706→707;singleton 台账补 `tailMetaSink`(high,ceiling 89→90 成文理由)。
47
+ - 已知局限(本版新增):meta 判别位的**消费臂在端上**(壳 #280 围栏 T6 翻红后接线);
48
+ `riskDescriptor.probeCause` 与 `shadowedRule` 同样不在 SDK `PendingCheckpoint.riskDescriptor`
49
+ 声明里(类型半场候 SDK 班车,durable 腿为结构视图读)。
50
+
51
+ ## 0.30.3
52
+
53
+ - **#244 F3 wire/会话判定上收 · 包半场(A-028.9/.10/.11/.12/.13/.15 族E;新三件
54
+ `principalWire.ts` / `wireErrorTriage.ts` / `sessionMap.ts`,+14 公面运行期导出)**(2026-08-15;
55
+ 常驻钉 = `run-client-core-pure-test.mjs` F3E 段 58 checks):
56
+ - 🔴 **BEHAVIOR CHANGE(A-028.10,cli 主会话裁定)**:`engineWireTargetFor()` 的 principal
57
+ 在场性判定从裸 truthy 收编为 **trim 判**(新原语 `normalizeWirePrincipal`)——
58
+ **全空白** `SEMA_LIVE_PRINCIPAL`(env 臂)与全空白 `installed.principal`(显式装配臂)
59
+ 从「发出全空白 `x-agent-principal` 头」改为「键缺席=不发头(owner-null)」。
60
+ 理由:F-011 停发纪律([3279])的语义是「缺席=不发头,绝不铸哨兵值」,全空白值是垃圾值
61
+ 伪装在场 —— 壳 `livePrincipal.ts` 早已按 trim 判(裁定=壳语义为正),包侧对齐消除两侧
62
+ 判定相反的分脑。同批 `makeEngineWireClient` 的 `!== ''` 判升级为同一把 trim 尺
63
+ (全空白 principal 同罪归缺席)。非空白实值(含空白包围形如 `' alice '`)一字不动。
64
+ - **`wireErrorTriage.ts`(A-028.11/.13 新件)**:turn 错误分型判定半场
65
+ (`classifyTurnWireError` 四臂:http / stream-ended-without-terminal / transport / internal;
66
+ `isWireTransportError` / `isPreStreamDrainingReject` / `isResumeAtRejection` /
67
+ `drainingRetryDelayMs` / `WIRE_NETWORK_ERROR_PATTERN`)+ scenario 拒绝判型
68
+ (`scenarioDenyFromError`,allowlist 防御过滤单源)。语义 = cli `seamQuery` /
69
+ `engineTarget.isEngineTransportError` / `scenarioNotAllowedCopy` 逐字,人话文案与渲染归端;
70
+ web `turn-error-classify.ts` 第二实现与逐 token 跨仓 parity 门的退役半场归 web(发布帖点名)。
71
+ - **`sessionMap.ts`(A-028.12 新件)**:「客户端会话 id ↔ 引擎会话 id」映射的单一键形
72
+ (`SessionMapRecord` / `EngineSessionEntry`,壳形为正)+ `engineNamespaceKeyFor` +
73
+ 两个纯 merge 判定(`mergeSessionMapRecord` / `mergeEngineEntry`,拒写带 reason 出境)+
74
+ `SessionMapStorePort`(存储归端:cli 文件锁/原子写,web localStorage)。
75
+ - **`engineErrorCodes.ts` +3 常量**:`DRAINING_ERROR_CODE` / `SCENARIO_NOT_ALLOWED_ERROR_CODE` /
76
+ `RESUME_AT_ERROR_CODE_PREFIX`(`REWIND_ERROR_CODE_PREFIXES[0]` 改引同源;resume_at 窄形
77
+ 刻意不并入 rewind 宽形 —— 自动重发安全性论证只对 resume_at 族成立)。
78
+ - **`applyLiveRequestDefaults` 补 `additionalReadDirectories` 位(A-028.9)**:壳独有真行为
79
+ (#257 配置目录+tmp 族只读宽根)上收 —— 值(广度闸/realpath 判决)归端算,包做
80
+ 「缺席时补位」;`LIVE_DEFAULT_FIELDS` 九件→十件,`unregisteredRequestKeys` 门随表跟上。
81
+
19
82
  ## 0.30.2
20
83
 
21
84
  - **#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.4
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)。 */
@@ -238,6 +238,18 @@ export interface ApprovalCardRequest {
238
238
  * 压根没有规则命中(缺席),而命中且清掉了的那些根本不会变成卡。
239
239
  */
240
240
  persistedRuleShadowed?: string;
241
+ /**
242
+ * 结构化探针因由(#280 件1,0.30.4;server ≥7.21.0-rc #253)——**双源合流**
243
+ * ({@link governanceForced} 同形,两腿在场性可以不一致):
244
+ * · **活卡帧腿**:{@link ToolApprovalFrame.probeCause} 原样;
245
+ * · **durable park 行腿**:`PendingCheckpoint.riskDescriptor.probeCause`(server 对
246
+ * riskDescriptor 整体透传,与活帧同值 —— core `buildRiskDescriptor` 铸)。
247
+ * 🔴 **开集(unknown),包只搬运**:载体形判(非 null 非数组对象)之外零校验零改写 ——
248
+ * 内部结构(code/roots/further)的窄读、消毒与呈现全部归端(壳 `probeCauseNote` 窄读器:
249
+ * 形不合整只按缺席,呈现走 wireNote 车道)。缺席 = 无因由/老引擎/坏载体形(三者同形,不猜),
250
+ * 卡形字节不变。UNTRUSTED-for-display:code/roots 是工具作文面,只渲染绝不回喂模型/工具入参。
251
+ */
252
+ probeCause?: unknown;
241
253
  }
242
254
  /**
243
255
  * 🔴 **拆缝口** —— 弹「三选卡」并等人的决断。壳 = vendored CC `PermissionRequest`;
@@ -352,6 +364,28 @@ export interface ToolApprovalFrame {
352
364
  * 耐久对偶仍未消费——那一路是独立的一件,不在本键的施工面内。
353
365
  */
354
366
  persistedRuleShadowed?: string;
367
+ /**
368
+ * server ≥7.21.0-rc(#253 件 G1,core 5.33.0 backlog #239;**ADDITIVE**,`"tool_approval"` only。
369
+ * 真发直证 = engine 7.22.0 fixture `@sema-agent/server/dist/tool-approval.d.ts` 同名键)——
370
+ * 这只 ask **为什么**被收紧的**结构化探针因由**:被拦工具自声明 `reversibilityProbe` 且判不出
371
+ * 可回滚时,引擎在 maybe 档收紧点铸;wire 契约形 = `{ code, roots:{shown,total},
372
+ * further?:{shown,total} }`(server `ProbeCauseSchema` `.strict()`,活卡帧与 durable `card_json`
373
+ * 经同一 `readProbeCause` 单点铸造 ⇒ 两面结构性同值)。
374
+ *
375
+ * 🔴 **本包刻意不拥有这个形**(类型 = unknown 开集;#280 件1,0.30.4):内部结构(code/roots/
376
+ * further)的校验与呈现归呈卡端的窄读器(壳 `probeCauseNote.readProbeCauseKey` 同款「形不合整只
377
+ * 按缺席」纪律),包只做**载体形**判(非 null 非数组对象)+ 原样搬运 —— 两层各校一遍会让
378
+ * 「谁把键窄没的」在排障时说不清,且包侧校严一档就把上游 additive 内键挡在边界外。
379
+ * 🔴 **缺席 ≠「没有原因」**:只有 maybe 档收紧且探针真给了 cause 的 ask 才有它(工具 opt-in
380
+ * 供给,非常驻字段 —— [3980] test 定谳的负控:mandate 路径 ask 帧不带)。
381
+ * 🔴 本键**领先** SDK 运行期锚一代(sdk 6.17.2 的 `TOOL_APPROVAL_FRAME_KEYS` 尚无)⇒ 对账门
382
+ * (run-approval-frame-keys-test.mjs)AHEAD_OF_ANCHOR 带退出条件登记,#144
383
+ * `persistedRuleShadowed` 同形先例:SDK 锚补上当天登记自红逼删。
384
+ * 耐久路对偶 = `PendingCheckpoint.riskDescriptor.probeCause`(同值;server 对 riskDescriptor
385
+ * 整体透传),由 {@link surfaceFsApprovalAndDecide} 的行 → 卡重铸处消费。
386
+ * UNTRUSTED-for-display:code/roots 是工具作文面(server 已 redact),端呈前消毒,只渲染绝不回喂。
387
+ */
388
+ probeCause?: unknown;
355
389
  }
356
390
  /** {@link ToolApprovalFrame.delegation} 的形(命名形,不用内联匿名 —— typeshape 门 B4 棘轮口径)。 */
357
391
  export interface ToolApprovalDelegation {
@@ -367,7 +401,7 @@ export interface ToolApprovalDelegation {
367
401
  * `TOOL_APPROVAL_FRAME_KEYS` 比对——SDK additive 增键时对账当天红,不再人肉追平。
368
402
  * 下面两个类型钉保证镜像与 interface 本身不可能漂移(少键/多键都是编译错)。
369
403
  */
370
- export declare const TOOL_APPROVAL_FRAME_KEYS_MIRROR: readonly ["type", "approvalId", "toolCallId", "toolName", "sourceTaskId", "fromSubagent", "sourceAgentName", "message", "args", "argsOmitted", "governanceForced", "ruleSuggestions", "persistedRuleShadowed", "delegation", "outcome"];
404
+ export declare const TOOL_APPROVAL_FRAME_KEYS_MIRROR: readonly ["type", "approvalId", "toolCallId", "toolName", "sourceTaskId", "fromSubagent", "sourceAgentName", "message", "args", "argsOmitted", "governanceForced", "ruleSuggestions", "persistedRuleShadowed", "probeCause", "delegation", "outcome"];
371
405
  /** 子代帧判别:显式键 fromSubagent(core 1.378 RB-39②)优先;缺席退 sourceTaskId 在场性权宜式
372
406
  * (server 1.258 [1549]①3,旧代际兼容)。 */
373
407
  export declare function isFromSubagent(frame: ToolApprovalFrame): boolean;
@@ -255,6 +255,11 @@ export async function surfaceFsApprovalAndDecide(deps, taskId, argsByCall, signa
255
255
  // 合形窄化,落**只读键**(红线见 ApprovalCardRequest.ruleSuggestionsReadOnly 顶注:/decide
256
256
  // 无规则位,落可兑付位=假 affordance)。
257
257
  const ruleSuggestionsReadOnly = readRuleSuggestions(pending.ruleSuggestions);
258
+ // #280 件1(0.30.4):durable 行腿的探针因由载体 = `riskDescriptor.probeCause`(server 对
259
+ // riskDescriptor 整体透传,与活卡帧同值)。SDK 6.17.2 的 riskDescriptor 声明尚无此键 ⇒ 结构
260
+ // 视图读(与 caps 防御读同款姿势),类型半场候 SDK 班车;载体形不合 ⇒ 不铸键(内部结构不校,
261
+ // 归呈卡端窄读器 —— isProbeCauseCarrier 顶注)。
262
+ const durableProbeCause = pending.riskDescriptor?.probeCause;
258
263
  const card = await surfaceApprovalCard({
259
264
  toolName,
260
265
  args,
@@ -262,6 +267,7 @@ export async function surfaceFsApprovalAndDecide(deps, taskId, argsByCall, signa
262
267
  ...(signal ? { signal } : {}),
263
268
  ...(pending.governanceForced === true ? { governanceForced: true } : {}),
264
269
  ...(ruleSuggestionsReadOnly !== undefined ? { ruleSuggestionsReadOnly } : {}),
270
+ ...(isProbeCauseCarrier(durableProbeCause) ? { probeCause: durableProbeCause } : {}),
265
271
  });
266
272
  switch (card.kind) {
267
273
  case 'failed':
@@ -347,6 +353,11 @@ export const TOOL_APPROVAL_FRAME_KEYS_MIRROR = [
347
353
  // (sdk 6.14.0/6.15.0 的 TOOL_APPROVAL_FRAME_KEYS 都还没有它)⇒ 对账门里有一条**带退出条件**的
348
354
  // 领先登记(AHEAD_OF_ANCHOR):SDK 锚一旦补上,那条登记当场红,逼人删登记而不是让豁免长住。
349
355
  'persistedRuleShadowed',
356
+ // #280 件1(client-core 0.30.4):server 7.21.0-rc(#253)起真发 `probeCause`(直证 = engine
357
+ // 7.22.0 fixture 的 `@sema-agent/server/dist/tool-approval.d.ts`)。⚠️ 本键**领先** SDK 运行期锚
358
+ // 一代(sdk 6.17.2 尚无)⇒ 对账门里有一条带退出条件的领先登记(AHEAD_OF_ANCHOR),SDK 锚一旦
359
+ // 补上,那条登记当场红,逼人删登记回到逐元素相等 —— #144 persistedRuleShadowed 同形先例。
360
+ 'probeCause',
350
361
  'delegation',
351
362
  'outcome',
352
363
  ];
@@ -458,6 +469,16 @@ function pathFromGateMessage(message) {
458
469
  * 渲一个按下去必被 server 拒的选项。上限 8 条(server 端候选本就 ≤2,超长=坏形,截不留痕会
459
470
  * 掩盖注入,整体降缺席)。
460
471
  */
472
+ /**
473
+ * `probeCause` 的**载体形**判(#280 件1):非 null 非数组对象才搬运(wire 契约的载体就是对象,
474
+ * 串/数/数组/null 是坏载体不是「另一种因由」)。刻意只判到这一层 —— 内部结构(code/roots/further)
475
+ * 校验归呈卡端窄读器(server `readProbeCause` 的消费端对偶,壳 `probeCauseNote.readProbeCauseKey`),
476
+ * 包内校严一档就会把上游 additive 内键挡在边界外,还让「谁把键窄没的」在排障时多一层嫌疑。
477
+ * 两条决断腿(活帧/durable 行)共用这一把,判词绝不各写各的。
478
+ */
479
+ function isProbeCauseCarrier(v) {
480
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
481
+ }
461
482
  function readRuleSuggestions(v) {
462
483
  if (!Array.isArray(v))
463
484
  return undefined;
@@ -570,6 +591,9 @@ export async function surfaceToolApprovalFrameAndRespond(frame, respond, streamA
570
591
  ...(typeof frame.persistedRuleShadowed === 'string' && frame.persistedRuleShadowed.trim() !== ''
571
592
  ? { persistedRuleShadowed: frame.persistedRuleShadowed }
572
593
  : {}),
594
+ // #280 件1(0.30.4):探针因由随卡透传 —— 载体形判之外零校验零改写(内部结构归呈卡端窄读器,
595
+ // isProbeCauseCarrier 顶注);缺席/坏载体 ⇒ 键不 stamp,卡形字节不变。
596
+ ...(isProbeCauseCarrier(frame.probeCause) ? { probeCause: frame.probeCause } : {}),
573
597
  });
574
598
  const decision = card.kind === 'allow' ? (card.allowSession ? 'allow_session' : 'allow') : 'deny';
575
599
  if (card.kind === 'failed') {
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
+ }
@@ -20,6 +20,26 @@ export interface SubagentActivity {
20
20
  export type SubagentActivitySink = (a: SubagentActivity) => void;
21
21
  /** 装/卸 Progress 段落点。传 null 卸。返回还原函数。 */
22
22
  export declare function installSubagentActivitySink(sink: SubagentActivitySink | null): () => void;
23
+ /**
24
+ * 一帧 tail `event: meta` 的发布载荷(#280 件2,0.30.4)。`meta` 是连接语义帧(server 建连即发:
25
+ * `{version, runId, target, status, seq?, contentFrames?, …}`),**不进内容面** —— 但其中的
26
+ * **供给形判别位 `contentFrames`**(server e439a6c,闭集 {on | progress_only | unknown})是端
27
+ * 区分「内容帧**永远不会来**(forward 缺席宿主,结构性无内容)vs **还没来**」的唯一诚实依据;
28
+ * 0.30.3 及之前它被消费循环的 forward-only 过滤整类 skip,端结构性到不了。
29
+ * 🔴 `meta` 为**开集原文**(SDK subagentStream 产物 `Record<string, unknown>` 原样):判别位的
30
+ * 窄读(闭集判词/缺席处置)归消费端,包只搬运 —— additive 上游往 meta 里加键零施工。
31
+ * ⚠️ 供给形登记随句柄终态在 server 侧撤销(SubagentTailBus.forgetHandleContentMode)⇒ 判别位
32
+ * 要在子代存活窗内消费,终态后重开的 tail 读到 `unknown` 不是回归。
33
+ */
34
+ export interface SubagentTailMeta {
35
+ taskId: string;
36
+ /** meta 帧原文(开集;`contentFrames` 等判别位的窄读归消费端)。 */
37
+ meta: Record<string, unknown>;
38
+ }
39
+ /** 端装的 meta 帧落点(#280 件2)。不装 ⇒ 判别位不达端(不是错误态,该面不启用;0.30.3 现状字节不变)。 */
40
+ export type SubagentTailMetaSink = (m: SubagentTailMeta) => void;
41
+ /** 装/卸 tail meta 帧落点。传 null 卸。返回还原函数。 */
42
+ export declare function installSubagentTailMetaSink(sink: SubagentTailMetaSink | null): () => void;
23
43
  /**
24
44
  * tail 是否正对该行开着 —— **两源互斥判别口**(行帧 `currentTool` 臂在 tail 活跃时让位:
25
45
  * tail 的 tool_start 带真 args,渲染更细;两源同录 = 每步双行)。
@@ -84,6 +84,15 @@ export function installSubagentActivitySink(sink) {
84
84
  activitySink = prev;
85
85
  };
86
86
  }
87
+ let tailMetaSink = null;
88
+ /** 装/卸 tail meta 帧落点。传 null 卸。返回还原函数。 */
89
+ export function installSubagentTailMetaSink(sink) {
90
+ const prev = tailMetaSink;
91
+ tailMetaSink = sink;
92
+ return () => {
93
+ tailMetaSink = prev;
94
+ };
95
+ }
87
96
  /* `coerceOutput`(tool_end.output 的字符串化口)**已搬到 `subagentContentStore.ts`**
88
97
  * (#158 移交① / [3674](d) 姊妹病,2026-08-12):喂那个 store 的是两条腿(本 tail 腿 + seam
89
98
  * 适配器的 C1 分流臂),字符串化口必须只有一份,故跟着它服务的那个位走。公面导出名不变
@@ -143,8 +152,35 @@ export function tailEngineSubagent(taskId) {
143
152
  // 连接分界线:meta 帧即到 = SSE 建连成功(与「子代此刻恰好在跑长工具、没内容可发」区分开)
144
153
  hostLog('debug', `[subagent-tail] ${taskId} stream connected +${Date.now() - openedAt}ms`);
145
154
  }
146
- if (ev.event !== 'forward')
147
- continue; // meta/heartbeat:连接语义帧,不进内容面
155
+ // #280 件2(0.30.4):帧分类闸 —— `meta` 经发布口出去(`contentFrames` 判别位在其中,
156
+ // 见 SubagentTailMeta 顶注),`forward` 进内容面,其余(heartbeat / 未来新连接语义帧)
157
+ // 维持 skip。🔴 开闸范围**只放 meta**(最小化):放宽 default 臂 = 把未知帧灌进发布口,
158
+ // 消费端会把连接杂音当判别位读。
159
+ switch (ev.event) {
160
+ case 'meta':
161
+ // 连接语义帧,不进内容面;不装 sink = 现状。🔴 消费端 sink 失败**绝不撕裂 tail**
162
+ //(本文件头注铁律「一切失败 fail-soft…绝不 throw」;runStream onDroppedFrame
163
+ // 「sink 抛错绝不打断流且痕迹落回」同族)——两臂都要:同步 throw 由 try/catch 兜,
164
+ // **异步拒绝**(类型虽 void,宿主传 async 函数合法)也要有界观察 —— 不观察 =
165
+ // unhandledRejection = Node 缺省整进程崩,TUI 宿主最坏形。痕迹留 hostLog,内容帧照常消费。
166
+ try {
167
+ const out = tailMetaSink?.({ taskId, meta: ev.data });
168
+ if (out !== null && typeof out === 'object' && typeof out.then === 'function') {
169
+ ;
170
+ out.then(undefined, (e) => {
171
+ hostLog('debug', `[subagent-tail] ${taskId} meta sink rejected: ${String(e).slice(0, 160)}`);
172
+ });
173
+ }
174
+ }
175
+ catch (e) {
176
+ hostLog('debug', `[subagent-tail] ${taskId} meta sink threw: ${String(e).slice(0, 160)}`);
177
+ }
178
+ continue;
179
+ case 'forward':
180
+ break;
181
+ default:
182
+ continue; // heartbeat 等连接语义帧,不进内容面
183
+ }
148
184
  const d = ev.data;
149
185
  const t = d.type;
150
186
  frames += 1;
@@ -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
+ | 公开导出面 | **707** 个运行期符号(+ 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`(**707** 项)。
103
103
  > 本节**不逐名抄**,只给「域 → 承重导出 → 用途 → 实现锚」。承重导出 = 一个端为了让这个域干活
104
104
  > **必须**直接调到的那几个符号;其余是它们的类型、变体与辅助位。
105
105
  > 单一入口:`import { … } from '@sema-agent/client-core'`(`exports` 只有 `.` 一个;
@@ -109,28 +109,28 @@ 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
+ 实测:707 项 **100% 是运行期导出,零 type-only**。
113
113
 
114
114
  **推论(端必须知道)**:
115
- - barrel 导出的**类型**面比 692 大得多,且**不被这道门看守** —— `AdapterContext` / `SeamEvent` /
115
+ - barrel 导出的**类型**面比 707 大得多,且**不被这道门看守** —— `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
+ 707 项的内部构成(帮助端估读表大小):**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 域,逐域计数之和 = 707)
127
127
 
128
128
  | # | 域 | 名数 | 承重导出 | 用途 | 实现锚 |
129
129
  |---|---|---|---|---|---|
130
130
  | 1 | **适配内核(下行主链)** | 29 | `adapt` · `createWireToCcAdapter` · `runStream` · `eventToSdkMessage` · `terminalToSdkResult` · `turnUsageToModelUsage` · `isRunStreamActive` · `ADAPTER_DIVERGENCES` | 引擎 SSE `AgentEvent` → 端要渲的**双面输出**:transcript(`SDKMessage`)+ chrome(瞬态 `ChromeEvent`)。**本包存在的理由** | `src/adapt.ts`、`src/adapt/{arms,wireShapes,panelTasks}.ts`(经 `adapt.ts` 再导出)、`src/adapter/runStream.ts`、`src/adapter/downstream/*`、`src/adapter/types.ts` |
131
131
  | 2 | **seam 公共契约** | 2(其余为 type-only) | `CHROME_ARMS` · `deriveTranscriptId` | 公共词汇 + **id 确定性不变量**(同一条流重放 ⇒ 同一串 id)。`CHROME_ARMS` = 端「我要消费哪些 chrome 臂」的对照清单 | `src/seam.ts` |
132
132
  | 3 | **HITL 决断卡链**(§4/§5 主战场) | 119 | `makeHitlCanUseTool` · `HitlBridge` · `findPendingForTask` · `HitlSafetyError` · `bridgeAskUserQuestionGates` · `surfaceToolApprovalFrameAndRespond` / `surfaceFsApprovalAndDecide` · `readToolApprovalRespondAck` · `installApprovalCardPort(For)` · `installHitlHostSurface(For)` · `armPlanReviewApproval` · `reopenPlanReviewCard` · `decidePlanReview` · `startApprovalsFeed` · `pendingRowIsOwnedByThisSession` · `approvalCallKey`/`liveFrameCallKey`/`planReviewQuestionId` · `registerArmedGateFor`/`wasGateArmedFor`/`clearArmedGateFor` · `waitForGateArmed(For)`/`onGateArmed(For)`/`gateArmedWaitMs`(#244 F1 呈现回执事件源) · `planReviewArmedKey(For)`/`notePlanReviewAnswered(For)`/`notePlanReviewAnsweredIfDecisive(For)`(A-024.4 plan 呈现分代) · `toolEndOutputText` · `isAskTool` · `waitForParkRowBirth` · `classifyAskParkRows` / `askParkRowArm` / `classifyAskParkChainFailure` · `readDecisionNoteAudit` / `decisionNoteAuditLine` · `resumeRunningOptions` / `resumeChoiceFromLabels` · `persistedRulesLaneAvailable`/`persistedRulesGovernanceAvailable` · `classifyRulesFailure` · `listAllPersistedRules` · `classifySkippedReason` · `readRulePersistOutcome`(#244 F2:persist-ack 读口与 `readToolApprovalRespondAck` 合成一处) · `parseLocalAllowRule`(durable 腿本地落规则窄化骨架,谓词经 `LocalAllowRuleDeps` 注入) | suspended→decide→resume 环。🔴 **D-1 两元组 verbatim 回显**是字节级断言的安全不变量,端**不许重实现它的任何一段**。🔴 键空间边界(web [C1] d3 拦截):`gateIdentity` 四常量两函数只覆盖 HITL questionId/callKey 空间;seat 的 `TOOL_PERMISSION_REQUEST_ID_DOMAINS`(`plan:` 等)是另一键空间,**两者绝不合并**(合并=座位校验器静默拒全部 plan-review 卡) | `src/hitl/hitlBridge.ts`、`toolApprovalWire.ts`、`askGateWire.ts`、`planReviewWire.ts`、`hitlHostSurface.ts`、`gateIdentity.ts`、`armedGateRegistry.ts`、`parkOwnership.ts`、`parkResolver.ts`、`approvalsFeed.ts`、`frameRouter.ts`(**只挑名导出** `toolEndOutputText`/`ENGINE_ABORT_TOOL_RESULT`/`isAskTool`/`HITL_REJECT_MESSAGE`)、`parkRowBirthWait.ts`、`approvalDecisionNoteAudit.ts`、`askParkRowRouting.ts`、`resumeRunningCard.ts`(#265 上收的判定层)、`persistedRulesWire.ts`、`localAllowRule.ts`(#244 F2 规则侧) |
133
- | 4 | **子代 wire + 面板侧信道台账** | 68 | `tailEngineSubagent` · `installSubagentActivitySink` · `stopEngineTask` + `classifyTaskStopConflict` · `fetchEngineSubagentReport` · `steerEngineSubagent` · `subscribeSubagentContent` · `subscribeEngineAgentPanel` · `publishQuestionFrame` / `respondToQuestion` | 驱动与观测委派子代;经 module 级台账喂活体 agent/task 面板。全部**能力位 gate**(§5b) | `src/subagent/*.ts`、`src/subagentContentStore.ts`、`src/engineAgentPanelStore.ts`、`src/engineInlineTaskStats.ts`、`src/engineToolLabelStore.ts`、`src/liveQuestionStore.ts` |
133
+ | 4 | **子代 wire + 面板侧信道台账** | 69 | `tailEngineSubagent` · `installSubagentActivitySink` · `installSubagentTailMetaSink`(#280 件2:tail meta 帧发布口,`contentFrames` 判别位载体)· `stopEngineTask` + `classifyTaskStopConflict` · `fetchEngineSubagentReport` · `steerEngineSubagent` · `subscribeSubagentContent` · `subscribeEngineAgentPanel` · `publishQuestionFrame` / `respondToQuestion` | 驱动与观测委派子代;经 module 级台账喂活体 agent/task 面板。全部**能力位 gate**(§5b) | `src/subagent/*.ts`、`src/subagentContentStore.ts`、`src/engineAgentPanelStore.ts`、`src/engineInlineTaskStats.ts`、`src/engineToolLabelStore.ts`、`src/liveQuestionStore.ts` |
134
134
  | 5 | **fleet 投影** | 43 | `createFleetLedger` · `projectTasks` · `projectWorkflows` · `projectFleetAgentRows` · `readEngineActiveBgTasks` · `FLEET_TASK_VIEW_KEYS` | 老 `fleetClient` 那一刀的成品:**帧体归库、连接归端** —— 端持 SSE 连接,库做行投影 + 保留台账 | `src/fleet/fleetProjection.ts`、`src/fleet/fleetLedger.ts`、`src/fleetAgentPanelProjection.ts`、`src/fleetTaskDesc.ts` |
135
135
  | 6 | **请求装配(上行唯一构造口)** | 8 | `buildTaskRequest` · `REQUEST_FIELD_MATRIX` · `unregisteredRequestKeys` · `applyLiveRequestDefaults` · `taskNotificationToPrintFrame` | 两条车道(`interactive`/`print`)出站请求的**唯一**构造器;`unregisteredRequestKeys` 是可执行门 —— 端偷带一个未登记键上 wire 就红 | `src/request/taskRequest.ts`、`src/request/printNotification.ts` |
136
136
  | 7 | **通知与 outstanding 台账** | 37 | `installNotificationQueuePort` · `normalizeTaskNotification` · `taskNotificationDedupKeyFromWire` · `registerOutstandingBgTask` / `registerOutstandingWorkflowRun` · `notificationQueuePortMisses` · `subscribeOutstandingWorkflows` · `outstandingDeliverableWorkflowCount` | `task_notification` 归一 + 去重 + 投递进宿主命令队列的**一把闸**;`outstandingDeliverableWorkflowCount()` 是 headless `-p` 的**退出门** | `src/notifications.ts`(11 个 module 台账) |
@@ -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`**,
@@ -414,6 +414,7 @@ durable park 腿走 `HitlBridge.decideTool(outcome, toolUseID, opts, preResolved
414
414
  | `installHitlHostSurface(For)` | `src/hitl/hitlHostSurface.ts` | 通知不上屏(与壳原文 `if (!store) return` 同语义) | **是**(`hitlHostSurfaceMisses()` 恒应为 0) | §4a 的两条**安全告知**(remember-not-applied / edit-not-forwarded)+ cancel-by-deny warn + Recent Denials 记账**全丢** |
415
415
  | `installEngineWireTarget(For)` | `src/engineWireTarget.ts` | 回落 env 派生 | — | 🔴 **非 Node 宿主(web / desktop 渲染进程)没有 env** ⇒ 根本拿不到引擎目标。对它们这是**唯一**入口(设了就压过 env);不装 ⇒ `engineRowStopGate` 等按 `baseUrl` 查 caps 的门恒判假。⚠️ **凭证面有边界**:`EngineWireTarget.token` 今天是 `string`,**装不进** `{ mode: 'same-origin-relay' }` —— 浏览器同源宿主必读 **§5d** 与缺口 **P-28** |
416
416
  | `installSubagentActivitySink` | `src/subagent/engineSubagentTail.ts` | Subagent Progress 段无数据 | 否 | 明确**不是**错误态:那个面就是没启用 |
417
+ | `installSubagentTailMetaSink` | `src/subagent/engineSubagentTail.ts`(#280 件2,0.30.4) | tail `event: meta` 帧不发布(0.30.3 现状) | 否 | `contentFrames` 供给形判别位(闭集 {on, progress_only, unknown})不达端 ⇒ 端把「结构性无内容(forward 缺席宿主)」误读成「还没来」永远空挂。载荷 = `{taskId, meta}`(meta 为帧**开集原文**,判别位窄读归端);⚠️ 判别位要在子代存活窗内消费(server 侧登记随句柄终态撤销,终态后 tail 读到 `unknown` 不是回归) |
417
418
  | `installWorkflowStatusProbe` | `src/notifications.ts` | watcher 没有带对 baseUrl/token/principal 的 `workflows.get` | — | **workflow 完成通知永远不落地** |
418
419
  | `installBgTaskStatusProbe` | `src/notifications.ts` | bg 状态 watcher 空转 | — | **后台子代完成通知永远不落地**(这个 watcher 存在的理由正是 idle 期推送不可靠) |
419
420
  | `CatalogFetchJson`(注入,非 install) | `src/model/catalog.ts` | 线上腿**整条不启用**(`online.reason = 'no-fetch-port'`) | — | 目录只走包内兜底(端可据 `source` + `online.reason` 诚实渲「内置版本(离线)」) |
@@ -786,6 +787,7 @@ reason 里写明「枚举器盲区形」。已知两形:
786
787
  - [ ] `kickEngineCapsProbe(baseUrl, probe)`(caps 门的数据源)
787
788
  - [ ] `installWorkflowStatusProbe` / `installBgTaskStatusProbe` —— 不装 = **workflow / 后台子代的完成通知永远不落地**
788
789
  - [ ] `installSubagentActivitySink`(可选:Subagent Progress 段的数据源;不装不是错误态)
790
+ - [ ] `installSubagentTailMetaSink`(可选,0.30.4 #280 件2:tail meta 帧的 `contentFrames` 判别位数据源;不装不是错误态,但查看态会把「结构性无内容」渲成永远的「还没来」)
789
791
  - [ ] **启动校验用存在性读口**(§5a 的 (a) 表,🔴 **哨兵逐口不同,逐个照抄别推广**;多会话宿主用 `*For` 那一支):`hasApprovalCardPort() === true` · `hasApprovalCardPortFor(key) === true` · **`hitlHostSurfaceFor(key) !== null`**(哨兵是 `null`,写 `!== undefined` **恒真** = 假绿) · `hostSettings() !== undefined` · `hostSettingsFor(key) !== undefined` · `hostFs() !== undefined` · `hostFsFor(key) !== undefined` · `hostSession() !== undefined` · `hostSessionFor(key) !== undefined` · `engineWireTarget() !== null` · `engineWireTargetFor(key) !== null`(`hostTimers()` / `hostTimersFor(key)` 同为 `!== undefined`,按你真装的挑)
790
792
  - [ ] **miss 计数当回归探针,不当装配自检**(#252 复审 R3/R4,§5a (b) / 缺口 **P-29**):`hostPortMisses()` 空 / `notificationQueuePortMisses() === 0` / `approvalCardPortMisses() === 0` / `hitlHostSurfaceMisses() === 0` / `compensationSplitViolations()` 空 —— 🔴 **这几个数初值就是 0/空**,「完全没装口 + 还没发生任何调用」时它们照样全绿,**启动瞬间的断言证明不了装配**。正确用法 = 跑过**一轮真流量**之后再断言它们仍为 0。⚠️ **通知队列口今天没有存在性读口**(P-29),那一条只能靠你自己记得调过
791
793
 
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.4",
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",