@sema-agent/server 7.130.0 → 7.131.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.
Files changed (103) hide show
  1. package/MIGRATION.md +87 -0
  2. package/README.md +8 -5
  3. package/README.zh-CN.md +6 -4
  4. package/USAGE.md +36 -8
  5. package/dist/audit.js +4 -1
  6. package/dist/bench/s1/live-deps.js +2 -0
  7. package/dist/boot/resolve-spec.js +38 -15
  8. package/dist/boot/stage-02-web-admin-pull.js +9 -1
  9. package/dist/boot/stage-10-http-server.js +6 -3
  10. package/dist/capabilities/builtin-tools.js +2 -1
  11. package/dist/capabilities/composite-members.d.ts +1 -0
  12. package/dist/capabilities/composite-members.js +1 -0
  13. package/dist/capabilities/hands-lane.js +2 -2
  14. package/dist/capabilities/host-tool-names.d.ts +17 -0
  15. package/dist/capabilities/host-tool-names.js +15 -0
  16. package/dist/capabilities/mcp-probe.js +3 -3
  17. package/dist/capabilities/repo-tools.js +4 -3
  18. package/dist/capabilities/scenarios.d.ts +8 -7
  19. package/dist/capabilities/scenarios.js +5 -4
  20. package/dist/capabilities/select-environment-tool.js +2 -1
  21. package/dist/capabilities/send-user-file-tool.d.ts +0 -1
  22. package/dist/capabilities/send-user-file-tool.js +2 -2
  23. package/dist/capabilities/tool-defer.js +2 -1
  24. package/dist/config-catalog.js +16 -5
  25. package/dist/config-invariants.js +2 -2
  26. package/dist/config-provider.js +2 -2
  27. package/dist/config-types.d.ts +6 -0
  28. package/dist/config.d.ts +2 -2
  29. package/dist/config.js +18 -17
  30. package/dist/elicitation.d.ts +7 -0
  31. package/dist/elicitation.js +20 -5
  32. package/dist/hooks/hook-llm.js +2 -2
  33. package/dist/hooks/hook-runner.d.ts +54 -8
  34. package/dist/hooks/hook-runner.js +86 -16
  35. package/dist/http/admission.js +2 -2
  36. package/dist/http/approval-sweeps.js +2 -2
  37. package/dist/http/checkpoint-ctx.d.ts +12 -11
  38. package/dist/http/checkpoint-ctx.js +4 -4
  39. package/dist/http/dispatch.js +1 -1
  40. package/dist/http/resume-legs.js +6 -5
  41. package/dist/http/routes/approvals-assistant.js +1 -1
  42. package/dist/http/routes/capabilities.js +6 -16
  43. package/dist/http/routes/runs.js +6 -10
  44. package/dist/http/routes/sessions.js +22 -10
  45. package/dist/http/routes/tasks.js +16 -11
  46. package/dist/http/scenario-read-face.d.ts +14 -0
  47. package/dist/http/scenario-read-face.js +5 -0
  48. package/dist/http/send.d.ts +10 -0
  49. package/dist/http/send.js +6 -0
  50. package/dist/http/server.d.ts +16 -12
  51. package/dist/http/server.js +2 -2
  52. package/dist/http/stored-body-replay.d.ts +10 -3
  53. package/dist/http/stored-body-replay.js +27 -6
  54. package/dist/http/wire-types.d.ts +18 -5
  55. package/dist/index.d.ts +2 -1
  56. package/dist/index.js +2 -1
  57. package/dist/mcp-assertion-header.d.ts +2 -2
  58. package/dist/mcp-assertion-header.js +3 -3
  59. package/dist/model-entry-refusal.d.ts +1 -7
  60. package/dist/model-route-endpoint.d.ts +51 -11
  61. package/dist/model-route-endpoint.js +45 -23
  62. package/dist/model-route-gap.d.ts +5 -4
  63. package/dist/model-route-gap.js +1 -1
  64. package/dist/model-select.d.ts +58 -0
  65. package/dist/model-select.js +31 -0
  66. package/dist/namespaced-rule-name.d.ts +7 -4
  67. package/dist/namespaced-rule-name.js +4 -3
  68. package/dist/observability/fail-open.d.ts +11 -6
  69. package/dist/observability/fail-open.js +5 -1
  70. package/dist/plugins/local-session-store.js +2 -2
  71. package/dist/plugins/one-time-migrations.d.ts +10 -1
  72. package/dist/plugins/one-time-migrations.js +16 -1
  73. package/dist/question.d.ts +18 -9
  74. package/dist/question.js +21 -13
  75. package/dist/request-key-closure.d.ts +2 -2
  76. package/dist/request-key-closure.js +16 -9
  77. package/dist/rule-entry-list.d.ts +124 -17
  78. package/dist/rule-entry-list.js +140 -24
  79. package/dist/run-local.d.ts +3 -3
  80. package/dist/run-local.js +8 -7
  81. package/dist/runs.d.ts +42 -2
  82. package/dist/runs.js +45 -2
  83. package/dist/spec-fields.d.ts +8 -4
  84. package/dist/stored-request-hooks-m20.d.ts +23 -0
  85. package/dist/stored-request-hooks-m20.js +124 -0
  86. package/dist/stored-request-tool-axes-m19.d.ts +1 -1
  87. package/dist/task-mcp.d.ts +41 -77
  88. package/dist/task-mcp.js +143 -75
  89. package/dist/task-settings.d.ts +41 -5
  90. package/dist/task-settings.js +39 -25
  91. package/dist/trace/core-keyset-guard.d.ts +2 -2
  92. package/dist/trace/ledger-sink.d.ts +29 -0
  93. package/dist/trace/ledger-sink.js +11 -0
  94. package/dist/trace/project.d.ts +6 -0
  95. package/dist/trace/project.js +11 -0
  96. package/dist/trace/wire-projection-faces.d.ts +1 -1
  97. package/dist/trace/wire-projection-faces.js +1 -1
  98. package/dist/user-authored-text.d.ts +7 -0
  99. package/dist/user-authored-text.js +3 -0
  100. package/dist/web-admin-client/apply-effective.js +10 -5
  101. package/dist/web-admin-client/types.d.ts +1 -1
  102. package/package.json +2 -2
  103. package/scripts/drain-check-local.mjs +3 -2
@@ -1,5 +1,5 @@
1
1
  import { adjudicateModelRoute, joinRequestUrl, resolveRouteCredential, routeKeyOf, routeRefusalText } from "@sema-agent/core";
2
- import { matchCatalogModel, refusedCatalogEntry } from "../model-select.js";
2
+ import { matchCatalogModel, refusedCatalogEntry, servedModelTableLeg } from "../model-select.js";
3
3
  import { modelUnavailableMessage } from "../model-entry-refusal.js";
