@sema-agent/client-core 0.30.1 → 0.30.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,236 @@
1
+ import { engineCapTrue } from '../engineCapsCache.js';
2
+ import { hostLog } from '../host.js';
3
+ import { readToolApprovalRespondAck } from './toolApprovalWire.js';
4
+ // ── 能力位 gate ──────────────────────────────────────────────────────────────────────────────
5
+ /** 规则车道在不在(四口 501 同源谓词)。未判/缺键/base 缺席 = false = 整面藏。 */
6
+ export function persistedRulesLaneAvailable(baseUrl) {
7
+ return engineCapTrue(baseUrl, 'permissionRules');
8
+ }
9
+ /**
10
+ * 治理面(list + revoke)在不在。**两段合取**:
11
+ * · `permissionRulesRevoke` 在场 = 撤销面路由已铸(缺席 = worker 比撤销面老 ⇒ 藏);
12
+ * · `permissionRules` 为真 = 车道真答话(店 + 旋钮)。
13
+ * 只查前者会在「7.11.0 且规则店在」的 worker 上渲出一个恒 404 的治理入口。
14
+ */
15
+ export function persistedRulesGovernanceAvailable(baseUrl) {
16
+ return engineCapTrue(baseUrl, 'permissionRulesRevoke') && engineCapTrue(baseUrl, 'permissionRules');
17
+ }
18
+ function errShape(e) {
19
+ const o = (e ?? {});
20
+ return {
21
+ ...(typeof o.status === 'number' ? { status: o.status } : {}),
22
+ ...(typeof o.errorCode === 'string' ? { errorCode: o.errorCode } : {}),
23
+ message: typeof o.message === 'string' && o.message !== '' ? o.message : String(e),
24
+ };
25
+ }
26
+ /** `Retry-After` 秒数(SDK 把它折进 `retryAfterMs`)。非数/负数 ⇒ 缺席。 */
27
+ function retryAfterSecOf(e) {
28
+ const ms = e.retryAfterMs;
29
+ if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0)
30
+ return undefined;
31
+ return Math.ceil(ms / 1000);
32
+ }
33
+ export function classifyRulesFailure(e) {
34
+ const { status, errorCode, message } = errShape(e);
35
+ if (status === 501 || errorCode === 'capability.rule_store_required') {
36
+ return { kind: 'lane-unavailable', message };
37
+ }
38
+ if (status === 404) {
39
+ // 🔴 两个 404 是**不同处置**(SDK 头注):route 缺席 = 版本太老(藏面);rule_ticket = 票没了
40
+ // (丢票重来)。其余 404 一律按通用错误 —— 绝不按成因猜。
41
+ if (errorCode === 'not_found.route')
42
+ return { kind: 'route-missing', message };
43
+ if (errorCode === 'not_found.rule_ticket')
44
+ return { kind: 'ticket-dead', message };
45
+ return { kind: 'error', message };
46
+ }
47
+ if (errorCode === 'state.rule_import_retry') {
48
+ const sec = retryAfterSecOf(e);
49
+ return { kind: 'retry-same-ticket', message, ...(sec !== undefined ? { retryAfterSec: sec } : {}) };
50
+ }
51
+ if (errorCode === 'state.rule_remove_failed')
52
+ return { kind: 'retryable', message };
53
+ if (status === 400 && errorCode === 'request.query_invalid')
54
+ return { kind: 'cursor-stale', message };
55
+ if (status === 413)
56
+ return { kind: 'too-many-candidates', message };
57
+ if (status === 403 && errorCode === 'auth.operator_only')
58
+ return { kind: 'forbidden', message };
59
+ return { kind: 'error', message };
60
+ }
61
+ /** 一页要多少条。**必须显式给**(codex 对抗复审 [medium] 实撞):server 缺省是 **50**,而页帽
62
+ * 按「200/页」算 ⇒ 真实上界只有 1250 条,一位规则多于 1250 的 principal 会恒拿到「翻不完」的
63
+ * 失败、整个治理面打不开,而注释还写着 5000。夹取语义在 server(非数/越界夹进 1..200),所以给
64
+ * 上限最省往返、也让页帽的算术与现实一致。 */
65
+ const PAGE_LIMIT = 200;
66
+ /** 页数硬帽:server 恒给 nextCursor 的坏形不该让治理面无限翻(PAGE_LIMIT × 25 = 5000 条)。 */
67
+ const MAX_PAGES = 25;
68
+ /**
69
+ * 列全一位 principal 名下活着的规则。
70
+ *
71
+ * 🔴 **翻页要翻完**:`nextCursor` 缺席才是终点 —— 半途停下拿到的是一份不全的清单,而治理视图
72
+ * 恰恰最不能拿不全的清单当全量。所以这里 drain 到底,任何一页失败都不交部分结果。
73
+ * 🔴 **游标绑 `(rev, principal, scope)`**:两页之间有人加/删了规则 ⇒ 第二页 400
74
+ * `request.query_invalid`。处置 = **丢游标从头列一次**(静默重置成「接着上一页」会得到一份既漏行
75
+ * 又重行的清单);从头再撞一次 ⇒ 如实报 cursor-stale,由调用方(人按 r 刷新)决定。
76
+ * 🔴 **调用方 cursor 不收**(codex F2 对抗复审 [medium]):类型上剔掉 `cursor` 还不够 —— JS
77
+ * 调用方仍能塞进来,而首页的 `...params` 会把它原样送出 ⇒ 「列全」从**中途**开始却报 `ok:true`
78
+ * 完整清单(治理面据此藏掉仍然生效的规则)。运行期显式剥除 + 留痕:drain 恒从第一页起,
79
+ * 「接着别人的 keyset」证明不了完整性,与本函数的契约(rules 恒完整)结构性冲突。
80
+ */
81
+ export async function listAllPersistedRules(facade, params = {}, opts) {
82
+ const { cursor: callerCursor, ...cleanParams } = params;
83
+ if (callerCursor !== undefined) {
84
+ hostLog('debug', 'persistedRulesWire: caller-supplied cursor ignored by listAllPersistedRules — a drain that starts mid-keyset cannot prove completeness, so it always starts from the top');
85
+ }
86
+ for (let attempt = 0; attempt < 2; attempt++) {
87
+ const rules = [];
88
+ let cursor;
89
+ let rev = null;
90
+ let restart = false;
91
+ for (let page = 0; page < MAX_PAGES; page++) {
92
+ let res;
93
+ try {
94
+ // limit 显式给:①页帽的算术要与真实页大小一致(见 PAGE_LIMIT);②keyset 游标绑的是
95
+ // 一份确定的翻页参数,页大小在两页之间变会让「接着上一页」失去意义。调用方可覆盖。
96
+ res = await facade.list({ limit: PAGE_LIMIT, ...cleanParams, ...(cursor !== undefined ? { cursor } : {}) }, opts);
97
+ }
98
+ catch (e) {
99
+ const failure = classifyRulesFailure(e);
100
+ if (failure.kind === 'cursor-stale' && cursor !== undefined && attempt === 0) {
101
+ hostLog('debug', 'persistedRulesWire: rule list cursor was minted on another rev/principal/scope — dropping it and re-listing from the top (never resuming a stale keyset)');
102
+ restart = true;
103
+ break;
104
+ }
105
+ return { ok: false, failure };
106
+ }
107
+ // 🔴 页体 fail-closed 窄化(codex F2 轮二 [high]):SDK 传输层只 JSON.parse,不做运行期
108
+ // schema 校验 —— 一个 2xx 的 `{rev:9}`(无 rules 数组)在旧读法下会被认证成「完整的空清单」
109
+ // (`ok:true, rules:[]`),治理面据此宣称「没有持久规则」而活规则不可见、无法撤销
110
+ // (版本偏斜/后端降级下静默发生)。坏形页 = 判不出,绝不当「读到了空的」:
111
+ // · `rules` 必须是数组、`rev` 必须是有限数(SDK `RuleListResult` 两键皆必填);
112
+ // · `nextCursor` 只有两种合法形:**缺席**(= 终页)或**非空字符串**(= 还有下一页);
113
+ // `''`/null/数字等坏形不许被折成「到头了」—— 猜终点与猜续点同罪。
114
+ if (typeof res !== 'object' ||
115
+ res === null ||
116
+ !Array.isArray(res.rules) ||
117
+ typeof res.rev !== 'number' ||
118
+ !Number.isFinite(res.rev)) {
119
+ return {
120
+ ok: false,
121
+ failure: {
122
+ kind: 'error',
123
+ message: 'rule list page was malformed (missing/ill-typed rules array or rev on a 2xx) — refusing to certify it as a complete governance list',
124
+ },
125
+ };
126
+ }
127
+ // 🔴 跨页 rev 钉(codex F2 轮三 [high]):游标契约上绑 rev —— 清单变了,续页**该** 400
128
+ // cursor-stale。一个 2xx 却换了 rev 的续页 = server 违约或降级形,拼起来是**混合快照**;
129
+ // 认证它为完整清单,比 400 那条腿(丢游标重列)更坏 —— 这里不猜不修补,如实 failure。
130
+ if (rev !== null && res.rev !== rev) {
131
+ return {
132
+ ok: false,
133
+ failure: {
134
+ kind: 'error',
135
+ message: `rule list rev changed mid-drain (page rev ${res.rev} ≠ first page rev ${rev}) on a 2xx — refusing to stitch a mixed snapshot into one governance list`,
136
+ },
137
+ };
138
+ }
139
+ // 🔴 行级窄化(同轮):撤销承重的两键(`rule` / `scope`,revoke 的按内容身份对)必须是
140
+ // 非空串 —— `rules:[null]` / 缺 scope 的行被展进 `PersistedRule[]`,下游要么渲空白治理项、
141
+ // 要么按 undefined 撤销(什么都对不上)。坏行 = 坏页(丢行会把活规则藏起来,正是本函数
142
+ // 拒绝的病);其余展示键(tool/match/command/adds)不在此过度收紧 —— server additive 演进
143
+ // 不该把整面打红,消费端对展示键自有坏形容忍。
144
+ for (const r of res.rules) {
145
+ const rr = r;
146
+ if (rr === null ||
147
+ typeof rr !== 'object' ||
148
+ typeof rr.rule !== 'string' ||
149
+ rr.rule === '' ||
150
+ typeof rr.scope !== 'string' ||
151
+ rr.scope === '') {
152
+ return {
153
+ ok: false,
154
+ failure: {
155
+ kind: 'error',
156
+ message: 'rule list page carried a malformed rule row (missing/ill-typed rule or scope) — refusing to present a list that hides or garbles live rules',
157
+ },
158
+ };
159
+ }
160
+ }
161
+ rev = res.rev;
162
+ rules.push(...res.rules);
163
+ const next = res.nextCursor;
164
+ if (next === undefined)
165
+ return { ok: true, rules, rev };
166
+ if (typeof next !== 'string' || next === '') {
167
+ return {
168
+ ok: false,
169
+ failure: {
170
+ kind: 'error',
171
+ message: 'rule list page carried a malformed nextCursor (neither absent nor a non-empty string) — refusing to guess where the list ends',
172
+ },
173
+ };
174
+ }
175
+ cursor = next;
176
+ }
177
+ if (!restart) {
178
+ return {
179
+ ok: false,
180
+ failure: {
181
+ kind: 'error',
182
+ message: `rule list did not terminate within ${MAX_PAGES} pages — refusing to present a partial governance list`,
183
+ },
184
+ };
185
+ }
186
+ }
187
+ return { ok: false, failure: { kind: 'cursor-stale', message: 'rule list kept changing under the cursor — try again' } };
188
+ }
189
+ // ── skipped.reason 分类 ─────────────────────────────────────────────────────────────────────
190
+ /**
191
+ * `skipped[].reason` 的分类。**只按第一个 `:` 前缀**,且必须容得下**没有前缀**的两种真值形
192
+ * (整层 JSON 解不开 / 条目不是串)——SDK 头注逐字:reason 是给人看的散文,不是机读码,
193
+ * 全串等值匹配与「裸码」两种读法都会漂。
194
+ * 前缀形判据刻意保守(`RuleRejectCode` 形:小写 + 点/下划线),防把散文里的第一个冒号误读成码。
195
+ */
196
+ export function classifySkippedReason(reason) {
197
+ const text = typeof reason === 'string' ? reason : String(reason);
198
+ const idx = text.indexOf(':');
199
+ if (idx <= 0)
200
+ return { text };
201
+ const head = text.slice(0, idx);
202
+ if (!/^[a-z][a-z0-9_]*(\.[a-z0-9_]+)*$/.test(head))
203
+ return { text };
204
+ return { code: head, text };
205
+ }
206
+ /**
207
+ * respond 回执上「不再询问到底存上了没」的**结构化读口**。
208
+ *
209
+ * 🔴 **单一台账**(A-028.14 收口本体):`rulePersisted` / `ruleRefusal` 的结构窄化只有
210
+ * {@link readToolApprovalRespondAck} 这一份 —— 本函数不对 wire 原料二次开读,ack 过不了包内
211
+ * 结构门(缺 `delivery:'applied'` / `approvalId` / 三词闭集 `decision`)⇒ **`unknown`**:
212
+ * 一张连回执身份都不成形的 ack,不配驱动一行「已保存」的用户告知(诚实缺席优先)。
213
+ * 收口前 cli 的裸读会把 `{rulePersisted:true}` 这类半形对象读成 `persisted` —— 那正是双份台账
214
+ * 各漂各的形,常驻套对这一格有反向钉。
215
+ *
216
+ * 🔴 `rulePersisted` 缺席 ≠ `false`(未带 persistRule 的回决 / 旧 server ⇒ 字段省略);
217
+ * 🔴 规则没存上**从不翻转裁决** —— 200 + `rulePersisted:false` + `ruleRefusal` 是诚实形,
218
+ * 宿主只说「这次放行了,但『不再询问』没存上」,绝不渲成整次审批失败。
219
+ * 🔴 `rule_lane_unavailable` **四种成因共用一格、两种是本卡局限** ⇒ 禁据一帧判断整台部署的车道
220
+ * 在不在(那要看能力位)。
221
+ */
222
+ export function readRulePersistOutcome(ack) {
223
+ const parsed = readToolApprovalRespondAck(ack);
224
+ if (parsed === undefined)
225
+ return { state: 'unknown' };
226
+ if (parsed.rulePersisted === true)
227
+ return { state: 'persisted' };
228
+ if (parsed.rulePersisted === false) {
229
+ // 空串/非串已在 readToolApprovalRespondAck 降缺席(单一台账),这里只剩「在场即带」。
230
+ return {
231
+ state: 'refused',
232
+ ...(typeof parsed.ruleRefusal === 'string' ? { reason: parsed.ruleRefusal } : {}),
233
+ };
234
+ }
235
+ return { state: 'unknown' };
236
+ }
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';
@@ -228,6 +231,8 @@ export * from './hitl/parkRowBirthWait.js';
228
231
  export * from './hitl/approvalDecisionNoteAudit.js';
