@sema-agent/client-core 0.8.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -5,6 +5,35 @@
5
5
  * 沿革:@sema-agent/wire-cc-adapter 0.1.0 = seam 类型 + id 确定性派生 + 首批踩坑纯函数;
6
6
  * 0.1.2(#52a)= adapt() 管线首批(纯投影臂全落 + 壳态耦合臂投影成 ChromeEvent + 差分守卫);
7
7
  * 0.2.0 = 迁入 sema-client-core 独立仓并改名(旧 npm 名 deprecate 指本包);
8
+ * 0.10.0 = **B8 批**(搬迁总线**末批**,设计稿 §8-5「print lane 收编」):
9
+ * ① **请求拼装合一收官**:B4 建的 `buildTaskRequest(input, lane)` 从此是 print lane
10
+ * (`seamQueryEngine.ask`)的**唯一**构造口 —— 壳里那份「`-p` 自己另拼一份」的字面量废除;
11
+ * 车道差异全在 `REQUEST_FIELD_MATRIX`(print 独有 finalVerification/limits/interactiveTools、
12
+ * TUI 独有 ultracode 等,逐条带理由与 `gap` 判断)。新增可执行判据 `unregisteredRequestKeys`
13
+ * —— **真上 wire 的那份**请求也要过表,再漏一个键就当场红(表以前只管得住构造器自己)。
14
+ * ② **`task_notification` 字段集合一**(`request/printNotification.ts`):print 出口帧与交互面
15
+ * 共用 `normalizeTaskNotification` 这**一把闸**(空 task_id 丢帧 / summary 兜底 / status 回落),
16
+ * 并补上**残局归因**(stoppedBy / resumable / partial / exitCode / diagnostics / result /
17
+ * lines / recentSteps / editedFiles / task_type / source / seq / injected)—— 修前这些位在
18
+ * `-p` 出口整段丢失,而 `-p` 恰恰是唯一没有人当场看着的车道。🔴 车道差异只剩 `status` 的
19
+ * killed⇒stopped 一条(CC 187 print 出口形,表里留注);去重键不出第二个名字,print 侧那份
20
+ * 手写算法废除,直接用 `taskNotificationDedupKeyFromWire`。
21
+ * ③ **留壳**(§8-5 明写,本批一行没动):`PrintStreamProjector`(stream-json 帧序/SSE 重铸)与
22
+ * 全部 stderr 分诊文案 —— 渲染面与人话面属端。
23
+ * 0.9.0 = **B7 批**(设计稿 §3 B7「完成事件单一权威 + HITL」):
24
+ * ① **HITL 族四件**(新目录 `hitl/`):`hitlBridge`(整搬,🔴 **D-1 两元组 verbatim 回显**
25
+ * 与「409 绝不自动重试」两段**逐字节**搬,pure 门 B7 段用冻结字面量 + 壳树双向对拍 +
26
+ * 毒化行为口径三重钉)· `toolApprovalWire`(**拆**:判定/编排进包,三选卡本体经
27
+ * `ApprovalCardPort` 留端)· `askGateWire`(整搬;footer 通知与 Recent Denials 记账经
28
+ * `HitlHostSurface` 留端)· `planReviewWire`(整搬,**第七份 `wireConfig()` 收编闭合**)。
29
+ * ② **完成事件族 T2/T3**:`taskNotificationDedupKeyFromWire`(裸 wire 载荷键形)+
30
+ * `parseTranscriptNotificationSeeds` / `seedTaskNotificationDedupFromTranscript`
31
+ * (#63 resume 回植的 XML 反解)搬入 `notifications.ts`。**P1-3 未到货 ⇒ 只搬不删**,
32
+ * 四道去重原样;退役条件登记进 `compensations.ts`(T1/T2/T3/T25/T53 五条)。
33
+ * ③ **`approvals.stream()` 换轮询**(census G20):`hitl/approvalsFeed.ts` —— SDK 原生推送优先、
34
+ * 断流/无端点**回落轮询**的韧性臂保留;两条腿都收敛到「非 heartbeat 事件 ⇒ 重取权威
35
+ * `list()`」(SDK 自述 payload 是 FORWARD-DRAFT,绝不信未确认的 delta 形)。
36
+ * 🆕 队列口新增**可选**动词 `enqueueMetaPrompt`(plan_review 结局回植;既有宿主不破)。
8
37
  * 0.8.0 = **B6 批**(设计稿 §3 B6,分两轮落):
9
38
  * ——【前半,fleet/子代身份族】fleetClient 沿 §2.5 拆两半(帧体归库、连接归端):
10
39
  * `fleet/fleetProjection` + `fleet/fleetLedger` · 子代 wire 族六件(`subagent/*`)·
@@ -80,6 +109,12 @@
80
109
  * · liveInitToolFace.ts(`cached`)—— 两份只是多探一次 `/v1/capabilities/scenarios/:name`,
81
110
  * **不是**静默失效;仍登记,免得下次读表的人以为它没状态。
82
111
  * · model/providerPresets.ts(`presetIndexCache`)—— 由不可变 JSON 派生的纯 memo,两份只费内存。
112
+ * —— B7 新增(两条都是**装在一份、读另一份 = 面直接不工作**,比台账分裂更响):
113
+ * · **hitl/toolApprovalWire.ts**(`cardPort` + `cardPortMisses`)—— 写方 = 宿主启动装配
114
+ * `installApprovalCardPort()`,读方 = 两条决断腿。两份 ⇒ `hasApprovalCardPort()` 恒 false ⇒
115
+ * **每一张写权限 gate 都走 fail-closed deny(卡根本不弹)**,而宿主那边"我装了啊"。
116
+ * · **hitl/askGateWire.ts**(`hostSurface` + `hostSurfaceMisses`)—— 写方 = 宿主装配,
117
+ * 读方 = cancel-deny warn 与分类器 deny 记账。两份 ⇒ 那一行 warn 与 Recent Denials 全丢。
83
118
  * 🔴 **宿主装配**——B4 起总入口是 `installHost({log,probe,queue,timers,settings,fs,session})`
84
119
  * (`installNotificationQueuePort()` 仍是队列的真源出口,`installHost({queue})` 直通它,两者不分裂);
85
120
  * 自检口 `hostPortMisses()` 恒应为空对象,非空 = 有口漏装、对应的面已在静默失效。
@@ -217,7 +252,30 @@ export * from './liveInitToolFace.js';
217
252
  // 来源。表不在包里,那条中央规则在 web/桌面上恒拿不到 cap ⇒ 对 deepseek-chat(8192)这类模型
218
253
  // 超发 ⇒ 网关 400。搬迁期与壳树两份 JSON 由 pure 门的 byte-identity 断言锁住。
219
254
  export * from './model/providerPresets.js';
255
+ // ── B7 批:HITL 族(2026-07-27,设计稿 §3 B7)────────────────────────────────────────────────
256
+ // 🔴 `hitl/hitlBridge.ts` 里的 **D-1 两元组 verbatim 回显**是安全不变量(`bindingOf` 绝不重算
257
+ // hash;409 ⇒ `HitlSafetyError('binding_mismatch')` 绝不自动重试)—— pure 门 B7 段做**字节级**
258
+ // 断言,改一个字符就红。
259
+ // 🔴 两个新拆缝口(缺席都计 miss,宿主自检恒应为 0):
260
+ // · `installApprovalCardPort` —— 三选卡本体(vendored CC `PermissionRequest` / 桌面模态);
261
+ // · `installHitlHostSurface` —— footer 通知与 /permissions Recent Denials 记账(端状态形状)。
262
+ // 🔴 单实例:`toolApprovalWire` 的卡口槽 + `askGateWire` 的宿主面槽是 module 级变量;两份实例 ⇒
263
+ // 装在一份、读另一份 = 每张 gate 都走 fail-closed deny(卡根本不弹)。
264
+ export * from './hitl/hitlBridge.js';
265
+ export * from './hitl/toolApprovalWire.js';
266
+ export * from './hitl/askGateWire.js';
267
+ export * from './hitl/planReviewWire.js';
268
+ // B7 ③(census G20,**行为改动**不是搬迁):pending-approvals 推送 feed(stream 优先 / 断流回落
269
+ // 轮询 / 定期再试)。🔴 它**不替换** D-1 的取件 —— 那三处必须继续走权威 `list()`(见文件头)。
270
+ export * from './hitl/approvalsFeed.js';
220
271
  // ── B6 余项③(P5):补偿层登记表 —— 把「哪条补偿拆了、拆缝对面是谁、什么时候能退休」做成数据。
221
272
  // 🔴 它**不是** `ADAPTER_DIVERGENCES`(那张表说的是 adapt 与 cli 行为不同的地方;本表里的东西
222
273
  // 两侧行为相同)。自检口 `compensationSplitViolations()` 恒应为空。
223
274
  export * from './compensations.js';
275
+ // ── B8 批:print lane 收编(2026-07-27,设计稿 §8-5 裁决 —— 总线末批)──────────────────────────
276
+ // `task_notification` 字段集合一:`-p` 的 SDK 出口帧从 6 位手写字面量换成与交互面**同一把闸**
277
+ // (normalizeTaskNotification)+ 一张逐字段对照表;残局归因(stoppedBy/resumable/partial/
278
+ // exitCode/…)从此不在 print 出口丢失。🔴 车道差异只剩 `status` 的 killed⇒stopped 一条(表里留注)。
279
+ // 去重键**不出第二个名字**:print 侧那份手写算法废除,直接用 `taskNotificationDedupKeyFromWire`。
280
+ // 请求面同批补上可执行判据 `unregisteredRequestKeys`(见 request/taskRequest.ts 文件末)。
281
+ export * from './request/printNotification.js';
@@ -49,6 +49,46 @@ export declare function taskNotificationDedupKey(n: {
49
49
  seq?: number | string;
50
50
  taskType?: string;
51
51
  }): string;
52
+ /**
53
+ * ⇄ B7(T2):同一把键的 **裸 wire 载荷** 入口 —— cli `upstreamBridge.engineTaskNotificationDedupKey`
54
+ * 的等价物(入参是引擎 `task_notification` 帧的原始 payload:snake_case `task_id` / `task_type`)。
55
+ *
56
+ * 🔴 **不是第二份算法**:字段归一之后直接委托给上面那一个 `taskNotificationDedupKey`。
57
+ * 两份手写算法正是这条键在 0.1.1 被 codex 对抗复审抓到过的形(无 seq 追不追段的差异会让
58
+ * 「重放去重」整条失效而两边代码看着都对);pure 门 B7 段对两个入口做**同键对拍**。
59
+ *
60
+ * 键形:`[external:]taskId:status[:seq]`。缺 `task_id` ⇒ 空 taskId 段(与 cli 逐字一致 ——
61
+ * 空 id 的通知本就在 `normalizeTaskNotification` 那一层被丢弃,这里不额外加语义)。
62
+ *
63
+ * ⚠️ **P1-3(完成事件单一权威)未到货 ⇒ 只搬不删**:引擎给出 `completionId` 之后,这把
64
+ * 「客户端按 taskId:status[:seq] 自己重算」的键才能换成「读引擎发的 id」。退役条件见
65
+ * `compensations.ts` 的 T2 行。
66
+ */
67
+ export declare function taskNotificationDedupKeyFromWire(n: Record<string, unknown>): string;
68
+ /**
69
+ * ⇄ B7(T3):从**落盘转录**里的 `<task-notification>` 块反解去重键 —— #63 resume 回植的
70
+ * **纯解析半场**(适配器实例的 `importLedgerFromTranscript` 与宿主的 resume 装载腿共用这一份)。
71
+ *
72
+ * 为什么要单独导出:cli 里这段解析有**两份**(`upstreamBridge.seedEngineTaskNotificationDedupFromTranscript`
73
+ * 与包内 `WireToCcAdapterImpl.importLedgerFromTranscript`),两份的差异会让 resume 回植在某一侧
74
+ * 静默失效。B7 把解析收成一份纯函数,两个消费口都调它;壳侧那份 shim 化后直接调
75
+ * `bridgeAdapter().importLedgerFromTranscript(...)`。
76
+ *
77
+ * 🔴 键形必须是**无 seq** 的:落盘 XML 不带 seq,Monitor 的 `:seq` 批键形与之不同 —— 这正是
78
+ * 「绝不误伤 Monitor 多批流」的判据(cli `taskNotificationResumeSeed` 单测双向钉的就是它)。
79
+ *
80
+ * ⚠️ **P1-3 到货后**:仍需回植(crash 窗口是客观存在的),但不必再解 XML —— 转录里会直接带
81
+ * `completionId`。退役条件见 `compensations.ts` 的 T3 行。
82
+ */
83
+ export interface TranscriptNotificationSeeds {
84
+ /** 渲染去重键(`[external:]taskId:status`,**无 seq 段**)。 */
85
+ renderedKeys: string[];
86
+ /** 跨通道记账键(裸 taskId;仅**非 external** 的终态 completed/failed/killed)。 */
87
+ notifiedKeys: string[];
88
+ /** 解出的块数(= cli `seeded` 返回值)。 */
89
+ seeded: number;
90
+ }
91
+ export declare function parseTranscriptNotificationSeeds(messages: ReadonlyArray<unknown>): TranscriptNotificationSeeds;
52
92
  /** 宿主队列条目的最小结构形(壳 QueuedCommand 的子集;本包只读这三个键)。 */
53
93
  export interface QueuedCommandLike {
54
94
  mode?: unknown;
@@ -63,6 +103,23 @@ export interface NotificationQueuePort {
63
103
  }): void;
64
104
  /** 壳 = messageQueueManager.removeByFilter;返回被摘掉的条目(数量是判据)。 */
65
105
  removeByFilter(predicate: (cmd: QueuedCommandLike) => boolean): QueuedCommandLike[];
106
+ /**
107
+ * B7:`mode:'prompt'` 的 **isMeta** 回植(今天唯一的消费者 = plan_review 结局,
108
+ * `hitl/planReviewWire.ts`)。与上面那条是**不同的队列语义**(task-notification 走
109
+ * `<task-notification>` 通道;这条是一个 isMeta 的 prompt turn),所以是第二个动词而不是
110
+ * 给第一个加参数。
111
+ *
112
+ * 🔴 **可选**(`?`):既有宿主(壳 B2 起装的那份)不实现它照样满足接口 —— 加必填方法会让所有
113
+ * 已装配的宿主在类型层一起红,而这条动词只有一个消费者。缺席 ⇒ 计 miss + 该结局不回植
114
+ * (模型在 approve 之后原地不动),**不是**静默可接受的面,所以计 miss 而不是静默。
115
+ */
116
+ enqueueMetaPrompt?(command: {
117
+ value: string;
118
+ mode: 'prompt';
119
+ priority: 'later';
120
+ isMeta: true;
121
+ workload: string;
122
+ }): void;
66
123
  }
67
124
  /** 宿主装配:壳侧 shim / web 宿主在**模块加载期**装(装之前的调用会计入 miss)。 */
68
125
  export declare function installNotificationQueuePort(port: NotificationQueuePort | null): void;
@@ -81,6 +138,16 @@ export declare function _resetNotificationQueuePortForTest(): void;
81
138
  * (enqueue 两族与 bridge 渲染同用 escapeXml,键形字节对齐)。仅主线程 task-notification
82
139
  * 条目(agentId undefined);用户输入/其他模式绝不触碰。
83
140
  */
141
+ /**
142
+ * B7:plan_review 结局回植(`hitl/planReviewWire.ts` 的唯一调用点)。走队列口的
143
+ * `enqueueMetaPrompt` 可选动词 —— 口或动词缺席 ⇒ 计 miss(与 `port()` 同一个计数器,宿主
144
+ * 只看 `hostPortMisses()` 一个数)并**返回 false**,调用方 fail-soft。
145
+ *
146
+ * 🔴 为什么不复用 `enqueuePendingNotification`:那条走的是 `<task-notification>` 通道(队列
147
+ * 的 `task-notification` 模式,drain 时按通知语义处理);plan_review 结局是一个 **isMeta 的
148
+ * prompt turn**。混用 = 结局被当成后台任务完成通知渲染,是「看起来送到了」的假绑定。
149
+ */
150
+ export declare function enqueuePlanReviewOutcome(value: string): boolean;
84
151
  export declare function dropQueuedNotificationsForRun(taskId: string): number;
85
152
  /**
86
153
  * Pre-seed the runId dedup WITHOUT enqueueing — the completion already reached the model in-band
@@ -99,6 +99,80 @@ export function taskNotificationDedupKey(n) {
99
99
  const lane = n.taskType === 'external' ? 'external:' : '';
100
100
  return `${lane}${n.taskId}:${n.status}${seq ? `:${seq}` : ''}`;
101
101
  }
102
+ /**
103
+ * ⇄ B7(T2):同一把键的 **裸 wire 载荷** 入口 —— cli `upstreamBridge.engineTaskNotificationDedupKey`
104
+ * 的等价物(入参是引擎 `task_notification` 帧的原始 payload:snake_case `task_id` / `task_type`)。
105
+ *
106
+ * 🔴 **不是第二份算法**:字段归一之后直接委托给上面那一个 `taskNotificationDedupKey`。
107
+ * 两份手写算法正是这条键在 0.1.1 被 codex 对抗复审抓到过的形(无 seq 追不追段的差异会让
108
+ * 「重放去重」整条失效而两边代码看着都对);pure 门 B7 段对两个入口做**同键对拍**。
109
+ *
110
+ * 键形:`[external:]taskId:status[:seq]`。缺 `task_id` ⇒ 空 taskId 段(与 cli 逐字一致 ——
111
+ * 空 id 的通知本就在 `normalizeTaskNotification` 那一层被丢弃,这里不额外加语义)。
112
+ *
113
+ * ⚠️ **P1-3(完成事件单一权威)未到货 ⇒ 只搬不删**:引擎给出 `completionId` 之后,这把
114
+ * 「客户端按 taskId:status[:seq] 自己重算」的键才能换成「读引擎发的 id」。退役条件见
115
+ * `compensations.ts` 的 T2 行。
116
+ */
117
+ export function taskNotificationDedupKeyFromWire(n) {
118
+ return taskNotificationDedupKey({
119
+ taskId: typeof n.task_id === 'string' ? n.task_id : '',
120
+ status: typeof n.status === 'string' ? n.status : 'completed',
121
+ ...(typeof n.seq === 'number' || typeof n.seq === 'string' ? { seq: n.seq } : {}),
122
+ ...(typeof n.task_type === 'string' ? { taskType: n.task_type } : {}),
123
+ });
124
+ }
125
+ export function parseTranscriptNotificationSeeds(messages) {
126
+ const unescape = (s) => s
127
+ .replace(/&lt;/g, '<')
128
+ .replace(/&gt;/g, '>')
129
+ .replace(/&quot;/g, '"')
130
+ .replace(/&#39;/g, "'")
131
+ .replace(/&amp;/g, '&');
132
+ const renderedKeys = [];
133
+ const notifiedKeys = [];
134
+ let seeded = 0;
135
+ for (const m of messages) {
136
+ const rec = m;
137
+ if (rec?.type !== 'user')
138
+ continue;
139
+ const content = rec.message?.content;
140
+ const texts = [];
141
+ if (typeof content === 'string')
142
+ texts.push(content);
143
+ else if (Array.isArray(content)) {
144
+ for (const b of content) {
145
+ const bb = b;
146
+ if (bb?.type === 'text' && typeof bb.text === 'string')
147
+ texts.push(bb.text);
148
+ }
149
+ }
150
+ for (const text of texts) {
151
+ if (!text.includes(`<${TASK_NOTIFICATION_TAG}`))
152
+ continue;
153
+ const blockRe = new RegExp(`<${TASK_NOTIFICATION_TAG}(\\s[^>]*)?>([\\s\\S]*?)</${TASK_NOTIFICATION_TAG}>`, 'g');
154
+ for (const bm of text.matchAll(blockRe)) {
155
+ const attrs = bm[1] ?? '';
156
+ const body = bm[2] ?? '';
157
+ const idM = body.match(new RegExp(`<${TASK_ID_TAG}>([\\s\\S]*?)</${TASK_ID_TAG}>`));
158
+ const stM = body.match(new RegExp(`<${STATUS_TAG}>([\\s\\S]*?)</${STATUS_TAG}>`));
159
+ if (!idM || !stM)
160
+ continue;
161
+ const taskId = unescape(idM[1] ?? '');
162
+ const status = unescape(stM[1] ?? '');
163
+ if (taskId.length === 0 || status.length === 0)
164
+ continue;
165
+ const external = /\btype="external"/.test(attrs);
166
+ renderedKeys.push(`${external ? 'external:' : ''}${taskId}:${status}`);
167
+ if (!external && (status === 'completed' || status === 'failed' || status === 'killed')) {
168
+ notifiedKeys.push(taskId);
169
+ }
170
+ seeded++;
171
+ }
172
+ }
173
+ }
174
+ return { renderedKeys, notifiedKeys, seeded };
175
+ }
102
176
  // ══════════════════════════════════════════════════════════════════════════════════════════════
103
177
  // ⇄ B2 批搬迁(2026-07-27,多端改造设计稿 §3 B2「通知族合并」):cli 三个文件并入本模块
104
178
  // ① src/sema/engineTaskNotification.ts(347 行,台账 + watcher + 入队合成)
@@ -180,6 +254,26 @@ const notifiedRunIds = new Set();
180
254
  * (enqueue 两族与 bridge 渲染同用 escapeXml,键形字节对齐)。仅主线程 task-notification
181
255
  * 条目(agentId undefined);用户输入/其他模式绝不触碰。
182
256
  */
257
+ /**
258
+ * B7:plan_review 结局回植(`hitl/planReviewWire.ts` 的唯一调用点)。走队列口的
259
+ * `enqueueMetaPrompt` 可选动词 —— 口或动词缺席 ⇒ 计 miss(与 `port()` 同一个计数器,宿主
260
+ * 只看 `hostPortMisses()` 一个数)并**返回 false**,调用方 fail-soft。
261
+ *
262
+ * 🔴 为什么不复用 `enqueuePendingNotification`:那条走的是 `<task-notification>` 通道(队列
263
+ * 的 `task-notification` 模式,drain 时按通知语义处理);plan_review 结局是一个 **isMeta 的
264
+ * prompt turn**。混用 = 结局被当成后台任务完成通知渲染,是「看起来送到了」的假绑定。
265
+ */
266
+ export function enqueuePlanReviewOutcome(value) {
267
+ const p = port();
268
+ if (!p)
269
+ return false;
270
+ if (typeof p.enqueueMetaPrompt !== 'function') {
271
+ queuePortMisses++;
272
+ return false;
273
+ }
274
+ p.enqueueMetaPrompt({ value, mode: 'prompt', priority: 'later', isMeta: true, workload: 'plan-review' });
275
+ return true;
276
+ }
183
277
  export function dropQueuedNotificationsForRun(taskId) {
184
278
  const needle = `<${TASK_ID_TAG}>${escapeXml(taskId)}</${TASK_ID_TAG}>`;
185
279
  const p = port();
@@ -0,0 +1,33 @@
1
+ /** 帧身份位(壳侧从被投影的那条 SDK 消息上取,包内不铸号)。 */
2
+ export interface PrintFrameIdentity {
3
+ uuid?: unknown;
4
+ session_id?: unknown;
5
+ }
6
+ /**
7
+ * print 出口帧的**逐字段对照表** —— 与 `REQUEST_FIELD_MATRIX` 同一纪律:
8
+ * `cc:true` = CC 自己命名过的位,**改名即偏离 parity**;`cc:false` = 本次收编补上的归因位
9
+ * (wire 原名 = SDK `AgentEvent.task_notification` 词汇)。`from` 写明它从 wire 的哪一位来。
10
+ */
11
+ export interface PrintNotificationFieldSpec {
12
+ /** print 出口帧上的键名。 */
13
+ field: string;
14
+ /** wire 侧来源键(`—` = 信封位/由归一层派生)。 */
15
+ from: string;
16
+ /** CC 逐字位(改名即偏离 parity)。 */
17
+ cc: boolean;
18
+ why: string;
19
+ }
20
+ export declare const PRINT_NOTIFICATION_FIELD_MATRIX: readonly PrintNotificationFieldSpec[];
21
+ /**
22
+ * 引擎 `task_notification` 帧载荷 → CC `system/task_notification` 出口帧(print lane 唯一构造口)。
23
+ *
24
+ * 判定全部委托 `normalizeTaskNotification`(交互面同一把闸);缺席位一律**不铸空键**
25
+ * (tolerate-absent —— 老引擎不发的字段不该在出口变成 `undefined` 值键)。
26
+ * 返回 `null` = 该帧不该出口(task_id 空/缺)。
27
+ */
28
+ export declare function taskNotificationToPrintFrame(n: Record<string, unknown>, identity?: PrintFrameIdentity): Record<string, unknown> | null;
29
+ /**
30
+ * 出口帧里出现了**没进上表**的键就点名它(与 `unregisteredRequestKeys` 同纪律):
31
+ * 顺手加一位很容易,加了之后「字段集合一」就又退化成一句注释 —— 加位必须先改表(带理由)。
32
+ */
33
+ export declare function unregisteredPrintNotificationKeys(frame: Record<string, unknown>): string[];
@@ -0,0 +1,116 @@
1
+ /**
2
+ * request/printNotification.ts — **`task_notification` 字段集合一**(B8,多端改造设计稿 §8-5 裁决
3
+ * 第二件;§2.5 拆缝表 `seamQueryEngine.ts:690-708` 那一格)。
4
+ *
5
+ * ## 它解决的是什么
6
+ *
7
+ * 同一条引擎 `task_notification` 帧,壳里有**两个**消费者,各自决定「哪些字段算数」:
8
+ * · 交互 lane:`notifications.normalizeTaskNotification` → `renderTaskNotificationXml`
9
+ * (模型面 XML + 转录面),字段集 = `TaskNotificationFields` 15 位;
10
+ * · print lane(`-p` 的 SDK 出口):`seamQueryEngine.ask` 里**手写的一段对象字面量**,
11
+ * 只挑了 6 位(task_id / tool_use_id / status / output_file / summary / usage)。
12
+ *
13
+ * 后果是**残局归因在 `-p` 出口整段丢失**:引擎明明发了 `stoppedBy`(谁停的)/`resumable`
14
+ * (还能不能续)/`partial`(残果)/`exitCode` / `diagnostics` / `result` / `lines` /
15
+ * `recentSteps` / `editedFiles`,交互面 XML 里逐条都在,而**唯一没有人类当场看着的那条车道**
16
+ * ——集成/自动化用的 `-p --output-format stream-json`——拿不到任何归因位。
17
+ * (设计稿 §7.3(b) 记的是 fleet `bg_notification` lane 的同款不对称;print 出口这一处是
18
+ * B8 落码时逐行核出来的第二处,同族。)
19
+ *
20
+ * ## 合一的口径(逐字段留注 = `PRINT_NOTIFICATION_FIELD_MATRIX`)
21
+ *
22
+ * 🔴 **判据同源**:本模块**不重新判**任何一位 —— 先过交互面那把闸
23
+ * `normalizeTaskNotification`(空 task_id 丢帧 = RB-75 同族;summary 缺省兜底句;status 非串
24
+ * 回落 `completed`),再把归一结果映到 CC 的 print 帧信封上。两面从此只有**一份**判据。
25
+ *
26
+ * 🔴 **车道差异只剩一条**:`status` 的 `killed ⇒ stopped` 归一 —— 那是 CC 自己 print/SDK 出口的
27
+ * 形(FULL-SOURCE-187 line ~32320),XML 面按引擎原词渲染。这一条差异是**有理由的**,写在表里。
28
+ *
29
+ * **命名口径**:CC 已经命名过的 6 位保持 CC 逐字(`task_id` / `tool_use_id` / `output_file` /
30
+ * `status` / `summary` / `usage`);新增的归因位用 **wire 原名**(`stoppedBy` / `resumable` / …)
31
+ * —— 也就是 SDK `AgentEvent.task_notification` 的词汇表,`-p` 的 SDK 消费方与直连 SDK 的消费方
32
+ * 因此看到同一套键名,不必再学第三套。additive:老消费方按 key 取值,多出来的键不碍事。
33
+ *
34
+ * ## 留壳的部分(§8-5 明写)
35
+ *
36
+ * `PrintStreamProjector`(stream-json 帧序/SSE 重铸)与全部 stderr 分诊文案**留壳**:那是
37
+ * 渲染面与人话面,不是数据面。本模块只做「wire 帧 → CC 帧」这一跳。
38
+ */
39
+ import { normalizeTaskNotification } from '../notifications.js';
40
+ export const PRINT_NOTIFICATION_FIELD_MATRIX = [
41
+ // ── 信封(CC system 帧形)────────────────────────────────────────────────────────────────────
42
+ { field: 'type', from: '—', cc: true, why: "CC system 帧信封:{type:'system'}" },
43
+ { field: 'subtype', from: '—', cc: true, why: "CC 完成通知的 subtype:'task_notification'(187 print/SDK 出口形)" },
44
+ { field: 'uuid', from: '—', cc: true, why: '帧身份:壳侧从被投影的 SDK 消息取(包内不铸号)' },
45
+ { field: 'session_id', from: '—', cc: true, why: 'CC 每帧带会话 id;壳侧透传' },
46
+ // ── CC 已命名的 6 位(逐字,改名即偏离 parity)──────────────────────────────────────────────
47
+ { field: 'task_id', from: 'task_id', cc: true, why: 'CC 逐字 snake 名;空/缺 ⇒ 整帧丢弃(RB-75:空 id 会塌成共享桶互吞)' },
48
+ { field: 'status', from: 'status', cc: true, why: "CC 逐字;**唯一车道差异** killed ⇒ stopped(187 print 出口形;XML 面按引擎原词渲染)" },
49
+ { field: 'summary', from: 'summary', cc: true, why: '缺省走交互面同一句兜底(`Background task "<id>" <status>`)—— 修前 print 出口发空串' },
50
+ { field: 'tool_use_id', from: 'toolUseId', cc: true, why: 'CC 逐字 snake 名(wire 是 camel);把通知钉回发起它的那次工具调用' },
51
+ { field: 'output_file', from: 'output_file', cc: true, why: 'CC 逐字;后台输出落盘路径' },
52
+ { field: 'usage', from: 'usage', cc: true, why: 'CC 逐字 optional;计量面(交互面 XML 反而不读它 —— 那是渲染面口径)' },
53
+ // ── B8 补上的残局归因位(wire 原名 = SDK task_notification 词汇)────────────────────────────
54
+ { field: 'stoppedBy', from: 'stoppedBy', cc: false, why: '🔴 残局归因核心位:谁停的(用户/上限/引擎)。修前 `-p` 出口零携带' },
55
+ { field: 'resumable', from: 'resumable', cc: false, why: '🔴 残局归因:还能不能 resume —— 自动化要靠它决定重试还是放弃' },
56
+ { field: 'partial', from: 'partial', cc: false, why: 'server 1.258:被终止子代的残果(消费方须渲成不完整,不得当完整结果)' },
57
+ { field: 'exitCode', from: 'exitCode', cc: false, why: '后台 bash 退出码(137=OOM 这类归因只在这一位上)' },
58
+ { field: 'diagnostics', from: 'diagnostics', cc: false, why: '引擎侧诊断串(UNTRUSTED,service 已 redact)' },
59
+ { field: 'result', from: 'result', cc: false, why: '完成结果正文(#49 C1:帧带 result 必入 XML,print 出口同理不得独漏)' },
60
+ { field: 'lines', from: 'lines', cc: false, why: '尾部输出行(UNTRUSTED);交互面折进 <output-lines>' },
61
+ { field: 'recentSteps', from: 'recentSteps', cc: false, why: '结构化残局步骤 {tool,target,outcome}[] —— **原样透传**,不降级成交互面那份显示串(SDK 消费方要结构)' },
62
+ { field: 'editedFiles', from: 'editedFiles', cc: false, why: '结构化改档清单 {path,edits}[];同上原样透传' },
63
+ { field: 'task_type', from: 'task_type', cc: false, why: 'lane 位(external/background_agent/…)—— 去重键的分域依据,消费方要能自己算同一把键' },
64
+ { field: 'source', from: 'source', cc: false, why: 'external lane 的来源标识(交互面渲成开标签 from= 属性)' },
65
+ { field: 'seq', from: 'seq', cc: false, why: '停机周期/批次计数([508]②)—— 与 task_id+status 一起构成去重键' },
66
+ { field: 'injected', from: 'injected', cc: false, why: '[1818]§四:本帧**不**证明 XML 已进模型上下文;消费方须按此位判,不得按帧到达判' },
67
+ ];
68
+ const PRINT_FRAME_KEYS = new Set(PRINT_NOTIFICATION_FIELD_MATRIX.map(r => r.field));
69
+ /**
70
+ * 引擎 `task_notification` 帧载荷 → CC `system/task_notification` 出口帧(print lane 唯一构造口)。
71
+ *
72
+ * 判定全部委托 `normalizeTaskNotification`(交互面同一把闸);缺席位一律**不铸空键**
73
+ * (tolerate-absent —— 老引擎不发的字段不该在出口变成 `undefined` 值键)。
74
+ * 返回 `null` = 该帧不该出口(task_id 空/缺)。
75
+ */
76
+ export function taskNotificationToPrintFrame(n, identity = {}) {
77
+ const f = normalizeTaskNotification(n);
78
+ if (f === null)
79
+ return null;
80
+ const structured = (key) => Array.isArray(n[key]) && n[key].length > 0 ? { [key]: n[key] } : {};
81
+ return {
82
+ type: 'system',
83
+ subtype: 'task_notification',
84
+ task_id: f.taskId,
85
+ // CC 逐字:killed ⇒ stopped(唯一车道差异,表里有理由)。
86
+ status: f.status === 'killed' ? 'stopped' : f.status,
87
+ summary: f.summary,
88
+ ...(f.toolUseId !== undefined ? { tool_use_id: f.toolUseId } : {}),
89
+ ...(f.outputFile !== undefined ? { output_file: f.outputFile } : {}),
90
+ ...(n.usage !== undefined ? { usage: n.usage } : {}),
91
+ // ── 残局归因(B8 补;归一层已做过类型与空值判定)──────────────────────────────────────
92
+ ...(f.stoppedBy !== undefined ? { stoppedBy: f.stoppedBy } : {}),
93
+ ...(f.resumable !== undefined ? { resumable: f.resumable } : {}),
94
+ ...(f.partial !== undefined ? { partial: f.partial } : {}),
95
+ ...(f.exitCode !== undefined ? { exitCode: f.exitCode } : {}),
96
+ ...(f.diagnostics !== undefined ? { diagnostics: f.diagnostics } : {}),
97
+ ...(f.result !== undefined ? { result: f.result } : {}),
98
+ ...(f.lines !== undefined ? { lines: f.lines } : {}),
99
+ // 结构化两位取**裸 wire**(归一层把 recentSteps 折成了交互面的显示串,那是渲染口径)。
100
+ ...structured('recentSteps'),
101
+ ...structured('editedFiles'),
102
+ ...(f.taskType !== undefined ? { task_type: f.taskType } : {}),
103
+ ...(f.externalSource !== undefined ? { source: f.externalSource } : {}),
104
+ ...(typeof n.seq === 'number' || typeof n.seq === 'string' ? { seq: n.seq } : {}),
105
+ ...(typeof n.injected === 'boolean' ? { injected: n.injected } : {}),
106
+ ...(identity.uuid !== undefined ? { uuid: identity.uuid } : {}),
107
+ ...(identity.session_id !== undefined ? { session_id: identity.session_id } : {}),
108
+ };
109
+ }
110
+ /**
111
+ * 出口帧里出现了**没进上表**的键就点名它(与 `unregisteredRequestKeys` 同纪律):
112
+ * 顺手加一位很容易,加了之后「字段集合一」就又退化成一句注释 —— 加位必须先改表(带理由)。
113
+ */
114
+ export function unregisteredPrintNotificationKeys(frame) {
115
+ return Object.keys(frame).filter(k => !PRINT_FRAME_KEYS.has(k));
116
+ }
@@ -133,3 +133,20 @@ export interface LiveDefaultsInput {
133
133
  export declare function applyLiveRequestDefaults(req: TaskRequestLike, host: LiveDefaultsInput): TaskRequestLike;
134
134
  /** 出口约束:构造结果确实是一个 `TaskRequest`(型只在这里碰 SDK,运行时零依赖)。 */
135
135
  export type BuiltTaskRequest = TaskRequest;
136
+ /**
137
+ * ⇄ B8(§8-5 请求拼装合一的**可执行判据**)—— 一份请求体里出现了**没进车道表**的键就点名它。
138
+ *
139
+ * 为什么要有:B4 把字段集做成了 `REQUEST_FIELD_MATRIX` 这张表,但表当时只管住了
140
+ * `buildTaskRequest` 自己;端上真正上 wire 的那份请求仍可以在构造器之外多塞一个键(print lane
141
+ * 修前正是「自己另拼一份」),于是「表说了算」就退化成一句注释。本函数让**真上 wire 的那份**
142
+ * 也过一遍表:print lane 收编之后,`-p` 的请求体逐键都能报出登记出处,再漏就当场红。
143
+ *
144
+ * 判据口径(三类不点名):
145
+ * ① 车道表里的字段(`lanes` 含本车道);② `LIVE_DEFAULT_FIELDS` —— live 兜底层追加、两条车道
146
+ * 都经过,故本就不进车道表;③ `settings` 的子键按表认(`settings.<sub>`),但**交互车道**的
147
+ * `settings.<resolved>` 是 resolver 的 effective 快照 = **开放集**,子键不可枚举 ⇒ 整体放行。
148
+ *
149
+ * 🔴 它**不判**「该不该有」——只判「有没有登记」。补齐一条真实差异要改表(带理由),
150
+ * 而不是绕过本函数:改表这个动作本身就会在 diff 里显形,这正是本判据存在的意义。
151
+ */
152
+ export declare function unregisteredRequestKeys(req: TaskRequestLike, lane: RequestLane): string[];
@@ -17,7 +17,10 @@ export const REQUEST_FIELD_MATRIX = [
17
17
  { field: 'promptProfile', lanes: ['interactive', 'print'], live: true, why: 'core 1.328 呈现轴 A/B 口,缺省不 stamp = 引擎缺省 simple' },
18
18
  { field: 'enableFork', lanes: ['interactive', 'print'], live: true, why: '显式布尔才 stamp([503]② fail-OPEN 教训:off 必须显式)' },
19
19
  { field: 'attachments', lanes: ['interactive', 'print'], live: true, why: 'design/133 + G1 缺省开的 turn 边界 attachment' },
20
- { field: 'skills', lanes: ['interactive', 'print'], live: true, why: '本地 .claude/skills 投影;两条车道都由 launchRepl 载入面注入' },
20
+ // ⚠️ 这条 why 原文写的是本地技能目录的**字面路径**,B8 把本模块拉进壳的生产闭包之后,那串
21
+ // 字面量随 bundle 进了 `dist/sema.js` 并被壳的品牌门当场逮住(壳的 R11 codemod 只走自己
22
+ // 的 src,走不到 node_modules ⇒ 包里的 CC 路径字面 = 品牌面泄漏)。见本文件末的包内纪律。
23
+ { field: 'skills', lanes: ['interactive', 'print'], live: true, why: '本地技能目录投影(布局取 CC 同款);两条车道都由 launchRepl 载入面注入' },
21
24
  { field: 'mcpServers', lanes: ['interactive', 'print'], live: true, why: '本地 .mcp.json 投影;service 单用户门决定是否兑现' },
22
25
  { field: 'settings.webSearch', lanes: ['interactive', 'print'], live: true, why: 'SEMA_WEBSEARCH_* / settings.json;per-request 配置赢过部署 env' },
23
26
  { field: 'settings.hooks', lanes: ['interactive', 'print'], live: true, why: '[495]① 用户 settings 文件 hooks 逐字上 wire' },
@@ -174,3 +177,56 @@ export function applyLiveRequestDefaults(req, host) {
174
177
  }
175
178
  return out;
176
179
  }
180
+ /**
181
+ * ⇄ B8(§8-5 请求拼装合一的**可执行判据**)—— 一份请求体里出现了**没进车道表**的键就点名它。
182
+ *
183
+ * 为什么要有:B4 把字段集做成了 `REQUEST_FIELD_MATRIX` 这张表,但表当时只管住了
184
+ * `buildTaskRequest` 自己;端上真正上 wire 的那份请求仍可以在构造器之外多塞一个键(print lane
185
+ * 修前正是「自己另拼一份」),于是「表说了算」就退化成一句注释。本函数让**真上 wire 的那份**
186
+ * 也过一遍表:print lane 收编之后,`-p` 的请求体逐键都能报出登记出处,再漏就当场红。
187
+ *
188
+ * 判据口径(三类不点名):
189
+ * ① 车道表里的字段(`lanes` 含本车道);② `LIVE_DEFAULT_FIELDS` —— live 兜底层追加、两条车道
190
+ * 都经过,故本就不进车道表;③ `settings` 的子键按表认(`settings.<sub>`),但**交互车道**的
191
+ * `settings.<resolved>` 是 resolver 的 effective 快照 = **开放集**,子键不可枚举 ⇒ 整体放行。
192
+ *
193
+ * 🔴 它**不判**「该不该有」——只判「有没有登记」。补齐一条真实差异要改表(带理由),
194
+ * 而不是绕过本函数:改表这个动作本身就会在 diff 里显形,这正是本判据存在的意义。
195
+ */
196
+ export function unregisteredRequestKeys(req, lane) {
197
+ /** live 兜底层的键名(表项写法含 `|` 备选位与括号说明,取裸键名)。 */
198
+ const liveDefaults = new Set(LIVE_DEFAULT_FIELDS.flatMap(row => row.split('|')).map(name => name.replace(/\(.*\)$/, '').trim()));
199
+ const laneRows = REQUEST_FIELD_MATRIX.filter(f => f.lanes.includes(lane));
200
+ /** 顶层键 → 表项(`resumeAt/rewindFiles/rewindFilesTo` 这类斜杠合写项逐键展开)。 */
201
+ const laneTopKeys = new Set();
202
+ for (const row of laneRows) {
203
+ if (row.field.startsWith('settings.'))
204
+ continue;
205
+ for (const key of row.field.split('/'))
206
+ laneTopKeys.add(key);
207
+ }
208
+ const laneSettingsKeys = new Set(laneRows
209
+ .filter(f => f.field.startsWith('settings.') && f.field !== 'settings.<resolved>')
210
+ .map(f => f.field.slice('settings.'.length)));
211
+ const settingsIsOpenSet = laneRows.some(f => f.field === 'settings.<resolved>');
212
+ const out = [];
213
+ for (const key of Object.keys(req)) {
214
+ if (key === 'settings') {
215
+ if (settingsIsOpenSet)
216
+ continue;
217
+ const settings = req.settings;
218
+ if (settings === null || typeof settings !== 'object')
219
+ continue;
220
+ for (const sub of Object.keys(settings)) {
221
+ if (laneSettingsKeys.has(sub) || liveDefaults.has(`settings.${sub}`))
222
+ continue;
223
+ out.push(`settings.${sub}`);
224
+ }
225
+ continue;
226
+ }
227
+ if (laneTopKeys.has(key) || liveDefaults.has(key))
228
+ continue;
229
+ out.push(key);
230
+ }
231
+ return out;
232
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/client-core",
3
- "version": "0.8.1",
3
+ "version": "0.10.0",
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",
@@ -25,7 +25,7 @@
25
25
  },
26
26
  "devDependencies": {
27
27
  "@sema-agent/agent-types": "^0.2.0",
28
- "@sema-agent/sdk": "^0.0.118",
28
+ "@sema-agent/sdk": "^0.0.125",
29
29
  "esbuild": "^0.27.4",
30
30
  "typescript": "^6.0.2"
31
31
  }