@sema-agent/client-core 0.11.15 → 0.11.16

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/README.md CHANGED
@@ -23,7 +23,7 @@ Renamed from **`@sema-agent/wire-cc-adapter`** (0.1.x, deprecated — see *Migra
23
23
 
24
24
  ## Scope
25
25
 
26
- **Version:** 0.11.15
26
+ **Version:** 0.11.16
27
27
 
28
28
  - **Today** — the adapter seam, the whole `adapt()` pipeline (all 14 A-layer arms plus the
29
29
  B/D/E tool-card layers), the notification/caps/model families, the adapter kernel (stream driver
@@ -214,6 +214,7 @@ itself (FAILED names + skipped names + arithmetic reconciliation). All-SKIP repo
214
214
  | `scripts/run-sdk-floor-test.mjs` | The SDK version floor — and, more to the point, that the *installed* type declarations still carry the keys this package reads |
215
215
  | `scripts/run-engine-vocab-floor-test.mjs` | Engine-mirrored vocabularies (structured card whitelist, self-reported tool face, control verbs, recogniser sets) against the *installed* `@sema-agent/core` |
216
216
  | `scripts/run-streamjson-timing-honesty-test.mjs` | Stream timing & terminal honesty ([2084]): held errored fs-write results release on model progress; wall-clock timeout maps to `error_during_execution` with a truthful salvage note; the synthetic API-error assistant row carries the `<synthetic>` in-message sentinel |
217
+ | `scripts/run-background-view-test.mjs` | `createBackgroundView` lifecycle: polling/notify pairing, per-source degrade (`501 → not-configured` vs `unavailable`), the capabilities `scheduler` probe, and dispose really aborting the in-flight fleet snapshot (pure projection lives in the pure suite's W-A segment) |
217
218
  | `scripts/run-public-surface-test.mjs` | The outward promises: the npm export surface baseline, the peer floor witness, and this README's claims |
218
219
 
219
220
  Each suite carries a floor that only moves up — a refactor that stops executing a group of
@@ -0,0 +1,76 @@
1
+ /**
2
+ * backgroundView — design/161 平行入口 `createBackgroundView(client)` 的实现(实现批 A,2026-07-30)。
3
+ *
4
+ * 契约锚 = ./contract.ts(类型已冻结,clay 二裁/终裁):`assistant.tasks` 与 `fleet` 都是**会话外
5
+ * 读面**(assistant 跨 session;fleet 是 idle 壳唯一保持订阅的常驻 SSE),生命周期不绑任何会话的
6
+ * dispose(),所以归一投影在 createAgentSession 之外单开一门。
7
+ *
8
+ * ── 两源两张映射表(第三套中性 7 值词表,不用任一方词表)────────────────────────────────────
9
+ * · assistant(SDK types.d.ts AssistantTask.status,3 值闭集):活跃调度行词表 —— 无终态臂。
10
+ * · fleet(SDK resources/fleet.d.ts FleetTaskStatus,9 值 wire 闭集 / 读侧开)。
11
+ * 两张表各自显式穷举 + `never` 卫兵:上游加词当天**编译红**,不静默落兜底;wire 真发了未知词
12
+ * (更新的 server × 旧 SDK)时走注释里的 open-set 回落 —— 与 workflowClient coerce 族同姿势。
13
+ *
14
+ * ── 拉取姿势(照 workflowClient 0.11.15 的订阅纪律)─────────────────────────────────────────
15
+ * · 轮询(可配 intervalMs 缺省 2000)+ **解析即通知**(首帧不等宿主 tick)+ **订阅即对账**
16
+ * (已解析后订阅同步补一发 —— 配对机制必须共享前提);订阅者抛异常吞;dispose abort 在飞
17
+ * 请求(含 fleet.snapshot 的 SSE 短连)并停循环。
18
+ * · 两源**独立拉取独立降级**:一源挂另一源照常;本源绝不 throw 进宿主。
19
+ * · fleet 快照走 SDK `fleet.snapshot()`(G19 一次性快照 verb:开流取首 snapshot 帧即断 ——
20
+ * fleet 面无独立 REST 快照端点,这是契约形不是绕路)。
21
+ *
22
+ * ── 'not-configured' 判别(诚实缺席纪律)────────────────────────────────────────────────────
23
+ * · fleet:面 gated off 时路由在任何帧之前答 **501**(typed)—— 501 ⇒ 'not-configured',
24
+ * 其它错 ⇒ 'unavailable'(拒绝形分档;错误判型走 controlRouter.errCodes 单源)。
25
+ * · assistant:无 TiDB run store 时 worker 回 `{tasks:[]}` **原样透传** —— wire 上与真空
26
+ * 不可区分。能探则探:`GET /v1/capabilities` 的 `scheduler` 位(server `schedulerEnabled &&
27
+ * requirePrincipal!==true && remoteExec==="host"` 三与)为 false/缺席 ⇒ 'not-configured';
28
+ * caps 面自己不可达 ⇒ 判别缺席,**如实回 'ok'**(绝不编造判别)。🔴 残余缺口候引擎位:
29
+ * scheduler:true 而 run store 未配的 `{tasks:[]}` 仍与真空同形 —— 需要 wire 长出判别位
30
+ * (caps 或响应信封)才能收口,本实现不猜。
31
+ *
32
+ * 字段纪律:缺席=键不在场(undefined),🔴 绝不 `?? 0` / `?? false`;ISO→epoch ms 只在
33
+ * assistant 行边界转一次;fleet 复合 id("<runId> <taskId>")整串不拆;fleet 子行(parentId
34
+ * 在场)不进 rows(子代不是用户的独立后台任务)。
35
+ */
36
+ import type { AgentClient, AssistantTask, FleetTaskRow, FleetTaskStatus } from '@sema-agent/sdk';
37
+ import type { BackgroundRow, BackgroundSourceHealth, BackgroundStatus, BackgroundView, CreateBackgroundView } from './contract.js';
38
+ /** assistant 3 值 → 中性 7 值。suspended(durable HITL park)与 needs_review(plan_review/dry-run
39
+ * park)都是「引擎停着等人」⇒ 同落 'waiting-on-human'(区分度由行上 gate.kind 携带,不铸新词)。 */
40
+ export declare function backgroundStatusFromAssistant(s: AssistantTask['status']): BackgroundStatus;
41
+ /** fleet 9 值 → 中性 7 值。'waiting'(durable suspend)与 'awaiting approval'(needs-review /
42
+ * plan-approval park)都在等人;'idle' 是「活着但此刻没在推进」—— 中性词表无此臂,按活跃行渲
43
+ * 'running'(诚实取舍:它既不 queued 也不终态,更不是在等人)。 */
44
+ export declare function backgroundStatusFromFleet(s: FleetTaskStatus): BackgroundStatus;
45
+ /** 一行 AssistantTask → BackgroundRow。gate 是 `AssistantGate | null`(wire 显式 null 形)——
46
+ * null 折成键缺席,非 null 原引用透传(契约位是 unknown,包不重构造)。 */
47
+ export declare function assistantTaskToBackgroundRow(t: AssistantTask): BackgroundRow;
48
+ /** 一行 FleetTaskRow → BackgroundRow;子行(parentId 在场)⇒ null(调用方剔除)。
49
+ * 复合 id 不拆;`currentTool` 契约位是一行字符串 —— 优先 server 已 redact 的 `currentAction`
50
+ * 原文,缺席时从结构化 `currentTool` 合成;`needsAttention` fleet wire 无显式位,从 park 态
51
+ * 推导(awaiting approval / awaitingPlanApproval / waiting),非 park 行键缺席(缺席≠false)。 */
52
+ export declare function fleetTaskToBackgroundRow(r: FleetTaskRow): BackgroundRow | null;
53
+ export interface BackgroundViewInputs {
54
+ readonly assistant: {
55
+ readonly health: BackgroundSourceHealth;
56
+ readonly tasks: readonly AssistantTask[];
57
+ };
58
+ readonly fleet: {
59
+ readonly health: BackgroundSourceHealth;
60
+ readonly tasks: readonly FleetTaskRow[];
61
+ };
62
+ }
63
+ /** 纯组合:两源(各带三态健康位)→ BackgroundView。行序 = assistant 先(core 已按 triage 排,
64
+ * 消费方不再排)、fleet 后(帧到达序);非 'ok' 源的行不出(那份列表不是证词);sources 三态
65
+ * 恒在场 —— 源不可用/未配置与「空列表」是三件事,端必须渲它。 */
66
+ export declare function projectBackgroundView(i: BackgroundViewInputs): BackgroundView;
67
+ export interface BackgroundViewOptions {
68
+ /** 轮询间隔(缺省 2000ms)。 */
69
+ readonly intervalMs?: number;
70
+ }
71
+ /**
72
+ * createBackgroundView(client, opts?) — 后台任务归一视图(契约 `CreateBackgroundView`;第二参是
73
+ * 实现旋钮,可选参不缩窄契约形)。生命周期:构造即起后台轮询;`snapshot()` 首解析前 null;
74
+ * `subscribe` 解析即通知 + 订阅即对账;`dispose()` abort 在飞请求、停循环、清订阅。
75
+ */
76
+ export declare function createBackgroundView(client: AgentClient, opts?: BackgroundViewOptions): ReturnType<CreateBackgroundView>;
@@ -0,0 +1,254 @@
1
+ import { errCodes } from '../controlRouter.js';
2
+ import { engineWireDebugEnabled } from '../engineWireTarget.js';
3
+ import { hostLog } from '../host.js';
4
+ // ── status 映射表 ×2(显式穷举 + never 卫兵)──────────────────────────────────────────────────
5
+ /** assistant 3 值 → 中性 7 值。suspended(durable HITL park)与 needs_review(plan_review/dry-run
6
+ * park)都是「引擎停着等人」⇒ 同落 'waiting-on-human'(区分度由行上 gate.kind 携带,不铸新词)。 */
7
+ export function backgroundStatusFromAssistant(s) {
8
+ switch (s) {
9
+ case 'running':
10
+ return 'running';
11
+ case 'suspended':
12
+ return 'waiting-on-human';
13
+ case 'needs_review':
14
+ return 'waiting-on-human';
15
+ default: {
16
+ const exhaustive = s; // 上游加词 ⇒ 这里编译红(闭集穷举卫兵)
17
+ void exhaustive;
18
+ // wire 开集回落:assistant.tasks 只列活跃调度行,未知新词按「活着」渲(同 workflowClient coerce 族)。
19
+ return 'running';
20
+ }
21
+ }
22
+ }
23
+ /** fleet 9 值 → 中性 7 值。'waiting'(durable suspend)与 'awaiting approval'(needs-review /
24
+ * plan-approval park)都在等人;'idle' 是「活着但此刻没在推进」—— 中性词表无此臂,按活跃行渲
25
+ * 'running'(诚实取舍:它既不 queued 也不终态,更不是在等人)。 */
26
+ export function backgroundStatusFromFleet(s) {
27
+ switch (s) {
28
+ case 'queued':
29
+ return 'queued';
30
+ case 'running':
31
+ return 'running';
32
+ case 'waiting':
33
+ return 'waiting-on-human';
34
+ case 'stopping':
35
+ return 'stopping';
36
+ case 'awaiting approval':
37
+ return 'waiting-on-human';
38
+ case 'idle':
39
+ return 'running';
40
+ case 'completed':
41
+ return 'done';
42
+ case 'failed':
43
+ return 'failed';
44
+ case 'killed':
45
+ return 'stopped';
46
+ default: {
47
+ const exhaustive = s; // 上游加词 ⇒ 编译红;wire 未知词(读侧开集)⇒ 按活跃行回落
48
+ void exhaustive;
49
+ return 'running';
50
+ }
51
+ }
52
+ }
53
+ // ── 行投影 ×2 ────────────────────────────────────────────────────────────────────────────────
54
+ /** ISO 串 → epoch ms,只在这一处转;烂串 ⇒ undefined(诚实缺席,绝不 NaN/0 外泄)。 */
55
+ function isoToEpochMs(iso) {
56
+ const ms = Date.parse(iso);
57
+ return Number.isFinite(ms) ? ms : undefined;
58
+ }
59
+ /** 一行 AssistantTask → BackgroundRow。gate 是 `AssistantGate | null`(wire 显式 null 形)——
60
+ * null 折成键缺席,非 null 原引用透传(契约位是 unknown,包不重构造)。 */
61
+ export function assistantTaskToBackgroundRow(t) {
62
+ const updatedAtMs = isoToEpochMs(t.updatedAt);
63
+ return {
64
+ key: { source: 'assistant', taskId: t.taskId },
65
+ status: backgroundStatusFromAssistant(t.status),
66
+ ...(updatedAtMs !== undefined ? { updatedAtMs } : {}),
67
+ needsAttention: t.needsAttention,
68
+ ...(t.gate !== null ? { gate: t.gate } : {}),
69
+ };
70
+ }
71
+ /** 一行 FleetTaskRow → BackgroundRow;子行(parentId 在场)⇒ null(调用方剔除)。
72
+ * 复合 id 不拆;`currentTool` 契约位是一行字符串 —— 优先 server 已 redact 的 `currentAction`
73
+ * 原文,缺席时从结构化 `currentTool` 合成;`needsAttention` fleet wire 无显式位,从 park 态
74
+ * 推导(awaiting approval / awaitingPlanApproval / waiting),非 park 行键缺席(缺席≠false)。 */
75
+ export function fleetTaskToBackgroundRow(r) {
76
+ if (typeof r.parentId === 'string' && r.parentId !== '')
77
+ return null;
78
+ const currentTool = typeof r.currentAction === 'string' && r.currentAction !== ''
79
+ ? r.currentAction
80
+ : r.currentTool !== undefined
81
+ ? `${r.currentTool.toolName}${r.currentTool.target !== undefined ? ` ${r.currentTool.target}` : ''}`
82
+ : undefined;
83
+ const parked = r.status === 'awaiting approval' || r.status === 'waiting' || r.awaitingPlanApproval === true;
84
+ return {
85
+ key: { source: 'fleet', rowId: r.id },
86
+ status: backgroundStatusFromFleet(r.status),
87
+ ...(parked ? { needsAttention: true } : {}),
88
+ ...(typeof r.name === 'string' && r.name !== '' ? { name: r.name } : {}),
89
+ ...(typeof r.description === 'string' && r.description !== '' ? { description: r.description } : {}),
90
+ ...(typeof r.tokens === 'number' ? { tokens: r.tokens } : {}),
91
+ ...(typeof r.toolUses === 'number' ? { toolUses: r.toolUses } : {}),
92
+ ...(currentTool !== undefined ? { currentTool } : {}),
93
+ };
94
+ }
95
+ /** 纯组合:两源(各带三态健康位)→ BackgroundView。行序 = assistant 先(core 已按 triage 排,
96
+ * 消费方不再排)、fleet 后(帧到达序);非 'ok' 源的行不出(那份列表不是证词);sources 三态
97
+ * 恒在场 —— 源不可用/未配置与「空列表」是三件事,端必须渲它。 */
98
+ export function projectBackgroundView(i) {
99
+ const rows = [];
100
+ if (i.assistant.health === 'ok') {
101
+ for (const t of i.assistant.tasks)
102
+ rows.push(assistantTaskToBackgroundRow(t));
103
+ }
104
+ if (i.fleet.health === 'ok') {
105
+ for (const r of i.fleet.tasks) {
106
+ const row = fleetTaskToBackgroundRow(r);
107
+ if (row !== null)
108
+ rows.push(row);
109
+ }
110
+ }
111
+ return {
112
+ sources: { assistant: i.assistant.health, fleet: i.fleet.health },
113
+ rows,
114
+ };
115
+ }
116
+ /** 501 判读走 errCodes 单源(SDK typed APIError 的 status;`statusCode` 变体同吃)。 */
117
+ function isNotImplemented(err) {
118
+ const { status } = errCodes(err);
119
+ return status === 501;
120
+ }
121
+ function debugLog(msg) {
122
+ if (engineWireDebugEnabled())
123
+ hostLog('debug', `[client-core][backgroundView] ${msg}`);
124
+ }
125
+ /**
126
+ * createBackgroundView(client, opts?) — 后台任务归一视图(契约 `CreateBackgroundView`;第二参是
127
+ * 实现旋钮,可选参不缩窄契约形)。生命周期:构造即起后台轮询;`snapshot()` 首解析前 null;
128
+ * `subscribe` 解析即通知 + 订阅即对账;`dispose()` abort 在飞请求、停循环、清订阅。
129
+ */
130
+ export function createBackgroundView(client, opts) {
131
+ const intervalMs = opts?.intervalMs ?? 2000;
132
+ let disposed = false;
133
+ let current = null;
134
+ let lastSerialized = null;
135
+ /** caps 探针结果:null = 还没探到/caps 面不可达(判别缺席);boolean = scheduler 位。 */
136
+ let capsScheduler = null;
137
+ const listeners = new Set();
138
+ const abort = new AbortController();
139
+ function notify() {
140
+ if (disposed)
141
+ return;
142
+ for (const l of [...listeners]) {
143
+ try {
144
+ l();
145
+ }
146
+ catch (err) {
147
+ debugLog(`subscriber threw (ignored): ${String(err)}`);
148
+ }
149
+ }
150
+ }
151
+ /** caps 只需探到一次;失败保持 null 下轮重试(caps 面挂 ≠ assistant 面挂,判别缺席不编造)。 */
152
+ async function probeCaps() {
153
+ if (capsScheduler !== null)
154
+ return;
155
+ try {
156
+ const caps = await client.capabilities({ signal: abort.signal });
157
+ capsScheduler = caps.scheduler === true;
158
+ }
159
+ catch (err) {
160
+ debugLog(`caps probe unavailable (scheduler 判别缺席,保持 ok 口径): ${String(err)}`);
161
+ }
162
+ }
163
+ async function pullAssistant() {
164
+ try {
165
+ const { tasks } = await client.assistant.tasks({ signal: abort.signal });
166
+ return { health: capsScheduler === false ? 'not-configured' : 'ok', tasks: tasks ?? [] };
167
+ }
168
+ catch (err) {
169
+ debugLog(`assistant.tasks degrade: ${String(err)}`);
170
+ const health = capsScheduler === false || isNotImplemented(err) ? 'not-configured' : 'unavailable';
171
+ return { health, tasks: [] };
172
+ }
173
+ }
174
+ async function pullFleet() {
175
+ try {
176
+ // G19 一次性快照 verb:SDK 内部开 SSE 流取首 snapshot 帧即断;abort.signal 直通 ⇒ dispose 能掐在飞连接。
177
+ const { tasks } = await client.fleet.snapshot({ signal: abort.signal });
178
+ return { health: 'ok', tasks: tasks ?? [] };
179
+ }
180
+ catch (err) {
181
+ debugLog(`fleet.snapshot degrade: ${String(err)}`);
182
+ return { health: isNotImplemented(err) ? 'not-configured' : 'unavailable', tasks: [] };
183
+ }
184
+ }
185
+ async function pollOnce() {
186
+ await probeCaps();
187
+ // 两源并行、各自 try/catch —— 独立拉取独立降级,一源挂另一源照常。
188
+ const [assistant, fleet] = await Promise.all([pullAssistant(), pullFleet()]);
189
+ if (disposed || abort.signal.aborted)
190
+ return;
191
+ current = projectBackgroundView({ assistant, fleet });
192
+ const serialized = JSON.stringify(current);
193
+ if (serialized !== lastSerialized) {
194
+ lastSerialized = serialized;
195
+ notify();
196
+ }
197
+ }
198
+ /** abort 感知的 sleep:dispose 立即唤醒并清定时器(不留悬挂 timer 拖住宿主进程)。 */
199
+ function sleep(ms) {
200
+ return new Promise((resolve) => {
201
+ const timer = setTimeout(() => {
202
+ abort.signal.removeEventListener('abort', onAbort);
203
+ resolve();
204
+ }, ms);
205
+ const onAbort = () => {
206
+ clearTimeout(timer);
207
+ resolve();
208
+ };
209
+ abort.signal.addEventListener('abort', onAbort, { once: true });
210
+ });
211
+ }
212
+ async function loop() {
213
+ while (!disposed && !abort.signal.aborted) {
214
+ try {
215
+ await pollOnce();
216
+ }
217
+ catch (err) {
218
+ // pullAssistant/pullFleet 各自兜底,这里只剩投影/通知的意外 —— 记录不外抛(本源绝不 throw 进宿主)。
219
+ debugLog(`poll cycle error (kept alive): ${String(err)}`);
220
+ }
221
+ if (disposed || abort.signal.aborted)
222
+ return;
223
+ await sleep(intervalMs);
224
+ }
225
+ }
226
+ void loop();
227
+ return {
228
+ snapshot() {
229
+ return current;
230
+ },
231
+ subscribe(onChange) {
232
+ listeners.add(onChange);
233
+ // 订阅即对账:订阅前已解析的话,notify 那一发已经打空 —— 同步补一发,宿主不用白等一个 tick。
234
+ if (current !== null && !disposed) {
235
+ try {
236
+ onChange();
237
+ }
238
+ catch (err) {
239
+ debugLog(`subscriber threw on reconcile (ignored): ${String(err)}`);
240
+ }
241
+ }
242
+ return () => {
243
+ listeners.delete(onChange);
244
+ };
245
+ },
246
+ dispose() {
247
+ disposed = true;
248
+ abort.abort();
249
+ listeners.clear();
250
+ },
251
+ };
252
+ }
253
+ // 契约锚(编译期钉):实现必须可赋给冻结契约形 —— 参数缩窄/返回面缺员在这里红,不等运行期。
254
+ void createBackgroundView;
@@ -0,0 +1,152 @@
1
+ /**
2
+ * design/161 — client-core 顶层单入口:**契约收敛物**(纯类型,零运行时;实现分批跟进)。
3
+ *
4
+ * 这是立项的唯一权威签名源(clay 三裁 2026-07-30 + 对抗复审 r1 收敛;两份文档签名互不相容的
5
+ * 教训 = 收敛物必须是 tsc 管得住的代码,见 sema-internal design-161-review-r1.md §〇)。
6
+ * 实现工单以本文件为锚:W1 per-session 化 → 入口实现 → W5 冷接 → 三端迁移(desktop → sema-web
7
+ * 聊天区 → web-client)。**本文件不进 index.ts barrel,实现落地一件出口一件。**
8
+ *
9
+ * ══ 三个入口,不是一个 ═══════════════════════════════════════════════════════════════════
10
+ * 1. `createAgentSession(config)` —— per-session 会话流面(AgentEvent → SDKMessage + HITL 桥 +
11
+ * 终态投影)。wire 只有一种:SDK 的 `AgentClientConfig` 判别联合(client/relay 本就是它的两臂,
12
+ * r1 E3 —— 在这里再判一次就是复制 SDK)。
13
+ * 2. `attachSeatSession(seatApi)` —— 层 2 远程代理:座位(Electron renderer / web seat lane)侧
14
+ * **没有 AgentEvent 流**(收到的已是 main 进程 adapt 后的 SDKMessage,seatContract:49-56),
15
+ * 所以它不是第三个 wire 臂,是层 1 返回面的**同型转发**(内部零 wire、零 adapt)。
16
+ * 3. `createBackgroundView(client)` —— 平行入口(clay 二裁 2026-07-30):`assistant.tasks` 与
17
+ * `fleet` 都是**会话外读面**(assistant 跨 session;fleet 是 idle 壳唯一保持订阅的常驻 SSE),
18
+ * 生命周期不该绑在某个会话的 dispose() 上,所以归一投影不进 createAgentSession。
19
+ */
20
+ import type { AgentClient, AgentClientConfig } from '@sema-agent/sdk';
21
+ /**
22
+ * 已构造 client 的接入臂 **必须同时带构造参数** —— `AgentClient.config` 是 private
23
+ * (sdk client.d.ts:124),包拿不回 baseUrl/authToken/principal,而入口要用它们做四件事:
24
+ * caps 探针 kick ×2、task-agents wire 预备、durable 读面的 client 另建(planReview 同款)。
25
+ * 不带这三样的「裸 client」臂在 r1 E3-2 已证伪。
26
+ */
27
+ export interface ProvidedClientWire {
28
+ readonly client: AgentClient;
29
+ readonly baseUrl: string;
30
+ readonly authToken: AgentClientConfig['authToken'];
31
+ readonly principal?: string;
32
+ }
33
+ /** wire 入参:给配置则入口自建 client;给 ProvidedClientWire 则复用宿主的(参数齐备)。 */
34
+ export type AgentSessionWire = AgentClientConfig | ProvidedClientWire;
35
+ export interface AgentSessionHostSurface {
36
+ /** 转录消费口(SDKMessage 流的宿主端)。缺席 ⇒ 入口拒绝启动(没有它整个入口无意义)。 */
37
+ onTranscript(msg: unknown): void;
38
+ /**
39
+ * chrome 装饰臂(seam.ts CHROME_ARMS 表)。缺席的 required 臂 = 功能静默不出现
40
+ * (desktop 今天缺 7/8,r1 E8c)——所以本口的自检是**表驱动**的:实现必须提供
41
+ * `chromeArmMisses()`,对 `required:true` 且未被消费的臂逐臂计数,门断言恒 0。
42
+ */
43
+ onChrome?(event: unknown): void;
44
+ /**
45
+ * 工具审批卡口。两条腿:
46
+ * sync-frame 腿:缺席 ⇒ 主动 respond deny(现行为,保留);
47
+ * durable-park 腿:缺席 ⇒ 🔴 **主动 decide deny + 一条诚实告知行**(行为变更,替换今天的
48
+ * fail-stuck;candidates 见下方 OPEN-1 —— headless「留 park 待接手」场景是否豁免候 clay)。
49
+ */
50
+ approvalCard?: unknown;
51
+ /** AskUserQuestion overlay 口。durable 腿缺席后果同上(deny 语义 = respond 取消形)。 */
52
+ askQuestion?: unknown;
53
+ /**
54
+ * plan review 口。缺席 ⇒ 今天是「卡永不出现且静默」(planReviewWire.ts:77 fail-soft)——
55
+ * 改为:至少产出一条可见终态行(引擎在等,人必须知道)。卡的**皮肤归端**(desktop 的
56
+ * ExitPlanMode 权限卡形 vs cli 的 overlay 形都合法,包只出数据与决断口;OPEN-2)。
57
+ */
58
+ planReview?: unknown;
59
+ /** 通知队列口(缺席=计 miss,静默可接受 —— 引擎不等)。 */
60
+ notify?: unknown;
61
+ /** 后台任务转发口(会话自己流上的 task_notification / subagent_lifecycle;跨会话视图归 createBackgroundView)。 */
62
+ onBackgroundTask?(event: unknown): void;
63
+ /** 身份采集口(meta.taskId + done.result.sessionId;三端各写一份的第 5 缺口)。 */
64
+ onIdentity?(identity: AgentSessionIdentity): void;
65
+ /** 未知帧告警口(包内四层 default 静默的补偿;milestone 节流姿势收编 web-client frame-warnings)。 */
66
+ onUnknownFrame?(frame: unknown): void;
67
+ /** turn 级错误口(9 类 taxonomy + 状态码链 + wire-code 闭集表 —— 收编 web-client 三件套并接
68
+ * controlRouter.errCodes 单源,r1 E7:不是「三分诊」)。 */
69
+ onTurnError?(err: unknown): void;
70
+ }
71
+ export interface AgentSessionIdentity {
72
+ readonly taskId?: string;
73
+ readonly engineSessionId?: string;
74
+ }
75
+ export interface AgentSessionConfig {
76
+ readonly wire: AgentSessionWire;
77
+ /** 请求车道(taskRequest.ts 的 RequestLane;web 传输 lane 是另一个轴,别混,r1 角度2)。 */
78
+ readonly lane: 'interactive' | 'print';
79
+ readonly hostSurface: AgentSessionHostSurface;
80
+ /**
81
+ * per-session 键(W1):包内单槽位全部改「sessionKey → 注册表」,零参旧 API = default-key
82
+ * 兼容层(cli 的 module-load 装配不动)。多会话宿主(desktop)每会话一键,互不顶盖。
83
+ */
84
+ readonly sessionKey?: string;
85
+ }
86
+ export interface AgentSession {
87
+ /** 发起一个 turn(内部:buildTaskRequest 唯一构造口 + runStream 内建去重 + HITL 桥)。 */
88
+ query(input: unknown): AsyncIterable<unknown>;
89
+ /**
90
+ * 冷接(W5,r1 E9):按调用方给的 id 接上一个**不是本进程发起**的在飞 run(页面刷新 / 桌面
91
+ * 重启 / 跨端接手)。与 query() 同一消费面;park 帧在冷接腿上同样可决断。
92
+ * 这是 sema-web stream-core 旧腿退役的前置能力 —— 没有它退役 = 删掉唯一的刷新恢复路径。
93
+ */
94
+ attach(target: {
95
+ taskId: string;
96
+ sessionId?: string;
97
+ }): AsyncIterable<unknown>;
98
+ interrupt(): Promise<void>;
99
+ steer(text: string): Promise<void>;
100
+ resumeAfterDecision(): Promise<void>;
101
+ /** 面缺席自检出口(测试面,恒 0 断言;不是运行期告警 —— Ink 吞 console,r1 E6)。 */
102
+ surfaceMisses(): Readonly<Record<string, number>>;
103
+ identity(): AgentSessionIdentity;
104
+ dispose(): void;
105
+ }
106
+ /** 层 1:唯一的 wire 入口。 */
107
+ export type CreateAgentSession = (config: AgentSessionConfig) => AgentSession;
108
+ /**
109
+ * 层 2:座位远程代理 —— 与 AgentSession **同型**(同一个接口),内部把每个方法转发给座位 IPC
110
+ * (seatContract 的 verb 面),零 wire 零 adapt。座位可达字段集是 9 键闭集(< interactive 的
111
+ * ~25 键)—— 该不对称必须**显式暴露**(supportedFields),端上的控件按它显隐,不许各自再判。
112
+ */
113
+ export type AttachSeatSession = (seatApi: unknown) => AgentSession & {
114
+ readonly supportedFields: readonly string[];
115
+ };
116
+ export type BackgroundSource = 'assistant' | 'fleet';
117
+ /** not-configured = assistant 无 TiDB 时的 `{tasks:[]}` 形 —— 与「真没有任务」必须可区分。 */
118
+ export type BackgroundSourceHealth = 'ok' | 'unavailable' | 'not-configured';
119
+ /** 第三套中性词表(不用任一方词表:3 值压不住 9 值,9 值会造 assistant 侧不存在的态)。 */
120
+ export type BackgroundStatus = 'running' | 'waiting-on-human' | 'queued' | 'stopping' | 'done' | 'failed' | 'stopped';
121
+ export interface BackgroundRow {
122
+ /** 带来源判别;fleet 复合 id("<runId> <taskId>")不拆 —— 裸 id union 会重复计数。 */
123
+ readonly key: {
124
+ readonly source: 'assistant';
125
+ readonly taskId: string;
126
+ } | {
127
+ readonly source: 'fleet';
128
+ readonly rowId: string;
129
+ };
130
+ readonly status: BackgroundStatus;
131
+ /** 统一 epoch ms;assistant 的 ISO 串只在边界转一次。 */
132
+ readonly updatedAtMs?: number;
133
+ readonly needsAttention?: boolean;
134
+ readonly name?: string;
135
+ readonly description?: string;
136
+ readonly parentId?: string;
137
+ readonly tokens?: number;
138
+ readonly toolUses?: number;
139
+ readonly currentTool?: string;
140
+ readonly gate?: unknown;
141
+ }
142
+ export interface BackgroundView {
143
+ /** 🔴 一等返回位,端必须渲它 —— 源不可用/未配置与「空列表」是三件事。 */
144
+ readonly sources: Readonly<Record<BackgroundSource, BackgroundSourceHealth>>;
145
+ /** fleet 子行(parentId 在场)不进跨 session 视图(子代不是用户的独立后台任务)。 */
146
+ readonly rows: readonly BackgroundRow[];
147
+ }
148
+ export type CreateBackgroundView = (client: AgentClient) => {
149
+ snapshot(): BackgroundView | null;
150
+ subscribe(onChange: () => void): () => void;
151
+ dispose(): void;
152
+ };
@@ -0,0 +1,13 @@
1
+ export {};
2
+ // ── 已裁定(clay 终裁 2026-07-31,档 sema-internal e2b874c;实现必须遵守)──────────────────────
3
+ // RULED-1 durable park 腿面缺席 = **分形**:
4
+ // · 面「注册过但坏了」(卡口异常)→ 主动 decide deny + 诚实告知行(解锁优先);
5
+ // · 面「从未注册」(print/headless 本无卡口)→ 保留 park + 打印含 resume 指令的告知行
6
+ // (接手优先 —— headless 故意留 park 换 REPL/desktop 决断是合法场景)。
7
+ // 🔴 附加不变式(clay 原话「danger 权限保证全部畅通即可」):bypassPermissions/danger 模式下
8
+ // 审批链全程畅通 —— 零 park、零卡、零 deny,本缺席矩阵整表不适用;实现必带一条 danger-lane
9
+ // e2e 断言(danger 会话跑到审批点零停顿直通)。
10
+ // RULED-2 plan review 卡形 = **皮肤归端**:包只出数据+决断口;desktop 保持 ExitPlanMode 权限
11
+ // 卡形(CC 对位),cli 保持 overlay 形,不强制统一 UI。
12
+ // OPEN-3(实现期):file_link / live elicitation 两条 wire 缺口先补进 adapt(E8a/E8b,
13
+ // elicitation 方向 = 把 desktop/web 那份**上交**成包件)。
@@ -189,3 +189,11 @@ export declare class ControlRouter {
189
189
  }): Promise<RunReceipt | null>;
190
190
  private nextId;
191
191
  }
192
+ /** Read a normalized error code/status off any SDK error shape variant (errorCode / code / name / status).
193
+ * design/161 批 A 起导出:错误判型单源 —— 包内新读面(backgroundView 等)一律吃这一口,不再各自手摸
194
+ * `(e as any).status`。 */
195
+ export declare function errCodes(e: unknown): {
196
+ code?: unknown;
197
+ name?: unknown;
198
+ status?: unknown;
199
+ };
@@ -214,8 +214,10 @@ function uuid() {
214
214
  return v.toString(16);
215
215
  });
