@sema-agent/client-core 0.5.0 → 0.6.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.
@@ -0,0 +1,135 @@
1
+ /**
2
+ * request/taskRequest.ts — **请求面合一**(B4,多端改造设计稿 §3 B4 第二件 + §8-5 裁决)。
3
+ *
4
+ * ## 它解决的是什么
5
+ *
6
+ * 搬迁前壳里有 **三个**独立的请求构造器,谁也不知道对方长什么样:
7
+ * · `S/seamQuery.toTaskRequest`(:535-869)—— 交互 REPL 车道;
8
+ * · `S/seamQueryEngine`(:546-578)—— headless `-p` 车道,**自己另拼一份**;
9
+ * · `S/liveClient.toLiveRequest`(:322-448)—— 两条车道共用的 live 兜底层。
10
+ *
11
+ * census §3.4 实测的漂移(B4 复核确认,逐字段见 `REQUEST_FIELD_MATRIX`):
12
+ * · **print 独有** `finalVerification` / `limits` / `interactiveTools`;
13
+ * · **TUI 独有** `ultracode` / `systemPrompt` / `reasoningEffort` / `images` / `model` /
14
+ * rewind 三件 / `clientContext` / `scratchpadDir` / 已解析 settings。
15
+ * 这不是「两条车道本来就该不同」——里面**一部分是有理由的,一部分纯粹是漏了**。合一的目的不是
16
+ * 把它们抹平(那是行为改动),而是把「谁有谁没有、为什么」变成**一张表**:再漏就会在 diff 里显形。
17
+ *
18
+ * ## 分工铁律
19
+ *
20
+ * 🔴 本模块**零 env、零全局状态、零 IO**——所有值由端解析好之后放进 `TaskRequestInput`。
21
+ * 壳读 `process.env` / `getAppState()` / `getSessionId()` / `process.argv`,web 读 BFF 下发的配置,
22
+ * 桌面读主进程。包只负责三件**端无关**的事:
23
+ * ① **字段集**:哪条车道 stamp 哪些字段(`REQUEST_FIELD_MATRIX`,带逐条理由);
24
+ * ② **合并语义**:`settings` 四源合并、objective 的一次性装饰序、sessionId 三态;
25
+ * ③ **live 门**:一个 `input.live` 布尔,替掉壳里散落的 20 处 `process.env.SEMA_LIVE_BASEURL` 判读。
26
+ */
27
+ import type { TaskRequest } from '@sema-agent/sdk';
28
+ /** 请求车道(§8-5:print lane 收编进来,但 stderr 分诊文案与 PrintStreamProjector 留壳)。 */
29
+ export type RequestLane = 'interactive' | 'print';
30
+ /** `TaskRequest` 的可写投影(包内按结构操作;SDK 型只做出口约束)。 */
31
+ export type TaskRequestLike = Record<string, unknown>;
32
+ /**
33
+ * 字段矩阵的一条 —— **这就是「不许静默统一」的载体**。
34
+ *
35
+ * `lanes` 是**事实**(今天哪条车道 stamp 它),`why` 是**理由**(为什么另一条没有)。
36
+ * 两者分开写:`lanes` 变了会被下面的自洽断言逮住,`why` 让下一棒知道该不该补齐。
37
+ * `gap:true` = 这条差异**没有正当理由,是漏的**(候补齐;补齐是行为改动,要单独一条测试)。
38
+ */
39
+ export interface RequestFieldSpec {
40
+ field: string;
41
+ lanes: readonly RequestLane[];
42
+ /** 仅在 live 车道 stamp(mock/pty fixture 车道请求形状恒不变)。 */
43
+ live: boolean;
44
+ why: string;
45
+ gap?: true;
46
+ }
47
+ /**
48
+ * 逐字段对照表(B4 从三个构造器**逐行**读出来的,不是设计出来的)。
49
+ *
50
+ * 读法:`lanes` 只有一条 = 今天的漂移面;`gap:true` = 我判它是**漏**而不是**设计**。
51
+ * 🔴 `gap` 是【判断】不是【事实】——补齐任何一条都要按宪法三问单独立项,别当 B4 的收尾顺手做。
52
+ */
53
+ export declare const REQUEST_FIELD_MATRIX: readonly RequestFieldSpec[];
54
+ /** live 兜底层(`toLiveRequest`)追加的字段 —— 两条车道**都**经过,故不进上表。 */
55
+ export declare const LIVE_DEFAULT_FIELDS: readonly ["cwd", "additionalDirectories", "forwardSubagentEvents", "retainSubagentSessions", "agents", "appendSystemPrompt|settings.outputStyle", "suggestNextPrompts", "compactionModel", "sessionId(三态解析)"];
56
+ /** 端解析好的输入 —— 每一项都是**值**,不是取值方式(取值方式属端)。 */
57
+ export interface TaskRequestInput {
58
+ /** 本 turn 的模型面输入(已含 slash-skill 正文 / 注入式 meta / 历史种子等端侧组装)。 */
59
+ objective: string;
60
+ /** 壳会话 id(单命名空间);print 非 live 车道传 mock 常量。 */
61
+ sessionId: string;
62
+ /** live 车道(= 壳的 `process.env.SEMA_LIVE_BASEURL` 判真)。false ⇒ 全部 live-gated 字段不 stamp。 */
63
+ live: boolean;
64
+ scenario?: string;
65
+ systemPrompt?: string;
66
+ reasoningEffort?: string;
67
+ model?: string;
68
+ images?: readonly unknown[];
69
+ skills?: unknown;
70
+ mcpServers?: unknown;
71
+ agents?: unknown;
72
+ clientContext?: Record<string, unknown>;
73
+ scratchpadDir?: string;
74
+ sandboxImageProfile?: string;
75
+ permissionMode?: string;
76
+ selfOrchestration?: boolean;
77
+ deferTools?: readonly string[];
78
+ retainBackgroundProcesses?: boolean;
79
+ promptProfile?: string;
80
+ enableFork?: boolean;
81
+ attachments?: Record<string, unknown>;
82
+ finalVerification?: boolean;
83
+ limits?: Record<string, unknown>;
84
+ interactiveTools?: false;
85
+ /** rewind 三件(E18);端自己判空。 */
86
+ rewind?: Record<string, unknown>;
87
+ /** `settings` 的四个来源(端各自解析;本模块只负责合并序与「全空则不 stamp」)。 */
88
+ settings?: {
89
+ resolved?: Record<string, unknown>;
90
+ webSearch?: Record<string, unknown>;
91
+ ultracode?: boolean;
92
+ hooks?: Record<string, unknown>;
93
+ outputStyle?: string;
94
+ };
95
+ }
96
+ /**
97
+ * 合一后的请求构造器 —— **按车道出两形**,字段集差异全部由 `REQUEST_FIELD_MATRIX` 决定。
98
+ *
99
+ * 🔴 行为纪律:本函数**不做任何统一**。print 没有 `ultracode` 就是没有(表里 `gap:true` 记着账),
100
+ * 补齐要另立项 —— 在这里顺手加一行,就是把「合一」偷换成「行为改动」。
101
+ */
102
+ export declare function buildTaskRequest(input: TaskRequestInput, lane: RequestLane): TaskRequestLike;
103
+ /** `applyLiveRequestDefaults` 的宿主输入(端解析好的值,同样零取值方式)。 */
104
+ export interface LiveDefaultsInput {
105
+ cwd?: string;
106
+ additionalDirectories?: readonly string[];
107
+ agents?: unknown;
108
+ /** 引擎 caps 判真 ⇒ 走 `appendSystemPrompt` 一等位;否则借道 `settings.outputStyle`。 */
109
+ appendSystemPromptCapable: boolean;
110
+ /** 自我认知块的合并结果(端把既有值传进来,包只决定落哪个位)。 */
111
+ selfKnowledge?: {
112
+ forAppend: string;
113
+ forOutputStyle: string;
114
+ };
115
+ /** E12 opt-in(缺省关 —— 开着 = 每个 completed run 后端多跑一次 LLM pass)。 */
116
+ suggestNextPrompts?: boolean;
117
+ /** 三重门都过之后的 cheap 槽模型名;端负责 caps + 配置 + 目录同步校验。 */
118
+ compactionModel?: string;
119
+ /** 前一 turn 捕获的引擎 session id(连续性最高优先)。 */
120
+ capturedSessionId?: string;
121
+ /** 端的 mock session 常量(命中 ⇒ 删 sessionId 让引擎新铸)。 */
122
+ mockSessionIdConstant?: string;
123
+ /** 需要从 scenario 位剔除的 mock 关键字。 */
124
+ mockScenarioKeys?: ReadonlySet<string>;
125
+ }
126
+ /**
127
+ * live 兜底层(cli `liveClient.toLiveRequest` 的**包内半场**)—— 两条车道共用。
128
+ *
129
+ * 🔴 与 cli 的差分记账:cli 版在函数体里直接读 `process.cwd()` / `getAppStateStoreRef()` /
130
+ * `getLiveModelCatalog()` / `engineCapTrue()`。这些是**取值方式**,全部上移到 `LiveDefaultsInput`。
131
+ * 「调用方已带值就不覆盖」的语义(cli 每条都是 `if (out.X === undefined)`)在这里逐条保留。
132
+ */
133
+ export declare function applyLiveRequestDefaults(req: TaskRequestLike, host: LiveDefaultsInput): TaskRequestLike;
134
+ /** 出口约束:构造结果确实是一个 `TaskRequest`(型只在这里碰 SDK,运行时零依赖)。 */
135
+ export type BuiltTaskRequest = TaskRequest;
@@ -0,0 +1,176 @@
1
+ /**
2
+ * 逐字段对照表(B4 从三个构造器**逐行**读出来的,不是设计出来的)。
3
+ *
4
+ * 读法:`lanes` 只有一条 = 今天的漂移面;`gap:true` = 我判它是**漏**而不是**设计**。
5
+ * 🔴 `gap` 是【判断】不是【事实】——补齐任何一条都要按宪法三问单独立项,别当 B4 的收尾顺手做。
6
+ */
7
+ export const REQUEST_FIELD_MATRIX = [
8
+ // ── 两条车道都有 ────────────────────────────────────────────────────────────────────────────
9
+ { field: 'objective', lanes: ['interactive', 'print'], live: false, why: '本 turn 的真实输入,两条车道都必需' },
10
+ { field: 'sessionId', lanes: ['interactive', 'print'], live: false, why: '单命名空间(TP-A Step 2):壳 id 即引擎 session id;print 在非 live 下保留 mock 常量' },
11
+ { field: 'scenario', lanes: ['interactive', 'print'], live: false, why: 'TUI 走 selectScenario(objective) 嗅探,print 走 --scenario/SEMA_HEADLESS_SCENARIO 显式值' },
12
+ { field: 'sandboxImageProfile', lanes: ['interactive', 'print'], live: true, why: '[686]② 显式 profile;auto 档只在 TUI 有(一次性 advisory 进 objective)' },
13
+ { field: 'permissionMode', lanes: ['interactive', 'print'], live: true, why: 'TUI 读 AppState.toolPermissionContext(带 stampDefault 门),print 读 flag>env(headlessPermissionModeWire)' },
14
+ { field: 'selfOrchestration', lanes: ['interactive', 'print'], live: true, why: 'SEMA_SELF_ORCHESTRATION opt-in → core 挂 run_workflow' },
15
+ { field: 'deferTools', lanes: ['interactive', 'print'], live: true, why: '[1052]① Workflow 延迟披露;selfOrchestration 前置门' },
16
+ { field: 'retainBackgroundProcesses', lanes: ['interactive', 'print'], live: true, why: '缺省 ON(clay 2026-07-26 翻转);SEMA_RETAIN_BACKGROUND=0 逃生' },
17
+ { field: 'promptProfile', lanes: ['interactive', 'print'], live: true, why: 'core 1.328 呈现轴 A/B 口,缺省不 stamp = 引擎缺省 simple' },
18
+ { field: 'enableFork', lanes: ['interactive', 'print'], live: true, why: '显式布尔才 stamp([503]② fail-OPEN 教训:off 必须显式)' },
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 载入面注入' },
21
+ { field: 'mcpServers', lanes: ['interactive', 'print'], live: true, why: '本地 .mcp.json 投影;service 单用户门决定是否兑现' },
22
+ { field: 'settings.webSearch', lanes: ['interactive', 'print'], live: true, why: 'SEMA_WEBSEARCH_* / settings.json;per-request 配置赢过部署 env' },
23
+ { field: 'settings.hooks', lanes: ['interactive', 'print'], live: true, why: '[495]① 用户 settings 文件 hooks 逐字上 wire' },
24
+ // ── print 独有(§8-5 认定的**有理由**的差异)──────────────────────────────────────────────
25
+ { field: 'finalVerification', lanes: ['print'], live: true, why: 'P1-1 终验:无人值守车道才需要引擎自证;交互 REPL 由人当场看结果。#106 裁 B(让位+告知)后交互面默认关' },
26
+ { field: 'limits', lanes: ['print'], live: true, why: 'P2-3-b:`-p` 的预算护栏(--max-* flag 族),交互 REPL 由人随时 Esc' },
27
+ { field: 'interactiveTools', lanes: ['print'], live: true, why: '[909]B 件3:无人值守 stamp false,从 roster 源头灭掉 AskUserQuestion/plan 门。交互车道 stamp false 等于自废武功' },
28
+ // ── TUI 独有 ────────────────────────────────────────────────────────────────────────────────
29
+ { field: 'settings.ultracode', lanes: ['interactive'], live: true, why: 'design/111:sticky `/effort ultracode` 拨盘 + 当轮关键词嗅探,两个来源都只在交互面存在', gap: true },
30
+ { field: 'systemPrompt', lanes: ['interactive'], live: false, why: 'CC QueryParams.systemPrompt;print 腿的 params 没有这一位' },
31
+ { field: 'reasoningEffort', lanes: ['interactive'], live: false, why: '`/effort` 拨盘存在 AppState,print 无 AppState', gap: true },
32
+ { field: 'model', lanes: ['interactive'], live: false, why: '`/model` 中途换模型读 toolUseContext.options.mainLoopModel;print 的模型走 MODEL_ID/--model 另一条路', gap: true },
33
+ { field: 'images', lanes: ['interactive'], live: false, why: '贴图提交是交互动作,`-p` 的 stdin 没有图片块' },
34
+ { field: 'resumeAt/rewindFiles/rewindFilesTo', lanes: ['interactive'], live: false, why: 'E18 `/rewind` 是交互命令' },
35
+ { field: 'clientContext', lanes: ['interactive'], live: false, why: 'IANA 时区 + 可选邮箱 → core 本地化 `# Environment` 的 today;print 同样跑在用户机器上,缺席让引擎误标 (UTC)', gap: true },
36
+ { field: 'scratchpadDir', lanes: ['interactive'], live: true, why: '[816]③ per-session 暂存目录;print 也有 session,缺席让 `-p` 的工具写不进 exemptDir', gap: true },
37
+ { field: 'settings.<resolved>', lanes: ['interactive'], live: true, why: 'settings resolver 的 effective 快照(hooks/env/model + 已解析权限);print 只带 webSearch+hooks 两键', gap: true },
38
+ ];
39
+ /** live 兜底层(`toLiveRequest`)追加的字段 —— 两条车道**都**经过,故不进上表。 */
40
+ export const LIVE_DEFAULT_FIELDS = [
41
+ 'cwd',
42
+ 'additionalDirectories',
43
+ 'forwardSubagentEvents',
44
+ 'retainSubagentSessions',
45
+ 'agents',
46
+ 'appendSystemPrompt|settings.outputStyle',
47
+ 'suggestNextPrompts',
48
+ 'compactionModel',
49
+ 'sessionId(三态解析)',
50
+ ];
51
+ const laneHas = (field, lane) => REQUEST_FIELD_MATRIX.find(f => f.field === field)?.lanes.includes(lane) ?? false;
52
+ /**
53
+ * 合一后的请求构造器 —— **按车道出两形**,字段集差异全部由 `REQUEST_FIELD_MATRIX` 决定。
54
+ *
55
+ * 🔴 行为纪律:本函数**不做任何统一**。print 没有 `ultracode` 就是没有(表里 `gap:true` 记着账),
56
+ * 补齐要另立项 —— 在这里顺手加一行,就是把「合一」偷换成「行为改动」。
57
+ */
58
+ export function buildTaskRequest(input, lane) {
59
+ const live = input.live;
60
+ /** stamp 门:字段在本车道的表里 ∧(非 live-gated ∨ 本次是 live 车道)∧ 值非空。 */
61
+ const on = (field, value) => {
62
+ if (value === undefined || value === null)
63
+ return false;
64
+ const spec = REQUEST_FIELD_MATRIX.find(f => f.field === field);
65
+ if (spec === undefined)
66
+ return false;
67
+ if (!spec.lanes.includes(lane))
68
+ return false;
69
+ if (spec.live && !live)
70
+ return false;
71
+ return true;
72
+ };
73
+ // settings 四源合并(cli 原式:任一存在则 stamp,全缺则整个 `settings` 键都不出现)。
74
+ // ultracode 只在交互车道进表 ⇒ print 传了也不 stamp(表说了算,不是调用方说了算)。
75
+ const s = input.settings ?? {};
76
+ const settingsOut = {
77
+ ...(laneHas('settings.<resolved>', lane) && live ? (s.resolved ?? {}) : {}),
78
+ ...(on('settings.webSearch', s.webSearch) ? { webSearch: s.webSearch } : {}),
79
+ ...(on('settings.ultracode', s.ultracode) ? { ultracode: s.ultracode } : {}),
80
+ ...(on('settings.hooks', s.hooks) ? { hooks: s.hooks } : {}),
81
+ ...(s.outputStyle !== undefined ? { outputStyle: s.outputStyle } : {}),
82
+ };
83
+ const out = {
84
+ objective: input.objective,
85
+ sessionId: input.sessionId,
86
+ ...(on('scenario', input.scenario) ? { scenario: input.scenario } : {}),
87
+ ...(on('systemPrompt', input.systemPrompt) ? { systemPrompt: input.systemPrompt } : {}),
88
+ ...(on('reasoningEffort', input.reasoningEffort) ? { reasoningEffort: input.reasoningEffort } : {}),
89
+ ...(on('model', input.model) ? { model: input.model } : {}),
90
+ ...(on('images', input.images) && (input.images?.length ?? 0) > 0 ? { images: input.images } : {}),
91
+ ...(on('skills', input.skills) ? { skills: input.skills } : {}),
92
+ ...(on('mcpServers', input.mcpServers) ? { mcpServers: input.mcpServers } : {}),
93
+ ...(on('resumeAt/rewindFiles/rewindFilesTo', input.rewind) ? input.rewind : {}),
94
+ ...(on('permissionMode', input.permissionMode) ? { permissionMode: input.permissionMode } : {}),
95
+ ...(on('sandboxImageProfile', input.sandboxImageProfile)
96
+ ? { sandboxImageProfile: input.sandboxImageProfile }
97
+ : {}),
98
+ ...(on('selfOrchestration', input.selfOrchestration) ? { selfOrchestration: input.selfOrchestration } : {}),
99
+ ...(on('deferTools', input.deferTools) ? { deferTools: input.deferTools } : {}),
100
+ ...(on('retainBackgroundProcesses', input.retainBackgroundProcesses)
101
+ ? { retainBackgroundProcesses: input.retainBackgroundProcesses }
102
+ : {}),
103
+ ...(on('promptProfile', input.promptProfile) ? { promptProfile: input.promptProfile } : {}),
104
+ ...(on('enableFork', input.enableFork) ? { enableFork: input.enableFork } : {}),
105
+ ...(on('attachments', input.attachments) ? { attachments: input.attachments } : {}),
106
+ ...(on('clientContext', input.clientContext) ? { clientContext: input.clientContext } : {}),
107
+ ...(on('scratchpadDir', input.scratchpadDir) ? { scratchpadDir: input.scratchpadDir } : {}),
108
+ ...(on('finalVerification', input.finalVerification)
109
+ ? { finalVerification: input.finalVerification }
110
+ : {}),
111
+ ...(on('limits', input.limits) ? { limits: input.limits } : {}),
112
+ ...(on('interactiveTools', input.interactiveTools) && input.interactiveTools === false
113
+ ? { interactiveTools: false }
114
+ : {}),
115
+ ...(Object.keys(settingsOut).length > 0 ? { settings: settingsOut } : {}),
116
+ };
117
+ return out;
118
+ }
119
+ /**
120
+ * live 兜底层(cli `liveClient.toLiveRequest` 的**包内半场**)—— 两条车道共用。
121
+ *
122
+ * 🔴 与 cli 的差分记账:cli 版在函数体里直接读 `process.cwd()` / `getAppStateStoreRef()` /
123
+ * `getLiveModelCatalog()` / `engineCapTrue()`。这些是**取值方式**,全部上移到 `LiveDefaultsInput`。
124
+ * 「调用方已带值就不覆盖」的语义(cli 每条都是 `if (out.X === undefined)`)在这里逐条保留。
125
+ */
126
+ export function applyLiveRequestDefaults(req, host) {
127
+ const out = { ...req };
128
+ if (out.cwd == null && host.cwd !== undefined)
129
+ out.cwd = host.cwd;
130
+ if (out.additionalDirectories === undefined && (host.additionalDirectories?.length ?? 0) > 0) {
131
+ out.additionalDirectories = [...(host.additionalDirectories ?? [])];
132
+ }
133
+ // C1 / design/144:交互与 headless 都常开 —— 不开则引擎不建 SubagentRetainLedger,
134
+ // 后台子代 SendMessage 唤醒直接 "session was not retained" 拒绝。老引擎按 body→spec 白名单
135
+ // 忽略未知字段,version-safe。
136
+ if (out.forwardSubagentEvents === undefined)
137
+ out.forwardSubagentEvents = true;
138
+ if (out.retainSubagentSessions === undefined)
139
+ out.retainSubagentSessions = true;
140
+ if (out.agents === undefined && host.agents !== undefined)
141
+ out.agents = host.agents;
142
+ if (typeof out.scenario === 'string' && host.mockScenarioKeys?.has(out.scenario)) {
143
+ delete out.scenario;
144
+ }
145
+ // 自我认知块双臂([1487]/[1478] R1 终形):caps 判真走一等位,否则借道 outputStyle。
146
+ // 两臂同一合并序(知识块恒前、调用方块恒后)——序由端在 selfKnowledge 里拼好,包只选位。
147
+ if (host.selfKnowledge !== undefined) {
148
+ if (host.appendSystemPromptCapable) {
149
+ out.appendSystemPrompt = host.selfKnowledge.forAppend;
150
+ }
151
+ else {
152
+ const settings = (out.settings ?? {});
153
+ out.settings = { ...settings, outputStyle: host.selfKnowledge.forOutputStyle };
154
+ }
155
+ }
156
+ // E12:与 verify/cascade 互斥(那两个返 result 非 streamed run ⇒ 400)。
157
+ if (out.suggestNextPrompts === undefined &&
158
+ out.verify === undefined &&
159
+ out.cascade === undefined &&
160
+ host.suggestNextPrompts === true) {
161
+ out.suggestNextPrompts = true;
162
+ }
163
+ if (out.compactionModel === undefined && host.compactionModel !== undefined) {
164
+ out.compactionModel = host.compactionModel;
165
+ }
166
+ // 单命名空间三态(cli 逐字):① 已捕获的引擎 session 赢(连续性 —— 引擎 session 绝不在
167
+ // 会话中途被换掉);③ 还带着 mock 常量 ⇒ 删掉让引擎新铸;② 其余保留(首 turn 壳 id 直传)。
168
+ if (host.capturedSessionId) {
169
+ out.sessionId = host.capturedSessionId;
170
+ }
171
+ else if (host.mockSessionIdConstant !== undefined &&
172
+ out.sessionId === host.mockSessionIdConstant) {
173
+ delete out.sessionId;
174
+ }
175
+ return out;
176
+ }
@@ -0,0 +1,16 @@
1
+ export interface ScratchpadDirField {
2
+ scratchpadDir?: string;
3
+ }
4
+ /**
5
+ * Per-turn scratchpadDir stamp。gate off(tengu_scratch)⇒ 空对象(请求形状不变,mock parity)。
6
+ * 路径 per-session 稳定(sessionId 定 getScratchpadDir),重复调用幂等。
7
+ *
8
+ * 与 cli 的差分记账:
9
+ * · `ensureScratchpadDir` 在壳里是 async(mkdir 0o700),这里 **两种返回都吞**(端可给同步实现);
10
+ * 建失败只影响引擎首写时自建(server 半场也建),**不阻 turn** —— 与 cli 逐字同语义。
11
+ * · fs 口缺席(浏览器宿主)⇒ 返回空对象 = 不 stamp。additive 字段缺席对引擎无害,
12
+ * 但会计一次 `hostPortMisses().fs`,让「Node 宿主漏装」与「浏览器本就没有」在自检里可区分。
13
+ */
14
+ export declare function scratchpadDirField(): ScratchpadDirField;
15
+ /** 测试钩:复位 module 级建目录闩。 */
16
+ export declare function _resetScratchpadEnsuredForTest(): void;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * ⇄ B4 批搬迁(2026-07-27,多端改造设计稿 §3 B4 第五件):cli `src/sema/scratchpadWireCaps.ts` 搬入。
3
+ * 拆缝 = 两条外向边换 host port:`utils/permissions/filesystem`(getScratchpadDir /
4
+ * isScratchpadEnabled / ensureScratchpadDir)→ `hostFs()`;`utils/debug.logForDebugging` → `hostLog()`。
5
+ * 本文件是 B2 判给 B4 的两件「宿主耦合 WireCaps」里的**小的那件**,同时充当 `FsPort` 的端到端证明。
6
+ */
7
+ /**
8
+ * scratchpad 域的 wire 半场([816] 壳侧承诺③ / [819]④ / [820]③,2026-07-14)。
9
+ *
10
+ * 链路:core `TaskSpec.envFacts.scratchpadDir` 字段 + 提示词消费链(buildScratchpadSection)1.257.3
11
+ * 起全在;server 半场 = buildEnvFacts 填字段 + 建目录 + 塞进 createFsWriteGatePolicy 的 exemptDirs。
12
+ * 客户端把 getScratchpadDir()(CC 同型:`/tmp/claude-{uid}/{sanitized-cwd}/{sessionId}/scratchpad`)
13
+ * stamp 到 `TaskRequest.scratchpadDir` —— server 半场没到时字段发了引擎忽略 = 无害(additive 铁律);
14
+ * 到货后 server 以客户端带的路径为准(本机 TOC 引擎与壳同 fs,壳造的目录引擎直接可写),或自造(TOB 远端)。
15
+ *
16
+ * 🔴 [1840] 勘误:G3 `scratchpadDir` **不是死键** —— server 已把「死键」的说法收回改接单
17
+ * ([816]③/[820]③ additive 预挂)。别再按「没人读」把这条 stamp 删掉。
18
+ *
19
+ * 🔴 单实例纪律:本模块持 module 级 `ensured` 闩(整进程只 fire-and-forget 建一次目录)。
20
+ * 两份实例 = 建两次(幂等无害),但与本包其余台账同理,壳侧必须只解析到一份。
21
+ */
22
+ import { hostFs, hostLog } from './host.js';
23
+ let ensured = false;
24
+ /**
25
+ * Per-turn scratchpadDir stamp。gate off(tengu_scratch)⇒ 空对象(请求形状不变,mock parity)。
26
+ * 路径 per-session 稳定(sessionId 定 getScratchpadDir),重复调用幂等。
27
+ *
28
+ * 与 cli 的差分记账:
29
+ * · `ensureScratchpadDir` 在壳里是 async(mkdir 0o700),这里 **两种返回都吞**(端可给同步实现);
30
+ * 建失败只影响引擎首写时自建(server 半场也建),**不阻 turn** —— 与 cli 逐字同语义。
31
+ * · fs 口缺席(浏览器宿主)⇒ 返回空对象 = 不 stamp。additive 字段缺席对引擎无害,
32
+ * 但会计一次 `hostPortMisses().fs`,让「Node 宿主漏装」与「浏览器本就没有」在自检里可区分。
33
+ */
34
+ export function scratchpadDirField() {
35
+ try {
36
+ const fs = hostFs();
37
+ if (fs === undefined)
38
+ return {};
39
+ if (!fs.isScratchpadEnabled())
40
+ return {};
41
+ const dir = fs.getScratchpadDir();
42
+ if (!ensured) {
43
+ ensured = true;
44
+ try {
45
+ const r = fs.ensureScratchpadDir();
46
+ if (r !== undefined && typeof r.catch === 'function') {
47
+ void r.catch((e) => {
48
+ hostLog('debug', `scratchpadWireCaps: ensureScratchpadDir failed (non-fatal): ${String(e)}`);
49
+ });
50
+ }
51
+ }
52
+ catch (e) {
53
+ hostLog('debug', `scratchpadWireCaps: ensureScratchpadDir threw (non-fatal): ${String(e)}`);
54
+ }
55
+ }
56
+ return dir !== undefined ? { scratchpadDir: dir } : {};
57
+ }
58
+ catch {
59
+ return {};
60
+ }
61
+ }
62
+ /** 测试钩:复位 module 级建目录闩。 */
63
+ export function _resetScratchpadEnsuredForTest() {
64
+ ensured = false;
65
+ }
package/dist/seam.d.ts CHANGED
@@ -80,7 +80,15 @@ export type ChromeEvent = {
80
80
  kind: 'retry_status';
81
81
  laneProof: LaneProof;
82
82
  status: unknown;
83
- } | {
83
+ }
84
+ /**
85
+ * footer 面板行事件(载荷 = 本包 `EngineAgentPanelEvent`,B1 已搬入)。
86
+ * 宿主消费义务:原样 `publishEngineAgentPanelEvent(event)`。
87
+ * 🔴 **session 常驻台账(`markEnginePanelTaskResident`/`clearEnginePanelTaskResident`)不在宿主义务里**
88
+ * —— B4 起由适配器**整臂自持**(读者 `isEnginePanelTaskResident` 也在本包,写读同包 = R4 纪律)。
89
+ * 宿主再写一遍不会更对,只会两臂打架。
90
+ */
91
+ | {
84
92
  kind: 'panel_task';
85
93
  laneProof: LaneProof;
86
94
  event: unknown;
@@ -149,10 +157,14 @@ export type ChromeEvent = {
149
157
  }
150
158
  /**
151
159
  * 一条终态 task_notification 抵达(引擎已 server-side steer-inject 过,壳侧只做记账)。
152
- * 宿主消费义务(cli 现役三件,缺一即回归):
153
- * ① drop 壳队列里同 run 的 Path B 条目(workflow_complete/probe feeder),否则 idle
154
- * auto-submit 会把同一完成再喂模型一遍(跨进程双投的壳侧半场);
155
- * ② background_bash:清 resident 台账(面板行终态由本帧落,不由 turn sweep 假结)。
160
+ * 宿主消费义务(cli 现役三件里 **B4 起只剩零件**——见下,本臂现在纯属「告知」):
161
+ * ① ~~drop 壳队列里同 run 的 Path B 条目~~ B2 起 `dropQueuedNotificationsForRun` 已在本包,
162
+ * B4 起由适配器自己调(宿主只需在启动时 `installNotificationQueuePort`);
163
+ * ② ~~清 resident 台账~~ B4 起由适配器自己调(`clearEnginePanelTaskResident` 在本包)。
164
+ * ⇒ 宿主对本臂的义务 = **可选的 UI 提示**(如"后台任务完成"toast)。<br>
165
+ * 🔴 配对纪律([paired-mechanisms-must-share-premise]):这两件从「宿主义务」改成「库自持」是
166
+ * **整臂让位**,不是两边都做——宿主若仍照旧实现,两次 drop/clear 幂等无害,但**记号必须删**,
167
+ * 否则下一棒会以为库没做。
156
168
  * 跨通道去重(「补发通道不得再注入模型一遍」)由**适配器自持台账**负责,不摊给宿主——
157
169
  * 见 adapt.ts 的 notifiedRuns / workflow_notification_enqueue 臂的发射门。
158
170
  * 注:面板行 end 事件本身走 panel_task 臂、SubagentStop 走 subagent_lifecycle 臂,不重复。
@@ -166,10 +178,14 @@ export type ChromeEvent = {
166
178
  }
167
179
  /**
168
180
  * 子代生命周期 hook 触发点(cli fireSubagentStartHook / fireSubagentStopHook)。
169
- * `guard:'if-started'` = 宿主必须先查自己的「已 fire Start」台账,没 fire 过 Start 的
170
- * taskId(workflow runId / 外来 id)绝不 fire Stop。
171
- * 宿主消费义务:fire SubagentStart/SubagentStop hooks(observe-only,fail-soft,永不阻塞流);
172
- * Start 对一个 taskId 一生只许一次(进程级台账去重,frame-lane-matrix #4)。
181
+ * 宿主消费义务:fire SubagentStart/SubagentStop hooks(observe-only,fail-soft,永不阻塞流)。
182
+ *
183
+ * 🔴 **B4 起「一生只许一次」的台账由适配器自持**(实例级 = 一个 session 一份,cli 的 module 级
184
+ * 等价物):`phase:'start'` 对同一 taskId **本臂只会发一次**,宿主收到就 fire,不必再去重。
185
+ * `guard:'if-started'` 同理已由适配器解掉——**只有真 fire 过 Start 的 taskId 才会收到带 guard 的
186
+ * stop**(cli `task_notification` 臂 `firedSubagentStartHookTaskIds.has()` 的等价面)。guard 字段
187
+ * 保留只为让宿主能识别这条 stop 的来源臂,**不是**要宿主再判一次(判据锚在能决定结果的量上:
188
+ * 台账在库里,宿主手上根本没有那个量)。
173
189
  */
174
190
  | {
175
191
  kind: 'subagent_lifecycle';
@@ -179,6 +195,44 @@ export type ChromeEvent = {
179
195
  agentType?: string;
180
196
  guard?: 'if-started';
181
197
  }
198
+ /**
199
+ * B4 新臂 ①(A 层 `task_progress` 搬入)—— **inline 群组行**的累计统计(cli
200
+ * `engineInlineTaskStats` 的三个写口:`publishEngineInlineTaskTick` / `settleEngineInlineTaskStats`
201
+ * / `settleAllEngineInlineTaskStats`)。转录里那条 "Running N agents… ├ … 12 tool uses" 的数字面。
202
+ *
203
+ * 为什么与 `panel_task` 分臂:两者键不同 —— 本臂按**委派卡 id**(`cardId` = tool_use id,渲染面
204
+ * 的 AgentProgressLine 只有卡 id 在手),`panel_task` 按 **taskId**(footer 面板行)。同一 tick
205
+ * 同时喂两面是 cli 现役行为,合臂会逼消费端反解键。
206
+ *
207
+ * 宿主消费义务(三个 op 逐一,缺一即回归):
208
+ * · `op:'tick'` → `publishEngineInlineTaskTick(cardId, stats)`(已 settle 的卡是 no-op);
209
+ * · `op:'settle'` → `settleEngineInlineTaskStats(cardId, isError)`(冻结终值,幂等);
210
+ * · `op:'settle_all'` → `settleAllEngineInlineTaskStats()`(turn 末/abort 的防御 sweep 孪生腿)。
211
+ * 三个函数本包已导出(B1 搬入),宿主直接转发即可,不必自己实现语义。
212
+ */
213
+ | {
214
+ kind: 'inline_task_stats';
215
+ laneProof: LaneProof;
216
+ op: 'tick';
217
+ /** 委派 tool_use id(渲染面订阅键)。 */
218
+ cardId: string;
219
+ stats: {
220
+ toolUses: number;
221
+ totalTokens: number;
222
+ durationMs: number;
223
+ currentAction?: string;
224
+ };
225
+ } | {
226
+ kind: 'inline_task_stats';
227
+ laneProof: LaneProof;
228
+ op: 'settle';
229
+ cardId: string;
230
+ isError: boolean;
231
+ } | {
232
+ kind: 'inline_task_stats';
233
+ laneProof: LaneProof;
234
+ op: 'settle_all';
235
+ }
182
236
  /**
183
237
  * 带外后台 workflow 完成推送(service 1.75 Path B:引擎**没有** server-side 注入,壳负责喂模型)。
184
238
  * 宿主消费义务:把 `message`(模型面 `<task-notification>` 正文,适配器已按 core 同形铸好)
@@ -246,6 +300,24 @@ export type ChromeEvent = {
246
300
  laneProof: LaneProof;
247
301
  result: unknown;
248
302
  };
303
+ /** `ChromeEvent` 的判别键(臂名)—— 覆盖率断言与宿主装配自检都锚在它上。 */
304
+ export type ChromeArmKind = ChromeEvent['kind'];
305
+ /**
306
+ * chrome 臂**覆盖率断言出口**(§8-1 裁决的一半:client-core 定接口不定实现)。
307
+ *
308
+ * 用途:任何宿主(TUI / web / 桌面)在自检里遍历本表,对 `required:true` 的臂断言"我实现了消费口"。
309
+ * 不实现 = 该 UI 面在那个宿主上是**哑的**(不是报错,是静默没有)—— 这正是多端最容易漏的一类,
310
+ * 所以把义务做成**数据**而不是散落在注释里。
311
+ *
312
+ * `required` 判据:**不实现会让已发生的行为丢失**(转录/模型输入/hook/去重)⇒ true;
313
+ * 只影响可选装饰(spinner 计量、活体预览、chips)⇒ false。
314
+ * `duty` = 一句话义务(完整义务在上方每臂的注释里,那里是真源)。
315
+ */
316
+ export declare const CHROME_ARMS: readonly {
317
+ kind: ChromeArmKind;
318
+ required: boolean;
319
+ duty: string;
320
+ }[];
249
321
  /** 车道纪律一等公民([1617] 家族固化):chrome 事件构造必须携判别证明。 */
250
322
  export type LaneProof = {
251
323
  lane: 'main';
package/dist/seam.js CHANGED
@@ -1,3 +1,33 @@
1
+ /**
2
+ * chrome 臂**覆盖率断言出口**(§8-1 裁决的一半:client-core 定接口不定实现)。
3
+ *
4
+ * 用途:任何宿主(TUI / web / 桌面)在自检里遍历本表,对 `required:true` 的臂断言"我实现了消费口"。
5
+ * 不实现 = 该 UI 面在那个宿主上是**哑的**(不是报错,是静默没有)—— 这正是多端最容易漏的一类,
6
+ * 所以把义务做成**数据**而不是散落在注释里。
7
+ *
8
+ * `required` 判据:**不实现会让已发生的行为丢失**(转录/模型输入/hook/去重)⇒ true;
9
+ * 只影响可选装饰(spinner 计量、活体预览、chips)⇒ false。
10
+ * `duty` = 一句话义务(完整义务在上方每臂的注释里,那里是真源)。
11
+ */
12
+ export const CHROME_ARMS = [
13
+ { kind: 'retry_status', required: false, duty: '渲/清 spinner 的重试覆盖层' },
14
+ { kind: 'panel_task', required: true, duty: 'publishEngineAgentPanelEvent(event) 原样转发' },
15
+ { kind: 'bgshell_settle', required: true, duty: '后台 shell 面板行落终态(含 outputPath)' },
16
+ { kind: 'tasks_expand', required: false, duty: '展开 ctrl+t 任务面板' },
17
+ { kind: 'thinking_activity', required: false, duty: '开/收活体 "∴ Thinking…" 行' },
18
+ { kind: 'request_start', required: false, duty: 'spinner 置 requesting 态' },
19
+ { kind: 'stream_delta', required: false, duty: '渲活体预览 + 推进 responseLength' },
20
+ { kind: 'response_metrics', required: false, duty: '驱动 responseLength reducer(ttft/对账)' },
21
+ { kind: 'attachment', required: false, duty: '渲 attachment 行(必须带 default 臂)' },
22
+ { kind: 'notification_terminal', required: false, duty: '可选 UI 提示(记账已由库自持)' },
23
+ { kind: 'subagent_lifecycle', required: true, duty: 'fire SubagentStart/Stop hooks(去重已由库解)' },
24
+ { kind: 'workflow_notification_enqueue', required: true, duty: 'message 入完成通知队列,idle 时喂模型' },
25
+ { kind: 'task_ledger_sync', required: true, duty: '按 toolUseId 幂等落 TaskCreate/TaskUpdate' },
26
+ { kind: 'inline_task_stats', required: true, duty: 'tick/settle/settle_all 转发给 engineInlineTaskStats' },
27
+ { kind: 'prompt_suggestions', required: false, duty: '推 composer 上方 chips(绝不回喂模型)' },
28
+ { kind: 'last_turn_usage', required: true, duty: '落最近一次 turn 真 usage(statusline 回落源)' },
29
+ { kind: 'plan_review_park', required: true, duty: '弹 plan 审批卡(fail-soft,不挡后续终态帧)' },
30
+ ];
1
31
  /**
2
32
  * transcript id 确定性派生([1653] 定案):稳定键优先序 = 帧 id > seq > toolCallId;
3
33
  * 全缺才用 ctx.uuid()。同流重放 ⇒ 同 id 序列(往返守卫钉此不变量)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/client-core",
3
- "version": "0.5.0",
3
+ "version": "0.6.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",