@sema-agent/client-core 0.19.0 → 0.20.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.
@@ -4,8 +4,7 @@
4
4
  /**
5
5
  * src/sema/attachmentsWireCaps.ts — design/133 + G1 turn-boundary attachments WIRE pure-projection:
6
6
  * the shell's intent → the top-level `TaskRequest.attachments` object the service normalizes
7
- * (spec-fields.ts normalizeAttachments, @sema-ai/server 1.128.0) → core `TaskSpec.attachments`
8
- * (all producers opt-in engine-side; core 1.253).
7
+ * (spec-fields.ts normalizeAttachments) → core `TaskSpec.attachments`.
9
8
  *
10
9
  * DEFAULT ON for the TOC live path ([487]② — CC parity): CC's own /compact announces the still
11
10
  * pending/running background tasks after compaction and notices newly-available deferred tools at
@@ -19,8 +18,54 @@
19
18
  * only; interactive deployments stay byte-identical) is respected until a behavior leg says
20
19
  * otherwise.
21
20
  *
22
- * Core wire contract (board [479] ask-2): literal-true unions — "off = DELETE the key, never send
23
- * false". This projection never emits `false`; OFF ⇒ `undefined` ⇒ no `attachments` field at all.
21
+ * ══ 🔴 FIX⑤(2026-08-07)—— 这个字段下面其实躺着**两族契约**,此前只实现了一族 ═══════════════
22
+ *
23
+ * 契约真源 = `@sema-agent/sdk` `types.d.ts` 的 `TaskRequest.attachments`(6.3.0 起 10 键)。
24
+ * 两族的「关」是两种**不同**的动作,混为一谈就必然有一族关不掉:
25
+ *
26
+ * ① **literal-true 族**(7 员:todoReminder / changedFiles / planModeReminder / budgetUsd /
27
+ * backgroundTasks / toolsDelta / mcpInstructions,外加取值型的 todoReminderMode):
28
+ * 引擎侧默认**关**,键在场即开。板 [479] ask-2 的原话是「off = DELETE the key, never send
29
+ * false」—— 对这一族,发 `false` 是违约(核心根本没有「显式关」这个语义位),所以本模块对
30
+ * 它们**永不产出 false**:OFF ⇒ 键不出现。
31
+ *
32
+ * ② **explicit-false 族**(2 员:agentListing / skillsListing):
33
+ * 它们在 core 里是 **DEFAULT-ON**,SDK 头注写死「`agentListing`/`skillsListing` 是 core
34
+ * DEFAULT-ON —— **explicit false 才关**(1.254 起 false 真透传;更老 server 静默丢)」。
35
+ * 于是「删键」在这一族上的含义是**保持开着**,而不是关掉。旧的 `AttachmentsSpec` 把全部键
36
+ * 钉成 `?: true`,literal 类型层面就表达不出 `false` ⇒ 这两个 DEFAULT-ON 的附件**没有任何
37
+ * 路径可以关闭**(不是「难关」,是类型层封死)。本批把这两键的类型放宽成 `boolean` 并给出
38
+ * 产出 `false` 的路径。
39
+ *
40
+ * ── env 面(`SEMA_ATTACHMENTS`)的取值语法,以及为什么选这一种 ────────────────────────────────
41
+ * 目标是「**默认行为逐字节不变** + 有路可关 DEFAULT-ON 两键」,所以选了**在既有值上加逗号 token
42
+ * 列表**,而不是新开一个 env:
43
+ * · 新开一个 env(如 `SEMA_ATTACHMENTS_OFF`)会让「附件面怎么配」有两个入口,而两个入口之间的
44
+ * 优先级又要再定义一次 —— 这类第二真源正是本仓反复登记的病族;
45
+ * · 逗号 token 列表对**存量取值零影响**:`unset` / `full` / `all` / off 拼法四种老写法解析结果
46
+ * 逐字节不变(FIX7 段 ⑤ 有正面钉),新语法只在用户真写了逗号 token 时才被触发。
47
+ * 语法(逗号分隔,大小写不敏感,允许空白):
48
+ * · `off|0|false|no|none`(整值) ⇒ **真·全关**:literal-true 族一个不发(那一族的 off 就是
49
+ * 删键)+ DEFAULT-ON 两键发 `false`。
50
+ * 🔴 codex 复审 [medium](2026-08-07)判的真病:这一支此前直接 `return undefined`(不发字段),
51
+ * 而「不发字段」对 DEFAULT-ON 两键的含义**恰恰是保持开着** —— 一个叫 off 的总开关关不掉十件里
52
+ * 的两件,且用户没有任何办法察觉。旧行为在 <1.254 的 server 上与新行为**逐字节等价**(那些
53
+ * server 静默丢 false),在 ≥1.254 上新行为才第一次兑现这个旋钮的字面承诺。
54
+ * ⚠️ **能力边界说清楚**(codex 复审二轮):本模块是**纯 env→spec 投影**,手里没有 server
55
+ * 版本/capability 这个量,所以对 <1.254 的部署它兑现不了「全关」—— 那两键仍会默认开着,而
56
+ * 壳侧无从察觉。补这个洞的正位在**请求装配层**(按已探到的 capability 判,与 limitsWire 的
57
+ * 两道地板同族),已记进 docs/refactor/README.md 的宿主/上游工单表。本层只保证两件:
58
+ * ① 新行为在任何 server 代际上都**不比旧行为差**(旧行为在所有代际上都关不掉那两键);
59
+ * ② 不在自己的能力之外说大话 —— 所以上面那句「真·全关」的适用范围就是 ≥1.254。
60
+ * · `full` / `all` ⇒ literal-true 族 7 员全开(**不含** DEFAULT-ON 两键 ——
61
+ * 不发 = 保持 core 默认开,壳不替 core 做那个决定)
62
+ * · `default` ⇒ CC-parity 那一对(显式写出「默认」这个意思)
63
+ * · `<key>` / `+<key>` ⇒ 单点打开一个 literal-true 族成员
64
+ * · `-agentListing` / `-skillsListing` ⇒ 产出 **`false`**(explicit-false 族唯一的关法)
65
+ * · `todoReminderMode=baseline|off` ⇒ 取值型键
66
+ * 🔴 未登记 token / 对 literal-true 族用 `-` / todoReminderMode 取闭集外的值 ⇒ **fail-loud 抛错**。
67
+ * 静默忽略是这条链上最坏的失效形:用户以为自己关掉了 agentListing,而请求照发、附件照进上下文,
68
+ * 并且没有任何一处会说出来。
24
69
  *
25
70
  * PURE — no env/file IO of its own (callers pass `env`), the verified wireCaps pattern. The CALLER
26
71
  * (seamQuery / the print-mode ask) gates the read to LIVE mode so the mock request shape never
@@ -28,21 +73,150 @@
28
73
  */