229
232
  export * from './hitl/askParkRowRouting.js';
230
233
  export * from './hitl/resumeRunningCard.js';
234
+ export * from './hitl/persistedRulesWire.js';
235
+ export * from './hitl/localAllowRule.js';
231
236
  export * from './hitl/approvalsFeed.js';
232
237
  export * from './compensations.js';
233
238
  export * from './request/printNotification.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';
@@ -340,6 +350,19 @@ export * from './hitl/parkRowBirthWait.js';
340
350
  export * from './hitl/approvalDecisionNoteAudit.js';
341
351
  export * from './hitl/askParkRowRouting.js';
342
352
  export * from './hitl/resumeRunningCard.js';
353
+ // ── A-028.14 / #244 F2(2026-08-14):HITL 规则侧上收 —— 持久规则车道通用判定半场 + durable 腿
354
+ // 本地落规则判定骨架(源形 = cli persistedRulesWire / localAllowRuleWrite 的纯判定面)。
355
+ // · persistedRulesWire:能力位双段 gate(permissionRules / permissionRulesRevoke 合取)、
356
+ // RulesFailure 处置分类(404 两支绝不共用一格)、keyset 翻页收口(游标失效重列 + 页数硬帽)、
357
+ // skipped.reason 前缀分类、persist-ack 三态读口 —— 读口与 `readToolApprovalRespondAck`
358
+ // **合成一处**(census top-08 #1 的双份台账收口本体);CC settings 三层 fs 读、SDK facade
359
+ // 装配、通知呈现留宿主。
360
+ // · localAllowRule:parseLocalAllowRule 窄化五步(①整工具拒/②工具名对齐/③字面锚/⑤裸解释器
361
+ // 前缀/⑤b canonical 危险谓词)骨架;危险谓词与规则语法经 `LocalAllowRuleDeps` 注入
362
+ // (parkOwnership deps 同形)—— 谓词表本体(BARE_SHELL_PREFIXES / dangerousPatterns)留宿主,
363
+ // 包内绝不自建第二份名单。🔴 安全面等值契约:拒绝集文案是三端可观察行为,cli 常驻套逐字锚。
364
+ export * from './hitl/persistedRulesWire.js';
365
+ export * from './hitl/localAllowRule.js';
343
366
  // B7 ③(census G20,**行为改动**不是搬迁):pending-approvals 推送 feed(stream 优先 / 断流回落
