@sema-agent/core 5.55.0 → 5.57.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 (81) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/dist/agents/send-message-tool.d.ts +11 -0
  3. package/dist/agents/send-message-tool.js +81 -13
  4. package/dist/agents/subagent.js +250 -89
  5. package/dist/agents/team.d.ts +10 -1
  6. package/dist/agents/team.js +1 -0
  7. package/dist/brain/anthropic.js +15 -5
  8. package/dist/brain/circuit-breaker.js +2 -1
  9. package/dist/brain/degrading.js +4 -1
  10. package/dist/brain/failover.js +16 -1
  11. package/dist/brain/open-responses.js +15 -5
  12. package/dist/brain/openai.js +16 -5
  13. package/dist/brain/request-params.d.ts +30 -27
  14. package/dist/brain/request-params.js +1 -7
  15. package/dist/brain/route-adjudicator.d.ts +190 -0
  16. package/dist/brain/route-adjudicator.js +189 -0
  17. package/dist/brain/route-conformance.d.ts +55 -0
  18. package/dist/brain/route-conformance.js +136 -0
  19. package/dist/brain/routing.js +8 -3
  20. package/dist/core/auto-compaction.d.ts +17 -4
  21. package/dist/core/auto-compaction.js +3 -0
  22. package/dist/core/context-edit.d.ts +55 -6
  23. package/dist/core/context-edit.js +12 -1
  24. package/dist/core/hooks.d.ts +293 -11
  25. package/dist/core/hooks.js +158 -11
  26. package/dist/core/human-input-projection.d.ts +20 -2
  27. package/dist/core/human-input-projection.js +9 -0
  28. package/dist/core/mcp.js +4 -4
  29. package/dist/core/memory-engine/engine.d.ts +15 -5
  30. package/dist/core/memory-engine/engine.js +3 -1
  31. package/dist/core/permission-rule-consent.d.ts +45 -0
  32. package/dist/core/permission-rule-consent.js +40 -11
  33. package/dist/core/permission-rule-model.d.ts +110 -75
  34. package/dist/core/permission-rule-model.js +61 -28
  35. package/dist/core/permission-rules.d.ts +23 -15
  36. package/dist/core/permission-rules.js +40 -31
  37. package/dist/core/runner/prepare-task.d.ts +8 -0
  38. package/dist/core/runner/prepare-task.js +66 -26
  39. package/dist/core/runner/runtask.d.ts +4 -1
  40. package/dist/core/runner/runtask.js +206 -21
  41. package/dist/core/runner/session-rule-policy.js +5 -5
  42. package/dist/core/scheduler.d.ts +5 -0
  43. package/dist/core/session-reconcile.d.ts +32 -0
  44. package/dist/core/session-reconcile.js +15 -0
  45. package/dist/core/side-query.d.ts +12 -5
  46. package/dist/core/task-notification.d.ts +34 -7
  47. package/dist/core/task-notification.js +11 -1
  48. package/dist/core/task-registry-agent.d.ts +20 -3
  49. package/dist/core/task-registry-agent.js +31 -2
  50. package/dist/core/tool-policy.d.ts +14 -9
  51. package/dist/core/tool-policy.js +27 -22
  52. package/dist/core/types.d.ts +69 -11
  53. package/dist/core/untrusted-text.js +8 -0
  54. package/dist/engine/compaction/compaction.d.ts +77 -7
  55. package/dist/engine/compaction/compaction.js +98 -9
  56. package/dist/engine/compaction/utils.d.ts +4 -0
  57. package/dist/engine/compaction/utils.js +6 -0
  58. package/dist/engine/harness/agent-harness.d.ts +84 -0
  59. package/dist/engine/harness/agent-harness.js +114 -13
  60. package/dist/engine/harness/messages.d.ts +4 -2
  61. package/dist/engine/harness/messages.js +7 -2
  62. package/dist/engine/harness/types.d.ts +16 -6
  63. package/dist/engine/llm/types.d.ts +65 -0
  64. package/dist/engine/loop/types.d.ts +7 -0
  65. package/dist/engine/session/import-validate.js +10 -0
  66. package/dist/engine/session/session.js +2 -2
  67. package/dist/index.d.ts +4 -1
  68. package/dist/index.js +3 -1
  69. package/dist/internal/llm.d.ts +1 -1
  70. package/dist/orchestration/run-spec.js +8 -1
  71. package/dist/prompts/default.d.ts +12 -6
  72. package/dist/prompts/default.js +2 -0
  73. package/dist/scenarios/scenario-registry.d.ts +5 -1
  74. package/dist/scenarios/scenario-registry.js +4 -2
  75. package/dist/tools/fs/index.js +8 -1
  76. package/dist/tools/scheduler-tools.js +28 -6
  77. package/dist/tools/web.d.ts +15 -0
  78. package/dist/tools/web.js +8 -2
  79. package/dist/tools/worktree.js +2 -2
  80. package/package.json +1 -1
  81. package/test/export-surface.snapshot.json +19 -1
@@ -20,26 +20,12 @@ export const SHELL_RESERVED_WORDS = new Set([
20
20
  "function", "if", "in", "select", "then", "time", "until", "while",
21
21
  ]);
22
22
  export const SUGGESTION_LEXICON = [
23
- "git status", "git log", "git diff", "git show", "git branch", "git checkout", "git switch",
24
- "git add", "git commit", "git push", "git pull", "git fetch", "git merge", "git rebase",
25
- "git tag", "git blame", "git describe", "git cherry-pick", "git restore", "git reset",
26
- "git rev-parse", "git ls-files", "git grep",
27
23
  "git stash list", "git stash show", "git stash push", "git stash pop", "git stash drop",
28
24
  "git remote show", "git worktree list", "git submodule update", "git submodule status",
29
- "npm run", "npm test", "npm ci", "npm ls", "npm view", "npm outdated", "npm audit",
30
- "npm pack", "npm publish", "npm version", "npm why",
31
- "pnpm run", "pnpm test", "pnpm ls", "pnpm outdated", "pnpm audit", "pnpm why",
32
- "yarn run", "yarn test", "yarn workspaces list",
33
- "cargo build", "cargo test", "cargo run", "cargo check", "cargo clippy", "cargo fmt",
34
- "cargo doc", "cargo tree", "cargo bench", "cargo update", "cargo metadata",
35
- "go build", "go test", "go vet", "go fmt", "go doc",
25
+ "yarn workspaces list",
36
26
  "go mod tidy", "go mod download", "go mod verify", "go mod graph",
37
- "docker ps", "docker images", "docker logs", "docker inspect", "docker build", "docker pull",
38
- "docker push", "docker stop", "docker start", "docker restart", "docker rm", "docker rmi",
39
27
  "docker compose up", "docker compose down", "docker compose ps", "docker compose logs",
40
28
  "docker compose build", "docker compose pull",
41
- "kubectl get", "kubectl describe", "kubectl logs", "kubectl apply", "kubectl delete",
42
- "kubectl diff", "kubectl explain", "kubectl top",
43
29
  "kubectl rollout status", "kubectl rollout restart", "kubectl rollout history",
44
30
  "gh pr view", "gh pr list", "gh pr diff", "gh pr checks", "gh pr status", "gh pr create",
45
31
  "gh pr merge", "gh issue view", "gh issue list", "gh issue create", "gh repo view",
@@ -48,6 +34,31 @@ export const SUGGESTION_LEXICON = [
48
34
  "gh search repos", "gh search issues", "gh search prs", "gh search code",
49
35
  ];
50
36
  const LEXICON_BODIES = SUGGESTION_LEXICON.map((b) => b.split(" "));
37
+ const GENERIC_SUBCOMMAND_WORD = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
38
+ const SCREENED_HEAD_NAMES = new Set([...BARE_INTERPRETER_NAMES].map((n) => n.toLowerCase()));
39
+ const TRAILING_DOTS = /\.+$/;
40
+ const EXECUTABLE_SUFFIX = /\.(?:exe|com|bat|cmd)$/;
41
+ const PROPOSABLE_HEAD_NAME = /^[A-Za-z0-9._+-]+$/;
42
+ function screenedHeadName(head) {
43
+ let name = commandBasename(head).toLowerCase();
44
+ for (;;) {
45
+ const peeled = name.replace(TRAILING_DOTS, "").replace(EXECUTABLE_SUFFIX, "");
46
+ if (peeled === name || peeled === "")
47
+ return name;
48
+ name = peeled;
49
+ }
50
+ }
51
+ function genericPrefixBody(tokens) {
52
+ const head = tokens[0];
53
+ const second = tokens[1];
54
+ if (head === undefined || second === undefined)
55
+ return undefined;
56
+ if (!PROPOSABLE_HEAD_NAME.test(commandBasename(head)))
57
+ return undefined;
58
+ if (SCREENED_HEAD_NAMES.has(screenedHeadName(head)))
59
+ return undefined;
60
+ return GENERIC_SUBCOMMAND_WORD.test(second) ? `${head} ${second}` : undefined;
61
+ }
51
62
  function longestReviewedBody(tokens) {
52
63
  let best;
53
64
  for (const words of LEXICON_BODIES) {
@@ -151,6 +162,9 @@ export function escapeForDisclosure(value) {
151
162
  });
152
163
  return escaped.length <= DISCLOSED_RULE_TEXT_MAX_CHARS ? escaped : `${escaped.slice(0, DISCLOSED_RULE_TEXT_MAX_CHARS)}…`;
153
164
  }
165
+ export function hasUnrenderableCharacters(text) {
166
+ return CONTROL_CHARS_RE.test(text);
167
+ }
154
168
  export function parseAllowRuleText(text, opts) {
155
169
  if (text.length > MAX_RULE_TEXT_CHARS) {
156
170
  return reject("invalid.too_long", `rule text exceeds ${MAX_RULE_TEXT_CHARS} characters`);
@@ -178,12 +192,11 @@ export function parseAllowRuleText(text, opts) {
178
192
  ? reject("invalid.empty_command", `rule "${text}" names no command`)
179
193
  : reject("invalid.not_simple_command", `rule "${text}" is not a command this lane can name (${shape.reject})`);
180
194
  }
181
- if (match === "prefix" && shape.segments.length > 1) {
182
- return reject("invalid.not_simple_command", `prefix rule "${text}" names more than one command — a prefix admits anything appended to its body, so only the exact form may name a connector chain`);
183
- }
184
- const head = commandBasename(shape.names[0] ?? "");
195
+ const head = commandBasename(shape.names[shape.names.length - 1] ?? "");
185
196
  if (match === "prefix" && BARE_INTERPRETER_NAMES.has(head) && opts?.direction !== "tighten") {
186
- return reject("invalid.bare_interpreter_prefix", `prefix rule "${text}" is headed by the interpreter "${head}" — such a rule authorizes running arbitrary programs, which one approval click cannot be read as having granted (an exact rule naming the whole command line is accepted)`);
197
+ return reject("invalid.bare_interpreter_prefix", shape.segments.length > 1
198
+ ? `prefix rule "${text}" leaves its final segment open-ended under the interpreter "${head}" — such a rule authorizes running arbitrary programs, which one approval click cannot be read as having granted (an exact rule naming the whole command line is accepted)`
199
+ : `prefix rule "${text}" is headed by the interpreter "${head}" — such a rule authorizes running arbitrary programs, which one approval click cannot be read as having granted (an exact rule naming the whole command line is accepted)`);
187
200
  }
188
201
  const command = foldSpacing(body);
189
202
  if (command === undefined) {
@@ -209,7 +222,10 @@ function admitsUnder(rule, command, reading) {
209
222
  return false;
210
223
  if (rule.match === "exact")
211
224
  return folded === rule.command;
212
- if (shape.segments.length > 1)
225
+ const bodyShape = ruleLaneShapeOf(rule.command, reading);
226
+ if ("reject" in bodyShape)
227
+ return false;
228
+ if (shape.segments.length !== bodyShape.segments.length)
213
229
  return false;
214
230
  return folded === rule.command || folded.startsWith(rule.command + " ");
215
231
  }
@@ -259,13 +275,30 @@ export function suggestRulesForCommand(command) {
259
275
  if ("rule" in exact && exact.rule.match === "exact" && ruleAdmitsCommand(exact.rule, command)) {
260
276
  out.push({ rule: exact.rule.rule, match: "exact", command: exact.rule.command });
261
277
  }
262
- if (out.length === 1 && shape.segments.length === 1) {
263
- const body = longestReviewedBody(folded.split(" "));
264
- if (body !== undefined) {
265
- const text = formatAllowRuleText(body, "prefix");
266
- const parsed = parseAllowRuleText(text);
267
- if ("rule" in parsed && parsed.rule.match === "prefix" && ruleAdmitsCommand(parsed.rule, command)) {
268
- out.push({ rule: parsed.rule.rule, match: "prefix", command: parsed.rule.command });
278
+ const offerPrefix = (body) => {
279
+ if (body === undefined)
280
+ return;
281
+ const text = formatAllowRuleText(body, "prefix");
282
+ const parsed = parseAllowRuleText(text);
283
+ if ("rule" in parsed && parsed.rule.match === "prefix" && ruleAdmitsCommand(parsed.rule, command)) {
284
+ out.push({ rule: parsed.rule.rule, match: "prefix", command: parsed.rule.command });
285
+ }
286
+ };
287
+ const tokensOf = (s) => s.split(/\s+/).filter((t) => t !== "");
288
+ const bodyOf = (tokens) => longestReviewedBody(tokens) ?? genericPrefixBody(tokens);
289
+ if (out.length === 1) {
290
+ if (shape.segments.length === 1) {
291
+ offerPrefix(bodyOf(tokensOf(folded)));
292
+ }
293
+ else {
294
+ const foldedShape = ruleLaneShapeOf(folded, MATCH_READING);
295
+ if (!("reject" in foldedShape) && foldedShape.segments.length === shape.segments.length) {
296
+ const rawLast = foldedShape.segments[foldedShape.segments.length - 1] ?? "";
297
+ const lastBody = bodyOf(tokensOf(rawLast));
298
+ if (lastBody !== undefined) {
299
+ const cut = folded.length - rawLast.length + (rawLast.length - rawLast.trimStart().length);
300
+ offerPrefix(folded.slice(0, cut) + lastBody);
301
+ }
269
302
  }
270
303
  }
271
304
  }
@@ -46,6 +46,13 @@ export interface PermissionRuleIssue {
46
46
  /** design/179 D7: a Bash prefix rule under `bashPrefixLane:"rules-store"` — reported so a migration
47
47
  * report can tell "route this through the rule store" apart from "this is a mistake". */
48
48
  | "unsupported.bash_prefix_rules_store" | "unsupported.file_glob" | "unsupported.mcp_paren"
49
+ /** A parenthesised rule whose NAME is a covering spelling in a non-MCP protocol namespace
50
+ * (`a2a__<peer>__*(x:y)`, `a2a__<peer>(x:y)`). The param DSL is keyed by EXACT tool name, and a
51
+ * covering spelling is not one — no mounted tool can ever bear it, so the rule compiled clean and
52
+ * matched nothing. Additive member; the MCP spelling keeps its own {@link
53
+ * PermissionRuleIssue.code} `unsupported.mcp_paren` (that refusal is wider — it covers exact MCP
54
+ * names too, for CC parity — and fires first, so no MCP rule's reported code changes). */
55
+ | "unsupported.covering_paren"
49
56
  /** HRD-PRM-7: a param-level rule naming a parameter the target tool does not carry. Additive member —
50
57
  * the lane that used to compile silently and never match (see {@link FILE_TOOL_PARAMS}). */
51
58
  | "unsupported.unknown_param";
@@ -105,27 +112,28 @@ export declare function parsePermissionRule(rule: string): ParsedPermissionRule;
105
112
  */
106
113
  export declare function wildcardMatch(pattern: string, value: string): boolean;
107
114
  /**
108
- * Does an MCP-namespaced RULE name cover `toolName`? — the tail branch of CC's rule matcher `VRp`
115
+ * Does a namespaced RULE name cover `toolName`? — the tail branch of CC's rule matcher `VRp`
109
116
  * (:595780), which is UNCONDITIONAL there (its `globMatching` option gates only the whole-name glob
110
117
  * branch, so CC's allow lane — `mVo` :595803, which passes no options — matches MCP segments too).
111
- * Three covering forms, matching CC's three disjuncts:
112
- * · `mcp__<server>` — every tool of that server (rule tool segment absent);
113
- * · `mcp__<server>__*` — the same set, spelled with the star CC's own settings validator
114
- * recommends (`"MCP rules do not support patterns in parentheses …
115
- * use mcp__srv__*"`);
116
- * · `mcp__<server>__get_*` — a glob over the tool segment.
118
+ * Three covering forms, matching CC's three disjuncts (`<ns>` = any protocol-table prefix):
119
+ * · `<ns>__<peer>` — every tool of that peer (rule tool segment absent);
120
+ * · `<ns>__<peer>__*` — the same set, spelled with the star CC's own settings validator
121
+ * recommends (`"MCP rules do not support patterns in parentheses …
122
+ * use mcp__srv__*"`);
123
+ * · `<ns>__<peer>__get_*` — a glob over the tool segment.
117
124
  * A LITERAL tool segment is deliberately NOT covered here: it is an exact tool name, and the exact
118
125
  * table already answers it — the same split CC makes with its leading `ruleName === toolName`.
119
126
  *
120
- * Server segments compare EXACTLY (CC `s.serverName === a.serverName`). A rule must therefore spell
121
- * the server the way the mint does; that is the same string an operator reads off any mounted tool
122
- * name, and inventing a fuzzier comparison here would let one rule reach a server it does not name.
127
+ * Rule and target must be in the SAME namespace, and peer segments compare EXACTLY (CC
128
+ * `s.serverName === a.serverName`). A rule must therefore spell the peer the way the mint does; that
129
+ * is the same string an operator reads off any mounted tool name, and inventing a fuzzier comparison
130
+ * here would let one rule reach a peer — or a protocol — it does not name.
123
131
  */
124
- export declare function mcpRuleNameCovers(ruleName: string, toolName: string): boolean;
125
- /** True ⇔ this rule name is an MCP name whose reach is a SET of tools rather than one exact tool
126
- * i.e. the shapes {@link mcpRuleNameCovers} answers and an exact name table cannot. Every lane that
127
- * keys rules by exact tool name needs this to know which of its entries it must NOT key that way. */
128
- export declare function isMcpCoveringRuleName(name: string): boolean;
132
+ export declare function namespacedRuleNameCovers(ruleName: string, toolName: string): boolean;
133
+ /** True ⇔ this rule name is a namespaced name whose reach is a SET of tools rather than one exact tool
134
+ * i.e. the shapes {@link namespacedRuleNameCovers} answers and an exact name table cannot. Every
135
+ * lane that keys rules by exact tool name needs this to know which entries it must NOT key that way. */
136
+ export declare function isNamespacedCoveringRuleName(name: string): boolean;
129
137
  /** Exported for the lockstep guard only (see {@link FILE_TOOL_PARAMS}) — not part of the rule DSL. */
130
138
  export declare const fileToolParamVocabulary: (canonicalTool: string) => ReadonlySet<string> | undefined;
131
139
  /** Dry-run 校验/迁移报告(codex 127 审 B5):不 throw,返回全部 issues(含 `unsupported.*` 分类,
@@ -1,4 +1,4 @@
1
- import { MCP_NAMESPACE } from "./protocol-table.js";
1
+ import { MCP_NAMESPACE, protocolOf } from "./protocol-table.js";
2
2
  export const BASH_GENERIC_PARAMS = new Set(["command", "timeout", "description", "run_in_background"]);
3
3
  const DEFAULT_CAPS = {
4
4
  maxRules: 256,
@@ -77,31 +77,34 @@ export function wildcardMatch(pattern, value) {
77
77
  p++;
78
78
  return p === pattern.length;
79
79
  }
80
- function parseMcpRuleName(name) {
81
- if (!name.startsWith(MCP_NAMESPACE.prefix))
80
+ function parseNamespacedRuleName(name) {
81
+ const ns = protocolOf(name);
82
+ if (ns === undefined)
82
83
  return undefined;
83
- const segments = name.slice(MCP_NAMESPACE.prefix.length).split("__");
84
- const server = segments[0];
85
- if (server === undefined || server === "")
84
+ const segments = name.slice(ns.prefix.length).split("__");
85
+ const peer = segments[0];
86
+ if (peer === undefined || peer === "")
86
87
  return undefined;
87
88
  const rest = segments.slice(1);
88
- return rest.length > 0 ? { server, tool: rest.join("__") } : { server };
89
+ return rest.length > 0 ? { protocol: ns.id, peer, tool: rest.join("__") } : { protocol: ns.id, peer };
89
90
  }
90
- export function mcpRuleNameCovers(ruleName, toolName) {
91
- const rule = parseMcpRuleName(ruleName);
91
+ export function namespacedRuleNameCovers(ruleName, toolName) {
92
+ const rule = parseNamespacedRuleName(ruleName);
92
93
  if (rule === undefined)
93
94
  return false;
94
- const target = parseMcpRuleName(toolName);
95
+ const target = parseNamespacedRuleName(toolName);
95
96
  if (target === undefined)
96
97
  return false;
97
- if (rule.server !== target.server)
98
+ if (rule.protocol !== target.protocol)
99
+ return false;
100
+ if (rule.peer !== target.peer)
98
101
  return false;
99
102
  if (rule.tool === undefined || rule.tool === "*")
100
103
  return true;
101
104
  return target.tool !== undefined && rule.tool.includes("*") && wildcardMatch(rule.tool, target.tool);
102
105
  }
103
- export function isMcpCoveringRuleName(name) {
104
- const parsed = parseMcpRuleName(name);
106
+ export function isNamespacedCoveringRuleName(name) {
107
+ const parsed = parseNamespacedRuleName(name);
105
108
  return parsed !== undefined && (parsed.tool === undefined || parsed.tool.includes("*"));
106
109
  }
107
110
  const PRIMARY_FIELDS = {
@@ -138,13 +141,13 @@ function countStars(s) {
138
141
  }
139
142
  function compile(rules, caps, primaryFieldGeneric, bashPrefixLane = "reject") {
140
143
  const byTool = new Map();
141
- const mcpCovering = { deny: [], ask: [], allow: [] };
144
+ const namespacedCovering = { deny: [], ask: [], allow: [] };
142
145
  const issues = [];
143
146
  const bad = (rule, code, message) => {
144
147
  issues.push({ rule, code, message });
145
148
  };
146
149
  if (rules.length > caps.maxRules) {
147
- return { byTool, mcpCovering, issues: [{ rule: "", code: "invalid.cap_exceeded", message: `${rules.length} rules > maxRules ${caps.maxRules}` }] };
150
+ return { byTool, namespacedCovering, issues: [{ rule: "", code: "invalid.cap_exceeded", message: `${rules.length} rules > maxRules ${caps.maxRules}` }] };
148
151
  }
149
152
  for (const r of rules) {
150
153
  const text = r.rule;
@@ -171,14 +174,20 @@ function compile(rules, caps, primaryFieldGeneric, bashPrefixLane = "reject") {
171
174
  }
172
175
  }
173
176
  const toolName = parsed.toolName;
174
- if (toolName.startsWith(MCP_NAMESPACE.prefix) && (parsed.ruleContent !== undefined || indexOfUnescaped(text, "(") !== -1)) {
177
+ const parenthesised = parsed.ruleContent !== undefined || indexOfUnescaped(text, "(") !== -1;
178
+ if (toolName.startsWith(MCP_NAMESPACE.prefix) && parenthesised) {
175
179
  bad(text, "unsupported.mcp_paren", "MCP rules do not support patterns in parentheses (CC parity); use the toolAxes system");
176
180
  continue;
177
181
  }
182
+ if (parenthesised && isNamespacedCoveringRuleName(toolName)) {
183
+ bad(text, "unsupported.covering_paren", `"${toolName}" reaches a SET of tools, and the generic param DSL is keyed by exact tool name — a parenthesised rule on it can never match. ` +
184
+ `Write the parenthesised rule against one exact tool name, or drop the parentheses to keep the covering rule.`);
185
+ continue;
186
+ }
178
187
  const entry = byTool.get(toolName) ?? { bare: {}, param: { deny: [], ask: [] } };
179
188
  if (parsed.ruleContent === undefined) {
180
- if (isMcpCoveringRuleName(toolName)) {
181
- mcpCovering[r.behavior].push({ ruleName: toolName, ruleText: text, ...(r.source !== undefined ? { source: r.source } : {}) });
189
+ if (isNamespacedCoveringRuleName(toolName)) {
190
+ namespacedCovering[r.behavior].push({ ruleName: toolName, ruleText: text, ...(r.source !== undefined ? { source: r.source } : {}) });
182
191
  continue;
183
192
  }
184
193
  entry.bare[r.behavior] = { ruleText: text, source: r.source };
@@ -238,7 +247,7 @@ function compile(rules, caps, primaryFieldGeneric, bashPrefixLane = "reject") {
238
247
  entry.param[r.behavior].push({ param, pattern, behavior: r.behavior, ruleText: text, source: r.source });
239
248
  byTool.set(toolName, entry);
240
249
  }
241
- return { byTool, mcpCovering, issues };
250
+ return { byTool, namespacedCovering, issues };
242
251
  }
243
252
  export function validatePermissionRules(rules, opts) {
244
253
  const caps = { ...DEFAULT_CAPS, ...opts?.caps };
@@ -272,7 +281,7 @@ function ruleMessage(kind, ruleText, source) {
272
281
  }
273
282
  export function createPermissionRulePolicy(rules, opts) {
274
283
  const caps = { ...DEFAULT_CAPS, ...opts?.caps };
275
- const { byTool, mcpCovering, issues } = compile(rules, caps, opts?.primaryFieldGeneric ?? "reject", opts?.bashPrefixLane ?? "reject");
284
+ const { byTool, namespacedCovering, issues } = compile(rules, caps, opts?.primaryFieldGeneric ?? "reject", opts?.bashPrefixLane ?? "reject");
276
285
  const setLevelCap = issues.some((i) => i.code === "invalid.cap_exceeded" && i.rule === "");
277
286
  if (issues.length > 0 && ((opts?.onInvalidRule ?? "throw") === "throw" || setLevelCap)) {
278
287
  const e = new Error(`createPermissionRulePolicy: ${issues.length} invalid/unsupported rule(s):\n` +
@@ -307,23 +316,23 @@ export function createPermissionRulePolicy(rules, opts) {
307
316
  nameSets.allow.push(toolName);
308
317
  }
309
318
  for (const behavior of ["deny", "ask", "allow"]) {
310
- for (const r of mcpCovering[behavior])
319
+ for (const r of namespacedCovering[behavior])
311
320
  nameSets[behavior].push(r.ruleName);
312
321
  }
313
- const coveringHit = (lane, toolName) => lane.length === 0 ? undefined : lane.find((r) => mcpRuleNameCovers(r.ruleName, toolName));
314
- const hasCovering = mcpCovering.deny.length > 0 || mcpCovering.ask.length > 0 || mcpCovering.allow.length > 0;
322
+ const coveringHit = (lane, toolName) => lane.length === 0 ? undefined : lane.find((r) => namespacedRuleNameCovers(r.ruleName, toolName));
323
+ const hasCovering = namespacedCovering.deny.length > 0 || namespacedCovering.ask.length > 0 || namespacedCovering.allow.length > 0;
315
324
  return {
316
325
  nameSets: [nameSets],
317
326
  check(req) {
318
327
  const entry = byTool.get(req.toolName);
319
- const covering = hasCovering && req.toolName.startsWith(MCP_NAMESPACE.prefix);
328
+ const covering = hasCovering && protocolOf(req.toolName) !== undefined;
320
329
  if (entry?.bare.deny) {
321
330
  return { action: "deny", message: ruleMessage("denied", entry.bare.deny.ruleText, entry.bare.deny.source) };
322
331
  }
323
332
  if (covering) {
324
- const mcpDeny = coveringHit(mcpCovering.deny, req.toolName);
325
- if (mcpDeny)
326
- return { action: "deny", message: ruleMessage("denied", mcpDeny.ruleText, mcpDeny.source) };
333
+ const coveringDeny = coveringHit(namespacedCovering.deny, req.toolName);
334
+ if (coveringDeny)
335
+ return { action: "deny", message: ruleMessage("denied", coveringDeny.ruleText, coveringDeny.source) };
327
336
  }
328
337
  if (entry) {
329
338
  const paramDeny = matchParamRules(entry.param.deny, req.args, caps.maxScalarValueChars);
@@ -335,9 +344,9 @@ export function createPermissionRulePolicy(rules, opts) {
335
344
  }
336
345
  }
337
346
  if (covering) {
338
- const mcpAsk = coveringHit(mcpCovering.ask, req.toolName);
339
- if (mcpAsk)
340
- return { action: "ask", message: ruleMessage("flagged", mcpAsk.ruleText, mcpAsk.source), matchedAskRule: mcpAsk.ruleText };
347
+ const coveringAsk = coveringHit(namespacedCovering.ask, req.toolName);
348
+ if (coveringAsk)
349
+ return { action: "ask", message: ruleMessage("flagged", coveringAsk.ruleText, coveringAsk.source), matchedAskRule: coveringAsk.ruleText };
341
350
  }
342
351
  if (entry) {
343
352
  const paramAsk = matchParamRules(entry.param.ask, req.args, caps.maxScalarValueChars);
@@ -348,7 +357,7 @@ export function createPermissionRulePolicy(rules, opts) {
348
357
  return { action: "allow" };
349
358
  }
350
359
  }
351
- if (covering && coveringHit(mcpCovering.allow, req.toolName)) {
360
+ if (covering && coveringHit(namespacedCovering.allow, req.toolName)) {
352
361
  return { action: "allow" };
353
362
  }
354
363
  if (defaultAction === "allow")
@@ -314,6 +314,14 @@ export interface Prepared {
314
314
  * leg always knows its identity.
315
315
  */
316
316
  hookIdentity: HookInvocationIdentity;
317
+ /**
318
+ * The per-invocation TIME BOUND every hook seat of this leg runs under (`Hooks.timeoutMs`, already
319
+ * validated — a garbage value was refused to the default and disclosed ONCE, here, rather than on
320
+ * every tool call). Published for the same reason `hookIdentity` is: the seats live in two files, and
321
+ * a number each station re-derived would be two answers to one wiring question — including two
322
+ * chances to re-refuse the same bad value.
323
+ */
324
+ hookTimeoutMs: number;
317
325
  promptManifest: {
318
326
  constitution: "core" | "replaced" | "provider-assembled";
319
327
  blocks: Array<{
@@ -30,16 +30,17 @@ import { isValidReminderMark, mintReminderMark, reminderMarkDeclaration } from "
30
30
  import { policyAskClassOf } from "../ask-class.js";
31
31
  import { emitTrace } from "../trace.js";
32
32
  import { createSessionRulePolicy, PATH_CONFINABLE_WRITE_TOOLS } from "./session-rule-policy.js";
33
- import { cloneObserverInput, createHookEnvCapabilities, createPreToolUseConstraintPolicy, formatHookFeedback, mintHookInvocationIdentity, persistedRuleMandateOf, runToolGate } from "../hooks.js";
33
+ import { cloneObserverInput, createHookEnvCapabilities, createPreToolUseConstraintPolicy, formatHookFeedback, mintHookInvocationIdentity, persistedRuleMandateOf, resolveHookTimeoutMs, runHookSeat, hookSeatExpiredError, runToolGate } from "../hooks.js";
34
34
  import { createWriteProtectionCheck } from "../write-protect.js";
35
35
  import { orgRuleVerdictFor } from "../permission-rule-org.js";
36
36
  import { CacheBreakDetector, toolsToFingerprintInputs } from "../cache-break-detector.js";
37
37
  import { reservedCollisions, reservedFor } from "../../brain/request-params.js";
38
+ import { adjudicateDerivedRoute, fallbackToPrimaryNotice, sameRouteIdentity } from "../../brain/route-adjudicator.js";
38
39
  import { STALL_CONNECT_MS, STALL_FIRST_TOKEN_MS, STALL_IDLE_MS, withBrainCallGuardrail } from "../../brain/timeout.js";
39
40
  import { defineTool, isDefineToolProduct } from "../tools.js";
40
41
  import { RETIRED_TOOL_NAMES } from "../tool-name-aliases.js";
41
42
  import { protocolOf } from "../protocol-table.js";
42
- import { isMcpCoveringRuleName, mcpRuleNameCovers } from "../permission-rules.js";
43
+ import { isNamespacedCoveringRuleName, namespacedRuleNameCovers } from "../permission-rules.js";
43
44
  import { pathToUri } from "../lsp-protocol.js";
44
45
  import { DEFAULT_TOOL_RESULT_THRESHOLD_CHARS, createOffloadPersist, firstPartyOffloadPolicy, InMemoryToolResultStore, RunnerSharedToolResultStore, ScopedToolResultStore, isVolatileOffloadStore, OFFLOAD_TOOL_NAME, createReadToolResultTool, withToolResultOffload, } from "../tool-result-store.js";
45
46
  import { OUTPUT_TOOL_NAME, REPORT_FINDINGS_TOOL_NAME, SKILL_CONTENT_MAX_CHARS, SKILL_TOOL_NAME, createOutputTool, createReportBlockedTool, createReportFindingsTool, createSkillTool, normalizeSkills } from "./synthetic-tools.js";
@@ -302,6 +303,20 @@ function raceAbort(p, signal, onAbort) {
302
303
  p.then(finish, () => finish(onAbort()));
303
304
  });
304
305
  }
306
+ function createDenyObserverNotifier(hooks, hookTimeoutMs, signal, report) {
307
+ if (hooks?.permissionDenied === undefined)
308
+ return undefined;
309
+ return async (payload) => {
310
+ try {
311
+ const seat = await runHookSeat("permissionDenied", { timeoutMs: hookTimeoutMs, signal }, (sig) => hooks.permissionDenied({ ...payload, signal: sig }));
312
+ if (seat.expired)
313
+ report(hookSeatExpiredError("permissionDenied", hookTimeoutMs, seat.cause, "the deny observation was abandoned; the deny itself is unchanged"));
314
+ }
315
+ catch (err) {
316
+ report(err);
317
+ }
318
+ };
319
+ }
305
320
  function mcpRevocationWiring(deps) {
306
321
  if (deps.mcpRevocations === undefined)
307
322
  return undefined;
@@ -458,6 +473,20 @@ async function adoptReminderMark(session, sessionId, seedMark, spawnMark, onErro
458
473
  }
459
474
  return { reminderMark, reminderDisclosureCounts: {} };
460
475
  }
476
+ async function derivedRouteFallsBack(args) {
477
+ try {
478
+ if (sameRouteIdentity(args.derived, args.primary))
479
+ return false;
480
+ const verdict = await adjudicateDerivedRoute({ brain: args.brain, model: args.derived, getApiKeyAndHeaders: args.getApiKeyAndHeaders });
481
+ if (verdict === undefined || verdict.ok)
482
+ return false;
483
+ deliverEngineNotice(args.onNotice, fallbackToPrimaryNotice({ seat: args.seat, from: args.derived.id, to: args.primary.id, verdict }));
484
+ return true;
485
+ }
486
+ catch {
487
+ return false;
488
+ }
489
+ }
461
490
  export async function prepareTask(spec, deps, sessions, resume, internals, runnerSelf) {
462
491
  const doors = prepareConfigDoors({ spec, deps, sessions, resume, internals });
463
492
  spec = doors.spec;
@@ -475,7 +504,13 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
475
504
  const sessionId = acquired.sessionId;
476
505
  const hostTaskId = spec.taskId ?? sessionId;
477
506
  const delegation = effectiveDelegationFacts(internals, resume?.seed.isDelegatedChild);
478
- warnCompactionWindowHazard(deps.tracer, spec, model, compModel, hostTaskId);
507
+ let effectiveCompModel = compModel;
508
+ if (spec.compactionModel === undefined &&
509
+ compModel !== undefined &&
510
+ (await derivedRouteFallsBack({ seat: "compaction-summary", derived: compModel, primary: model, brain: deps.brain, getApiKeyAndHeaders: spec.getApiKeyAndHeaders, onNotice: deps.onNotice }))) {
511
+ effectiveCompModel = undefined;
512
+ }
513
+ warnCompactionWindowHazard(deps.tracer, spec, model, effectiveCompModel, hostTaskId);
479
514
  const taskScope = internals?.registryScope ?? spec.principal ?? "default";
480
515
  internals?.peerSelfRef?.addAxis("s", sessionId);
481
516
  internals?.peerSelfRef?.addAxis("t", hostTaskId);
@@ -859,7 +894,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
859
894
  pc.autoMode?.decider === autoModeDecider)
860
895
  ? [
861
896
  {
862
- policy: createPreToolUseConstraintPolicy(ownPreToolUse, hookEnvFace, notifyOwnHookCrash),
897
+ policy: createPreToolUseConstraintPolicy(ownPreToolUse, hookEnvFace, notifyOwnHookCrash, hookTimeoutMs),
863
898
  preToolUse: ownPreToolUse,
864
899
  ...(hookEnvSource !== undefined ? { hookEnv: hookEnvSource } : {}),
865
900
  ...(frozenOnAsk !== undefined ? { onAsk: frozenOnAsk } : {}),
@@ -1059,6 +1094,9 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1059
1094
  catch {
1060
1095
  classifierModel = model;
1061
1096
  }
1097
+ if (await derivedRouteFallsBack({ seat: "auto-mode-classifier", derived: classifierModel, primary: model, brain: deps.brain, getApiKeyAndHeaders: spec.getApiKeyAndHeaders, onNotice: deps.onNotice })) {
1098
+ classifierModel = model;
1099
+ }
1062
1100
  const classifierSystemPrompt = buildAutoModePrompt(am);
1063
1101
  const classifierRuntime = brainToRuntime(deps.brain);
1064
1102
  autoModeDecider = createAutoModeDecider({
@@ -1069,7 +1107,12 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1069
1107
  const ctx = await session.buildContext();
1070
1108
  const known = ctx.messages.filter((m) => m.role === "user" || m.role === "assistant" || m.role === "toolResult");
1071
1109
  const userPrompt = renderAutoModeWindow(known, am.window) + renderAutoModeAction(input);
1072
- const response = await classifierRuntime.completeSimple(classifierModel, { systemPrompt: classifierSystemPrompt, messages: [{ role: "user", content: userPrompt, timestamp: Date.now() }] }, { signal });
1110
+ const classifierAuth = await spec.getApiKeyAndHeaders?.(classifierModel);
1111
+ const response = await classifierRuntime.completeSimple(classifierModel, { systemPrompt: classifierSystemPrompt, messages: [{ role: "user", content: userPrompt, timestamp: Date.now() }] }, {
1112
+ signal,
1113
+ ...(classifierAuth?.apiKey !== undefined ? { apiKey: classifierAuth.apiKey } : {}),
1114
+ ...(classifierAuth?.headers !== undefined ? { headers: classifierAuth.headers } : {}),
1115
+ });
1073
1116
  return response.content
1074
1117
  .filter((c) => c.type === "text")
1075
1118
  .map((c) => c.text)
@@ -1398,7 +1441,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1398
1441
  owner: hostTaskId,
1399
1442
  scope: taskScope,
1400
1443
  ...(sessionId !== undefined ? { sessionId } : {}),
1401
- ...(internals?.onTaskNotification !== undefined ? { notify: internals.onTaskNotification } : {}),
1444
+ ...(internals?.onTaskNotification !== undefined ? { notify: internals.onTaskNotification } : {}), ...(spec.oneShot !== undefined ? { oneShot: spec.oneShot } : {}),
1402
1445
  ...(internals?.onSubagentSpawn !== undefined ? { sink: internals.onSubagentSpawn } : {}),
1403
1446
  ...(internals?.parentNotify !== undefined
1404
1447
  ? { uplink: internals.parentNotify, ...(internals.parentPeerRef !== undefined ? { uplinkRecipient: internals.parentPeerRef } : {}) }
@@ -2711,7 +2754,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2711
2754
  for (const n of list ?? []) {
2712
2755
  if (known.has(n))
2713
2756
  continue;
2714
- if (isMcpCoveringRuleName(n) && [...known].some((k) => mcpRuleNameCovers(n, k)))
2757
+ if (isNamespacedCoveringRuleName(n) && [...known].some((k) => namespacedRuleNameCovers(n, k)))
2715
2758
  continue;
2716
2759
  const retired = RETIRED_TOOL_NAMES.get(n);
2717
2760
  if (retired !== undefined) {
@@ -2795,6 +2838,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2795
2838
  const denyNarrowingPolicy = narrowingLayers.length === 0 ? undefined : narrowingLayers.length === 1 ? narrowingLayers[0] : combinePolicies(...narrowingLayers);
2796
2839
  const basePolicyForResumeEdit = lockedPreflight.basePolicyForResumeEdit ?? lockedPreflight.toolPolicy;
2797
2840
  const hooks = spec.hooks ?? deps.hooks;
2841
+ const hookTimeoutMs = resolveHookTimeoutMs(hooks?.timeoutMs, notifyOwnHookCrash, hooks);
2798
2842
  const preToolUseObservational = hooks?.preToolUse !== undefined && hooks.preToolUseObservational === true;
2799
2843
  const ownGatePreToolUse = !preToolUseObservational
2800
2844
  ? hooks?.preToolUse
@@ -3571,21 +3615,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3571
3615
  }
3572
3616
  return resolved;
3573
3617
  };
3574
- const hooksWithPermissionDenied = hooks?.permissionDenied ? hooks : undefined;
3575
- const notifyPermissionDenied = hooksWithPermissionDenied
3576
- ? async (payload) => {
3577
- try {
3578
- await hooksWithPermissionDenied.permissionDenied(payload);
3579
- }
3580
- catch (err) {
3581
- try {
3582
- deps.onError?.(err instanceof Error ? err : new Error(String(err)), { phase: "hook", sessionId });
3583
- }
3584
- catch {
3585
- }
3586
- }
3587
- }
3588
- : undefined;
3618
+ const notifyPermissionDenied = createDenyObserverNotifier(hooks, hookTimeoutMs, abortController.signal, notifyOwnHookCrash);
3589
3619
  const notifyHookError = (err) => {
3590
3620
  try {
3591
3621
  deps.onError?.(err instanceof Error ? err : new Error(String(err)), { phase: "hook", sessionId });
@@ -4302,6 +4332,8 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4302
4332
  identity: hookIdentity,
4303
4333
  reminderMark,
4304
4334
  preToolUse: ownGatePreToolUse,
4335
+ hookTimeoutMs,
4336
+ ...(handsCwdRef !== undefined ? { trackedCwd: () => handsCwdRef.current } : {}),
4305
4337
  ...(hookEnvFace !== undefined ? { hookEnv: hookEnvFace } : {}),
4306
4338
  adjudicate,
4307
4339
  resolveAsk: resolveAskBound,
@@ -4418,7 +4450,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4418
4450
  }
4419
4451
  catch {
4420
4452
  }
4421
- const patch = await hooks.postToolUseFailure(e.toolName, e.input, {
4453
+ const failureSeat = await runHookSeat("postToolUseFailure", { timeoutMs: hookTimeoutMs, signal: abortController.signal }, (sig) => hooks.postToolUseFailure(e.toolName, e.input, {
4422
4454
  error: e.content
4423
4455
  .filter((c) => c.type === "text")
4424
4456
  .map((c) => c.text)
@@ -4426,7 +4458,11 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4426
4458
  isInterrupt: abortController.signal.aborted || spec.signal?.aborted === true,
4427
4459
  content: e.content.map((c) => ({ ...c })),
4428
4460
  details: clonedDetails,
4429
- }, { toolCallId: e.toolCallId, toolName: e.toolName, ...(hookEnvFace !== undefined ? { env: hookEnvFace } : {}), identity: hookIdentity });
4461
+ }, { toolCallId: e.toolCallId, toolName: e.toolName, signal: sig, ...(handsCwdRef?.current !== undefined ? { cwd: handsCwdRef.current } : {}), ...(hookEnvFace !== undefined ? { env: hookEnvFace } : {}), identity: hookIdentity }));
4462
+ if (failureSeat.expired) {
4463
+ notifyOwnHookCrash(hookSeatExpiredError("postToolUseFailure", hookTimeoutMs, failureSeat.cause, "the observation was abandoned and its additionalContext dropped; the failure result itself is unchanged"));
4464
+ }
4465
+ const patch = failureSeat.expired ? undefined : failureSeat.value;
4430
4466
  if (patch?.additionalContext) {
4431
4467
  content = [...content, { type: "text", text: formatHookFeedback(patch.additionalContext, reminderMark) }];
4432
4468
  changed = true;
@@ -4434,7 +4470,11 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4434
4470
  }
4435
4471
  }
4436
4472
  else if (hooks?.postToolUse) {
4437
- const patch = await hooks.postToolUse(e.toolName, e.input, { content: e.content, details: e.details, isError: e.isError }, { toolCallId: e.toolCallId, toolName: e.toolName, ...(hookEnvFace !== undefined ? { env: hookEnvFace } : {}), identity: hookIdentity });
4473
+ const successSeat = await runHookSeat("postToolUse", { timeoutMs: hookTimeoutMs, signal: abortController.signal }, (sig) => hooks.postToolUse(e.toolName, e.input, { content: e.content, details: e.details, isError: e.isError }, { toolCallId: e.toolCallId, toolName: e.toolName, signal: sig, ...(handsCwdRef?.current !== undefined ? { cwd: handsCwdRef.current } : {}), ...(hookEnvFace !== undefined ? { env: hookEnvFace } : {}), identity: hookIdentity }));
4474
+ if (successSeat.expired) {
4475
+ notifyOwnHookCrash(hookSeatExpiredError("postToolUse", hookTimeoutMs, successSeat.cause, "the seat was abandoned; the tool result is delivered to the model UNMODIFIED (no updatedOutput, no additionalContext)"));
4476
+ }
4477
+ const patch = successSeat.expired ? undefined : successSeat.value;
4438
4478
  if (patch?.updatedOutput) {
4439
4479
  content = patch.updatedOutput;
4440
4480
  changed = true;
@@ -4722,7 +4762,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4722
4762
  const effectiveReadFaceObserved = carrierReadFace();
4723
4763
  const effectiveReadDenyObserved = readDenyAdditionsNormalized.length > 0 ? readDenyAdditionsNormalized.map((e) => ({ ...e })) : undefined;
4724
4764
  const preparedHolder = {};
4725
- const buildPrepared = () => ({ harness, session, sessionId, reminderMark, reminderDisclosureCounts, taskRootPath: taskRootFinal, model, thinking, compModel, mcp: mcp, ...(a2a !== undefined && a2a.tools.length > 0 ? { a2a } : {}), blockedRef, outputRef, abortController, conflictRef, blockedToolCalls, approvalSettlement, batchHaltRef, nestedStats, ...(rewindNotes.length > 0 ? { rewindNotes } : {}), ...(effectiveReadFaceObserved !== undefined ? { effectiveReadFace: effectiveReadFaceObserved } : {}), ...(effectiveReadDenyObserved !== undefined ? { effectiveReadDenyPatterns: effectiveReadDenyObserved } : {}), effectiveMemoryScopes: memoryEffectiveScopes, cwdRef: handsCwdRef, ...(worktreeSessionRef !== undefined ? { worktreeSessionRef } : {}), ...(workspaceStateSettle !== undefined ? { workspaceStateSettle } : {}), denyNarrowingPolicy, ...(basePolicyForResumeEdit !== undefined ? { basePolicyForResumeEdit } : {}), ...(permissionRuleOrgLane !== undefined ? { permissionRuleOrg: permissionRuleOrgLane } : {}), releaseSignal, settleContentAskBindings, cacheBreakDetector, cacheFingerprint, wiringManifest, promptManifest, epochDeclaredSections, activeTools, ...(deferred.size > 0 ? { deferredToolNames: deferred } : {}), toolMaterializeStatic, deferDirectCall, ...(staticFaceForRef.current !== undefined ? { staticFaceFor: staticFaceForRef.current } : {}), ownedEnv, suspendRef, suspendProgressRef, reviewRef, remoteEnvFailures, reviewRequestRef, suspendLoopRef, suspendForResource, ...(suspendForPlatformLimit !== undefined ? { suspendForPlatformLimit } : {}), ...(envLifetimeSuspendAt !== undefined ? { envLifetimeSuspendAt } : {}), ...(usageGovernance !== undefined ? { usageGovernance } : {}), callIssuedAtRef, brainCallGuardrailRef, suspendForReview, resourceLedger: priorLedger, liveSpendRef, humanReviewRef, now, tools, toolEffects, wakeRecovered, promptOverheadTokens, lastBrainContext, readTaskFile, recentlyReadFiles, normalizeAttachmentPath, isDedupStubResult, ...(onCompactionApplied ? { onCompactionApplied } : {}), compactionReuseRef, trimPressureRef, ...(memoryEngineSession ? { memoryEngineSession } : {}), ...(subagentRetain ? { subagentRetain } : {}), ...(lspDiagnostics && nudgeLspOnEdit ? { lspDiagnostics: { registry: lspDiagnostics, nudge: nudgeLspOnEdit, runIdent: lspRunIdent } } : {}), planModeRef, ...(dateChange ? { dateChange } : {}), ...(instructionSources ? { instructionSources } : {}), ...(workflowSizeGuideline ? { workflowSizeGuideline } : {}), ...(detectExternalChanges ? { detectExternalChanges } : {}), ...(toolsDeltaRef ? { toolsDeltaRef } : {}), ...(agentListing ? { agentListing } : {}), ...(skillsListing ? { skillsListing } : {}), announcedListingsRef, gitStatusRef, listBackgroundTasks, hookIdentity, ...(turnSnapshotRef.current !== undefined ? { turnSnapshot: turnSnapshotRef.current } : {}), ...(centerCompactionCandidate !== undefined ? { centerCompactionCandidate } : {}) });
4765
+ const buildPrepared = () => ({ harness, session, sessionId, reminderMark, reminderDisclosureCounts, taskRootPath: taskRootFinal, model, thinking, compModel: effectiveCompModel, mcp: mcp, ...(a2a !== undefined && a2a.tools.length > 0 ? { a2a } : {}), blockedRef, outputRef, abortController, conflictRef, blockedToolCalls, approvalSettlement, batchHaltRef, nestedStats, ...(rewindNotes.length > 0 ? { rewindNotes } : {}), ...(effectiveReadFaceObserved !== undefined ? { effectiveReadFace: effectiveReadFaceObserved } : {}), ...(effectiveReadDenyObserved !== undefined ? { effectiveReadDenyPatterns: effectiveReadDenyObserved } : {}), effectiveMemoryScopes: memoryEffectiveScopes, cwdRef: handsCwdRef, ...(worktreeSessionRef !== undefined ? { worktreeSessionRef } : {}), ...(workspaceStateSettle !== undefined ? { workspaceStateSettle } : {}), denyNarrowingPolicy, ...(basePolicyForResumeEdit !== undefined ? { basePolicyForResumeEdit } : {}), ...(permissionRuleOrgLane !== undefined ? { permissionRuleOrg: permissionRuleOrgLane } : {}), releaseSignal, settleContentAskBindings, cacheBreakDetector, cacheFingerprint, wiringManifest, promptManifest, epochDeclaredSections, activeTools, ...(deferred.size > 0 ? { deferredToolNames: deferred } : {}), toolMaterializeStatic, deferDirectCall, ...(staticFaceForRef.current !== undefined ? { staticFaceFor: staticFaceForRef.current } : {}), ownedEnv, suspendRef, suspendProgressRef, reviewRef, remoteEnvFailures, reviewRequestRef, suspendLoopRef, suspendForResource, ...(suspendForPlatformLimit !== undefined ? { suspendForPlatformLimit } : {}), ...(envLifetimeSuspendAt !== undefined ? { envLifetimeSuspendAt } : {}), ...(usageGovernance !== undefined ? { usageGovernance } : {}), callIssuedAtRef, brainCallGuardrailRef, suspendForReview, resourceLedger: priorLedger, liveSpendRef, humanReviewRef, now, tools, toolEffects, wakeRecovered, promptOverheadTokens, lastBrainContext, readTaskFile, recentlyReadFiles, normalizeAttachmentPath, isDedupStubResult, ...(onCompactionApplied ? { onCompactionApplied } : {}), compactionReuseRef, trimPressureRef, ...(memoryEngineSession ? { memoryEngineSession } : {}), ...(subagentRetain ? { subagentRetain } : {}), ...(lspDiagnostics && nudgeLspOnEdit ? { lspDiagnostics: { registry: lspDiagnostics, nudge: nudgeLspOnEdit, runIdent: lspRunIdent } } : {}), planModeRef, ...(dateChange ? { dateChange } : {}), ...(instructionSources ? { instructionSources } : {}), ...(workflowSizeGuideline ? { workflowSizeGuideline } : {}), ...(detectExternalChanges ? { detectExternalChanges } : {}), ...(toolsDeltaRef ? { toolsDeltaRef } : {}), ...(agentListing ? { agentListing } : {}), ...(skillsListing ? { skillsListing } : {}), announcedListingsRef, gitStatusRef, listBackgroundTasks, hookIdentity, hookTimeoutMs, ...(turnSnapshotRef.current !== undefined ? { turnSnapshot: turnSnapshotRef.current } : {}), ...(centerCompactionCandidate !== undefined ? { centerCompactionCandidate } : {}) });
4726
4766
  const prepared = buildPrepared();
4727
4767
  preparedHolder.current = prepared;
4728
4768
  return prepared;
@@ -219,7 +219,10 @@ export declare class Runner {
219
219
  * - `tiers` omitted ⇒ the current tier bindings are kept (and re-applied over the new models);
220
220
  * explicitly passed (including `undefined`) ⇒ replaced.
221
221
  * - Success is announced via `config.models_swapped` (models/tiers counts — key material only,
222
- * never the catalog itself).
222
+ * never the catalog itself). A swap may additionally emit the advisory
223
+ * `route.base_url_changed_key_unchanged` notice (deliberately conservative — see its entry in
224
+ * the notice directory) for same-name entries whose URL moved while the Model-visible
225
+ * credential fingerprint did not.
223
226
  */
224
227
  swapModels(next: {
225
228
  models: Record<string, Model>;