29
74
  import { hostEnv } from './hostEnv.js';
30
75
  import { envFlagOff } from './envFlag.js';
31
- /** The env key the shell reads. An {@link envFlagOff} spelling(REF-CC-141 dup-02 单源:
32
- * `0`/`false`/`no`/`off`/`none`)⇒ no stamp; `full|all` ⇒ all five producers; unset/anything else
33
- * ⇒ the CC-parity default pair. */
76
+ /** The env key the shell reads. 取值语法见文件头注 §env。 */
34
77
  export const ATTACHMENTS_ENV = 'SEMA_ATTACHMENTS';
78
+ /** 键面台账(机读位):加键必须同批登记,否则下方 `Covers` 双向钉编译红。 */
79
+ export const ATTACHMENTS_SPEC_KEYS = [
80
+ 'todoReminder',
81
+ 'todoReminderMode',
82
+ 'changedFiles',
83
+ 'planModeReminder',
84
+ 'budgetUsd',
85
+ 'backgroundTasks',
86
+ 'toolsDelta',
87
+ 'mcpInstructions',
88
+ 'agentListing',
89
+ 'skillsListing',
90
+ ];
91
+ /** ② 族(core DEFAULT-ON,explicit false 才关)。 */
92
+ export const ATTACHMENTS_DEFAULT_ON_KEYS = ['agentListing', 'skillsListing'];
93
+ /** ① 族里 `full` 会全开的那 7 员(`todoReminderMode` 是取值型,不搭 full 的车)。 */
94
+ const LITERAL_TRUE_KEYS = [
95
+ 'todoReminder',
96
+ 'changedFiles',
97
+ 'planModeReminder',
98
+ 'budgetUsd',
99
+ 'backgroundTasks',
100
+ 'toolsDelta',
101
+ 'mcpInstructions',
102
+ ];
103
+ /** CC-parity 默认对(TOC live 路径,[487]②)。 */
104
+ const DEFAULT_PAIR = ['backgroundTasks', 'toolsDelta'];
105
+ const _attachmentsKeyPins = [true, true, true, true];
106
+ void _attachmentsKeyPins;
107
+ const TODO_REMINDER_MODES = ['baseline', 'off'];
108
+ /** fail-loud 的统一措辞:说清**哪个 token 坏了**、以及合法形是什么(判词要指得出下一步)。 */
109
+ function refuse(token, why) {
110
+ throw new Error(`${ATTACHMENTS_ENV}: refusing token "${token}" — ${why}. ` +
111
+ `Legal tokens: off | full | all | default | <literalTrueKey> | +<literalTrueKey> | ` +
112
+ `-agentListing | -skillsListing | todoReminderMode=baseline|off`);
113
+ }
35
114
  /**
36
- * ENV source → the `TaskRequest.attachments` stamp. Returns `undefined` (⇒ no field) on explicit
37
- * opt-out; the G1 pair by default; all five on `full`. Never `false` values (core contract).
115
+ * ENV source → the `TaskRequest.attachments` stamp.
116
+ *
117
+ * 恒返回一个对象:literal-true 族永远只出现 `true`(core 契约:off = 删键),而
118
+ * `agentListing`/`skillsListing` 只在被显式关掉时出现,值恒为 `false`(它们的 core 默认是开,
119
+ * 不发 = 不动那个默认)。返回型保留 `| undefined` 是给未来留位,今天没有分支产出它。
120
+ *
121
+ * @throws 未登记 token / 对 literal-true 族用 `-` / todoReminderMode 取闭集外的值(fail-loud:
122
+ * 静默忽略会让「我明明关了」与「它其实还开着」同时为真)。
38
123
  */