4
4
  import { resolveOpenaiRouteEndpoint } from "../model-route-endpoint.js";
5
5
  import { dispatchBrainLane } from "../model-provider.js";
@@ -24,7 +24,7 @@ export function createHookLlm(deps) {
24
24
  case "anthropic":
25
25
  return { ok: true, catalogRef, providerModelId, baseUrl: m?.baseUrl || anthropicRouteBaseUrl(), api: "anthropic-messages" };
26
26
  case "gateway": {
27
- const endpoint = resolveOpenaiRouteEndpoint(catalogRef, m?.baseUrl, config.gatewayBaseUrl);
27
+ const endpoint = resolveOpenaiRouteEndpoint(catalogRef, m, config.gatewayBaseUrl, servedModelTableLeg(config));
28
28
  return endpoint.ok ? { ok: true, catalogRef, providerModelId, baseUrl: endpoint.baseUrl, api: "openai-completions" } : { ok: false, error: endpoint.refusal };
29
29
  }
30
30
  }
@@ -1,4 +1,5 @@
1
- import { HooksConfig, type HookEventName } from "@sema-agent/settings-schema/hooks";
1
+ import { type HookEntry, type HookEntryType, type HookEventName, type HooksDroppedPiece, type HooksRejection } from "@sema-agent/settings-schema/hooks";
2
+ import { type ConfigPath } from "@sema-agent/settings-schema";
2
3
  import type { Hooks } from "@sema-agent/core";
3
4
  import type { Logger } from "../observability/logger.js";
4
5
  import type { HookNoticeObservation } from "../fleet/fleet-bus.js";
@@ -31,10 +32,45 @@ export declare const MAX_HOOK_OUTPUT_BYTES: number;
31
32
  * 且 4096 的单条帽保证**前两条**钩子的话仍整段进得去(截断是配置广度的代价,不是常态)。
32
33
  */
33
34
  export declare const MAX_HOOK_CONTEXT_TOTAL_CHARS = 10000;
35
+ /**
36
+ * 本仓对契约每一种 hook 条目类型的表态 —— **一张表**:受理面({@link parseHooksConfig})与执行侧({@link RunnableHookEntry},以及每个按条目类型分派的臂)读的都是它。
37
+ *
38
+ * `Record<HookEntryType, boolean>` 穷举:契约新增一种条目类型而这里没登记 ⇒ 编译红。`true` ⇒ 执行侧有这一型的臂 —— 把某一型改成 `true` 而执行侧没补臂,
39
+ * 分派处(`hookDisplayName` / `runMatchingEntries` 的类型分派)编译红;`false` ⇒ 本仓不跑它,受理面把含它的配置**整份拒**(提交 400),
40
+ * 不是记一条运维日志然后跳过:守卫事件里一条被跳过的条目,是「写的人以为在拦、其实没拦」。
41
+ */
42
+ declare const HOOK_ENTRY_TYPE_RUNS: {
43
+ readonly command: true;
44
+ readonly http: true;
45
+ readonly prompt: true;
46
+ readonly agent: true;
47
+ readonly mcp_tool: false;
48
+ };
49
+ /** {@link HOOK_ENTRY_TYPE_RUNS} 里表态为 `true` 的类型(由表派生,不另写一份)。 */
50
+ export type SupportedHookEntryType = {
51
+ [T in HookEntryType]: (typeof HOOK_ENTRY_TYPE_RUNS)[T] extends true ? T : never;
52
+ }[HookEntryType];
53
+ /** 本仓会真跑的条目类型闭集(契约 {@link HOOK_ENTRY_TYPES} 的次序)—— 拒句 `supported: …` 的词表与 {@link RunnableHookEntry} 的类型都出自这一张表。 */
54
+ export declare const SUPPORTED_HOOK_ENTRY_TYPES: readonly SupportedHookEntryType[];
55
+ /** 本 runner 会真跑的条目(契约 `HookEntry` 取表态为 `true` 的那几型)。 */
56
+ export type RunnableHookEntry = Extract<HookEntry, {
57
+ type: SupportedHookEntryType;
58
+ }>;
59
+ /** 一个匹配器组,条目只含 {@link RunnableHookEntry}。 */
60
+ export interface RunnableMatcherGroup {
61
+ matcher?: string;
62
+ hooks: RunnableHookEntry[];
63
+ }
64
+ /** 受理面通过后的 hooks 配置:每个事件下的条目都是本仓会跑的型 —— 执行侧({@link createTaskHooks})只收这一型,「不认识的类型」到不了它手里。 */
65
+ export type RunnableHooksConfig = Partial<Record<HookEventName, RunnableMatcherGroup[]>>;
66
+ /** 分类器点名的一块(被剔的块或整份被拒的理由):码 + 坐标 + 坏字段。 */
67
+ export type HooksRefusalPiece = HooksDroppedPiece | HooksRejection;
34
68
  export interface ParseHooksConfigResult {
35
- config?: HooksConfig;
36
- /** 人可读的第一条校验错误(HTTP 层用它 400 fail-loud;resume 防御路径用它 warn)。 */
69
+ config?: RunnableHooksConfig;
70
+ /** 人可读的第一条拒因(HTTP 层用它 400 fail-loud;resume 防御路径用它 warn)。 */
37
71
  error?: string;
72
+ /** 拒因来自**分类器**时,它点名的每一块(数据,文档序);本仓自己的拒(条目类型 / 服务侧上限)不带这一位。一次性迁移读它按名剥键(`stored-request-hooks-m20.ts`)—— 迁移不另起读者。 */
73
+ pieces?: readonly HooksRefusalPiece[];
38
74
  }
39
75
  /**
40
76
  * 🔴 **S-924 → S-995(clay [ref] ③;八病审计 SG H6-d;test 7.112.0 验收 OBS ①)—— 工具事件的 matcher 选中了工具**别名**、却选不中它的正名 ⇒ fresh 提交面响亮拒。**
@@ -184,10 +220,19 @@ export declare function buildToolMatcherRefusal(issue: ToolMatcherIssue): ToolMa
184
220
  * `budget` **仅供测试注入**({@link MatcherEvalBudget});生产调用点只传前两个实参。
185
221
  * **只有 `admission.ts` 的 fresh 提交门调它** —— resume 重放不调(理由见上面的头注)。未判的 matcher 过 `recordFailOpen`(tag `MATCHER_UNJUDGED_TAG`);超预算的不是未判,是拒。
186
222
  */