216
216
  }
217
- /** Read a normalized error code/status off any SDK error shape variant (errorCode / code / name / status). */
218
- function errCodes(e) {
217
+ /** Read a normalized error code/status off any SDK error shape variant (errorCode / code / name / status).
218
+ * design/161 批 A 起导出:错误判型单源 —— 包内新读面(backgroundView 等)一律吃这一口,不再各自手摸
219
+ * `(e as any).status`。 */
220
+ export function errCodes(e) {
219
221
  if (!e || typeof e !== 'object')
220
222
  return {};
221
223
  const o = e;
@@ -5,10 +5,17 @@ export interface EngineWireTarget {
5
5
  }
6
6
  /** 装/卸引擎 wire 目标。传 null 卸回 env 派生。返回还原函数。 */
7
7
  export declare function installEngineWireTarget(t: EngineWireTarget | null): () => void;
8
+ /** W1 带 key 变体:装/卸某会话的引擎 wire 目标(多会话宿主每会话一键,互不顶盖)。 */
9
+ export declare function installEngineWireTargetFor(sessionKey: string, t: EngineWireTarget | null): () => void;
8
10
  /**
9
11
  * 当前引擎 wire 目标。null = 无 wire(离线/mock/浏览器未装)—— 调用方走各自的回落路径,
10
12
  * **不是**错误态。
11
13
  */
12
14
  export declare function engineWireTarget(): EngineWireTarget | null;
15
+ /**
16
+ * W1 带 key 变体。env 派生回落是**进程级**(键无关):该键未显式装过 ⇒ 与零参同一条 env
17
+ * 回落路径(Node 壳单会话语义不变;浏览器宿主 env 恒空 ⇒ null)。
18
+ */
19
+ export declare function engineWireTargetFor(sessionKey: string): EngineWireTarget | null;
13
20
  /** 诊断开关(壳侧 `process.env.SEMA_DEBUG` 的等价读;本包零 process)。 */
14
21
  export declare function engineWireDebugEnabled(): boolean;
@@ -18,14 +18,20 @@
18
18
  * ⚠️ web/桌面宿主要用这条 wire,**不能靠 env** —— 用 `installEngineWireTarget()` 显式装。
19
19
  */
20
20
  import { hostEnv } from './hostEnv.js';
21
- /** 显式装配(非 Node 宿主唯一的入口;装了就**优先于** env,便于桌面/web 一页多引擎)。 */
22
- let installed = null;
21
+ import { createSessionSlot, DEFAULT_SESSION_KEY } from './sessionSlot.js';
22
+ /** 显式装配(非 Node 宿主唯一的入口;装了就**优先于** env,便于桌面/web 一页多引擎)。
23
+ * W1(design/161):sessionKey → 注册表;零参 API = DEFAULT_SESSION_KEY 兼容层。 */
24
+ const installedByKey = createSessionSlot();
23
25
  /** 装/卸引擎 wire 目标。传 null 卸回 env 派生。返回还原函数。 */
24
26
  export function installEngineWireTarget(t) {
25
- const prev = installed;
26
- installed = t;
27
+ return installEngineWireTargetFor(DEFAULT_SESSION_KEY, t);
28
+ }
29
+ /** W1 带 key 变体:装/卸某会话的引擎 wire 目标(多会话宿主每会话一键,互不顶盖)。 */
30
+ export function installEngineWireTargetFor(sessionKey, t) {
31
+ const prev = installedByKey.get(sessionKey);
32
+ installedByKey.set(sessionKey, t);
27
33
  return () => {
28
- installed = prev;
34
+ installedByKey.set(sessionKey, prev);
29
35
  };
30
36
  }
31
37
  /**
@@ -33,6 +39,14 @@ export function installEngineWireTarget(t) {
33
39
  * **不是**错误态。
34
40
  */
35
41
  export function engineWireTarget() {
42
+ return engineWireTargetFor(DEFAULT_SESSION_KEY);
43
+ }
44
+ /**
45
+ * W1 带 key 变体。env 派生回落是**进程级**(键无关):该键未显式装过 ⇒ 与零参同一条 env
46
+ * 回落路径(Node 壳单会话语义不变;浏览器宿主 env 恒空 ⇒ null)。
47
+ */
48
+ export function engineWireTargetFor(sessionKey) {
49
+ const installed = installedByKey.get(sessionKey) ?? null;
36
50
  if (installed !== null)
37
51
  return installed;
38
52
  const env = hostEnv();
@@ -77,8 +77,14 @@ export interface HitlHostSurface {
77
77
  }
78
78
  /** 装 HITL 宿主面(传 null 卸)。返回还原函数。 */
79
79
  export declare function installHitlHostSurface(surface: HitlHostSurface | null): () => void;
80
+ /** W1 带 key 变体:装/卸某会话的 HITL 宿主面(多会话宿主每会话一键,互不顶盖)。 */
81
+ export declare function installHitlHostSurfaceFor(sessionKey: string, surface: HitlHostSurface | null): () => void;
80
82
  /** 🔴 宿主自检:恒应为 0。非 0 = 有 HITL 副作用发生时口不在,那一行 warn / 那条记账丢了。 */
81
83
  export declare function hitlHostSurfaceMisses(): number;
84
+ /** W1 带 key 变体(miss 计数逐键)。 */
85
+ export declare function hitlHostSurfaceMissesFor(sessionKey: string): number;
86
+ /** W1 读端:某会话的 HITL 宿主面(未装 = null;**不计 miss** —— miss 只在副作用真发生时计)。 */
87
+ export declare function hitlHostSurfaceFor(sessionKey: string): HitlHostSurface | null;
82
88
  /** 测试钩:卸口 + 清计数。 */
83
89
  export declare function _resetHitlHostSurfaceForTest(): void;
84
90
  /**
@@ -1,30 +1,46 @@
1
1
  import { HitlBridge } from './hitlBridge.js';
2
2
  import { publishQuestionFrame, registerLocalQuestionResponder, hasQuestionOverlay, } from '../liveQuestionStore.js';
3
3
  import { hostLog } from '../host.js';
4
+ import { createSessionSlot, DEFAULT_SESSION_KEY } from '../sessionSlot.js';
4
5
  import { isFsApprovalGate, toolNameIsFsWrite, surfaceFsApprovalAndDecide, isToolApprovalFrame, isFromSubagent, surfaceToolApprovalFrameAndRespond, } from './toolApprovalWire.js';
5
6
  import { classifierDenyFromToolEnd } from '../classifierVerdictWire.js';
6
- let hostSurface = null;
7
- let hostSurfaceMisses = 0;
7
+ // W1(design/161):sessionKey → 注册表;零参 API = DEFAULT_SESSION_KEY 兼容层(cli 装配不动)。
8
+ const hostSurfaceByKey = createSessionSlot();
9
+ const hostSurfaceMissesByKey = new Map();
8
10
  /** 装 HITL 宿主面(传 null 卸)。返回还原函数。 */
9
11
  export function installHitlHostSurface(surface) {
10
- const prev = hostSurface;
11
- hostSurface = surface;
12
+ return installHitlHostSurfaceFor(DEFAULT_SESSION_KEY, surface);
13
+ }
14
+ /** W1 带 key 变体:装/卸某会话的 HITL 宿主面(多会话宿主每会话一键,互不顶盖)。 */
15
+ export function installHitlHostSurfaceFor(sessionKey, surface) {
16
+ const prev = hostSurfaceByKey.get(sessionKey);
17
+ hostSurfaceByKey.set(sessionKey, surface);
12
18
  return () => {
13
- hostSurface = prev;
19
+ hostSurfaceByKey.set(sessionKey, prev);
14
20
  };
15
21
  }
16
22
  /** 🔴 宿主自检:恒应为 0。非 0 = 有 HITL 副作用发生时口不在,那一行 warn / 那条记账丢了。 */
17
23
  export function hitlHostSurfaceMisses() {
18
- return hostSurfaceMisses;
24
+ return hitlHostSurfaceMissesFor(DEFAULT_SESSION_KEY);
25
+ }
26
+ /** W1 带 key 变体(miss 计数逐键)。 */
27
+ export function hitlHostSurfaceMissesFor(sessionKey) {
28
+ return hostSurfaceMissesByKey.get(sessionKey) ?? 0;
29
+ }
30
+ /** W1 读端:某会话的 HITL 宿主面(未装 = null;**不计 miss** —— miss 只在副作用真发生时计)。 */
31
+ export function hitlHostSurfaceFor(sessionKey) {
32
+ return hostSurfaceByKey.get(sessionKey) ?? null;
19
33
  }
20
34
  /** 测试钩:卸口 + 清计数。 */
21
35
  export function _resetHitlHostSurfaceForTest() {
22
- hostSurface = null;
23
- hostSurfaceMisses = 0;
36
+ hostSurfaceByKey.clear();
37
+ hostSurfaceMissesByKey.clear();
24
38
  }
25
39
  function surface() {
26
- if (hostSurface === null)
27
- hostSurfaceMisses++;
40
+ const hostSurface = hitlHostSurfaceFor(DEFAULT_SESSION_KEY);
41
+ if (hostSurface === null) {
42
+ hostSurfaceMissesByKey.set(DEFAULT_SESSION_KEY, (hostSurfaceMissesByKey.get(DEFAULT_SESSION_KEY) ?? 0) + 1);
43
+ }
28
44
  return hostSurface;
29
45
  }
30
46
  /**
@@ -61,10 +61,18 @@ export interface ApprovalCardRequest {
61
61
  export type ApprovalCardPort = (req: ApprovalCardRequest) => Promise<ApprovalCardDecision>;
62
62
  /** 装卡口(传 null 卸)。返回还原函数。 */
63
63
  export declare function installApprovalCardPort(port: ApprovalCardPort | null): () => void;
64
+ /** W1 带 key 变体:装/卸某会话的卡口(多会话宿主每会话一键,互不顶盖)。 */
65
+ export declare function installApprovalCardPortFor(sessionKey: string, port: ApprovalCardPort | null): () => void;
64
66
  /** 🔴 宿主自检:恒应为 0。非 0 = 有 gate 到达时卡口不在,那次审批走了 fail-closed 路径。 */
65
67
  export declare function approvalCardPortMisses(): number;
68
+ /** W1 带 key 变体(miss 计数逐键)。 */
69
+ export declare function approvalCardPortMissesFor(sessionKey: string): number;
66
70
  /** 卡口在不在(壳原文 `getLeaderToolUseConfirmQueue()` 那个存在性判据的等价物)。 */
67
71
  export declare function hasApprovalCardPort(): boolean;
72
+ /** W1 带 key 变体。 */
73
+ export declare function hasApprovalCardPortFor(sessionKey: string): boolean;
74
+ /** W1 读端:某会话的卡口(未装 = null;**不计 miss** —— miss 只在决断腿真到达时计)。 */
75
+ export declare function approvalCardPortFor(sessionKey: string): ApprovalCardPort | null;
68
76
  /** 测试钩:卸口 + 清计数(只卸口不清计数会让「未装配时 misses 为 0」的断言恒假 —— host.ts 同款教训)。 */
69
77
  export declare function _resetApprovalCardPortForTest(): void;
70
78
  /**
@@ -1,5 +1,6 @@
1
1
  import { HitlBridge } from './hitlBridge.js';
2
2
  import { hostLog } from '../host.js';
3
+ import { createSessionSlot, DEFAULT_SESSION_KEY } from '../sessionSlot.js';
3
4
  import { readEngineActiveBgTasks } from '../fleet/fleetLedger.js';
4
5
  /** fs 写权限 gate 判定:未来的一等 kind(tool_approval)或按 toolName(server 桥首批=fs 写三件,
5
6
  * [820] 表)。AskUserQuestion 永不进这里(ask 桥先判)。 */
@@ -36,28 +37,48 @@ export function structurallyEqual(a, b) {
36
37
  return false;
37
38
  return ka.every(k => structurallyEqual(a[k], b[k]));
38
39
  }
39
- let cardPort = null;
40
- let cardPortMisses = 0;
40
+ // W1(design/161):sessionKey → 注册表;零参 API = DEFAULT_SESSION_KEY 兼容层(cli 装配不动)。
41
+ const cardPortByKey = createSessionSlot();
42
+ const cardPortMissesByKey = new Map();
43
+ function bumpCardPortMiss(sessionKey) {
44
+ cardPortMissesByKey.set(sessionKey, (cardPortMissesByKey.get(sessionKey) ?? 0) + 1);
45
+ }
41
46
  /** 装卡口(传 null 卸)。返回还原函数。 */
42
47
  export function installApprovalCardPort(port) {
43
- const prev = cardPort;
44
- cardPort = port;
48
+ return installApprovalCardPortFor(DEFAULT_SESSION_KEY, port);
49
+ }
50
+ /** W1 带 key 变体:装/卸某会话的卡口(多会话宿主每会话一键,互不顶盖)。 */
51
+ export function installApprovalCardPortFor(sessionKey, port) {
52
+ const prev = cardPortByKey.get(sessionKey);
53
+ cardPortByKey.set(sessionKey, port);
45
54
  return () => {
46
- cardPort = prev;
55
+ cardPortByKey.set(sessionKey, prev);
47
56
  };
48
57
  }
49
58
  /** 🔴 宿主自检:恒应为 0。非 0 = 有 gate 到达时卡口不在,那次审批走了 fail-closed 路径。 */
50
59
  export function approvalCardPortMisses() {
51
- return cardPortMisses;
60
+ return approvalCardPortMissesFor(DEFAULT_SESSION_KEY);
61
+ }
62
+ /** W1 带 key 变体(miss 计数逐键)。 */
63
+ export function approvalCardPortMissesFor(sessionKey) {
64
+ return cardPortMissesByKey.get(sessionKey) ?? 0;
52
65
  }
53
66
  /** 卡口在不在(壳原文 `getLeaderToolUseConfirmQueue()` 那个存在性判据的等价物)。 */
54
67
  export function hasApprovalCardPort() {
55
- return cardPort !== null;
68
+ return hasApprovalCardPortFor(DEFAULT_SESSION_KEY);
69
+ }
70
+ /** W1 带 key 变体。 */
71
+ export function hasApprovalCardPortFor(sessionKey) {
72
+ return approvalCardPortFor(sessionKey) !== null;
73
+ }
74
+ /** W1 读端:某会话的卡口(未装 = null;**不计 miss** —— miss 只在决断腿真到达时计)。 */
75
+ export function approvalCardPortFor(sessionKey) {
76
+ return cardPortByKey.get(sessionKey) ?? null;
56
77
  }
57
78
  /** 测试钩:卸口 + 清计数(只卸口不清计数会让「未装配时 misses 为 0」的断言恒假 —— host.ts 同款教训)。 */
58
79
  export function _resetApprovalCardPortForTest() {
59
- cardPort = null;
60
- cardPortMisses = 0;
80
+ cardPortByKey.clear();
81
+ cardPortMissesByKey.clear();
61
82
  }
62
83
  /**
63
84
  * 共享卡面:弹 vendored CC 三选卡(PermissionRequest)→ 等用户决断。两条腿共用:
@@ -67,8 +88,9 @@ export function _resetApprovalCardPortForTest() {
67
88
  * (accept-session 升 intent,[816] 壳侧承诺② —— 与决断通道无关,两腿都要)由**宿主卡口**做。
68
89
  */
69
90
  export function surfaceApprovalCard(req) {
91
+ const cardPort = approvalCardPortFor(DEFAULT_SESSION_KEY);
70
92
  if (cardPort === null) {
71
- cardPortMisses++;
93
+ bumpCardPortMiss(DEFAULT_SESSION_KEY);
72
94
  return Promise.resolve({
73
95
  kind: 'failed',
74
96
  reason: 'no approval card port installed (print/non-REPL mode, or host forgot installApprovalCardPort)',
@@ -85,7 +107,7 @@ export function surfaceApprovalCard(req) {
85
107
  */
86
108
  export async function surfaceFsApprovalAndDecide(deps, taskId, argsByCall, signal) {
87
109
  if (!hasApprovalCardPort()) {
88
- cardPortMisses++;
110
+ bumpCardPortMiss(DEFAULT_SESSION_KEY);
89
111
  return { kind: 'failed', reason: 'no approval card port installed (print/non-REPL mode)' };
90
112
  }
91
113
  // pending 行 = gate 的权威源 + D-1 绑定 surface(与 ask 桥同姿势)。
package/dist/host.d.ts CHANGED
@@ -96,23 +96,39 @@ export interface HostPorts {
96
96
  * 返回一个还原函数(测试/热重载用)。
97
97
  */
98
98
  export declare function installHost(ports: HostPorts): () => void;
99
+ /**
100
+ * W1 带 key 变体:装配某会话的宿主口(多会话宿主每会话一键,互不顶盖;合并语义与零参同)。
101
+ * ⚠️ 入参不含 `queue` —— 通知队列在 W1 仍是包级单例(notifications.ts 不在五槽位切口内),
102
+ * per-session 队列归后续工单;要装队列走零参 `installHost({ queue })`。
103
+ */
104
+ export declare function installHostFor(sessionKey: string, ports: Omit<HostPorts, 'queue'>): () => void;
99
105
  /** 日志(缺席 = 静默,**不计 miss**:诊断面本就是可选的)。 */
100
106
  export declare function hostLog(level: AdapterLogLevel, msg: string, data?: unknown): void;
101
107
  /** 行式探针(缺席 = 静默,不计 miss:同上)。 */
102
108
  export declare function hostProbe(channel: string, line: string): void;
103
109
  /** settings 口(缺席 = undefined + 计 miss:漏装会让 hooks 整条 wire 静默空掉)。 */
104
110
  export declare function hostSettings(): SettingsPort | undefined;
111
+ /** W1 带 key 变体(缺席语义与零参同:undefined + 计该键 miss)。 */
112
+ export declare function hostSettingsFor(sessionKey: string): SettingsPort | undefined;
105
113
  /** fs 口(缺席 = undefined + 计 miss)。 */
106
114
  export declare function hostFs(): FsPort | undefined;
115
+ /** W1 带 key 变体。 */
116
+ export declare function hostFsFor(sessionKey: string): FsPort | undefined;
107
117
  /** 定时器口(缺席 = undefined,不计 miss:T40 竞速本就允许不启用)。 */
108
118
  export declare function hostTimers(): TimersPort | undefined;
119
+ /** W1 带 key 变体(同零参:不计 miss)。 */
120
+ export declare function hostTimersFor(sessionKey: string): TimersPort | undefined;
109
121
  /** session 口(缺席 = undefined + 计 miss:漏装会让整条 durable 子代读面 404 静默哑掉)。 */
110
122
  export declare function hostSession(): SessionPort | undefined;
123
+ /** W1 带 key 变体。 */
124
+ export declare function hostSessionFor(sessionKey: string): SessionPort | undefined;
111
125
  /**
112
126
  * 🔴 宿主自检出口 —— 恒应为空对象。非空 = 有口漏装,**对应的面已经在静默失效**。
113
127
  * 含通知队列的 miss(从 notifications.ts 汇过来,宿主只需看这一个数)。
114
128
  */
115
129
  export declare function hostPortMisses(): Record<string, number>;
130
+ /** W1 带 key 变体。⚠️ 通知队列是包级单例,其 miss 只折进 DEFAULT_SESSION_KEY 键的视图。 */
131
+ export declare function hostPortMissesFor(sessionKey: string): Record<string, number>;
116
132
  /**
117
133
  * 测试钩:清空装配与计数。
118
134
  * 🔴 队列那一路必须走 `_resetNotificationQueuePortForTest()` 而不是 `installNotificationQueuePort(null)`
package/dist/host.js CHANGED
@@ -1,11 +1,19 @@
1
+ import { DEFAULT_SESSION_KEY } from './sessionSlot.js';
1
2
  import { _resetNotificationQueuePortForTest, installNotificationQueuePort, notificationQueuePortMisses, } from './notifications.js';
2
- let host = {};
3
- const misses = Object.create(null);
4
- const warned = new Set();
5
- function miss(name) {
6
- misses[name] = (misses[name] ?? 0) + 1;
7
- if (!warned.has(name)) {
8
- warned.add(name);
3
+ const hostByKey = new Map();
4
+ function hostSlot(sessionKey) {
5
+ let slot = hostByKey.get(sessionKey);
6
+ if (!slot) {
7
+ slot = { state: {}, misses: Object.create(null), warned: new Set() };
8
+ hostByKey.set(sessionKey, slot);
9
+ }
10
+ return slot;
11
+ }
12
+ function miss(sessionKey, name) {
13
+ const slot = hostSlot(sessionKey);
14
+ slot.misses[name] = (slot.misses[name] ?? 0) + 1;
15
+ if (!slot.warned.has(name)) {
16
+ slot.warned.add(name);
9
17
  // eslint-disable-next-line no-console
10
18
  console.error(`[client-core] host port "${name}" NOT installed — the surface it drives is silently inert. ` +
11
19
  'Host must call installHost({ ' +
@@ -21,58 +29,90 @@ function miss(name) {
21
29
  * 返回一个还原函数(测试/热重载用)。
22
30
  */
23
31
  export function installHost(ports) {
24
- const prev = { ...host };
32
+ const restore = installHostFor(DEFAULT_SESSION_KEY, ports);
33
+ if ('queue' in ports)
34
+ installNotificationQueuePort(ports.queue ?? null);
35
+ return restore;
36
+ }
37
+ /**
38
+ * W1 带 key 变体:装配某会话的宿主口(多会话宿主每会话一键,互不顶盖;合并语义与零参同)。
39
+ * ⚠️ 入参不含 `queue` —— 通知队列在 W1 仍是包级单例(notifications.ts 不在五槽位切口内),
40
+ * per-session 队列归后续工单;要装队列走零参 `installHost({ queue })`。
41
+ */
42
+ export function installHostFor(sessionKey, ports) {
43
+ const slot = hostSlot(sessionKey);
44
+ const prev = { ...slot.state };
25
45
  if ('log' in ports)
26
- host.log = ports.log;
46
+ slot.state.log = ports.log;
27
47
  if ('probe' in ports)
28
- host.probe = ports.probe;
48
+ slot.state.probe = ports.probe;
29
49
  if ('timers' in ports)
30
- host.timers = ports.timers;
50
+ slot.state.timers = ports.timers;
31
51
  if ('settings' in ports)
32
- host.settings = ports.settings;
52
+ slot.state.settings = ports.settings;
33
53
  if ('fs' in ports)
34
- host.fs = ports.fs;
54
+ slot.state.fs = ports.fs;
35
55
  if ('session' in ports)
36
- host.session = ports.session;
37
- if ('queue' in ports)
38
- installNotificationQueuePort(ports.queue ?? null);
56
+ slot.state.session = ports.session;
39
57
  return () => {
40
- host = prev;
58
+ slot.state = prev;
41
59
  };
42
60
  }
43
61
  /** 日志(缺席 = 静默,**不计 miss**:诊断面本就是可选的)。 */
44
62
  export function hostLog(level, msg, data) {
45
- host.log?.(level, msg, data);
63
+ hostSlot(DEFAULT_SESSION_KEY).state.log?.(level, msg, data);
46
64
  }
47
65
  /** 行式探针(缺席 = 静默,不计 miss:同上)。 */
48
66
  export function hostProbe(channel, line) {
49
- host.probe?.(channel, line);
67
+ hostSlot(DEFAULT_SESSION_KEY).state.probe?.(channel, line);
50
68
  }
51
69
  /** settings 口(缺席 = undefined + 计 miss:漏装会让 hooks 整条 wire 静默空掉)。 */
52
70
  export function hostSettings() {
53
- return host.settings ?? miss('settings');
71
+ return hostSettingsFor(DEFAULT_SESSION_KEY);
72
+ }
73
+ /** W1 带 key 变体(缺席语义与零参同:undefined + 计该键 miss)。 */
74
+ export function hostSettingsFor(sessionKey) {
75
+ return hostSlot(sessionKey).state.settings ?? miss(sessionKey, 'settings');
54
76
  }
55
77
  /** fs 口(缺席 = undefined + 计 miss)。 */
56
78
  export function hostFs() {
57
- return host.fs ?? miss('fs');
79
+ return hostFsFor(DEFAULT_SESSION_KEY);
80
+ }
81
+ /** W1 带 key 变体。 */
82
+ export function hostFsFor(sessionKey) {
83
+ return hostSlot(sessionKey).state.fs ?? miss(sessionKey, 'fs');
58
84
  }
59
85
  /** 定时器口(缺席 = undefined,不计 miss:T40 竞速本就允许不启用)。 */
60
86
  export function hostTimers() {
61
- return host.timers;
87
+ return hostTimersFor(DEFAULT_SESSION_KEY);
88
+ }
89
+ /** W1 带 key 变体(同零参:不计 miss)。 */
90
+ export function hostTimersFor(sessionKey) {
91
+ return hostSlot(sessionKey).state.timers;
62
92
  }
63
93
  /** session 口(缺席 = undefined + 计 miss:漏装会让整条 durable 子代读面 404 静默哑掉)。 */
64
94
  export function hostSession() {
65
- return host.session ?? miss('session');
95
+ return hostSessionFor(DEFAULT_SESSION_KEY);
96
+ }
97
+ /** W1 带 key 变体。 */
98
+ export function hostSessionFor(sessionKey) {
99
+ return hostSlot(sessionKey).state.session ?? miss(sessionKey, 'session');
66
100
  }
67
101
  /**
68
102
  * 🔴 宿主自检出口 —— 恒应为空对象。非空 = 有口漏装,**对应的面已经在静默失效**。
69
103
  * 含通知队列的 miss(从 notifications.ts 汇过来,宿主只需看这一个数)。
70
104
  */
71
105
  export function hostPortMisses() {
72
- const out = { ...misses };
73
- const q = notificationQueuePortMisses();
74
- if (q > 0)
75
- out.queue = q;
106
+ return hostPortMissesFor(DEFAULT_SESSION_KEY);
107
+ }
108
+ /** W1 key 变体。⚠️ 通知队列是包级单例,其 miss 只折进 DEFAULT_SESSION_KEY 键的视图。 */
109
+ export function hostPortMissesFor(sessionKey) {
110
+ const out = { ...hostSlot(sessionKey).misses };
111
+ if (sessionKey === DEFAULT_SESSION_KEY) {
112
+ const q = notificationQueuePortMisses();
113
+ if (q > 0)
114
+ out.queue = q;
115
+ }
76
116
  return out;
77
117
  }
78
118
  /**
@@ -82,9 +122,6 @@ export function hostPortMisses() {
82
122
  * 让「未装配时 misses 为空」这类断言恒假(B4 首写时就撞上了)。
83
123
  */
84
124
  export function _resetHostForTest() {
85
- host = {};
86
- for (const k of Object.keys(misses))
87
- delete misses[k];
88
- warned.clear();
125
+ hostByKey.clear();
89
126
  _resetNotificationQueuePortForTest();
90
127
  }
package/dist/index.d.ts CHANGED
@@ -182,6 +182,7 @@ export * from './adapter/runStream.js';
182
182
  export * from './request/taskRequest.js';
183
183
  export * from './clientSlice.js';
184
184
  export * from './host.js';
185
+ export { DEFAULT_SESSION_KEY } from './sessionSlot.js';
185
186
  export * from './scratchpadWireCaps.js';
186
187
  export * from './toolResult.js';
187
188
  export * from './diff/patch.js';
@@ -217,3 +218,5 @@ export * from './hitl/approvalsFeed.js';
217
218
  export * from './compensations.js';
218
219
  export * from './request/printNotification.js';
219
220
  export * from './seatContract.js';
221
+ export * from './agentSession/contract.js';
222
+ export * from './agentSession/backgroundView.js';
package/dist/index.js CHANGED
@@ -197,6 +197,10 @@ export * from './clientSlice.js';
197
197
  // 包级宿主装配(per-turn 的 AdapterContext 之外的那一层:settings / fs / queue / timers)。
198
198
  // 🔴 `installNotificationQueuePort` 仍是队列的真源出口,`installHost({queue})` 只是直通它。
199
199
  export * from './host.js';
200
+ // W1(design/161):五个模块级单槽位的 per-session 注册表键。零参旧 API 全部等价于用这个键调
201
+ // `*For(sessionKey, …)` 变体;多会话宿主(desktop)每会话一键。`createSessionSlot` 是包内实现件,
202
+ // 刻意不出 barrel(公面只承诺键与各槽位的 *For API)。
203
+ export { DEFAULT_SESSION_KEY } from './sessionSlot.js';
200
204
  // B2 判给 B4 的宿主耦合件之一(小的那件,同时是 FsPort 的端到端证明)。
201
205
  export * from './scratchpadWireCaps.js';
202
206
  // ── B5 批:工具结果卡 B/D/E 层 + T20 客户端 diff(2026-07-27)────────────────────────────────
@@ -290,3 +294,11 @@ export * from './request/printNotification.js';
290
294
  // → `LOCAL_SESSIONS_SPEC` 键集(run-seat-contract-keys-test 运行期对账)。加 verb 忘了加 SPEC
291
295
  // 的后果不是报错而是 preload 不注册 channel、渲染端读到 undefined(假 affordance 家族)。
292
296
  export * from './seatContract.js';
297
+ // ── design/161 实现批 A:后台任务归一视图平行入口(2026-07-30)────────────────────────────────
298
+ // 契约收敛物 contract.ts 随首件实现落地转正常出口(「实现落地一件出口一件」——
299
+ // createBackgroundView 是第一件;portability 门 TYPE_ONLY_MODULES 同批摘除该登记)。
300
+ // contract.ts 仍是纯类型零运行时,`export *` 在 dist 里是空转发 —— 类型面从此进公面。
301
+ export * from './agentSession/contract.js';
302
+ // 两源(assistant.tasks 跨 session 调度总览 + fleet 常驻 SSE 快照)独立拉取独立降级的归一投影;
303
+ // 两张 status 映射表 never 卫兵穷举;'not-configured' 判别姿势见文件头注。
304
+ export * from './agentSession/backgroundView.js';
@@ -1,8 +1,3 @@
1
- /**
2
- * ⇄ B1 批搬迁(2026-07-27,多端改造设计稿 §2.4.1-B):cli src/sema/liveQuestionStore.ts 逐字搬入(零依赖;壳侧改 re-export)。
3
- * 本模块自持 AskUserQuestion 的 wire 词汇(SDK 0.0.44 删掉了 questions 资源导出),搬入后这套
4
- * 类型随包发布 —— web/桌面端的 HITL 覆盖层与 TUI 共用同一份 QuestionFrame/QuestionAnswer 形。
5
- */
6
1
  /**
7
2
  * src/sema/liveQuestionStore.ts — the SIDE-CHANNEL for §4④ live-stream AskUserQuestion (the conversation-seam
8
3
  * twin of liveSessionStore.ts).
@@ -80,11 +75,18 @@ export declare function registerLocalQuestionResponder(id: string, fn: RespondFn
80
75
  * break the live stream drain (the run must keep flowing; a lost frame just headless-defaults the ask).
81
76
  */
82
77
  export declare function publishQuestionFrame(frame: QuestionFrame): void;
78
+ /** W1 keyed variant: publish to ONE session's overlay (multi-session hosts demux by their own key). */
79
+ export declare function publishQuestionFrameFor(sessionKey: string, frame: QuestionFrame): void;
83
80
  /** Whether a UI overlay is currently subscribed (liveHitlAskWire gates on this: print/non-REPL mode has no
84
81
  * dialog to park on, so the suspended terminal must fall through honestly instead of hanging the stream). */
85
82
  export declare function hasQuestionOverlay(): boolean;
83
+ /** W1 keyed variant. */
84
+ export declare function hasQuestionOverlayFor(sessionKey: string): boolean;
86
85
  /** Subscribe the UI overlay to live question frames. Returns an unsubscribe. Last writer wins (one overlay). */
87
86
  export declare function onQuestionFrame(handler: QuestionFrameHandler): () => void;
87
+ /** W1 keyed variant: one overlay PER sessionKey (last writer wins within a key; keys never clobber
88
+ * each other — the r1 E2 dual-session hazard this registry exists to close). */
89
+ export declare function onQuestionFrameFor(sessionKey: string, handler: QuestionFrameHandler): () => void;
88
90
  /** Publish the live client's `questions.respond` binding (called from createLiveConversationClient). `null`
89
91
  * clears it (offline/mock → respondToQuestion rejects cleanly). */
90
92
  export declare function setQuestionRespond(fn: RespondFn | null): void;
@@ -3,43 +3,10 @@
3
3
  * 本模块自持 AskUserQuestion 的 wire 词汇(SDK 0.0.44 删掉了 questions 资源导出),搬入后这套
4
4
  * 类型随包发布 —— web/桌面端的 HITL 覆盖层与 TUI 共用同一份 QuestionFrame/QuestionAnswer 形。
5
5
  */
6
- /**
7
- * src/sema/liveQuestionStore.ts the SIDE-CHANNEL for §4④ live-stream AskUserQuestion (the conversation-seam
8
- * twin of liveSessionStore.ts).
9
- *
10
- * WHY THIS EXISTS
11
- * ---------------
12
- * In TOC (two-process) the MODEL runs in the engine; its `AskUserQuestion` tool fires on core's
13
- * `RunnerDeps.onQuestion` seam and the service (question.ts, 1.61.0) emits a NAMED SSE frame
14
- * (`event: question` / `event: question_complete`) that INTERLEAVES on the same live token stream as the
15
- * AgentEvents. That frame is NOT an AgentEvent arm (it is a DISTINCT vocabulary, exactly like elicitation) —
16
- * so it must be DEMUXED out of the transcript-render pipeline and routed to an interactive DIALOG overlay
17
- * instead. This tiny, dependency-FREE module is that route: liveClient WRITES the demuxed frame here (on each
18
- * `question`/`question_complete`) and publishes its `client.questions.respond` binding; the UI overlay
19
- * (main.tsx / REPL) READS the frames and answers via `respondToQuestion`. Same shape as liveSessionStore /
20
- * rewindAnchorStore — the overlay stays testable (no @sema-ai/sdk graph) and seamQuery/normalizeToolNames are
21
- * untouched.
22
- *
23
- * 🔴 LIVE-ONLY + SAME-REPLICA (the `steer()`-class of HITL): the service parks the answer promise in-memory,
24
- * so a respond that lands after the TTL (5 min) / after answer / on another replica → 404. No durable resume
25
- * anchor — a mid-question disconnect just lets the run proceed with the headless default (the model never
26
- * hangs). The overlay must therefore treat `respondToQuestion` as best-effort (a 404 = "already released",
27
- * dismiss the dialog, never retry).
28
- *
29
- * 🔴 UNTRUSTED: the `questions` payload is model-authored (service secret-redacted, but still UNTRUSTED for
30
- * display). The overlay RENDERS it; it must NEVER be re-fed to a model. The answer the overlay sends back is
31
- * fenced by CORE (`selected ⊆ options`, `note` untrusted-fenced) — the overlay must echo the EXACT option
32
- * labels the user saw, never fabricate one.
33
- */
34
- /* ── LOCAL wire types (owned HERE, not @sema-ai/sdk) ─────────────────────────────────────────────
35
- * SDK 0.0.44 DELETED the `questions` resource (engine 1.163 parks the run `suspended` instead of
36
- * emitting `question` SSE frames — see liveHitlAskWire.ts), and with it the QuestionFrame /
37
- * QuestionAnswer / AskQuestion exports. The shell still speaks this vocabulary INTERNALLY (synthetic
38
- * frames from liveHitlAskWire / planReviewWire + the legacy 1.61-era SSE demux in liveClient), so the
39
- * types now live in THIS dependency-free module — the store this whole seam already routes through.
40
- * Shapes are the ones the shell actually produces/consumes (askq-wire-probe + overlay usage), byte-
41
- * compatible with the old wire payload `{type,questionId,questions?,outcome?}`. */
42
- let frameHandler = null;
6
+ import { createSessionSlot, DEFAULT_SESSION_KEY } from './sessionSlot.js';
7
+ // W1(design/161):frameHandler 槽位改 sessionKey 注册表;零参 API = DEFAULT_SESSION_KEY
8
+ // 兼容层(cli 装配不动)。respondFn / localResponders 不在 W1 五槽位切口内,维持原样。
9
+ const frameHandlerByKey = createSessionSlot();
43
10
  let respondFn = null;
44
11
  /** SHELL-LOCAL question responders (plan-review approval cards etc. — synthetic frames whose id does
45
12
  * NOT exist engine-side, so POST /v1/questions/:id/respond would 404). Registered per-questionId by
@@ -59,6 +26,11 @@ export function registerLocalQuestionResponder(id, fn) {
59
26
  * break the live stream drain (the run must keep flowing; a lost frame just headless-defaults the ask).
60
27
  */
61
28
  export function publishQuestionFrame(frame) {
29
+ publishQuestionFrameFor(DEFAULT_SESSION_KEY, frame);
30
+ }
31
+ /** W1 keyed variant: publish to ONE session's overlay (multi-session hosts demux by their own key). */
32
+ export function publishQuestionFrameFor(sessionKey, frame) {
33
+ const frameHandler = frameHandlerByKey.get(sessionKey);
62
34
  if (!frameHandler)
63
35
  return;
64
36
  try {
@@ -71,14 +43,23 @@ export function publishQuestionFrame(frame) {
71
43
  /** Whether a UI overlay is currently subscribed (liveHitlAskWire gates on this: print/non-REPL mode has no
72
44
  * dialog to park on, so the suspended terminal must fall through honestly instead of hanging the stream). */
73
45
  export function hasQuestionOverlay() {
74
- return frameHandler !== null;
46
+ return hasQuestionOverlayFor(DEFAULT_SESSION_KEY);
47
+ }
48
+ /** W1 keyed variant. */
49
+ export function hasQuestionOverlayFor(sessionKey) {
50
+ return frameHandlerByKey.get(sessionKey) !== undefined;
75
51
  }
76
52
  /** Subscribe the UI overlay to live question frames. Returns an unsubscribe. Last writer wins (one overlay). */
77
53
  export function onQuestionFrame(handler) {
78
- frameHandler = handler;
54
+ return onQuestionFrameFor(DEFAULT_SESSION_KEY, handler);
55
+ }
56
+ /** W1 keyed variant: one overlay PER sessionKey (last writer wins within a key; keys never clobber
57
+ * each other — the r1 E2 dual-session hazard this registry exists to close). */
58
+ export function onQuestionFrameFor(sessionKey, handler) {
59
+ frameHandlerByKey.set(sessionKey, handler);
79
60
  return () => {
80
- if (frameHandler === handler)
81
- frameHandler = null;
61
+ if (frameHandlerByKey.get(sessionKey) === handler)
62
+ frameHandlerByKey.set(sessionKey, undefined);
82
63
  };
83
64
  }
84
65
  /** Publish the live client's `questions.respond` binding (called from createLiveConversationClient). `null`
@@ -104,6 +85,6 @@ export function respondToQuestion(id, answer, opts) {
104
85
  }
105
86
  /** Test-only reset. */
106
87
  export function _resetLiveQuestionStore() {
107
- frameHandler = null;
88
+ frameHandlerByKey.clear();
108
89
  respondFn = null;
109
90
  }
@@ -81,6 +81,8 @@ export interface TaskRequestInput {
81
81
  attachments?: Record<string, unknown>;
82
82
  /** 预上传附件句柄(`uploadAttachment` 回执 id)。空数组 = 没带附件 ⇒ 整键不 stamp。 */
83
83
  attachmentIds?: readonly string[];
84
+ /** 排除工具名单(SDK 真 wire 键;desktop 座位面在发)。空数组 = 不排除 ⇒ 整键不 stamp。 */
85
+ excludeTools?: readonly string[];
84
86
  finalVerification?: boolean;
85
87
  limits?: Record<string, unknown>;
86
88
  interactiveTools?: false;
@@ -34,6 +34,7 @@ export const REQUEST_FIELD_MATRIX = [
34
34
  // B4 从壳的三个构造器逐行读出来的,那时这条车道只有壳一个消费者。`attachmentIds` 是**反过来**
35
35
  // 的第一条:它今天只有 web/desktop 在发,壳反而没有这个入口。别把本节读成「壳独有」。
36
36
  { field: 'attachmentIds', lanes: ['interactive'], live: true, why: '附件**字节通道**(uploadAttachment 回执 id;server ≥1.289 绑定会话并把文件物化进 run 的 attachments/)。有上传入口的端才有 id 可引用:web/desktop 有,壳走 images / 本地文件路径,`-p` 车道连 uploadAttachment 都没有 ⇒ print 缺席是**没有来源**,不是漏。🔴 与上面的 `attachments` 同名不同物(那是 turn 边界的配置键),两条永远不许合并' },
37
+ { field: 'excludeTools', lanes: ['interactive'], live: true, why: 'design/161 W3(2026-07-30):真 wire 键(SDK types `excludeTools`)且早在 seatContract `START_SESSION_OPTION_KEYS` 里,却是 desktop 手写 taskReq 的第 9 键、矩阵外——而 stamp 门对未登记键**静默丢弃**,真实危险形=「用户显式排除的工具被静默放回」(权限方向回归,类型层不报)。今天只有 desktop 在发;壳的工具面走 roster/interactiveTools 另一条路,print 无排除入口' },
37
38
  { field: 'settings.ultracode', lanes: ['interactive'], live: true, why: 'design/111:sticky `/effort ultracode` 拨盘 + 当轮关键词嗅探,两个来源都只在交互面存在', gap: true },
38
39
  { field: 'systemPrompt', lanes: ['interactive'], live: false, why: 'CC QueryParams.systemPrompt;print 腿的 params 没有这一位' },
39
40
  { field: 'reasoningEffort', lanes: ['interactive'], live: false, why: '`/effort` 拨盘存在 AppState,print 无 AppState', gap: true },
@@ -115,6 +116,10 @@ export function buildTaskRequest(input, lane) {
115
116
  ...(on('attachmentIds', input.attachmentIds) && (input.attachmentIds?.length ?? 0) > 0
116
117
  ? { attachmentIds: [...(input.attachmentIds ?? [])] }
117
118
  : {}),
119
+ // 同款空数组语义:「不排除任何工具」不 stamp(缺席与空排除在 wire 上等价,发小的那个)。
120
+ ...(on('excludeTools', input.excludeTools) && (input.excludeTools?.length ?? 0) > 0
121
+ ? { excludeTools: [...(input.excludeTools ?? [])] }
122
+ : {}),
118
123
  ...(on('clientContext', input.clientContext) ? { clientContext: input.clientContext } : {}),
119
124
  ...(on('scratchpadDir', input.scratchpadDir) ? { scratchpadDir: input.scratchpadDir } : {}),
120
125
  ...(on('finalVerification', input.finalVerification)
@@ -0,0 +1,26 @@
1
+ /**
2
+ * sessionSlot.ts — W1(design/161):包内「模块级可变单槽位」的 per-session 注册表最小工厂。
3
+ *
4
+ * 背景(design/161 复审 r1 E2):包内五个模块级单槽位(host.ts `host` / askGateWire
5
+ * `hostSurface` / toolApprovalWire `cardPort` / liveQuestionStore `frameHandler` /
6
+ * engineWireTarget `installed`)与多会话宿主(desktop)正面冲突 —— 两个并行会话互相顶盖
7
+ * (A 会话的审批卡弹到 B 的面上 / B 的 responder 覆盖 A 的)。W1 把这五个槽位改成
8
+ * `Map<sessionKey, T>` 注册表:
9
+ * · **零参旧 API = DEFAULT_SESSION_KEY 兼容层**(cli 的 module-load 装配一行不动,
10
+ * default 键路径与改前单变量行为逐字节等价);
11
+ * · 带 key 的 `*For(sessionKey, …)` 变体给多会话宿主用,每会话一键,互不顶盖。
12
+ * 契约锚 = `agentSession/contract.ts` 的 `AgentSessionConfig.sessionKey`。
13
+ *
14
+ * 🔴 本文件只管「存取」,不带任何业务语义 —— miss 计数、fail-soft 文言、还原/合并语义都留在
15
+ * 各槽位模块里(五个槽位形状略异,强行归一就是过度设计)。
16
+ */
17
+ /** 零参旧 API 的兼容键(cli 单会话装配走它;多会话宿主每会话一键,别用这个值当会话 id)。 */
18
+ export declare const DEFAULT_SESSION_KEY = "__default__";
19
+ /** per-session 槽位存取面。`get` 未装过 = undefined(各槽位模块自己 `?? null` 归一)。 */
20
+ export interface SessionSlot<T> {
21
+ get(key: string): T | undefined;
22
+ /** `undefined` = 卸下该键(与「从未装过」同态 —— 槽位模块的 prev-还原语义靠它闭合)。 */
23
+ set(key: string, value: T | undefined): void;
24
+ clear(): void;
25
+ }
26
+ export declare function createSessionSlot<T>(): SessionSlot<T>;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * sessionSlot.ts — W1(design/161):包内「模块级可变单槽位」的 per-session 注册表最小工厂。
3
+ *
4
+ * 背景(design/161 复审 r1 E2):包内五个模块级单槽位(host.ts `host` / askGateWire
5
+ * `hostSurface` / toolApprovalWire `cardPort` / liveQuestionStore `frameHandler` /
6
+ * engineWireTarget `installed`)与多会话宿主(desktop)正面冲突 —— 两个并行会话互相顶盖
7
+ * (A 会话的审批卡弹到 B 的面上 / B 的 responder 覆盖 A 的)。W1 把这五个槽位改成
8
+ * `Map<sessionKey, T>` 注册表:
9
+ * · **零参旧 API = DEFAULT_SESSION_KEY 兼容层**(cli 的 module-load 装配一行不动,
10
+ * default 键路径与改前单变量行为逐字节等价);
11
+ * · 带 key 的 `*For(sessionKey, …)` 变体给多会话宿主用,每会话一键,互不顶盖。
12
+ * 契约锚 = `agentSession/contract.ts` 的 `AgentSessionConfig.sessionKey`。
13
+ *
14
+ * 🔴 本文件只管「存取」,不带任何业务语义 —— miss 计数、fail-soft 文言、还原/合并语义都留在
15
+ * 各槽位模块里(五个槽位形状略异,强行归一就是过度设计)。
16
+ */
17
+ /** 零参旧 API 的兼容键(cli 单会话装配走它;多会话宿主每会话一键,别用这个值当会话 id)。 */
18
+ export const DEFAULT_SESSION_KEY = '__default__';
19
+ export function createSessionSlot() {
20
+ const values = new Map();
21
+ return {
22
+ get: (key) => values.get(key),
23
+ set: (key, value) => {
24
+ if (value === undefined)
25
+ values.delete(key);
26
+ else
27
+ values.set(key, value);
28
+ },
29
+ clear: () => values.clear(),
30
+ };
31
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/client-core",
3
- "version": "0.11.15",
3
+ "version": "0.11.16",
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",