39
124
  export function attachmentsForRequest(env = hostEnv()) {
40
125
  const raw = (env[ATTACHMENTS_ENV] ?? '').trim().toLowerCase();
126
+ // 总开关 off = 真·全关(见文件头注 §env 的 codex [medium] 登记),不是「不发字段」。
41
127
  if (envFlagOff(raw))
42
- return undefined;
43
- if (raw === 'full' || raw === 'all') {
44
- return { todoReminder: true, changedFiles: true, planModeReminder: true, backgroundTasks: true, toolsDelta: true };
128
+ return allDefaultOnOff();
129
+ const tokens = raw.split(',').map(t => t.trim()).filter(t => t.length > 0);
130
+ // 存量取值零变化:空 / `full` / `all` token 走与本批之前逐字节相同的两条老路。
131
+ if (tokens.length === 0)
132
+ return { backgroundTasks: true, toolsDelta: true };
133
+ if (tokens.length === 1 && (tokens[0] === 'full' || tokens[0] === 'all'))
134
+ return allLiteralTrue();
135
+ // 单 token 且不是任何已登记词 ⇒ 维持历史语义(「unset/anything else ⇒ 默认对」),不 fail-loud:
136
+ // 那是本模块自建起就写在头注里的契约,收紧它属于行为面改动,不在本批。多 token 形是**本批新增
137
+ // 的语法**,没有存量语义可破,所以它逐 token fail-loud。
138
+ if (tokens.length === 1 && !isKnownToken(tokens[0])) {
139
+ return { backgroundTasks: true, toolsDelta: true };
45
140
  }
46
- // default (TOC CC parity): post-compact background-task recap + deferred-tools boundary notice.
47
- return { backgroundTasks: true, toolsDelta: true };
141
+ const spec = {};
142
+ for (const token of tokens) {
143
+ if (token === 'full' || token === 'all') {
144
+ Object.assign(spec, allLiteralTrue());
145
+ continue;
146
+ }
147
+ if (token === 'default') {
148
+ for (const k of DEFAULT_PAIR)
149
+ spec[k] = true;
150
+ continue;
151
+ }
152
+ if (token.startsWith('-')) {
153
+ const key = canonicalKey(token.slice(1));
154
+ if (key === undefined)
155
+ refuse(token, 'unknown attachments key');
156
+ if (!isDefaultOnKey(key)) {
157
+ refuse(token, `"${key}" belongs to the literal-true family whose OFF is "delete the key", not "send false" ` +
158
+ '(core wire contract, board [479] ask-2) — simply do not turn it on. ' +
159
+ 'Only the core DEFAULT-ON keys (agentListing / skillsListing) accept an explicit false');
160
+ }
161
+ spec[key] = false;
162
+ continue;
163
+ }
164
+ const [lhs, rhs] = splitAssign(token);
165
+ if (rhs !== undefined) {
166
+ if (canonicalKey(lhs) !== 'todoReminderMode')
167
+ refuse(token, 'only `todoReminderMode` takes a `key=value` form');
168
+ const mode = TODO_REMINDER_MODES.find(v => v === rhs);
169
+ if (mode === undefined)
170
+ refuse(token, `todoReminderMode must be one of ${TODO_REMINDER_MODES.join('|')}`);
171
+ spec.todoReminderMode = mode;
172
+ continue;
173
+ }
174
+ const key = canonicalKey(lhs.startsWith('+') ? lhs.slice(1) : lhs);
175
+ if (key === undefined)
176
+ refuse(token, 'unknown attachments key');
177
+ if (isDefaultOnKey(key)) {
178
+ refuse(token, `"${key}" is core DEFAULT-ON — turning it "on" is a no-op that would send a redundant true; ` +
179
+ `use "-${key}" to turn it OFF`);
180
+ }
181
+ if (key === 'todoReminderMode')
182
+ refuse(token, 'todoReminderMode needs a value: `todoReminderMode=baseline|off`');
183
+ spec[key] = true;
184
+ }
185
+ return spec;
186
+ }
187
+ /** 总开关 off 的产出:literal-true 族一个不发(删键即关)+ DEFAULT-ON 两键显式 `false`。 */
188
+ function allDefaultOnOff() {
189
+ const out = {};
190
+ for (const k of ATTACHMENTS_DEFAULT_ON_KEYS)
191
+ out[k] = false;
192
+ return out;
193
+ }
194
+ function allLiteralTrue() {
195
+ const out = {};
196
+ for (const k of LITERAL_TRUE_KEYS)
197
+ out[k] = true;
198
+ return out;
199
+ }
200
+ /** `k=v` 的一次拆分(值里再有 `=` 一律归右侧,交给取值校验去拒)。 */
201
+ function splitAssign(token) {
202
+ const i = token.indexOf('=');
203
+ return i < 0 ? [token, undefined] : [token.slice(0, i).trim(), token.slice(i + 1).trim()];
204
+ }
205
+ /** 大小写不敏感的键名归一(env 值整体已 lowercase,而键名是 camelCase)。 */
206
+ function canonicalKey(lower) {
207
+ return ATTACHMENTS_SPEC_KEYS.find(k => k.toLowerCase() === lower);
208
+ }
209
+ function isDefaultOnKey(k) {
210
+ return ATTACHMENTS_DEFAULT_ON_KEYS.includes(k);
211
+ }
212
+ /** 单 token 形的历史回落判据(见 `attachmentsForRequest` 里那段注释)。 */
213
+ function isKnownToken(token) {
214
+ if (token === 'full' || token === 'all' || token === 'default')
215
+ return true;
216
+ if (token.startsWith('-'))
217
+ return canonicalKey(token.slice(1)) !== undefined;
218
+ const [lhs, rhs] = splitAssign(token);
219
+ if (rhs !== undefined)
220
+ return canonicalKey(lhs) !== undefined;
221
+ return canonicalKey(lhs.startsWith('+') ? lhs.slice(1) : lhs) !== undefined;
48
222
  }
@@ -22,9 +22,10 @@
22
22
  * `runs.steer(taskId, { text, mode? })`. The text is untrusted DATA — the shell sends it RAW and the
23
23
  * SERVER fences it (the shell does NOT pre-sanitize control markers). AT-MOST-ONCE: steer is NOT
24
24
  * idempotent, so it is NEVER retried (runs.ts:43). `mode` drains queued steers at the TURN boundary
25
- * ("all" = inject all next turn; "one-at-a-time" = one per turn, FIFO). Errors are fail-closed:
26
- * `SteeringNotRunningError` (run suspended/terminalresolve its gate, don't steer) and
27
- * `SteeringInvalidContentError` (the text carried a control-plane escape SURFACE, never strip-and-retry).
25
+ * ("all" = inject all next turn; "one-at-a-time" = one per turn, FIFO). Errors are fail-closed and
26
+ * dispatched by the WHOLE `steering.*` family (FIX③ 2026-08-07此前只认 2 个码;server 7.4.0
27
+ * dist 实扫 7 个,主 run steer 路由可达 4 个)。逐码语义 + 处置见 `ControlSafetyCode` 与
28
+ * 文件底部的 `STEERING_CODE_TO_SAFETY` / `STEERING_ADVICE` 两张表。
28
29
  *
29
30
  * 3. **cancel** (IH-8, §5.3, catalog L84). HARD-STOP an async run: `runs.cancel(taskId)` → 202 `CancelAck`
30
31
  * (`status:"cancelling"` or a terminal no-op). The run then SETTLES to `failed` + `errorCode:"cancelled"`
@@ -71,14 +72,47 @@ export interface RunsResourceLike {
71
72
  export interface ControlClientLike {
72
73
  runs: RunsResourceLike;
73
74
  }
74
- /** A supervision-verb stop the shell must HANDLE, not retry (contract/04 §9.1). The `code` is stable so the
75
- * shell can branch: `not_running` (steer a non-running run) | `invalid_content` (steer carried an escape) |
76
- * `cancel_lost_race` (409: 挂起的 gate 被并发决定/过期 — 重读状态后重试 cancel) | `not_found`。 */
75
+ /**
76
+ * A supervision-verb stop the shell must HANDLE, not retry (contract/04 §9.1). The `code` is stable so the
77
+ * shell can branch.
78
+ *
79
+ * 🔴 FIX③(2026-08-07,server 7.4.0 `dist/` 亲扫)—— steering 族此前只认 2 个码。实测 `steering.*`
80
+ * 在 server 7.4.0 里共 **7 个**;主 run 的 `POST /v1/runs/:id/steer` 路由(本 router 唯一调用的那条)
81
+ * 可达其中 4 个:`not_running`(409)/ `invalid_content`(422)/ `queue_full`(409,core 5.14.0 队列
82
+ * 化后新出)/ `duplicate_input_id`(409,调用方带 `Idempotency-Key` 时可达);另外 3 个来自子代
83
+ * steer/resume 面与 workflow agent steer 面(`ambiguous_target` / `ambiguous_label` / `still_running`)。
84
+ * SDK 6.3.0 侧已把整族改成**前缀分派**(`SteeringError` 基类),所以「没认全」的后果不是崩溃,
85
+ * 而是那些码原样裸抛给壳 —— 壳只 `catch (e instanceof ControlSafetyError)` 就漏在外面。
86
+ *
87
+ * ⚠️ `still_running` 与 `not_running` **语义正相反**(前者=子代还在飞,该 steer 不该 resume;后者=
88
+ * 它不在飞)。两码塌进同一个判别位会让 UI 说反话,所以它们各占一个码,永不合并。
89
+ */
90
+ export type ControlSafetyCode =
91
+ /** steer 了一个不在飞的 run(suspended/terminal/跨副本);处置=先解它的 gate,别 steer。 */
92
+ 'not_running'
93
+ /** steer 正文带控制面越狱串,core 拒;处置=SURFACE,绝不 strip-and-retry。 */
94
+ | 'invalid_content'
95
+ /** 挂起 checkpoint 上的 steer 队列满了(core fail-loud,绝不驱逐已接受的指令);处置=等它 drain。 */
96
+ | 'queue_full'
97
+ /** 同一个 `Idempotency-Key` 配了**不同**正文;处置=换一个 key,别复用(重发原文没有意义)。 */
98
+ | 'duplicate_input_id'
99
+ /** `ambiguous_target`(子代面)/ `ambiguous_label`(workflow 面)—— 不止一个活体匹配;
100
+ * 两码同一处置(换唯一定位方式),故共用一个判别位,`cause.errorCode` 保留是哪条轴。 */
101
+ | 'ambiguous_target'
102
+ /** 对一个**还在飞**的子代调了 resume;处置=改调 steer,或等它 settle。`not_running` 的反面。 */
103
+ | 'still_running'
104
+ /** 开集兜底:`steering.` 前缀但本表不认得的**未来**码(SDK 前缀分派同款姿势)。
105
+ * 处置=按「这条 steer 没落地」呈现,并把 `cause.errorCode` 原样打进日志,别按成员猜语义。 */
106
+ | 'steering_other'
107
+ /** cancel 的 409:挂起的 gate 被并发决定/过期(重读状态后重试 cancel)。 */
108
+ | 'cancel_lost_race'
109
+ /** 404 — non-owner / unknown run(无存在性谕示器)。 */
110
+ | 'not_found';
77
111
  export declare class ControlSafetyError extends Error {
78
- readonly code: 'not_running' | 'invalid_content' | 'cancel_lost_race' | 'not_found';
112
+ readonly code: ControlSafetyCode;
79
113
  /** The original SDK error, for logging (never re-thrown blind). */
80
114
  readonly cause?: unknown | undefined;
81
- constructor(message: string, code: 'not_running' | 'invalid_content' | 'cancel_lost_race' | 'not_found',
115
+ constructor(message: string, code: ControlSafetyCode,
82
116
  /** The original SDK error, for logging (never re-thrown blind). */
83
117
  cause?: unknown | undefined);
84
118
  }
@@ -146,6 +180,9 @@ export declare class ControlRouter {
146
180
  * failure propagates; the router does NOT auto-resend (a double-steer must be impossible).
147
181
  * - **Steer needs a RUNNING run.** A suspended/terminal run → 409 `SteeringNotRunningError` → surfaced as
148
182
  * `ControlSafetyError('not_running')` (resolve its gate via the hitlBridge `decide`, don't steer).
183
+ * 🔴 FIX③:整个 `steering.*` 族(7 员)都归到 `ControlSafetyError` 上,含 core 5.14.0 队列化
184
+ * 新出的 `queue_full` / `duplicate_input_id`,以及与 `not_running` **语义相反**的 `still_running`
185
+ * (两者永不合并);表外的未来码落开集位 `steering_other`,不再裸抛。
149
186
  * - **`mode` drains at the TURN boundary** (FORWARD-DRAFT per-call mapping, runs.ts:49-50): `"all"` injects
150
187
  * all queued steers at the next turn; `"one-at-a-time"` (default) one per turn, FIFO. Steer applies at
151
188
  * the next turn boundary, not instantly.
@@ -1,12 +1,4 @@
1
1
  import { uuid } from './adapter/types.js';
2
- // ── Errors the router surfaces (fail-closed; never auto-retried) ───────────────
3
- //
4
- // Mirrors hitlBridge.HitlSafetyError: every safety-relevant control error is a signal to re-present to the
5
- // human or surface the error, NEVER to silently retry or auto-decide (contract/04 §9.1 fail-closed law). The
6
- // router catches the SDK's typed errors and re-raises a stable, shell-branchable code.
7
- /** A supervision-verb stop the shell must HANDLE, not retry (contract/04 §9.1). The `code` is stable so the
8
- * shell can branch: `not_running` (steer a non-running run) | `invalid_content` (steer carried an escape) |
9
- * `cancel_lost_race` (409: 挂起的 gate 被并发决定/过期 — 重读状态后重试 cancel) | `not_found`。 */
10
2
  export class ControlSafetyError extends Error {
11
3
  code;
12
4
  cause;
@@ -99,6 +91,9 @@ export class ControlRouter {
99
91
  * failure propagates; the router does NOT auto-resend (a double-steer must be impossible).
100
92
  * - **Steer needs a RUNNING run.** A suspended/terminal run → 409 `SteeringNotRunningError` → surfaced as
101
93
  * `ControlSafetyError('not_running')` (resolve its gate via the hitlBridge `decide`, don't steer).
94
+ * 🔴 FIX③:整个 `steering.*` 族(7 员)都归到 `ControlSafetyError` 上,含 core 5.14.0 队列化
95
+ * 新出的 `queue_full` / `duplicate_input_id`,以及与 `not_running` **语义相反**的 `still_running`
96
+ * (两者永不合并);表外的未来码落开集位 `steering_other`,不再裸抛。
102
97
  * - **`mode` drains at the TURN boundary** (FORWARD-DRAFT per-call mapping, runs.ts:49-50): `"all"` injects
103
98
  * all queued steers at the next turn; `"one-at-a-time"` (default) one per turn, FIFO. Steer applies at
104
99
  * the next turn boundary, not instantly.
@@ -113,11 +108,12 @@ export class ControlRouter {
113
108
  return await this.client.runs.steer(this.taskId, body, opts?.signal !== undefined ? { signal: opts.signal } : undefined);
114
109
  }
115
110
  catch (e) {
116
- if (isSteeringNotRunning(e)) {
117
- throw new ControlSafetyError('steering.not_running the run is suspended/terminal; resolve its gate via decide, do not steer', 'not_running', e);
118
- }
119
- if (isSteeringInvalidContent(e)) {
120
- throw new ControlSafetyError('steering.invalid_content the steer text carried a control-plane escape; surface it, do not strip-and-retry', 'invalid_content', e);
111
+ // FIX③:整族逐码分派(表见文件底部)。前缀在场即归族 —— 未来新码落 `steering_other`,
112
+ // 绝不再裸抛给壳(裸抛 = 壳的 `instanceof ControlSafetyError` 分支整条漏在外面)
113
+ const verdict = steeringVerdict(e);
114
+ if (verdict !== undefined) {
115
+ const wireCode = errCodes(e).code ?? errCodes(e).name ?? 'steering.<unknown>';
116
+ throw new ControlSafetyError(`${wireCode} — ${STEERING_ADVICE.get(verdict) ?? 'steering refused'}`, verdict, e);
121
117
  }
122
118
  // Any other error (auth/transport): NOT retried (steer is at-most-once) — propagate to the shell.
123
119
  throw e;
@@ -235,15 +231,51 @@ export function errCodes(e) {
235
231
  const status = typeof rawStatus === 'number' ? rawStatus : undefined;
236
232
  return { code, name, status };
237
233
  }
238
- /** 409 `steering.not_running` / `SteeringNotRunningError` the run is suspended/terminal (runs.ts:45). */
239
- function isSteeringNotRunning(e) {
240
- const { code, name } = errCodes(e);
241
- return code === 'steering.not_running' || name === 'SteeringNotRunningError';
242
- }
243
- /** 422 `steering.invalid_content` / `SteeringInvalidContentError` — control-plane escape (runs.ts:46). */
244
- function isSteeringInvalidContent(e) {
234
+ // ── steering 错误族的**逐码表**(FIX③,server 7.4.0 dist 亲扫 + SDK 6.3.0 errors.d.ts 对表)────
235
+ //
236
+ // 🔴 `Map` 不是对象字面量:键来自 wire 的 `errorCode` 字符串,对象字面量上 `map['__proto__']`
237
+ // 会摸到 Object.prototype(adapt/arms.ts 头注登记的同一条纪律)。
238
+ const STEERING_CODE_TO_SAFETY = new Map([
239
+ ['steering.not_running', 'not_running'],
240
+ ['steering.invalid_content', 'invalid_content'],
241
+ ['steering.queue_full', 'queue_full'],
242
+ ['steering.duplicate_input_id', 'duplicate_input_id'],
243
+ ['steering.ambiguous_target', 'ambiguous_target'],
244
+ ['steering.ambiguous_label', 'ambiguous_target'],
245
+ ['steering.still_running', 'still_running'],
246
+ ]);
247
+ /** SDK 的 typed 子类名兜底(错误对象被传输层剥掉 `errorCode` 时仍认得族;`SteeringError` 基类本身
248
+ * = 「是 steering 族但没有专属子类」⇒ 落开集臂)。 */
249
+ const STEERING_NAME_TO_SAFETY = new Map([
250
+ ['SteeringNotRunningError', 'not_running'],
251
+ ['SteeringInvalidContentError', 'invalid_content'],
252
+ ['SteeringAmbiguousTargetError', 'ambiguous_target'],
253
+ ['SteeringStillRunningError', 'still_running'],
254
+ ['SteeringError', 'steering_other'],
255
+ ]);
256
+ /** 每个判别位的处置指路(壳把它原样打给用户/日志 —— 判词必须说得出「现在该做什么」)。 */
257
+ const STEERING_ADVICE = new Map([
258
+ ['not_running', 'the run is suspended/terminal/on another replica — resolve its gate via decide, do not steer'],
259
+ ['invalid_content', 'the steer text carried a control-plane escape; surface it, do not strip-and-retry'],
260
+ ['queue_full', 'the parked steering queue on this checkpoint is full (core fails loud rather than evicting an accepted instruction) — wait for it to drain, do not resend'],
261
+ ['duplicate_input_id', 'this Idempotency-Key is already parked with DIFFERENT steering content — reissue with a fresh key, resending verbatim will never succeed'],
262
+ ['ambiguous_target', 'more than one live target matches — address it uniquely (sub-agent: parentToolCallId; workflow: a more specific label); retrying verbatim yields the same result'],
263
+ ['still_running', 'the target is STILL RUNNING — steer it instead of resuming, or wait for it to settle (this is the exact opposite of not_running: never collapse the two)'],
264
+ ['steering_other', 'an unrecognized steering.* refusal (open set) — the steer did NOT land; log the errorCode verbatim, do not guess its meaning'],
265
+ ]);
266
+ /**
267
+ * 把一个 steer 抛出的错误判成 steering 族的哪一位。不是 steering 族 ⇒ `undefined`(调用方原样抛,
268
+ * 路由器绝不越界吞 auth/transport 的错)。
269
+ */
270
+ function steeringVerdict(e) {
245
271
  const { code, name } = errCodes(e);
246
- return code === 'steering.invalid_content' || name === 'SteeringInvalidContentError';
272
+ if (typeof code === 'string' && code.startsWith('steering.')) {
273
+ // 前缀在场即归族:表里没有的**未来**码落开集臂,而不是漏出去(SDK 6.3.0 的同款前缀分派)。
274
+ return STEERING_CODE_TO_SAFETY.get(code) ?? 'steering_other';
275
+ }
276
+ if (typeof name === 'string')
277
+ return STEERING_NAME_TO_SAFETY.get(name);
278
+ return undefined;
247
279
  }
248
280
  /** A 409 on `cancel` = the pending gate was decided/expired concurrently (lost CAS race; server
249
281
  * `conflict.approval_settled`). 🔴 它**不再**表示「run 是 suspended」——[868] 起 suspended/needs_review
@@ -73,7 +73,7 @@ import { type AskGateWireDeps } from './frameRouter.js';
73
73
  export { HITL_REJECT_MESSAGE, ENGINE_ABORT_TOOL_RESULT } from './frameRouter.js';
74
74
  export type { AskGateWireDeps } from './frameRouter.js';
75
75
  export type { AskAnsweredOutput } from './gateLedger.js';
76
- export { toAnsweredOutput, isAlreadyResolvedGateReason } from './parkResolver.js';
76
+ export { toAnsweredOutput, isAlreadyResolvedGateReason, GATE_FAILURE_CODES } from './parkResolver.js';
77
77
  export type { GateOutcome, GateFailureCode } from './parkResolver.js';
78
78
  /**
79
79
  * 包一层 AgentEvent 流:把 AskUserQuestion 的 suspended park 变成「对话框 → decide → 续流」闭环。
@@ -19,7 +19,7 @@ import { resolvePark } from './parkResolver.js';
19
19
  // `toAnsweredOutput` / 下方 `bridgeAskUserQuestionGates`)、类型四个。定义搬去了实现所在的
20
20
  // 那一刀,本文件是它们对外的**唯一门牌**——下游 import 路径不变,public-export 基线不变。
21
21
  export { HITL_REJECT_MESSAGE, ENGINE_ABORT_TOOL_RESULT } from './frameRouter.js';
22
- export { toAnsweredOutput, isAlreadyResolvedGateReason } from './parkResolver.js';
22
+ export { toAnsweredOutput, isAlreadyResolvedGateReason, GATE_FAILURE_CODES } from './parkResolver.js';
23
23
  /**
24
24
  * 包一层 AgentEvent 流:把 AskUserQuestion 的 suspended park 变成「对话框 → decide → 续流」闭环。
25
25
  * 其它事件原样透传;非 AskUserQuestion 的 gate 保持现状。fail-soft:任何桥内失败回退为
@@ -300,7 +300,9 @@ async function routeToolApprovalFrame(ev, ctx) {
300
300
  const fromSubagent = isFromSubagent(ev);
301
301
  hostLog('debug', `liveHitlAskWire: tool_approval frame ${ev.approvalId} tool=${String(ev.toolName)}${fromSubagent ? ` from-subagent=${String(ev.sourceTaskId ?? ev.sourceAgentName ?? 'explicit')}` : ''}`);
302
302
  const gatedCallId = !fromSubagent ? led.lastPendingFsCall() : undefined;
303
- const decision = await surfaceToolApprovalFrameAndRespond(ev, deps.respondToolApproval, argsOfGatedStart(led, gatedCallId), ctx.signal);
303
+ // FIX①(2026-08-07):产物是 `{decision, ack?}` —— ack 上的 `rememberApplied`/`updatedInputForwarded`
304
+ // 由 toolApprovalWire 就地收敛成宿主通知(本路由只认决断词,不重复解释 ack)。
305
+ const { decision } = await surfaceToolApprovalFrameAndRespond(ev, deps.respondToolApproval, argsOfGatedStart(led, gatedCallId), ctx.signal);
304
306
  if (decision === 'deny' && !fromSubagent) {
305
307
  // 该 call 的报错收口帧 stamp REJECT 文案(vendored `User rejected <op> to <path>` 卡)。
306
308
  if (gatedCallId !== undefined)
@@ -131,11 +131,17 @@ export interface PlanReviewOutcome {
131
131
  export declare class HitlSafetyError extends Error {
132
132
  /** A stable code the shell can branch on (REF-CC-036,2026-08-02:闭集联合,不再是开集 string —
133
133
  * 与同族兄弟 `controlRouter.ts` 的 `ControlSafetyError.code` 同形)。 */
134
- readonly code: 'binding_mismatch' | 'no_pending' | 'wrong_gate' | 'bad_plan_edit';
134
+ /** 🔴 闭集(FIX② 2026-08-07 `empty_answer`,第 5 员)。加员必须**同批**改齐
135
+ * `parkResolver.GATE_FAILURE_CODES` 白名单 —— 那道白名单把不认得的码降成 `undefined`,
136
+ * 一边加一边不改另一边 = 新码在消费端被静默吞掉(比不加还坏:本地拦住了,判词却丢了)。 */
137
+ readonly code: 'binding_mismatch' | 'no_pending' | 'wrong_gate' | 'bad_plan_edit' | 'empty_answer';
135
138
  constructor(message: string,
136
139
  /** A stable code the shell can branch on (REF-CC-036,2026-08-02:闭集联合,不再是开集 string —
137
140
  * 与同族兄弟 `controlRouter.ts` 的 `ControlSafetyError.code` 同形)。 */
138
- code: 'binding_mismatch' | 'no_pending' | 'wrong_gate' | 'bad_plan_edit');
141
+ /** 🔴 闭集(FIX② 2026-08-07 `empty_answer`,第 5 )。加员必须**同批**改齐
142
+ * `parkResolver.GATE_FAILURE_CODES` 白名单 —— 那道白名单把不认得的码降成 `undefined`,
143
+ * 一边加一边不改另一边 = 新码在消费端被静默吞掉(比不加还坏:本地拦住了,判词却丢了)。 */
144
+ code: 'binding_mismatch' | 'no_pending' | 'wrong_gate' | 'bad_plan_edit' | 'empty_answer');
139
145
  }
140
146
  /** The active gate the downstream stream most recently suspended on (null when running). */
141
147
  export interface ActiveGate {
@@ -250,6 +256,17 @@ export declare class HitlBridge {
250
256
  * permission gate, but the answer rides `ApprovalDecision.answer` as the worker-validated
251
257
  * `{ answers: [{ header, selected, note? }] }` shape. The D-1 binding STILL binds (it is still a
252
258
  * checkpoint). Each answer's `header` MUST match a question header (the worker 400s on mismatch).
259
+ *
260
+ * 🔴 FIX②(2026-08-07)—— **本地 fail-loud**:此前本方法对 `answers: []` 与「某条 `selected: []`」
261
+ * 零校验,一条注定 400 的请求照样出门。这不是「多一次往返」而已 ——
262
+ * · 用户面只看到「作答失败」,而失败的原因(空作答)在壳侧一个字都没说;
263
+ * · `ToolPermissionDecision.answer` 的头注自己写着「a silently emptied answer is the user's
264
+ * choice being thrown away without anyone noticing」—— 那条纪律此前只写在类型注释里,
265
+ * 没有任何一层真的执行它;
266
+ * · `{answers:[]}` 在 wire 上另有确切含义(question 域 **deny** 的 NO_HUMAN 形),把它当成一次
267
+ * approve 发出去 = 拿 deny 的载荷冒充 approve。
268
+ * 三形一律抛 `HitlSafetyError('empty_answer')`,一次 decide 都不发:空 `answers[]` /
269
+ * 任一条 `selected[]` 为空 / 任一条 `header` 为空串。
253
270
  */
254
271
  answerQuestion(answers: AskAnswer[], toolUseID?: string, opts?: {
255
272
  signal?: AbortSignal;
@@ -8,6 +8,9 @@ export class HitlSafetyError extends Error {
8
8
  constructor(message,
9
9
  /** A stable code the shell can branch on (REF-CC-036,2026-08-02:闭集联合,不再是开集 string —
10
10
  * 与同族兄弟 `controlRouter.ts` 的 `ControlSafetyError.code` 同形)。 */
11
+ /** 🔴 闭集(FIX② 2026-08-07 加 `empty_answer`,第 5 员)。加员必须**同批**改齐
12
+ * `parkResolver.GATE_FAILURE_CODES` 白名单 —— 那道白名单把不认得的码降成 `undefined`,
13
+ * 一边加一边不改另一边 = 新码在消费端被静默吞掉(比不加还坏:本地拦住了,判词却丢了)。 */
11
14
  code) {
12
15
  super(message);
13
16
  this.code = code;
@@ -185,11 +188,25 @@ export class HitlBridge {
185
188
  * permission gate, but the answer rides `ApprovalDecision.answer` as the worker-validated
186
189
  * `{ answers: [{ header, selected, note? }] }` shape. The D-1 binding STILL binds (it is still a
187
190
  * checkpoint). Each answer's `header` MUST match a question header (the worker 400s on mismatch).
191
+ *
192
+ * 🔴 FIX②(2026-08-07)—— **本地 fail-loud**:此前本方法对 `answers: []` 与「某条 `selected: []`」
193
+ * 零校验,一条注定 400 的请求照样出门。这不是「多一次往返」而已 ——
194
+ * · 用户面只看到「作答失败」,而失败的原因(空作答)在壳侧一个字都没说;
195
+ * · `ToolPermissionDecision.answer` 的头注自己写着「a silently emptied answer is the user's
196
+ * choice being thrown away without anyone noticing」—— 那条纪律此前只写在类型注释里,
197
+ * 没有任何一层真的执行它;
198
+ * · `{answers:[]}` 在 wire 上另有确切含义(question 域 **deny** 的 NO_HUMAN 形),把它当成一次
199
+ * approve 发出去 = 拿 deny 的载荷冒充 approve。
200
+ * 三形一律抛 `HitlSafetyError('empty_answer')`,一次 decide 都不发:空 `answers[]` /
201
+ * 任一条 `selected[]` 为空 / 任一条 `header` 为空串。
188
202
  */
189
203
  async answerQuestion(answers, toolUseID, opts,
190
204
  /** REF-CC-029(2026-08-02):see `decideTool`'s same-named param — the pending row the caller already
191
205
  * fetched via `findPendingForTask`. */
192
206
  preResolvedPending) {
207
+ // 🔴 校验在 findPending **之前**:入参坏了跟 pending 行在不在场无关,而先 list 再拒会把
208
+ // 「你的作答是空的」这条真判词换成「no_pending」那条假判词(行恰好过期时)。
209
+ assertAnswersSendable(answers);
193
210
  const pending = preResolvedPending ?? (await this.findPending(toolUseID, opts));
194
211
  if (!pending) {
195
212
  throw new HitlSafetyError('no pending AskUserQuestion checkpoint to answer — refetch + re-present', 'no_pending');
@@ -304,6 +321,26 @@ export function makeHitlCanUseTool(bridge, prompt) {
304
321
  };
305
322
  }
306
323
  // ── helpers ───────────────────────────────────────────────────────────────────
324
+ /**
325
+ * FIX②(2026-08-07):作答**出门前**的本地闭集校验(fail-closed;详见 `answerQuestion` 头注)。
326
+ * 只判「这批作答在 wire 上有没有意义」,不判 header 与题面对不对得上 —— 后者的真源在 worker
327
+ * (它持有题面),壳侧猜一份题面副本只会造出第二个真源。
328
+ */
329
+ function assertAnswersSendable(answers) {
330
+ if (!Array.isArray(answers) || answers.length === 0) {
331
+ throw new HitlSafetyError('refusing to send an EMPTY AskUserQuestion answer set — `{answers:[]}` is the wire\'s DENY/NO_HUMAN form, ' +
332
+ 'sending it as an approve would throw the human\'s choice away and 400 the worker', 'empty_answer');
333
+ }
334
+ for (const a of answers) {
335
+ if (typeof a?.header !== 'string' || a.header.length === 0) {
336
+ throw new HitlSafetyError('refusing to send an answer with an EMPTY `header` — the worker matches answers to questions BY header (400)', 'empty_answer');
337
+ }
338
+ if (!Array.isArray(a.selected) || a.selected.length === 0) {
339
+ throw new HitlSafetyError(`refusing to send an answer with an EMPTY \`selected\` for header "${a.header}" — ` +
340
+ 'an emptied selection is the human\'s choice silently discarded (the worker 400s on it)', 'empty_answer');
341
+ }
342
+ }
343
+ }
307
344
  /** Marshal one answer into the worker-validated wire entry (contract/04 §4.1). Drops undefined `note`.
308
345
  * REF-CC-038(2026-08-02):return 类型是 `AskAnswer` 本身(唯一源),不再是一份重写的内联匿名形。 */
309
346
  function marshalAnswer(a) {
@@ -37,6 +37,33 @@ export declare function surfaceForCurrentSession(): HitlHostSurface | null;
37
37
  export declare const CANCEL_DENY_BUDGET_MS = 2000;
38
38
  /** warn 行文案(测试锁字面)。 */
39
39
  export declare const CANCEL_DENY_WARN_TEXT = "could not cancel the pending question \u2014 the session may stay locked; the run may need engine-side recovery";
40
+ /** 文案(测试锁字面)。措辞刻意描述**后果**而不是内部机制:用户要知道的是「下次还会问」。 */
41
+ export declare const REMEMBER_NOT_APPLIED_WARN_TEXT = "this session-wide allow was not remembered by the worker \u2014 you will be asked again for this tool";
42
+ /** 文案(测试锁字面)。"original" 是判据词:用户必须读到「跑的是原来那份」。 */
43
+ export declare const EDIT_NOT_FORWARDED_WARN_TEXT = "your edited tool input was NOT forwarded \u2014 the tool is running with the ORIGINAL input you saw before editing";
44
+ /**
45
+ * `allow_session` 决断的 ack 回报 `rememberApplied:false`(server 1.239 起诚实回显 —— 典型成因
46
+ * 是这条 run 没有 sessionId,grant 无处可落)。
47
+ *
48
+ * 🔴 为什么这必须上屏而不是只记 debug:三选卡第 2 项在用户心里签的是「本会话这个工具不再问我」。
49
+ * 记没落店而界面照渲「全放行」,就是**把一个没有发生的授权渲成发生了** —— 下一次弹卡时用户会以为
50
+ * 是 bug,更坏的是他可能因此养成「一路回车」的习惯。缺席(旧 server 不出这个位)= 未知,
51
+ * 按 [honest-absence-not-fabricated-zero] 一律不发通知,绝不把未知渲成失败。
52
+ * 无宿主口(print/headless 没屏)⇒ 静默,与 {@link surfaceCancelDenyWarn} 同语义。
53
+ */
54
+ export declare function surfaceRememberNotApplied(): void;
55
+ /**
56
+ * 「编辑并批准」带了 `updatedInput`,而 ack 回报 `updatedInputForwarded:false`(server 1.241 起
57
+ * 诚实回显)—— 引擎拿到的是**原始**入参。
58
+ *
59
+ * 🔴 这一条比 remember 那条严重一个量级,它是本批 codex 复审判的 [high]:用户在卡上把
60
+ * `rm -rf /tmp/x` 改成 `rm -rf /tmp/x/build` 然后按了批准,而真正执行的是他改之前那条。
61
+ * **事后没有 fail-closed 的余地**(allow 已经送达,工具已经在跑),所以唯一诚实的处置就是立刻
62
+ * 响亮地说出来 —— 静默(此前只写 debug 日志)等于让用户以为自己的编辑生效了。
63
+ * 上游的正位是让 server 在「带了 updatedInput 却转发不了」时 fail-closed 拒批;那属跨仓,
64
+ * 已记进 docs/refactor/README.md 的宿主/上游工单表,本层不做旁路补偿(只做如实告知)。
65
+ */
66
+ export declare function surfaceEditNotForwarded(): void;
40
67
  /**
41
68
  * 中断 deny 的有界观察(壳侧单测 `hitlCancelDeny.test.ts` 的被测面;REF-CC-023 起两条决断腿共用)。
42
69
  * 铁律:不 await 进 abort 返回路径(用户立即拿回控制);这里只管后台 settle 的«观察»: