@sema-agent/server 7.129.1 → 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 (117) hide show
  1. package/MIGRATION.md +102 -0
  2. package/README.md +8 -5
  3. package/README.zh-CN.md +6 -4
  4. package/USAGE.md +48 -9
  5. package/dist/audit.js +4 -1
  6. package/dist/bench/s1/live-deps.js +2 -0
  7. package/dist/boot/resolve-spec.js +43 -16
  8. package/dist/boot/stage-02-web-admin-pull.js +9 -1
  9. package/dist/boot/stage-10-http-server.d.ts +1 -1
  10. package/dist/boot/stage-10-http-server.js +7 -2
  11. package/dist/capabilities/builtin-tools.js +2 -1
  12. package/dist/capabilities/composite-members.d.ts +1 -0
  13. package/dist/capabilities/composite-members.js +1 -0
  14. package/dist/capabilities/hands-lane.js +2 -2
  15. package/dist/capabilities/host-tool-names.d.ts +17 -0
  16. package/dist/capabilities/host-tool-names.js +15 -0
  17. package/dist/capabilities/mcp-probe.js +19 -6
  18. package/dist/capabilities/repo-tools.js +4 -3
  19. package/dist/capabilities/scenarios.d.ts +34 -0
  20. package/dist/capabilities/scenarios.js +20 -4
  21. package/dist/capabilities/select-environment-tool.js +2 -1
  22. package/dist/capabilities/send-user-file-tool.d.ts +0 -1
  23. package/dist/capabilities/send-user-file-tool.js +2 -2
  24. package/dist/capabilities/skills.js +1 -1
  25. package/dist/capabilities/tool-defer.js +2 -1
  26. package/dist/config-catalog.js +16 -5
  27. package/dist/config-invariants.js +2 -2
  28. package/dist/config-provider.js +2 -2
  29. package/dist/config-types.d.ts +6 -0
  30. package/dist/config.d.ts +2 -2
  31. package/dist/config.js +18 -17
  32. package/dist/elicitation.d.ts +7 -0
  33. package/dist/elicitation.js +20 -5
  34. package/dist/exec-bounds.d.ts +6 -6
  35. package/dist/hooks/hook-llm.d.ts +11 -1
  36. package/dist/hooks/hook-llm.js +11 -14
  37. package/dist/hooks/hook-runner.d.ts +55 -9
  38. package/dist/hooks/hook-runner.js +86 -16
  39. package/dist/http/admission.js +2 -2
  40. package/dist/http/approval-sweeps.js +2 -2
  41. package/dist/http/checkpoint-ctx.d.ts +12 -11
  42. package/dist/http/checkpoint-ctx.js +4 -4
  43. package/dist/http/dispatch.js +1 -1
  44. package/dist/http/resume-legs.js +6 -5
  45. package/dist/http/routes/approvals-assistant.js +1 -1
  46. package/dist/http/routes/capabilities.js +13 -5
  47. package/dist/http/routes/runs.js +18 -14
  48. package/dist/http/routes/sessions.js +22 -10
  49. package/dist/http/routes/tasks.js +16 -11
  50. package/dist/http/scenario-read-face.d.ts +14 -0
  51. package/dist/http/scenario-read-face.js +5 -0
  52. package/dist/http/send.d.ts +10 -0
  53. package/dist/http/send.js +6 -0
  54. package/dist/http/server.d.ts +16 -4
  55. package/dist/http/server.js +2 -2
  56. package/dist/http/stored-body-replay.d.ts +10 -3
  57. package/dist/http/stored-body-replay.js +27 -6
  58. package/dist/http/wire-types.d.ts +18 -5
  59. package/dist/index.d.ts +2 -1
  60. package/dist/index.js +2 -1
  61. package/dist/mcp-assertion-header.d.ts +2 -2
  62. package/dist/mcp-assertion-header.js +3 -3
  63. package/dist/model-entry-refusal.d.ts +1 -7
  64. package/dist/model-route-endpoint.d.ts +51 -11
  65. package/dist/model-route-endpoint.js +45 -23
  66. package/dist/model-route-gap.d.ts +5 -4
  67. package/dist/model-route-gap.js +1 -1
  68. package/dist/model-select.d.ts +58 -0
  69. package/dist/model-select.js +31 -0
  70. package/dist/namespaced-rule-name.d.ts +7 -4
  71. package/dist/namespaced-rule-name.js +4 -3
  72. package/dist/observability/fail-open.d.ts +16 -7
  73. package/dist/observability/fail-open.js +10 -2
  74. package/dist/plugins/local-session-store.js +2 -2
  75. package/dist/plugins/one-time-migrations.d.ts +21 -1
  76. package/dist/plugins/one-time-migrations.js +33 -1
  77. package/dist/plugins/remote-env-adb.js +5 -4
  78. package/dist/plugins/remote-env-local-docker.js +5 -4
  79. package/dist/plugins/remote-env-ssh.js +5 -4
  80. package/dist/question.d.ts +18 -9
  81. package/dist/question.js +21 -13
  82. package/dist/request-key-closure.d.ts +2 -2
  83. package/dist/request-key-closure.js +16 -9
  84. package/dist/rule-entry-list.d.ts +124 -17
  85. package/dist/rule-entry-list.js +140 -24
  86. package/dist/run-local.d.ts +3 -3
  87. package/dist/run-local.js +8 -7
  88. package/dist/runs.d.ts +42 -2
  89. package/dist/runs.js +45 -2
  90. package/dist/runtime-caps-resolver.d.ts +7 -0
  91. package/dist/runtime-caps-resolver.js +1 -0
  92. package/dist/session-titler.d.ts +4 -1
  93. package/dist/session-titler.js +13 -3
  94. package/dist/spec-fields.d.ts +8 -4
  95. package/dist/stored-request-hooks-m20.d.ts +23 -0
  96. package/dist/stored-request-hooks-m20.js +124 -0
  97. package/dist/stored-request-tool-axes-m19.d.ts +29 -0
  98. package/dist/stored-request-tool-axes-m19.js +27 -0
  99. package/dist/task-mcp.d.ts +55 -60
  100. package/dist/task-mcp.js +197 -96
  101. package/dist/task-settings.d.ts +41 -5
  102. package/dist/task-settings.js +39 -25
  103. package/dist/tool-axis-words.d.ts +14 -17
  104. package/dist/trace/core-keyset-guard.d.ts +2 -2
  105. package/dist/trace/ledger-sink.d.ts +29 -0
  106. package/dist/trace/ledger-sink.js +11 -0
  107. package/dist/trace/project.d.ts +6 -0
  108. package/dist/trace/project.js +11 -0
  109. package/dist/trace/wire-projection-faces.d.ts +1 -1
  110. package/dist/trace/wire-projection-faces.js +1 -1
  111. package/dist/user-authored-text.d.ts +7 -0
  112. package/dist/user-authored-text.js +3 -0
  113. package/dist/web-admin-client/apply-effective.js +10 -5
  114. package/dist/web-admin-client/skills-mcp.d.ts +1 -1
  115. package/dist/web-admin-client/types.d.ts +1 -1
  116. package/package.json +2 -2
  117. package/scripts/drain-check-local.mjs +3 -2
@@ -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";
@@ -51,7 +52,7 @@ export async function handleCapabilities(req, res, match, ctx) {
51
52
  const name = match.params.name;
52
53
  const detail = deps.scenarioDetails?.[name];
53
54
  if (!detail) {
54
- sendError(res, 404, "scenario_not_found", "scenario not found");
55
+ sendScenarioNotFound(res);
55
56
  return;
56
57
  }
57
58
  sendJson(res, 200, detail);
@@ -146,6 +147,7 @@ export async function handleCapabilities(req, res, match, ctx) {
146
147
  disableSkills: true,
147
148
  userInvokedSkills: true,
148
149
  skillToolLists: true,
150
+ userAttachments: USER_ATTACHMENT_SEAT,
149
151
  projectContext: cwdHonored(deps.config),
150
152
  callerCwd: cwdHonored(deps.config) || deviceCwdHonored(deps.config),
151
153
  taskWriteFaceOpen: taskWriteFaceRefusal(deps.config) === undefined && autonomyKeepsOpenWriteFace(deps.config.autonomy),
@@ -217,7 +219,13 @@ export async function handleCapabilities(req, res, match, ctx) {
217
219
  sendPrincipalRefusal(res, req, deps.config);
218
220
  return;
219
221
  }
220
- await serveMcpProbeFace(res, ctx, principal, mcpForScenario(deps.config.mcpServers, deps.config.defaultScenario) ?? [], "deployment");
222
+ const scenarioParams = new URLSearchParams(match.search).getAll("scenario");
223
+ if (scenarioParams.length > 1) {
224
+ sendError(res, 400, "request.field_invalid", "scenario must be given at most once (omit it to read this deployment's default scenario)");
225
+ return;
226
+ }
227
+ const scenarioName = await deps.scenarioGate(principal, scenarioParams[0]);
228
+ await serveMcpProbeFace(res, ctx, principal, mcpForScenario(deps.config.mcpServers, scenarioName) ?? [], "deployment");
221
229
  return;
222
230
  }
223
231
  case "capability-mcp-probe": {
@@ -247,7 +255,7 @@ export async function handleCapabilities(req, res, match, ctx) {
247
255
  name,
248
256
  id: m.id,
249
257
  provider: m.provider,
250
- reasoning: m.reasoning,
258
+ ...(modelReasoningDecl(m) !== undefined ? { reasoning: modelReasoningDecl(m) } : {}),
251
259
  ...(modelImageInput(m) !== "undeclared" ? { vision: modelImageInput(m) === "yes" } : {}),
252
260
  ...(m.contextWindow ? { contextWindow: m.contextWindow } : {}),
253
261
  ...(m.maxTokens ? { maxOutputTokens: m.maxTokens } : {}),
@@ -599,6 +599,14 @@ export async function handleRuns(req, res, match, ctx) {
599
599
  };
600
600
  const job = prior.then(async () => {
601
601
  const cs = deps.checkpointStore;
602
+ const withdrawWakeCard = async (token, scope, arm) => {
603
+ try {
604
+ await cs.expire(token, scope);
605
+ }
606
+ catch (e) {
607
+ deps.logger?.warn?.("steer_wake_card_withdraw_failed", { taskId, sessionId: run.sessionId, arm, error: redactHead(e instanceof Error ? e.message : String(e), 2000) });
608
+ }
609
+ };
602
610
  const reprobe = await tryPark();
603
611
  if (reprobe === "parked") {
604
612
  mintResult.parked = true;
@@ -624,7 +632,7 @@ export async function handleRuns(req, res, match, ctx) {
624
632
  mintResult.noResumeCtx = true;
625
633
  return;
626
634
  }
627
- const unservable = storedBodyReplayRefusal(resumeRead.ctx, deps.config.autonomy);
635
+ const unservable = storedBodyReplayRefusal(resumeRead.ctx, deps.config);
628
636
  if (unservable !== undefined) {
629
637
  mintResult.unservable = unservable;
630
638
  return;
@@ -645,18 +653,18 @@ export async function handleRuns(req, res, match, ctx) {
645
653
  });
646
654
  const stillRead = await readResumeCtx(cs.getCtx(run.sessionId));
647
655
  if (stillRead.kind === "corrupt") {
648
- await cs.expire(wakeToken, cpScope).catch(() => undefined);
656
+ await withdrawWakeCard(wakeToken, cpScope, "resume_context_corrupt");
649
657
  mintResult.corruptCtxWhere = stillRead.where;
650
658
  return;
651
659
  }
652
660
  if (stillRead.kind !== "ok") {
653
- await cs.expire(wakeToken, cpScope).catch(() => undefined);
661
+ await withdrawWakeCard(wakeToken, cpScope, "resume_context_absent");
654
662
  mintResult.noResumeCtx = true;
655
663
  return;
656
664
  }
657
665
  const winner = await cs.findPendingTokenBySession(run.sessionId, undefined, { gateKinds: TERMINAL_PARK_GATE_KINDS, pick: "oldest" }).catch(() => null);
658
666
  if (winner !== null && winner !== wakeToken) {
659
- await cs.expire(wakeToken, cpScope).catch(() => undefined);
667
+ await withdrawWakeCard(wakeToken, cpScope, "converged_on_winner");
660
668
  await parkOnce(winner, cpScope);
661
669
  return;
662
670
  }
@@ -680,7 +688,7 @@ export async function handleRuns(req, res, match, ctx) {
680
688
  return;
681
689
  }
682
690
  if (mintResult.corruptCtxWhere !== undefined) {
683
- sendError(res, 500, "corrupt", "run already ended and this session's stored resume context cannot be read — no wake card was minted and nothing was queued. Ask whoever operates this deployment to repair or remove the row named in `where` before steering this session again.", { where: mintResult.corruptCtxWhere });
691
+ sendError(res, 500, "corrupt", "run already ended and this session's stored resume context cannot be read — nothing was queued. Ask whoever operates this deployment to repair or remove the row named in `where` before steering this session again.", { where: mintResult.corruptCtxWhere });
684
692
  return;
685
693
  }
686
694
  if (mintResult.unservable !== undefined) {
@@ -735,10 +743,6 @@ export async function handleRuns(req, res, match, ctx) {
735
743
  }
736
744
  const interruptBody = interruptNormalized;
737
745
  const interruptBare = !("text" in interruptBody);
738
- if (interruptBare && Object.keys(interruptBody).length > 0) {
739
- 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)");
740
- return;
741
- }
742
746
  let text = "";
743
747
  if (!interruptBare) {
744
748
  if (typeof interruptBody.text !== "string" || interruptBody.text.length === 0) {
@@ -746,11 +750,11 @@ export async function handleRuns(req, res, match, ctx) {
746
750
  return;
747
751
  }
748
752
  text = interruptBody.text;
749
- const keyIssue = steerBodyKeyIssue(interruptBody, "interrupt");
750
- if (keyIssue) {
751
- sendError(res, 400, "request.body_shape", keyIssue.message, { unknownKeys: keyIssue.unknownKeys, unsupportedKeys: keyIssue.unsupportedKeys });
752
- return;
753
- }
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;
754
758
  }
755
759
  const interruptIdemRaw = headerStr(req.headers["idempotency-key"]);
756
760
  const interruptInputId = interruptIdemRaw ? `idem-${idemKeyOf(interruptIdemRaw, idemCallerOf(req, ctx), "run").slice(0, 32)}` : uuidv7();
@@ -1,6 +1,6 @@
1
1
  import { SessionError, materializeA2aTools, materializeMcpTools, sanitizePathComponent } from "@sema-agent/core";
2
2
  import { a2aForScenario, mcpForScenario } from "../../web-admin-client/facade.js";
3
- import { isUuidShape, legacyUnownedSessionOk } from "../../security.js";
3
+ import { HttpError, isUuidShape, legacyUnownedSessionOk } from "../../security.js";
4
4
  import { windowMessages, truncateMessageBlobs, redactSessionViewForRead } from "../../audit.js";
5
5
  import { redactSecrets } from "../../trace/redact.js";
6
6
  import { recordFailOpen } from "../../observability/fail-open.js";
@@ -8,7 +8,7 @@ import { MCP_DIAL_DEADLINE_MS, raceDeadline } from "../mcp-dial-deadline.js";
8
8
  import { sentMemoryComplianceFailure } from "../memory-compliance-failure-reply.js";
9
9
  import { MAX_SETTINGS_OUTPUT_STYLE_CHARS, MAX_SETTINGS_ENV_VARS, MAX_SETTINGS_ENV_KEY_CHARS, MAX_SETTINGS_ENV_VALUE_CHARS } from "../../task-settings.js";
10
10
  import { readWritableSessionPermissionRules } from "../../session-policy-wire.js";
11
- import { sendJson, sendError, sendSessionError, sendSessionPolicyError, sseHeaders } from "../send.js";
11
+ import { sendJson, sendError, sendSessionError, sendSessionPolicyError, sseHeaders, httpErrorBody } from "../send.js";
12
12
  import { gatedPrincipal, explicitOperatorOk, sessionOwnerReadable, sendPrincipalRefusal, sendUnauthorized } from "../principal-gate.js";
13
13
  import { assertNever, SESSION_NOT_FOUND_MESSAGE } from "../route-ctx.js";
14
14
  import * as facts from "../../capability-facts.js";
@@ -144,10 +144,21 @@ export async function handleSessions(req, res, match, ctx) {
144
144
  return;
145
145
  }
146
146
  const lastLegMcp = await lastLegMcpFor(deps, safeDecode(match.params.sessionId), principal);
147
- const specs = mcpForScenario(deps.config.mcpServers, deps.config.defaultScenario);
148
- const cacheKey = `${deps.config.defaultScenario}::${principal ?? ""}`;
147
+ const ledgerHalf = lastLegMcp === undefined ? {} : { lastLegMcp };
148
+ let scenarioName;
149
+ try {
150
+ scenarioName = await deps.scenarioGate(principal, undefined);
151
+ }
152
+ catch (e) {
153
+ if (!(e instanceof HttpError))
154
+ throw e;
155
+ sendJson(res, 200, { asOf: new Date().toISOString(), ...ledgerHalf, scenarioRefusal: httpErrorBody(e) });
156
+ return;
157
+ }
158
+ const specs = mcpForScenario(deps.config.mcpServers, scenarioName);
159
+ const cacheKey = `${scenarioName}::${principal ?? ""}`;
149
160
  if (!specs || specs.length === 0) {
150
- sendJson(res, 200, { servers: [], asOf: new Date().toISOString(), ...(lastLegMcp === undefined ? {} : { lastLegMcp }) });
161
+ sendJson(res, 200, { servers: [], asOf: new Date().toISOString(), ...ledgerHalf });
151
162
  return;
152
163
  }
153
164
  const cached = mcpStatusCache.get(cacheKey);
@@ -179,23 +190,24 @@ export async function handleSessions(req, res, match, ctx) {
179
190
  })();
180
191
  mcpStatusCache.set(cacheKey, { at: Date.now(), panel });
181
192
  }
182
- sendJson(res, 200, { ...(await mcpStatusCache.get(cacheKey).panel), ...(lastLegMcp === undefined ? {} : { lastLegMcp }) });
193
+ sendJson(res, 200, { ...(await mcpStatusCache.get(cacheKey).panel), ...ledgerHalf });
183
194
  return;
184
195
  }
185
196
  }
186
197
  {
187
198
  if (match.decl.id === "session-a2a") {
188
- if (deps.config.requirePrincipal && !gatedPrincipal(req, deps.config)) {
199
+ const principal = gatedPrincipal(req, deps.config);
200
+ if (deps.config.requirePrincipal && !principal) {
189
201
  sendPrincipalRefusal(res, req, deps.config);
190
202
  return;
191
203
  }
192
- const specs = a2aForScenario(deps.config.a2aPeers, deps.config.defaultScenario);
204
+ const scenarioName = await deps.scenarioGate(principal, undefined);
205
+ const specs = a2aForScenario(deps.config.a2aPeers, scenarioName);
193
206
  if (!specs || specs.length === 0) {
194
207
  sendJson(res, 200, { peers: [], asOf: new Date().toISOString() });
195
208
  return;
196
209
  }
197
- const principal = gatedPrincipal(req, deps.config);
198
- const cacheKey = `${deps.config.defaultScenario}::${principal ?? ""}`;
210
+ const cacheKey = `${scenarioName}::${principal ?? ""}`;
199
211
  const cached = a2aStatusCache.get(cacheKey);
200
212
  if (!cached || Date.now() - cached.at > A2A_STATUS_TTL_MS) {
201
213
  if (a2aStatusCache.size > 1024)