187
- export declare function toolMatcherIssue(config: HooksConfig, depth: MatcherEvalDepth, budget?: MatcherEvalBudget): ToolMatcherIssue | undefined;
223
+ export declare function toolMatcherIssue(config: RunnableHooksConfig, depth: MatcherEvalDepth, budget?: MatcherEvalBudget): ToolMatcherIssue | undefined;
224
+ /** 坐标出门形:`settings.` + 契约包唯一的 stringify 点({@link formatConfigPath}),事件名等键段是用户文本 ⇒ 先过 {@link outgoingPath}(凭据形不原样回显)。 */
225
+ export declare function hooksCoordinate(path: ConfigPath): string;
188
226
  /**
189
- * 把 wire 上的 untrusted `settings.hooks` 校验成契约 {@link HooksConfig} + 服务侧上限。
190
- * 错误返回 error 字符串而不 throw——submit 路径 400 fail-loud、resume 防御路径 warn-drop,由调用方选。
227
+ * 把 wire 上的 untrusted `settings.hooks` 判成 {@link RunnableHooksConfig} + 服务侧上限 —— **hooks 配置在本仓的唯一入口**。
228
+ *
229
+ * · **一只读者**:合不合法只问契约包的 {@link classifyHooksConfig}(入参是 `{ hooks: raw }`,与契约 zod 面 `HooksConfig` 是同一读法的投影)。本仓只收 `loaded`:
230
+ * 一块被剔(`loaded-partial`)或整份被拒(`rejected`)都是拒 —— wire 面「剔掉坏块、其余照装」会让写的人以为生效的钩子无声消失(分类器的宽松粒度是读设置文件的宿主用的)。
231
+ * 拒句 = 分类器给的**码 + 坐标 + 坏字段**经 {@link hooksPieceRefusal} 铸(分类器不写句子)。
232
+ * · **本仓不执行的条目类型整份拒**:分类器受理契约的全部五型,而本仓只跑 {@link SUPPORTED_HOOK_ENTRY_TYPES}。含其余类型的配置整份拒,不留跳过臂。
233
+ * · **服务侧上限**(广度闸;契约本身不设量纲)。
234
+ *
235
+ * 错误返回 error 字符串而不 throw——submit 路径 400 fail-loud、resume 防御路径 warn-drop(hooks 整只不采纳),由调用方选;**读者与句子两腿同一只**,后果按腿分。
191
236
  */
192
237
  export declare function parseHooksConfig(raw: unknown): ParseHooksConfigResult;
193
238
  /**
@@ -320,10 +365,10 @@ export interface LlmHookExtra {
320
365
  transcript?: string;
321
366
  }
322
367
  /**
323
- * 把校验过的 {@link HooksConfig} 翻成 core `Hooks` 回调(阶段二:engine-owned 9 事件全点亮)。
368
+ * 把校验过的 {@link RunnableHooksConfig} 翻成 core `Hooks` 回调(阶段二:engine-owned 9 事件全点亮)。
324
369
  * 没有可点亮的条目 → undefined(spec 不挂 hooks,deps 路径原样)。
325
370
  */
326
- export declare function createTaskHooks(config: HooksConfig, ctx: HookRunnerContext): Hooks | undefined;
371
+ export declare function createTaskHooks(config: RunnableHooksConfig, ctx: HookRunnerContext): Hooks | undefined;
327
372
  /**
328
373
  * 把部署级 hooks 基线(deps.hooks,如 TOOL_TRACE 观测)折进 task 级 hooks——core 是整槽覆盖
329
374
  * (`spec.hooks ?? deps.hooks`),不折叠就静默 shadow 部署观测(runtask gateBaseline 注释点名的坑)。
@@ -338,4 +383,5 @@ export declare function createTaskHooks(config: HooksConfig, ctx: HookRunnerCont
338
383
  * 单在则透传。
339
384
  */
340
385
  export declare function composeHooks(deployment: Hooks | undefined, task: Hooks): Hooks;
386
+ export {};
341
387
  //# sourceMappingURL=hook-runner.d.ts.map
@@ -1,7 +1,8 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import vm from "node:vm";
3
3
  import { hostShell, resolveHostShell, adoptHostForegroundChild } from "../plugins/host-platform.js";
4
- import { HooksConfig, DEFAULT_HOOK_TIMEOUT_SECONDS, HOOK_EVENT_OWNER, HOOK_EVENT_MATCHER_FIELD, hookMatcherForm, hookMatcherSelects, interpolateHookEnvVars, } from "@sema-agent/settings-schema/hooks";
4
+ import { classifyHooksConfig, DEFAULT_HOOK_TIMEOUT_SECONDS, HOOK_ENTRY_TYPES, HOOK_EVENTS, HOOK_EVENT_OWNER, HOOK_EVENT_MATCHER_FIELD, hookMatcherForm, hookMatcherSelects, interpolateHookEnvVars, } from "@sema-agent/settings-schema/hooks";
5
+ import { formatConfigPath, outgoingPath } from "@sema-agent/settings-schema";
5
6
  import { describeToolCatalog, isEngineRefusal, receiveHookResult } from "@sema-agent/core";
6
7
  import { boundedNoticeText } from "../fleet/fleet-bus.js";
7
8
  import { recordFailOpen } from "../observability/fail-open.js";
@@ -57,6 +58,23 @@ function toAssistantTurnError(errorKind) {
57
58
  return "unknown";
58
59
  return Object.prototype.hasOwnProperty.call(BRAIN_TO_TURN_ERROR, errorKind) ? (BRAIN_TO_TURN_ERROR[errorKind] ?? "unknown") : "unknown";
59
60
  }
61
+ const HOOK_ENTRY_TYPE_RUNS = {
62
+ command: true,
63
+ http: true,
64
+ prompt: true,
65
+ agent: true,
66
+ mcp_tool: false,
67
+ };
68
+ function isSupportedHookEntryType(t) {
69
+ return HOOK_ENTRY_TYPE_RUNS[t];
70
+ }
71
+ export const SUPPORTED_HOOK_ENTRY_TYPES = HOOK_ENTRY_TYPES.filter(isSupportedHookEntryType);
72
+ function isHookEventName(key) {
73
+ return HOOK_EVENTS.includes(key);
74
+ }
75
+ function isRunnableHookEntry(entry) {
76
+ return isSupportedHookEntryType(entry.type);
77
+ }
60
78
  export const MAX_MATCHER_EVAL_CHARS = MAX_HOOK_COMMAND_CHARS;
61
79
  export const ADMISSION_MATCHER_EVAL_BUDGET_MS = 1_000;
62
80
  export const MATCHER_ECHO_CHARS = 200;
@@ -201,23 +219,61 @@ export function toolMatcherIssue(config, depth, budget = {}) {
201
219
  }
202
220
  return undefined;
203
221
  }