344
367
  // 轮询 / 定期再试)。🔴 它**不替换** D-1 的取件 —— 那三处必须继续走权威 `list()`(见文件头)。
345
368
  export * from './hitl/approvalsFeed.js';
@@ -0,0 +1,18 @@
1
+ /**
2
+ * principalWire.ts — principal 在场性判定的**唯一原语**(A-028.10,#244 族E,2026-08-15)。
3
+ *
4
+ * 裁定背景(cli 主会话裁,census top-07 §2):壳 `livePrincipal.ts` 与本包
5
+ * `engineWireTarget.engineWireTargetFor()` 各持一条 principal 解析,且**语义相反**:壳判
6
+ * `v && v.trim() !== ''`(全空白=缺席),包侧裸 truthy(全空白=在场)⇒ `SEMA_LIVE_PRINCIPAL=' '`
7
+ * 会发出一个全空白的 `x-agent-principal` 头 —— 垃圾值伪装在场,破坏 F-011 停发纪律
8
+ * ([3279]:缺席=不发头,owner-null;绝不铸哨兵值)。裁定=壳 trim 语义为正,包侧两臂
9
+ * (env 派生臂 + 显式装配臂)都收编本原语。
10
+ *
11
+ * 🔴 语义逐字(壳 `resolveLivePrincipal` 的判定半场):
12
+ * · undefined / '' / 全空白 ⇒ undefined(键缺席,不发头);
13
+ * · 其余 ⇒ **原值原样返回**(不 trim 改写 —— ` alice ` 照发 ` alice `,在场性判定与值改写
14
+ * 是两件事,本原语只做前者)。
15
+ * env 的读取方式留在端上(壳读 `process.env`,包内经 `hostEnv()`)——本原语零 IO 零 env。
16
+ */
17
+ /** principal 在场性判定:全空白=缺席(undefined);实值原样返回(绝不改写)。 */
18
+ export declare function normalizeWirePrincipal(v: string | undefined): string | undefined;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * principalWire.ts — principal 在场性判定的**唯一原语**(A-028.10,#244 族E,2026-08-15)。
3
+ *
4
+ * 裁定背景(cli 主会话裁,census top-07 §2):壳 `livePrincipal.ts` 与本包
5
+ * `engineWireTarget.engineWireTargetFor()` 各持一条 principal 解析,且**语义相反**:壳判
6
+ * `v && v.trim() !== ''`(全空白=缺席),包侧裸 truthy(全空白=在场)⇒ `SEMA_LIVE_PRINCIPAL=' '`
7
+ * 会发出一个全空白的 `x-agent-principal` 头 —— 垃圾值伪装在场,破坏 F-011 停发纪律
8
+ * ([3279]:缺席=不发头,owner-null;绝不铸哨兵值)。裁定=壳 trim 语义为正,包侧两臂
9
+ * (env 派生臂 + 显式装配臂)都收编本原语。
10
+ *
11
+ * 🔴 语义逐字(壳 `resolveLivePrincipal` 的判定半场):
12
+ * · undefined / '' / 全空白 ⇒ undefined(键缺席,不发头);
13
+ * · 其余 ⇒ **原值原样返回**(不 trim 改写 —— ` alice ` 照发 ` alice `,在场性判定与值改写
14
+ * 是两件事,本原语只做前者)。
15
+ * env 的读取方式留在端上(壳读 `process.env`,包内经 `hostEnv()`)——本原语零 IO 零 env。
16
+ */
17
+ /** principal 在场性判定:全空白=缺席(undefined);实值原样返回(绝不改写)。 */
18
+ export function normalizeWirePrincipal(v) {
19
+ return v !== undefined && v.trim() !== '' ? v : undefined;
20
+ }
@@ -52,7 +52,7 @@ export interface RequestFieldSpec {
52
52
  */
53
53
  export declare const REQUEST_FIELD_MATRIX: readonly RequestFieldSpec[];
54
54
  /** live 兜底层(`toLiveRequest`)追加的字段 —— 两条车道**都**经过,故不进上表。 */
55
- export declare const LIVE_DEFAULT_FIELDS: readonly ["cwd", "additionalDirectories", "forwardSubagentEvents", "retainSubagentSessions", "agents", "appendSystemPrompt|settings.outputStyle", "suggestNextPrompts", "compactionModel", "sessionId(三态解析)"];
55
+ export declare const LIVE_DEFAULT_FIELDS: readonly ["cwd", "additionalDirectories", "additionalReadDirectories", "forwardSubagentEvents", "retainSubagentSessions", "agents", "appendSystemPrompt|settings.outputStyle", "suggestNextPrompts", "compactionModel", "sessionId(三态解析)"];
56
56
  /** 端解析好的输入 —— 每一项都是**值**,不是取值方式(取值方式属端)。 */
57
57
  export interface TaskRequestInput {
58
58
  /** 本 turn 的模型面输入(已含 slash-skill 正文 / 注入式 meta / 历史种子等端侧组装)。 */
@@ -121,6 +121,10 @@ export declare function buildTaskRequest(input: TaskRequestInput, lane: RequestL
121
121
  export interface LiveDefaultsInput {
122
122
  cwd?: string;
123
123
  additionalDirectories?: readonly string[];
124
+ /** 只读面宽根(A-028.9 补位,壳 #257 真行为上收):配置目录 + tmp 族等**主机自身姿态**的
125
+ * read-boundary 根。值(广度闸/realpath 判决)归端算,包只做「缺席时补位」。
126
+ * 🔴 只宽读不宽写:与 `additionalDirectories`(/add-dir 用户显式写授权)是分开的两个口。 */
127
+ additionalReadDirectories?: readonly string[];
124
128
  agents?: unknown;
125
129
  /** 引擎 caps 判真 ⇒ 走 `appendSystemPrompt` 一等位;否则借道 `settings.outputStyle`。 */
126
130
  appendSystemPromptCapable: boolean;
@@ -60,6 +60,7 @@ export const REQUEST_FIELD_MATRIX = [
60
60
  export const LIVE_DEFAULT_FIELDS = [
61
61
  'cwd',
62
62
  'additionalDirectories',
63
+ 'additionalReadDirectories',
63
64
  'forwardSubagentEvents',
64
65
  'retainSubagentSessions',
65
66
  'agents',
@@ -166,6 +167,11 @@ export function applyLiveRequestDefaults(req, host) {
166
167
  if (out.additionalDirectories === undefined && (host.additionalDirectories?.length ?? 0) > 0) {
167
168
  out.additionalDirectories = [...(host.additionalDirectories ?? [])];
168
169
  }
170
+ // A-028.9:只读面宽根(壳 cli 逐字:`if (roots.length > 0) out.additionalReadDirectories = roots`,
171
+ // 「已带值不覆盖」的守卫同其余各条)。
172
+ if (out.additionalReadDirectories === undefined && (host.additionalReadDirectories?.length ?? 0) > 0) {
173
+ out.additionalReadDirectories = [...(host.additionalReadDirectories ?? [])];
174
+ }
169
175
  // C1 / design/144:交互与 headless 都常开 —— 不开则引擎不建 SubagentRetainLedger,
170
176
  // 后台子代 SendMessage 唤醒直接 "session was not retained" 拒绝。老引擎按 body→spec 白名单
171
177
  // 忽略未知字段,version-safe。
@@ -0,0 +1,95 @@
1
+ /**
2
+ * sessionMap.ts — 「客户端会话 id ↔ 引擎会话 id」映射的**单一键形与 merge 判定**
3
+ * (A-028.12,#244 族E,2026-08-15)。
4
+ *
5
+ * ## 收编前的形(census top-10 §2)
6
+ * 同一概念两端各持一份、**键名零重合**:
7
+ * · 壳 `sessionIdMapping.ts`:`{shellSessionId, engineSessionId, lastEngineTaskId, engines{}}`,
8
+ * `.session-map.json` 侧车(lock + temp→fsync→rename 原子写);
9
+ * · web `engine-history-wire.ts`:`{uiId:{s:engineId,t:activeTaskId}}`,localStorage。
10
+ * 与 B18 seatContract 修的「同一契约两份声明、编译器永不告警」同形。本件定**单一键形**
11
+ * (壳形为正:显式键名 + per-engine 命名空间,web 缩写形迁移归 web 半场)+ 两个纯 merge 判定;
12
+ * 存储经 {@link SessionMapStorePort} 归端(cli = lock+原子写两进程纪律,web = localStorage)。
13
+ *
14
+ * 🔴 merge 语义 = 壳 `persist`/`persistEngineEntry` 传给 lockedReadMergeWrite 的那两个闭包**逐字**:
15
+ * `partial ?? prior` 逐字段让位、`engines[key]` 命名空间不互相覆盖、身份键缺席=拒写(skip 判定
16
+ * 带 reason 出境,落日志的措辞归端)。
17
+ */
18
+ /**
19
+ * ONE engine's continuity record, keyed inside {@link SessionMapRecord.engines} by the engine
20
+ * NAMESPACE key ({@link engineNamespaceKeyFor}): task ids / sync watermarks minted by DIFFERENT
21
+ * engines must never overwrite each other (a cloud taskId fed to the local engine on resume =
22
+ * the id-collision this namespace exists to prevent). `instanceId` is the engine's /health
23
+ * identity stamp — a redeployed engine at the same origin stays distinguishable.
24
+ */
25
+ export interface EngineSessionEntry {
26
+ /** Engine session id ON THAT ENGINE(single-namespace 裁定下与 shell id 同值,仍显式持久)。 */
27
+ engineSessionId: string;
28
+ /** Engine /health `instanceId` at record time (precise engine identity, not just the origin). */
29
+ instanceId?: string;
30
+ /** Last `done.result.taskId` observed FROM THIS ENGINE. */
31
+ lastEngineTaskId?: string;
32
+ /** The session leafId at the last successful sync push/pull against this engine (sync watermark). */
33
+ lastSyncedLeafId?: string;
34
+ updatedAt: string;
35
+ }
36
+ export interface SessionMapRecord {
37
+ /** Shell/client (transcript) sessionId — the primary key / namespace anchor. */
38
+ shellSessionId: string;
39
+ /** Engine session id(LEGACY/current-engine view:写入时活跃引擎的值;跨引擎消费读 engines)。 */
40
+ engineSessionId?: string;
41
+ /** Last `done.result.taskId` observed for this session(rewind/resume continuity;同上注意)。 */
42
+ lastEngineTaskId?: string;
43
+ /** Per-engine continuity, keyed by {@link engineNamespaceKeyFor}(baseUrl)('local' | origin). */
44
+ engines?: Record<string, EngineSessionEntry>;
45
+ /** auto-sync per-session 覆盖:true/false 覆盖项目开关,undefined = 跟随。 */
46
+ autoSync?: boolean;
47
+ /** 冲突 pause:fork/stale 后停 auto 转手动;手动 push 成功 / sync on 清除。 */
48
+ autoSyncPaused?: {
49
+ reason: string;
50
+ at: string;
51
+ };
52
+ updatedAt: string;
53
+ }
54
+ /**
55
+ * The engine NAMESPACE key for a CONNECTED engine target's base url: the lowercased origin
56
+ * (scheme+host+port — TOFU 同粒度). ⚠️ ROLE decides the namespace, not the url shape: the
57
+ * SELF-SPAWNED engine is always keyed `'local'` (callers pass no url — its port can drift across
58
+ * boots, one logical engine), while a `connect`ed target is keyed by origin EVEN when loopback
59
+ * (a second local engine on another port is a DIFFERENT engine with a different data root).
60
+ * Pure; never throws. A malformed non-empty url falls back to the raw lowercased string
61
+ * (honest separation beats aliasing it onto another namespace).
62
+ */
63
+ export declare function engineNamespaceKeyFor(baseUrl: string | undefined): string;
64
+ /** merge 判定的出境形:拒写带 reason(措辞/日志归端),成写带整份下一记录。 */
65
+ export type SessionMapMergeVerdict = {
66
+ ok: true;
67
+ record: SessionMapRecord;
68
+ } | {
69
+ ok: false;
70
+ reason: 'no-shell-session-id' | 'no-engine-session-id';
71
+ };
72
+ /**
73
+ * 顶层记录 merge(壳 `persist` 闭包逐字):`shellSessionId` 取 `partial ?? prior`,两处都缺 =
74
+ * 拒写;成写 = `{...prior, ...partial, shellSessionId, updatedAt: now()}`。
75
+ */
76
+ export declare function mergeSessionMapRecord(prior: SessionMapRecord | undefined, partial: Partial<SessionMapRecord> & {
77
+ shellSessionId?: string;
78
+ }, now?: () => string): SessionMapMergeVerdict;
79
+ /**
80
+ * per-engine entry merge(壳 `persistEngineEntry` 闭包逐字):`engines[key]` 内
81
+ * `partial ?? priorEntry` 让位;`prior.engines` 作 spread 基底 —— 未被本次改动的其它 engine key
82
+ * 原样保留(两个不同 key 的并发写互不覆盖的**判定半场**;「基底必须是锁内新鲜读」的进程纪律
83
+ * 归端的存储实现)。拒写序:先 engineSessionId 后 shellSessionId(与壳日志序一致)。
84
+ */
85
+ export declare function mergeEngineEntry(prior: SessionMapRecord | undefined, key: string, partial: Partial<EngineSessionEntry>, shellSessionId?: string, now?: () => string): SessionMapMergeVerdict;
86
+ /**
87
+ * 存储端口(端注入):cli = per-session 文件 + proper-lockfile + temp→fsync→rename(REPL 单例与
88
+ * one-shot CLI 两个 OS 进程共写,`readMergeWrite` 的 prior 必须是**临界区内**的新鲜读);
89
+ * web = localStorage(单线程,直读直写即满足契约)。`build` 返回 undefined = 本次没有可写的
90
+ * 记录(身份键缺席)—— 实现方跳过写并返回 undefined。
91
+ */
92
+ export interface SessionMapStorePort {
93
+ read(): SessionMapRecord | undefined;
94
+ readMergeWrite(build: (prior: SessionMapRecord | undefined) => SessionMapRecord | undefined): SessionMapRecord | undefined;
95
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * sessionMap.ts — 「客户端会话 id ↔ 引擎会话 id」映射的**单一键形与 merge 判定**
3
+ * (A-028.12,#244 族E,2026-08-15)。
4
+ *
5
+ * ## 收编前的形(census top-10 §2)
6
+ * 同一概念两端各持一份、**键名零重合**:
7
+ * · 壳 `sessionIdMapping.ts`:`{shellSessionId, engineSessionId, lastEngineTaskId, engines{}}`,
8
+ * `.session-map.json` 侧车(lock + temp→fsync→rename 原子写);
9
+ * · web `engine-history-wire.ts`:`{uiId:{s:engineId,t:activeTaskId}}`,localStorage。
10
+ * 与 B18 seatContract 修的「同一契约两份声明、编译器永不告警」同形。本件定**单一键形**
11
+ * (壳形为正:显式键名 + per-engine 命名空间,web 缩写形迁移归 web 半场)+ 两个纯 merge 判定;
12
+ * 存储经 {@link SessionMapStorePort} 归端(cli = lock+原子写两进程纪律,web = localStorage)。
13
+ *
14
+ * 🔴 merge 语义 = 壳 `persist`/`persistEngineEntry` 传给 lockedReadMergeWrite 的那两个闭包**逐字**:
15
+ * `partial ?? prior` 逐字段让位、`engines[key]` 命名空间不互相覆盖、身份键缺席=拒写(skip 判定
16
+ * 带 reason 出境,落日志的措辞归端)。
17
+ */
18
+ /**
19
+ * The engine NAMESPACE key for a CONNECTED engine target's base url: the lowercased origin
20
+ * (scheme+host+port — TOFU 同粒度). ⚠️ ROLE decides the namespace, not the url shape: the
21
+ * SELF-SPAWNED engine is always keyed `'local'` (callers pass no url — its port can drift across
22
+ * boots, one logical engine), while a `connect`ed target is keyed by origin EVEN when loopback
23
+ * (a second local engine on another port is a DIFFERENT engine with a different data root).
24
+ * Pure; never throws. A malformed non-empty url falls back to the raw lowercased string
25
+ * (honest separation beats aliasing it onto another namespace).
26
+ */
27
+ export function engineNamespaceKeyFor(baseUrl) {
28
+ if (!baseUrl)
29
+ return 'local';
30
+ try {
31
+ return new URL(baseUrl).origin.toLowerCase();
32
+ }
33
+ catch {
34
+ return baseUrl.toLowerCase();
35
+ }
36
+ }
37
+ /** 缺省时钟(可注入 —— 测试与「同一批双写同刻」的端语义都经它)。 */
38
+ const isoNow = () => new Date().toISOString();
39
+ /**
40
+ * 顶层记录 merge(壳 `persist` 闭包逐字):`shellSessionId` 取 `partial ?? prior`,两处都缺 =
41
+ * 拒写;成写 = `{...prior, ...partial, shellSessionId, updatedAt: now()}`。
42
+ */
43
+ export function mergeSessionMapRecord(prior, partial, now = isoNow) {
44
+ const shellSessionId = partial.shellSessionId ?? prior?.shellSessionId;
45
+ if (!shellSessionId)
46
+ return { ok: false, reason: 'no-shell-session-id' };
47
+ return { ok: true, record: { ...prior, ...partial, shellSessionId, updatedAt: now() } };
48
+ }
49
+ /**
50
+ * per-engine entry merge(壳 `persistEngineEntry` 闭包逐字):`engines[key]` 内
51
+ * `partial ?? priorEntry` 让位;`prior.engines` 作 spread 基底 —— 未被本次改动的其它 engine key
52
+ * 原样保留(两个不同 key 的并发写互不覆盖的**判定半场**;「基底必须是锁内新鲜读」的进程纪律
53
+ * 归端的存储实现)。拒写序:先 engineSessionId 后 shellSessionId(与壳日志序一致)。
54
+ */
55
+ export function mergeEngineEntry(prior, key, partial, shellSessionId, now = isoNow) {
56
+ const priorEntry = prior?.engines?.[key];
57
+ const engineSessionId = partial.engineSessionId ?? priorEntry?.engineSessionId;
58
+ if (!engineSessionId)
59
+ return { ok: false, reason: 'no-engine-session-id' };
60
+ const sid = shellSessionId ?? prior?.shellSessionId;
61
+ if (!sid)
62
+ return { ok: false, reason: 'no-shell-session-id' };
63
+ const entry = { ...priorEntry, ...partial, engineSessionId, updatedAt: now() };
64
+ return {
65
+ ok: true,
66
+ record: {
67
+ ...prior,
68
+ shellSessionId: sid,
69
+ engines: { ...prior?.engines, [key]: entry },
70
+ updatedAt: now(),
71
+ },
72
+ };
73
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * 网络/传输层失败的词面基表(壳 seamQuery「件2c transport 收窄」的那条正则逐字)。
3
+ * 判据变更义务:加词=各端跟批;删词/改形=先与消费端对表(web 叠加宿主词的半场见其
4
+ * turn-error-classify 头注)。
5
+ */
6
+ export declare const WIRE_NETWORK_ERROR_PATTERN: RegExp;
7
+ /** turn 错误的结构化分型判决(渲染/文案归端;门与遥测按 kind 对账)。 */
8
+ export type TurnWireErrorVerdict =
9
+ /** 引擎应答了非 2xx(reachable)。`errorCode` 只认活键([2055]);缺席=不带。 */
10
+ {
11
+ kind: 'http';
12
+ status: number;
13
+ errorCode?: string;
14
+ message: string;
15
+ }
16
+ /** SDK typed park 信号:流结束但 run 未达终态(多半 suspended 候人决断)—— 不是故障。 */
17
+ | {
18
+ kind: 'stream-ended-without-terminal';
19
+ }
20
+ /** 真网络/传输层失败(引擎压根没应答)。detail = cause 原话 > message > String(err)。 */
21
+ | {
22
+ kind: 'transport';
23
+ detail: string;
24
+ }
25
+ /** 分类不明(多半是客户端自己的 bug)—— 如实报,绝不指去查引擎/网络。 */
26
+ | {
27
+ kind: 'internal';
28
+ name: string;
29
+ detail: string;
30
+ };
31
+ /**
32
+ * turn 错误分型(模块头②节的四臂;臂序=壳 `semaApiErrorContent` 逐字:http → park 信号 →
33
+ * transport/internal)。纯判定,永不抛。
34
+ */
35
+ export declare function classifyTurnWireError(err: unknown): TurnWireErrorVerdict;
36
+ /**
37
+ * 真·网络/传输层失败判(壳 `engineTarget.isEngineTransportError` 逐字语义):typed HTTP error
38
+ * (numeric `status` 在场 = 引擎应答了)恒 false —— 死端点分诊绝不误挂在活引擎的业务错误上。
39
+ * 消费面:headless `-p` 死 pin 提示 / 审批提交腿的重试判型(approvalSubmitRetry)。
40
+ */
41
+ export declare function isWireTransportError(err: unknown): boolean;
42
+ /**
43
+ * 判型:server drain 门的 pre-stream 拒收(503 + `errorCode:"draining"`)。结构判读不
44
+ * instanceof(mock/包装错误同判);机器码单腿(sdk 4.0.0 起 message 腿退役)。
45
+ * 消费面的重试铁律(严格限定 pre-stream、有界)归壳 seamQuery 的重试环。
46
+ */
47
+ export declare function isPreStreamDrainingReject(err: unknown): boolean;
48
+ /**
49
+ * #166 — `resume_at.*` 错误码族:提交带的 resumeAt 锚服务端不认(Esc 杀锚未持久化的典型形)。
50
+ * 🔴 刻意**不**用 `isRewindFamilyCode`(那是含 `rewind_snapshot.` 的宽形):去锚自动重发的安全性
51
+ * 论证只对 resume_at 族做过,放宽属行为变更(RESUME_AT_ERROR_CODE_PREFIX 头注)。
52
+ */
53
+ export declare function isResumeAtRejection(err: unknown): boolean;
54
+ /**
55
+ * 第 `attempt`(0-based)次 draining 重试前的等待:`err.retryAfterMs`(SDK 若带)封顶优先
56
+ * (上限 15s = server drain 门现值,防把一个 turn park 到分钟级),缺席走退避梯
57
+ * (默认 1s/2s/4s = 壳 S4-P0 实测值;超出梯长取末档)。`opts.ladder` = 端的测试钩子注入口
58
+ * (壳 `setDrainingBackoffForTest` 经此透传)。纯函数,零模块状态(singleton 门口径)。
59
+ */
60
+ export declare function drainingRetryDelayMs(err: unknown, attempt: number, opts?: {
61
+ ladder?: readonly number[];
62
+ retryAfterCapMs?: number;
63
+ }): number;
64
+ /** 结构化拒绝字段(与 SDK `ScenarioNotAllowedError` 同形;判定层不依赖 SDK 类型面)。 */
65
+ export interface ScenarioDenyDetail {
66
+ /** The principal's allowed scenario names (defensively filtered; may be empty). */
67
+ allowlist: string[];
68
+ }
69
+ /**
70
+ * 从一个被 catch 的错误判定「场景不在指派列表」并提取结构化 detail。
71
+ * duck-typed(不 instanceof — bundle 下跨包类标识可能双实例):`status===400 &&
72
+ * errorCode===SCENARIO_NOT_ALLOWED_ERROR_CODE` 即命中;allowlist 缺席/非数组/畸形项 → 逐项
73
+ * 防御过滤成空数组(兜底文案契约归端)。非该 code 的 400 / 其他错误 → null(绝不误吃普通 400)。
74
+ * 🔴 键位([2055] 死键纪律):只认 `errorCode`,退役 `code` 键不做兼容(clean-cut)。
75
+ */
76
+ export declare function scenarioDenyFromError(err: unknown): ScenarioDenyDetail | null;