222
+ const HOOKS_PIECE_MEANING = {
223
+ "hooks-not-object": "`hooks` must be an object keyed by hook event name",
224
+ "unknown-event": "not a hook event name",
225
+ "event-not-array": "an event's value must be an array of matcher groups",
226
+ "matcher-invalid": "a matcher group must be an object with an optional string `matcher` and an array `hooks`",
227
+ "entry-invalid": "a hook entry must be an object whose `type` and fields are valid for that type",
228
+ "guard-outside-hooks": "a PreToolUse / PermissionRequest hook sits outside `hooks`",
229
+ "hooks-is-matcher": "`hooks` holds a matcher group or an entry directly; it must be an object keyed by hook event name",
230
+ "guard-under-unknown-event": "an unknown event name holds a PreToolUse / PermissionRequest hook",
231
+ "guard-event-not-array": "a PreToolUse / PermissionRequest event's value must be an array of matcher groups",
232
+ "guard-matcher-invalid": "a PreToolUse / PermissionRequest matcher group is not valid",
233
+ "guard-entry-invalid": "a PreToolUse / PermissionRequest hook entry is not valid",
234
+ };
235
+ export function hooksCoordinate(path) {
236
+ return `settings.${formatConfigPath(outgoingPath(path))}`;
237
+ }
238
+ function hooksPieceRefusal(piece) {
239
+ const field = piece.field !== undefined ? ` (field: ${piece.field})` : "";
240
+ return `${hooksCoordinate(piece.path)}: ${piece.code} — ${HOOKS_PIECE_MEANING[piece.code]}${field}`;
241
+ }
242
+ function unsupportedHookEntryTypeRefusal(path, type) {
243
+ return `${hooksCoordinate(path)}: this server does not run hook entries of type ${type}; supported: ${SUPPORTED_HOOK_ENTRY_TYPES.join(" / ")}`;
244
+ }
204
245
  export function parseHooksConfig(raw) {
205
- const parsed = HooksConfig.safeParse(raw);
206
- if (!parsed.success) {
207
- const first = parsed.error.issues[0];
208
- return { error: `settings.hooks: ${first ? `${first.path.join(".")}: ${first.message}` : "invalid shape"}` };
246
+ const classified = classifyHooksConfig({ hooks: raw });
247
+ if (classified.outcome !== "loaded") {
248
+ const first = classified.outcome === "rejected" ? classified.rejections[0] : classified.dropped[0];
249
+ const pieces = classified.outcome === "rejected" ? classified.rejections : classified.dropped;
250
+ return { error: first !== undefined ? hooksPieceRefusal(first) : `${hooksCoordinate(["hooks"])}: the hooks reader refused this configuration without naming a piece`, pieces };
209
251
  }
210
- const config = parsed.data;
211
- for (const [event, groups] of Object.entries(config)) {
252
+ const loaded = classified.hooks;
253
+ if (loaded === undefined) {
254
+ return { error: hooksPieceRefusal({ code: "hooks-not-object", path: ["hooks"] }) };
255
+ }
256
+ const config = {};
257
+ for (const key of Object.keys(loaded)) {
258
+ if (!isHookEventName(key)) {
259
+ return { error: hooksPieceRefusal({ code: "unknown-event", path: ["hooks", key] }) };
260
+ }
261
+ const event = key;
262
+ const groups = loaded[event];
212
263
  if (!groups)
213
264
  continue;
214
265
  if (groups.length > MAX_HOOK_MATCHER_GROUPS) {
215
266
  return { error: `settings.hooks.${event}: ${groups.length} matcher groups exceeds cap ${MAX_HOOK_MATCHER_GROUPS}` };
216
267
  }
217
268
  let entries = 0;
218
- for (const group of groups) {
219
- for (const entry of group.hooks) {
269
+ const kept = [];
270
+ for (const [groupIndex, group] of groups.entries()) {
271
+ const hooks = [];
272
+ for (const [entryIndex, entry] of group.hooks.entries()) {
220
273
  entries++;
274
+ if (!isRunnableHookEntry(entry)) {
275
+ return { error: unsupportedHookEntryTypeRefusal(["hooks", event, groupIndex, "hooks", entryIndex, "type"], entry.type) };
276
+ }
221
277
  if (entry.type === "command" && entry.command.length > MAX_HOOK_COMMAND_CHARS) {
222
278
  return { error: `settings.hooks.${event}: command exceeds ${MAX_HOOK_COMMAND_CHARS} chars` };
223
279
  }
@@ -237,11 +293,14 @@ export function parseHooksConfig(raw) {
237
293
  return { error: `settings.hooks.${event}: allowedEnvVars exceeds cap ${MAX_HOOK_HTTP_HEADERS}` };
238
294
  }
239
295
  }
296
+ hooks.push(entry);
240
297
  }
298
+ kept.push(group.matcher !== undefined ? { matcher: group.matcher, hooks } : { hooks });
241
299
  }
242
300
  if (entries > MAX_HOOK_ENTRIES_PER_EVENT) {
243
301
  return { error: `settings.hooks.${event}: ${entries} entries exceeds cap ${MAX_HOOK_ENTRIES_PER_EVENT}` };
244
302
  }
303
+ config[event] = kept;
245
304
  }
246
305
  return { config };
247
306
  }
@@ -712,6 +771,23 @@ function parseHookStdout(stdout) {
712
771
  return undefined;
713
772
  }
714
773
  }
774
+ function runRunnableEntry(entry, payload, ctx, event, llmExtra) {
775
+ switch (entry.type) {
776
+ case "http":
777
+ return runHttpHook(entry, payload, ctx, event);
778
+ case "prompt":
779
+ return runLlmHook(entry, payload, ctx, llmExtra);
780
+ case "agent":
781
+ return runLlmHook(entry, payload, ctx, { system: ccAgentHookSystemFor(event), event });
782
+ case "command":
783
+ return runCommandHook(entry, payload, ctx);
784
+ default: {
785
+ const unreachable = entry;
786
+ void unreachable;
787
+ throw new Error(`hook entry of an unsupported type reached the runner although parseHooksConfig admits only ${SUPPORTED_HOOK_ENTRY_TYPES.join(" / ")}`);
788
+ }
789
+ }
790
+ }
715
791
  async function runMatchingEntries(event, groups, matchValue, payload, ctx, onceFired, deadlineAt, keyBase, llmExtra) {
716
792
  const results = [];
717
793
  let llmEntries = 0;
@@ -731,10 +807,6 @@ async function runMatchingEntries(event, groups, matchValue, payload, ctx, onceF
731
807
  const onceKey = `${keyBase ?? event}:${gi}:${ei}`;
732
808
  if (entry.once && onceFired.has(onceKey))
733
809
  continue;
734
- if (entry.type !== "command" && entry.type !== "http" && entry.type !== "prompt" && entry.type !== "agent") {
735
- ctx.logger.warn("hook_entries_skipped", { event, type: entry.type, reason: "type_not_supported" });
736
- continue;
737
- }
738
810
  if (entry.type === "prompt" || entry.type === "agent") {
739
811
  const carrier = entry.type === "prompt" ? ctx.hookLlm : ctx.hookAgent;
740
812
  if (!carrier) {
@@ -771,9 +843,7 @@ async function runMatchingEntries(event, groups, matchValue, payload, ctx, onceF
771
843
  onceFired.add(onceKey);
772
844
  if (entry.type === "prompt" || entry.type === "agent")
773
845
  llmEntries++;
774
- const run = entry.type === "http" ? await runHttpHook(entry, payload, ctx, event)
775
- : entry.type === "prompt" || entry.type === "agent" ? await runLlmHook(entry, payload, ctx, entry.type === "prompt" ? llmExtra : { system: ccAgentHookSystemFor(event), event })
776
- : await runCommandHook(entry, payload, ctx);
846
+ const run = await runRunnableEntry(entry, payload, ctx, event, llmExtra);
777
847
  if (run.spawnError) {
778
848
  ctx.logger.warn(entry.type === "http" ? "hook_http_request_failed" : entry.type === "prompt" || entry.type === "agent" ? "hook_llm_failed" : "hook_command_spawn_failed", { event, type: entry.type, error: clip(run.spawnError, 300) });
779
849
  if (!run.noticed)
@@ -16,7 +16,7 @@ import { verifyRoundsFromBody } from "./verify-rounds.js";
16
16
  import { skillCarriesRetiredManifest } from "./retired-skill-manifest.js";
17
17
  import { resolveDevicePlacementIntent } from "./device-placement.js";
18
18
  import { createRunAcceptance } from "../run-acceptance.js";
19
- import { sendError, httpErrorCode } from "./send.js";
19
+ import { sendError, sendHttpError } from "./send.js";
20
20
  import { ABSOLUTE_HOST_PATH_MAX, isAbsoluteHostPath, WEB_ADMIN_ENV } from "@sema-agent/settings-schema";
21
21
  const APPROVAL_REACH_WORDS = APPROVAL_REACH.map((word) => JSON.stringify(word)).join(" / ");
22
22
  export const MAX_SKILL_CONTENT_CHARS = 1_048_576;
@@ -655,7 +655,7 @@ export function createAdmission(ctx) {
655
655
  onTypedFailure(err);
656
656
  return null;
657
657
  }
658
- sendError(res, err.status, httpErrorCode(err.status, err.code), err.message, { ...(err.extra ?? {}) });
658
+ sendHttpError(res, err);
659
659
  return null;
660
660
  }
661
661
  throw err;
@@ -73,7 +73,7 @@ export function createApprovalSweeps(ctx) {
73
73
  return undefined;
74
74
  if (read.kind === "absent")
75
75
  return undefined;
76
- return storedBodyReplayRefusal(read.ctx, deps.config.autonomy);
76
+ return storedBodyReplayRefusal(read.ctx, deps.config);
77
77
  };
78
78
  let parkedSkipped = 0;
79
79
  const replaySkipped = [];
@@ -96,7 +96,7 @@ export function createApprovalSweeps(ctx) {
96
96
  deps.logger?.warn?.("deny_sweep_stored_body_skipped", {
97
97
  count: replaySkipped.length,
98
98
  skipped: replaySkipped,
99
- note: "these expired approvals belong to tasks whose stored request this version does not replay (kind allow_list_reading: settings.permissions.allow written under an earlier reading; kind skill_deny_inert: a skill disallowedTools entry the engine now reads as empty), so the SLA deny cannot be delivered. They stay suspended until the terminal backstop reaps them or someone cancels them; nothing was retried",
99
+ note: "these expired approvals belong to tasks whose stored request this version does not replay (kind allow_list_reading: settings.permissions.allow written under an earlier reading; kind inert_tool_list: a tool-list entry (a skill disallowedTools, an agent definition's denyTools / allowTools or the request's excludeTools / allowTools) the engine reads as empty), so the SLA deny cannot be delivered. They stay suspended until the terminal backstop reaps them or someone cancels them; nothing was retried",
100
100
  });
101
101
  }
102
102
  }
@@ -16,9 +16,10 @@
16
16
  * · 拒句按「这行是在哪种读法下写的」分两句({@link ALLOW_LIST_READING_ERAS} 穷举):无记号(或记号认不得)= 名单时代;`"statement"` = 表态时代。
17
17
  * · 体里没有 `allow`(或空数组 —— 三代都把空数组读成「没有」)的行不写记号、不受本规则影响。
18
18
  *
19
- * ## 第二问(7.117.0,引擎 7.42 逐字读规则条目):技能拒侧读成空的存量体
20
- * 同一面上的第二个「新提交会被拒、存量体重放不能放过」:提交体里技能(顶层 / 子代)的 `disallowedTools` 有引擎读成空的条目(`"Bash "` 这类 7.41 去白后有效、
21
- * 7.42 什么也不拒的条目)⇒ 提交 400(`resolveSpec`,拒点);存量停驻体重放 ⇒ 409 `resume_blocked_by_policy`。判据函数 = `rule-entry-list.ts` `skillDenyInertEntryOf`。
19
+ * ## 第二问(7.117.0 起,引擎 7.42 逐字读规则条目;7.131.0 起覆盖全部工具名清单):工具名清单读成空的存量体
20
+ * 同一面上的第二个「新提交会被拒、存量体重放不能放过」:提交体里工具名清单 —— 技能(顶层 / 子代)的 `disallowedTools`(7.117.0:`"Bash "` 这类 7.41 去白后有效、
21
+ * 7.42 什么也不拒的条目)、子代定义的 `denyTools` / `allowTools`、顶层 `excludeTools` / `allowTools`(7.131.0:限制侧近似名 / 大小写孪生、退役名、文法读不出,放行侧只近似名)—— 有引擎读成空的条目 ⇒ 提交 400(`resolveSpec`,拒点);
22
+ * 存量停驻体重放 ⇒ 409 `resume_blocked_by_policy`(`blockedBy` = 该层的拒码)。判据函数 = `rule-entry-list.ts` `inertToolListEntryOf`。
22
23
  *
23
24
  * ## 第三问(7.122.0):体里模式词的读法记号
24
25
  * 7.122.0 起一条 run 的模式词只有一个源 —— 存储的提交体顶层 `permissionMode`(提交入口把运维 `AUTONOMY=plan` 的地板盖进去一次;`plan_review` 写回改它),
@@ -36,7 +37,7 @@
36
37
  * 问点五个,同调同答:续跑驱动段(`resume-legs.ts` `resolveResumeSpecOrBlocked`,另四条腿的唯一判点)· plan_review 的 prepare 段(读类拒,在 nonce 消费之前)·
37
38
  * SLA 拒扫腿的跳过臂(`approval-sweeps.ts`)· 终态 run 的 steer 铸唤醒卡之前(`routes/runs.ts`:铸卡前先证明它赎得回,证不出 ⇒ 不铸、当场同一个拒)·
38
39
  * `resolveSpec` 本身留作真正的拒点(提交腿 400;续跑腿在到它之前已被本判据答掉)。再开第六个问点必须调这两只,不许各判各的 —— 此前铸卡前只判了
39
- * allow 记号,技能拒侧那一问漏在外面,卡铸出来了、`/wake` 恒 409([ref] 合并复审两腿同报)。
40
+ * allow 记号,工具名清单那一问漏在外面,卡铸出来了、`/wake` 恒 409([ref] 合并复审两腿同报)。
40
41
  *
41
42
  * ## 何时删(一次性迁移,不是长期双读)
42
43
  * 本判据、拒臂、记号写点与 `CheckpointCtx.permissionsAllowReading` 一起删,删除条件与窗登记在 `plugins/one-time-migrations.ts` 的
@@ -46,7 +47,7 @@
46
47
  import type { PermissionMode } from "@sema-agent/core";
47
48
  import type { CheckpointCtx } from "./route-ctx.js";
48
49
  import type { TaskRequestBody } from "./wire-types.js";
49
- import type { SkillDenyInertEntry } from "../rule-entry-list.js";
50
+ import type { InertToolListEntry } from "../rule-entry-list.js";
50
51
  /** 读法记号的闭集(一词 = 当前读法):这一行体里的 `settings.permissions.allow` 是按「交给引擎的 allow 层」写下、也该按它读的
51
52
  * (与能力位 `settings.permissions.allowLayer` 同一个说法)。 */
52
53
  export declare const PERMISSIONS_ALLOW_READINGS: readonly ["allow_layer"];
@@ -140,8 +141,8 @@ export type StoredBodyReplayRefusal = {
140
141
  readonly era: AllowListReadingEra;
141
142
  readonly writerVersion?: string;
142
143
  } | {
143
- readonly kind: "skill_deny_inert";
144
- readonly entry: SkillDenyInertEntry;
144
+ readonly kind: "inert_tool_list";
145
+ readonly entry: InertToolListEntry;
145
146
  } | {
146
147
  readonly kind: "permission_mode_reading";
147
148
  readonly stored: PermissionMode | undefined;
@@ -168,7 +169,7 @@ export interface ResumeRefusalAnswer {
168
169
  }
169
170
  /**
170
171
  * 「续跑被一条准入策略挡住」的**唯一**铸点:`resolveSpec` 在重放存量体时抛出的 `HttpError`(400 族)折成 409 `resume_blocked_by_policy`,
171
- * `blockedBy` = 那条 400 的码(属主 `http/send.ts` `httpErrorCode`),`extra` 原样随体。{@link storedBodyReplayAnswer} 的技能拒侧那一臂与
172
+ * `blockedBy` = 那条 400 的码(属主 `http/send.ts` `httpErrorCode`),`extra` 原样随体。{@link storedBodyReplayAnswer} 的工具名清单那一臂与
172
173
  * `resume-legs.ts` 的折叠口都调它 —— 句子与码只在这里写一次(冻结门 `api-error-text-freeze` 的模板骨架表锚这一处)。
173
174
  */
174
175
  export declare function resumeBlockedByPolicyAnswer(message: string, blockedBy: string, extra?: Record<string, unknown> | undefined): ResumeRefusalAnswer;
@@ -179,15 +180,15 @@ export declare function resumeBlockedByPolicyAnswer(message: string, blockedBy:
179
180
  export declare function storedBodyReplayLogFields(refusal: StoredBodyReplayRefusal): {
180
181
  kind: StoredBodyReplayRefusal["kind"];
181
182
  era?: AllowListReadingEra;
182
- layer?: SkillDenyInertEntry["layer"];
183
+ layer?: InertToolListEntry["layer"];
183
184
  storedMode?: PermissionMode | null;
184
185
  floor?: PermissionMode;
185
186
  note: string;
186
187
  };
187
188
  /**
188
189
  * `stored-body-replay.ts` `storedBodyReplayRefusal` 的答复:每条规则一只既有铸点(allow 记号 ⇒ {@link allowListReadingRefusal};模式词记号 ⇒ {@link permissionModeReadingRefusal};
189
- * 这两只答 `checkpoint.unsupported_version`,体按那一码的键律补两版本键,`readerVersion` = 调用方传的本进程服务版本;技能拒侧 ⇒ {@link resumeBlockedByPolicyAnswer},与提交腿的 400
190
- * `request.field_invalid` 同码同句 —— 体上只有这一个问题时;存量体同时还踩中 `resolveSpec` 更早的拒〔三桶超帽 / 托管姿态〕时,提交侧答更早那条、续跑侧答本句,两边都是拒,指路不同)。
190
+ * 这两只答 `checkpoint.unsupported_version`,体按那一码的键律补两版本键,`readerVersion` = 调用方传的本进程服务版本;工具名清单 ⇒ {@link resumeBlockedByPolicyAnswer},与提交腿的 400
191
+ * (该层的拒码:`request.field_invalid` / `config.agent.unknown_tool` / `config.tool_face_control`)同码同句 —— 体上只有这一个问题时;存量体同时还踩中 `resolveSpec` 更早的拒〔三桶超帽 / 托管姿态〕时,提交侧答更早那条、续跑侧答本句,两边都是拒,指路不同)。
191
192
  */
192
193
  export declare function storedBodyReplayAnswer(refusal: StoredBodyReplayRefusal, readerVersion: string): ResumeRefusalAnswer;
193
194
  //# sourceMappingURL=checkpoint-ctx.d.ts.map
@@ -87,8 +87,8 @@ export function storedBodyReplayLogFields(refusal) {
87
87
  switch (refusal.kind) {
88
88
  case "allow_list_reading":
89
89
  return { kind: refusal.kind, era: refusal.era, note: "this session's stored request carries settings.permissions.allow written under an earlier reading (list era: a whitelist; statement era: a statement that cleared only the unstated question); it is not replayed under the current reading — the task stays suspended until someone cancels it" };
90
- case "skill_deny_inert":
91
- return { kind: refusal.kind, layer: refusal.entry.layer, note: "this session's stored request carries a skill disallowedTools entry the engine now reads as empty (an earlier engine trimmed it into a working deny); it is not replayed — the task stays suspended until someone cancels it and resubmits with the entry fixed" };
90
+ case "inert_tool_list":
91
+ return { kind: refusal.kind, layer: refusal.entry.layer, note: "this session's stored request carries a tool-list entry the engine reads as empty (a skill disallowedTools, an agent definition's denyTools / allowTools, the request's excludeTools / allowTools, or the rules in settings.permissions.deny / ask: a retired name, a case twin, a spelling no tool name can have, a rule core's grammar cannot compile, or a ~/ path rule on a lane with no home), or a permission list that is malformed or over the entry cap in either direction; it is not replayed — the task stays suspended until someone cancels it and resubmits with the entry fixed" };
92
92
  case "permission_mode_reading":
93
93
  return { kind: refusal.kind, storedMode: refusal.stored ?? null, floor: refusal.floor, note: "this session's stored request was written before the operator's AUTONOMY plan floor was recorded in the task (earlier versions re-applied it on every resume); replaying it now would run under its submitted permission mode — the task stays suspended until someone cancels it, or AUTONOMY is set to read-only so it can finish with read-only hands" };
94
94
  default: {
@@ -103,8 +103,8 @@ export function storedBodyReplayAnswer(refusal, readerVersion) {
103
103
  const answer = allowListReadingRefusal(refusal.era);
104
104
  return { status: answer.status, body: { ...answer.body, ...buildCheckpointVersionKeys(refusal.writerVersion, readerVersion) } };
105
105
  }
106
- case "skill_deny_inert":
107
- return resumeBlockedByPolicyAnswer(refusal.entry.message, "request.field_invalid");
106
+ case "inert_tool_list":
107
+ return resumeBlockedByPolicyAnswer(refusal.entry.message, refusal.entry.code, refusal.entry.extra !== undefined ? { ...refusal.entry.extra } : undefined);
108
108
  case "permission_mode_reading": {
109
109
  const answer = permissionModeReadingRefusal();
110
110
  return { status: answer.status, body: { ...answer.body, ...buildCheckpointVersionKeys(refusal.writerVersion, readerVersion) } };
@@ -149,7 +149,7 @@ export function createDispatch(ctx) {
149
149
  configHash,
150
150
  pid: process.pid,
151
151
  startedAt: PROCESS_STARTED_AT_MS,
152
- port: deps.config.port,
152
+ port: req.socket.localPort,
153
153
  ...(deps.config.directDoorActive ? { directDoorActive: true } : {}),
154
154
  ...buildHealthSelfReport(deps, hasServiceAuth(deps.config)),
155
155
  ...(signal.restart ? { ...(restartActionable(signal) ? { restartRequired: true } : {}), restart: signal.restart } : {}),
@@ -422,11 +422,11 @@ export function createResumeLegs(ctx) {
422
422
  noteLateSettleIfAccepted(driven);
423
423
  return driven;
424
424
  }
425
- const ONE_SHOT_HISTORY_KEYS = ["resumeAt", "restoreFiles", "acceptPartialRestore", "rewindFilesTo", "rewindFiles"];
426
- const ONE_SHOT_HISTORY_STRIP = Object.fromEntries(ONE_SHOT_HISTORY_KEYS.map((k) => [k, undefined]));
425
+ const ONE_SHOT_SUBMIT_KEYS = ["resumeAt", "restoreFiles", "acceptPartialRestore", "rewindFilesTo", "rewindFiles", "userAttachments"];
426
+ const ONE_SHOT_SUBMIT_STRIP = Object.fromEntries(ONE_SHOT_SUBMIT_KEYS.map((k) => [k, undefined]));
427
427
  const resolveResumeSpec = async (ctx, req, auth) => {
428
428
  const engineNotices = [];
429
- const spec = await deps.resolveSpec({ ...ctx.body, ...ONE_SHOT_HISTORY_STRIP }, req, auth, {
429
+ const spec = await deps.resolveSpec({ ...ctx.body, ...ONE_SHOT_SUBMIT_STRIP }, req, auth, {
430
430
  leg: "resume",
431
431
  onNotice: (n) => engineNotices.push(n),
432
432
  ...(ctx.unattendedLeg === true ? { unattendedLeg: true } : {}),
@@ -438,7 +438,7 @@ export function createResumeLegs(ctx) {
438
438
  return storedBodyReplayAnswer(refusal, serviceVersion());
439
439
  };
440
440
  const resolveResumeSpecOrBlocked = async (ctx, req, auth) => {
441
- const replay = storedBodyReplayRefusal(ctx, deps.config.autonomy);
441
+ const replay = storedBodyReplayRefusal(ctx, deps.config);
442
442
  if (replay !== undefined)
443
443
  return { ok: false, answer: refuseStoredBodyReplay(auth.sessionId, replay) };
444
444
  try {
@@ -932,6 +932,7 @@ export function createResumeLegs(ctx) {
932
932
  break;
933
933
  }
934
934
  case "human_input":
935
+ anchor.onHumanInput(ev);
935
936
  await flush();
936
937
  await append("human_input", humanInputEventData(ev));
937
938
  break;
@@ -1400,7 +1401,7 @@ export function createResumeLegs(ctx) {
1400
1401
  return refuse(resumeCtxCorruptAnswer(ctxRead.where));
1401
1402
  if (ctxRead.kind === "absent")
1402
1403
  return refuse({ status: 409, body: { error: "resume context missing — cannot rebuild task config", errorCode: "conflict.resume_context_unavailable" } });
1403
- const replay = storedBodyReplayRefusal(ctxRead.ctx, deps.config.autonomy);
1404
+ const replay = storedBodyReplayRefusal(ctxRead.ctx, deps.config);
1404
1405
  if (replay !== undefined)
1405
1406
  return refuse(refuseStoredBodyReplay(sessionId, replay));
1406
1407
  return { ok: true, prepared: { sessionId, token, cp, ctx: ctxRead.ctx } };
@@ -22,7 +22,7 @@ import { callGrantBindingOf, questionGrantBindingOf } from "../../standing-answe
22
22
  import { sendResumeOutcome } from "../route-ctx.js";
23
23
  import { assertNever } from "../route-ctx.js";
24
24
  import * as facts from "../../capability-facts.js";
25
- import { readResumeCtx, resumeCtxCorruptAnswer } from "../resume-ctx-read.js";
25
+ import { readResumeCtx } from "../resume-ctx-read.js";
26
26
  import { recordFailOpen } from "../../observability/fail-open.js";
27
27
  import { MAX_KEY_ECHO, keyClosureClause, scanKeyClosure } from "../../request-key-closure.js";
28
28
  const DECIDE_BODY_DECLARED_KEYS = [
@@ -1,5 +1,5 @@
1
- import { PERMISSION_MODES, expandTiers, reasoningEffortLevelsOf, routePairingStatus } from "@sema-agent/core";
2
- import { modelImageInput, isAtMentionable, selectableCatalog } from "../../model-select.js";
1
+ import { PERMISSION_MODES, USER_ATTACHMENT_SEAT, expandTiers, reasoningEffortLevelsOf, routePairingStatus } from "@sema-agent/core";
2
+ import { modelImageInput, modelReasoningDecl, isAtMentionable, selectableCatalog } from "../../model-select.js";
3
3
  import { buildDroppedEntriesWire } from "../../web-admin-client/read-warnings.js";
4
4
  import { buildCandidateRejectedWire } from "../../web-admin-client/candidate-gate.js";
5
5
  import { modelKeyPoisonOf, resolveModelApiKey } from "../../key-resolver.js";
@@ -12,6 +12,7 @@ import { DEVICE_PROTOCOL_VERSION, DEVICE_WS_PATH } from "../../device-ws-protoco
12
12
  import { a2aInjectionHonored } from "../../task-a2a.js";
13
13
  import { buildEmptyMcpProbeFace, buildMcpInjectionRefusal, buildMcpProbeCacheKey, buildMcpProbeInflightRefusal, buildMcpProbeRateKey, buildMcpProbeRateRefusal, createMcpProbeLocal, buildMcpProbeDeclarationRefusal, buildMcpProbeIncompleteRefusal, McpProbeIncomplete, MCP_PROBE_HTTP_STATUS, MCP_PROBE_MAX_INFLIGHT, MCP_PROBE_VERDICT_TIMEOUT_MS, createMcpProbeWalk, readMcpProbeBody, } from "../../capabilities/mcp-probe.js";
14
14
  import { mcpForScenario } from "../../web-admin-client/facade.js";
15
+ import { sendScenarioNotFound } from "../scenario-read-face.js";
15
16
  import { sendJson, sendError } from "../send.js";
16
17
  import { assertNever } from "../route-ctx.js";
17
18
  import { fileHistoryCaptureOf } from "../../file-history-capture.js";
@@ -44,9 +45,6 @@ function retentionAdvertisement(config) {
44
45
  export function createCapabilitiesLocal() {
45
46
  return { mcpProbe: createMcpProbeLocal() };
46
47
  }
47
- function sendScenarioNotFound(res) {
48
- sendError(res, 404, "scenario_not_found", "scenario not found");
49
- }
50
48
  export async function handleCapabilities(req, res, match, ctx) {
51
49
  const { deps } = ctx;
52
50
  switch (match.decl.id) {
@@ -149,6 +147,7 @@ export async function handleCapabilities(req, res, match, ctx) {
149
147
  disableSkills: true,
150
148
  userInvokedSkills: true,
151
149
  skillToolLists: true,
150
+ userAttachments: USER_ATTACHMENT_SEAT,
152
151
  projectContext: cwdHonored(deps.config),
153
152
  callerCwd: cwdHonored(deps.config) || deviceCwdHonored(deps.config),
154
153
  taskWriteFaceOpen: taskWriteFaceRefusal(deps.config) === undefined && autonomyKeepsOpenWriteFace(deps.config.autonomy),
@@ -225,16 +224,7 @@ export async function handleCapabilities(req, res, match, ctx) {
225
224
  sendError(res, 400, "request.field_invalid", "scenario must be given at most once (omit it to read this deployment's default scenario)");
226
225
  return;
227
226
  }
228
- const requestedScenario = scenarioParams[0];
229
- let scenarioName;
230
- if (requestedScenario === undefined)
231
- scenarioName = deps.config.defaultScenario;
232
- else if (deps.scenarioGate === undefined) {
233
- sendScenarioNotFound(res);
234
- return;
235
- }
236
- else
237
- scenarioName = await deps.scenarioGate(principal, requestedScenario);
227
+ const scenarioName = await deps.scenarioGate(principal, scenarioParams[0]);
238
228
  await serveMcpProbeFace(res, ctx, principal, mcpForScenario(deps.config.mcpServers, scenarioName) ?? [], "deployment");
239
229
  return;
240
230
  }
@@ -265,7 +255,7 @@ export async function handleCapabilities(req, res, match, ctx) {
265
255
  name,
266
256
  id: m.id,
267
257
  provider: m.provider,
268
- reasoning: m.reasoning,
258
+ ...(modelReasoningDecl(m) !== undefined ? { reasoning: modelReasoningDecl(m) } : {}),
269
259
  ...(modelImageInput(m) !== "undeclared" ? { vision: modelImageInput(m) === "yes" } : {}),
270
260
  ...(m.contextWindow ? { contextWindow: m.contextWindow } : {}),
271
261
  ...(m.maxTokens ? { maxOutputTokens: m.maxTokens } : {}),
@@ -632,7 +632,7 @@ export async function handleRuns(req, res, match, ctx) {
632
632
  mintResult.noResumeCtx = true;
633
633
  return;
634
634
  }
635
- const unservable = storedBodyReplayRefusal(resumeRead.ctx, deps.config.autonomy);
635
+ const unservable = storedBodyReplayRefusal(resumeRead.ctx, deps.config);
636
636
  if (unservable !== undefined) {
637
637
  mintResult.unservable = unservable;
638
638
  return;
@@ -743,10 +743,6 @@ export async function handleRuns(req, res, match, ctx) {
743
743
  }
744
744
  const interruptBody = interruptNormalized;
745
745
  const interruptBare = !("text" in interruptBody);
746
- if (interruptBare && Object.keys(interruptBody).length > 0) {
747
- sendError(res, 400, "request.body_shape", "body must be {} (bare halt: cut the in-flight turn and stop the run at this boundary) or { text: string (non-empty) } (cut + steer: the text reaches the model after the cut)");
748
- return;
749
- }
750
746
  let text = "";
751
747
  if (!interruptBare) {
752
748
  if (typeof interruptBody.text !== "string" || interruptBody.text.length === 0) {
@@ -754,11 +750,11 @@ export async function handleRuns(req, res, match, ctx) {
754
750
  return;
755
751
  }
756
752
  text = interruptBody.text;
757
- const keyIssue = steerBodyKeyIssue(interruptBody, "interrupt");
758
- if (keyIssue) {
759
- sendError(res, 400, "request.body_shape", keyIssue.message, { unknownKeys: keyIssue.unknownKeys, unsupportedKeys: keyIssue.unsupportedKeys });
760
- return;
761
- }
753
+ }
754
+ const keyIssue = steerBodyKeyIssue(interruptBody, "interrupt");
755
+ if (keyIssue) {
756
+ sendError(res, 400, "request.body_shape", keyIssue.message, { unknownKeys: keyIssue.unknownKeys, unsupportedKeys: keyIssue.unsupportedKeys });
757
+ return;
762
758
  }
763
759
  const interruptIdemRaw = headerStr(req.headers["idempotency-key"]);
764
760
  const interruptInputId = interruptIdemRaw ? `idem-${idemKeyOf(interruptIdemRaw, idemCallerOf(req, ctx), "run").slice(0, 32)}` : uuidv7();