@sema-agent/core 5.54.0 → 5.56.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 (85) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/dist/agents/cumulative-stats.d.ts +26 -0
  3. package/dist/agents/cumulative-stats.js +56 -0
  4. package/dist/agents/observer.d.ts +11 -7
  5. package/dist/agents/observer.js +2 -4
  6. package/dist/agents/send-message-tool.js +48 -2
  7. package/dist/agents/subagent.js +250 -89
  8. package/dist/agents/verify.d.ts +27 -3
  9. package/dist/agents/verify.js +7 -2
  10. package/dist/core/auto-compaction.d.ts +17 -4
  11. package/dist/core/auto-compaction.js +3 -0
  12. package/dist/core/context-edit.d.ts +55 -6
  13. package/dist/core/context-edit.js +12 -1
  14. package/dist/core/governance-codes.js +14 -0
  15. package/dist/core/hooks.d.ts +293 -11
  16. package/dist/core/hooks.js +159 -12
  17. package/dist/core/human-input-projection.d.ts +20 -2
  18. package/dist/core/human-input-projection.js +9 -0
  19. package/dist/core/lsp-diagnostics.d.ts +19 -17
  20. package/dist/core/lsp-diagnostics.js +11 -5
  21. package/dist/core/mcp.d.ts +46 -0
  22. package/dist/core/mcp.js +132 -6
  23. package/dist/core/memory-engine/consolidation.d.ts +378 -0
  24. package/dist/core/memory-engine/consolidation.js +342 -0
  25. package/dist/core/memory-engine/dual-root.js +3 -0
  26. package/dist/core/memory-engine/engine.d.ts +237 -4
  27. package/dist/core/memory-engine/engine.js +1111 -4
  28. package/dist/core/memory-engine/export-bundle.js +9 -0
  29. package/dist/core/memory-engine/file-backend.js +27 -1
  30. package/dist/core/memory-engine/frontmatter.d.ts +20 -1
  31. package/dist/core/memory-engine/frontmatter.js +111 -0
  32. package/dist/core/memory-engine/index.d.ts +4 -2
  33. package/dist/core/memory-engine/index.js +3 -1
  34. package/dist/core/memory-engine/memory-backend-contract.js +131 -0
  35. package/dist/core/memory-engine/sync-client.js +26 -0
  36. package/dist/core/memory-engine/tools.d.ts +9 -0
  37. package/dist/core/memory-engine/tools.js +57 -13
  38. package/dist/core/memory-engine/types.d.ts +99 -0
  39. package/dist/core/memory-recall.js +4 -3
  40. package/dist/core/memory.d.ts +33 -3
  41. package/dist/core/memory.js +6 -4
  42. package/dist/core/permission-rules.d.ts +30 -0
  43. package/dist/core/permission-rules.js +71 -8
  44. package/dist/core/reminder-disclosure.d.ts +29 -4
  45. package/dist/core/reminder-disclosure.js +60 -12
  46. package/dist/core/runner/prepare-memory.js +7 -2
  47. package/dist/core/runner/prepare-task.d.ts +39 -1
  48. package/dist/core/runner/prepare-task.js +63 -35
  49. package/dist/core/runner/runtask.d.ts +8 -1
  50. package/dist/core/runner/runtask.js +170 -31
  51. package/dist/core/runner/session-rule-policy.js +5 -3
  52. package/dist/core/runner/synthetic-tools.js +4 -2
  53. package/dist/core/runner/turn-attachments.d.ts +16 -6
  54. package/dist/core/runner/turn-attachments.js +34 -20
  55. package/dist/core/session-reconcile.d.ts +32 -0
  56. package/dist/core/session-reconcile.js +15 -0
  57. package/dist/core/task-notification.d.ts +34 -7
  58. package/dist/core/task-notification.js +11 -1
  59. package/dist/core/task-registry-agent.d.ts +20 -3
  60. package/dist/core/task-registry-agent.js +31 -2
  61. package/dist/core/tool-policy.d.ts +23 -0
  62. package/dist/core/tool-policy.js +29 -13
  63. package/dist/core/types.d.ts +126 -17
  64. package/dist/core/untrusted-egress.js +12 -2
  65. package/dist/core/untrusted-text.d.ts +189 -3
  66. package/dist/core/untrusted-text.js +424 -6
  67. package/dist/engine/compaction/compaction.d.ts +77 -7
  68. package/dist/engine/compaction/compaction.js +98 -9
  69. package/dist/engine/compaction/utils.d.ts +4 -0
  70. package/dist/engine/compaction/utils.js +6 -0
  71. package/dist/engine/harness/agent-harness.d.ts +84 -0
  72. package/dist/engine/harness/agent-harness.js +88 -12
  73. package/dist/engine/harness/messages.d.ts +4 -2
  74. package/dist/engine/harness/messages.js +7 -2
  75. package/dist/engine/harness/types.d.ts +11 -5
  76. package/dist/engine/loop/types.d.ts +14 -0
  77. package/dist/engine/session/import-validate.js +10 -0
  78. package/dist/engine/session/session.js +2 -2
  79. package/dist/index.d.ts +1 -1
  80. package/dist/index.js +1 -1
  81. package/dist/orchestration/run-spec.js +8 -1
  82. package/dist/prompts/default.d.ts +22 -6
  83. package/dist/tools/fs/index.d.ts +3 -1
  84. package/package.json +1 -1
  85. package/test/export-surface.snapshot.json +28 -1
@@ -6,6 +6,92 @@ import { PROBE_REASON_MAX, normalizeProbeCause } from "./checkpoint-store.js";
6
6
  import { ASK_USER_QUESTION_TOOL_NAME } from "./ask-question.js";
7
7
  import { createSafeNotifier } from "./safe-notify.js";
8
8
  import { ORG_ADJUDICATION_TIMEOUT_MS, ORG_RULE_DECISION_REASON, ORG_UNAVAILABLE_DECISION_REASON, settleOrgVerdictWithin } from "./permission-rule-org.js";
9
+ export const DEFAULT_HOOK_TIMEOUT_MS = 600_000;
10
+ const MAX_HOOK_TIMEOUT_MS = 2_147_483_647;
11
+ const POST_ABORT_GRACE_MS = 1_000;
12
+ export function postAbortGraceMs(remainingMs) {
13
+ return Math.max(0, Math.min(POST_ABORT_GRACE_MS, remainingMs));
14
+ }
15
+ const badHookTimeoutReported = new WeakSet();
16
+ export function resolveHookTimeoutMs(supplied, report, owner) {
17
+ if (supplied === undefined)
18
+ return DEFAULT_HOOK_TIMEOUT_MS;
19
+ if (Number.isFinite(supplied) && supplied >= 0 && supplied <= MAX_HOOK_TIMEOUT_MS)
20
+ return supplied;
21
+ if (report !== undefined && !(owner !== undefined && badHookTimeoutReported.has(owner))) {
22
+ if (owner !== undefined)
23
+ badHookTimeoutReported.add(owner);
24
+ try {
25
+ report(new Error(`Hooks.timeoutMs must be a non-negative finite number no greater than ${MAX_HOOK_TIMEOUT_MS} (got ${String(supplied)}) — ` +
26
+ `every hook seat falls back to the ${DEFAULT_HOOK_TIMEOUT_MS}ms default`));
27
+ }
28
+ catch {
29
+ }
30
+ }
31
+ return DEFAULT_HOOK_TIMEOUT_MS;
32
+ }
33
+ export async function runHookSeat(seat, bound, call) {
34
+ void seat;
35
+ const timeoutMs = resolveHookTimeoutMs(bound.timeoutMs, bound.onBadTimeout, bound.owner);
36
+ const taskSignal = bound.signal;
37
+ const controller = new AbortController();
38
+ const pending = Promise.resolve(call(controller.signal));
39
+ const startedAt = performance.now();
40
+ return await new Promise((resolve, reject) => {
41
+ let settled = false;
42
+ let listening = false;
43
+ let timer;
44
+ const clearTimer = () => {
45
+ if (timer !== undefined)
46
+ clearTimeout(timer);
47
+ timer = undefined;
48
+ };
49
+ const finish = (act) => {
50
+ if (settled)
51
+ return;
52
+ settled = true;
53
+ clearTimer();
54
+ if (listening)
55
+ taskSignal?.removeEventListener("abort", onAbort);
56
+ act();
57
+ };
58
+ const expire = (cause) => finish(() => {
59
+ void pending.then(undefined, () => {
60
+ });
61
+ resolve({ expired: true, cause });
62
+ queueMicrotask(() => {
63
+ try {
64
+ controller.abort();
65
+ }
66
+ catch {
67
+ }
68
+ });
69
+ });
70
+ const collapseToGrace = () => {
71
+ if (settled)
72
+ return;
73
+ clearTimer();
74
+ timer = setTimeout(() => expire("aborted"), postAbortGraceMs(timeoutMs - (performance.now() - startedAt)));
75
+ };
76
+ const onAbort = () => (bound.abortEnds === true ? expire("aborted") : collapseToGrace());
77
+ timer = setTimeout(() => expire("timeout"), timeoutMs);
78
+ if (taskSignal !== undefined) {
79
+ if (taskSignal.aborted) {
80
+ onAbort();
81
+ }
82
+ else {
83
+ taskSignal.addEventListener("abort", onAbort, { once: true });
84
+ listening = true;
85
+ }
86
+ }
87
+ pending.then((value) => finish(() => resolve({ expired: false, value })), (err) => finish(() => reject(err instanceof Error ? err : new Error(String(err)))));
88
+ });
89
+ }
90
+ export function hookSeatExpiredError(seat, timeoutMs, cause, consequence) {
91
+ return new Error(cause === "timeout"
92
+ ? `the deployment's ${seat} hook did not answer within its ${timeoutMs}ms bound (Hooks.timeoutMs) — ${consequence}`
93
+ : `the deployment's ${seat} hook had not answered when the task was cancelled — ${consequence}`);
94
+ }
9
95
  export function cloneObserverInput(input) {
10
96
  try {
11
97
  return structuredClone(input);
@@ -202,17 +288,35 @@ function preToolUseCrashReason(subject, err) {
202
288
  `This is a failure of the deployment's hook, NOT of the tool itself — an identical retry reaches the ` +
203
289
  `same hook and fails the same way. Hook error: ${cause || "(no message)"}`);
204
290
  }
291
+ function preToolUseUnansweredReason(subject, timeoutMs, cause) {
292
+ return cause === "timeout"
293
+ ? `a PreToolUse hook did not answer within its ${timeoutMs}ms bound while screening ${subject}; the call was ` +
294
+ `NOT executed (fail-closed). This is a failure of the deployment's hook, NOT of the tool itself — the ` +
295
+ `screening face never returned a verdict, so nothing was decided about this call either way.`
296
+ : `the task was cancelled while a PreToolUse hook was still screening ${subject}; the call was NOT executed ` +
297
+ `(fail-closed). No verdict was reached — this is the run ending, not the tool failing.`;
298
+ }
205
299
  function screenPreToolUseResult(r) {
206
300
  if (r === undefined)
207
301
  return undefined;
208
302
  return refuseOutOfContractDecision(r, { reasonIsNonInput: true });
209
303
  }
210
- export function createPreToolUseConstraintPolicy(preToolUse, env, onCrash) {
304
+ export function createPreToolUseConstraintPolicy(preToolUse, env, onCrash, timeoutMs) {
211
305
  return brandPolicyAskClass({
212
306
  check: async (req) => {
213
307
  let r;
308
+ const boundMs = resolveHookTimeoutMs(timeoutMs, onCrash);
214
309
  try {
215
- r = screenPreToolUseResult(await preToolUse(req.toolName, req.args, { toolCallId: req.toolCallId, toolName: req.toolName, ...(env !== undefined ? { env } : {}) }));
310
+ const seat = await runHookSeat("preToolUse", { timeoutMs: boundMs }, (sig) => preToolUse(req.toolName, req.args, { toolCallId: req.toolCallId, toolName: req.toolName, signal: sig, ...(env !== undefined ? { env } : {}) }));
311
+ if (seat.expired) {
312
+ try {
313
+ onCrash?.(hookSeatExpiredError("preToolUse", boundMs, seat.cause, `the inherited screening of "${req.toolName}" was refused fail-closed`));
314
+ }
315
+ catch {
316
+ }
317
+ return { action: "deny", message: preToolUseUnansweredReason(`this call to "${req.toolName}"`, boundMs, seat.cause), decisionReason: "hook" };
318
+ }
319
+ r = screenPreToolUseResult(seat.value);
216
320
  }
217
321
  catch (err) {
218
322
  try {
@@ -284,9 +388,14 @@ export function persistedRuleMandateOf(marks) {
284
388
  export async function runToolGate(input) {
285
389
  const { event, preToolUse, adjudicate, resolveAsk, suspendAsk } = input;
286
390
  const { toolCallId, toolName } = event;
287
- const hookCtx = () => ({
391
+ const hookCtx = (seatSignal) => ({
288
392
  toolCallId,
289
393
  toolName,
394
+ ...(seatSignal !== undefined ? { signal: seatSignal } : {}),
395
+ ...(() => {
396
+ const c = input.trackedCwd?.();
397
+ return typeof c === "string" ? { cwd: c } : {};
398
+ })(),
290
399
  ...(input.hookEnv !== undefined ? { env: input.hookEnv } : {}),
291
400
  ...(input.identity !== undefined ? { identity: input.identity } : {}),
292
401
  });
@@ -296,17 +405,47 @@ export async function runToolGate(input) {
296
405
  let parkFailed;
297
406
  let askDenyResolution;
298
407
  const notifier = createSafeNotifier(input.onNotifyError !== undefined ? { onError: input.onNotifyError } : undefined);
408
+ const hookSeatMs = resolveHookTimeoutMs(input.hookTimeoutMs, (err) => traceHookCrash(input, err, notifier));
409
+ const seatBound = (abortEnds) => ({
410
+ timeoutMs: hookSeatMs,
411
+ ...(input.abortSignal !== undefined ? { signal: input.abortSignal } : {}),
412
+ ...(abortEnds ? { abortEnds: true } : {}),
413
+ });
414
+ const preToolUseUnansweredBlock = async (subject, cause) => {
415
+ const reason = preToolUseUnansweredReason(subject, hookSeatMs, cause);
416
+ if (cause === "timeout") {
417
+ traceHookCrash(input, hookSeatExpiredError("preToolUse", hookSeatMs, cause, `${subject} was NOT executed (fail-closed) and the screening face was signalled to stop`), notifier);
418
+ }
419
+ return reason;
420
+ };
421
+ const notifyPermissionDeniedSeat = async (payload) => {
422
+ if (input.permissionDenied === undefined)
423
+ return;
424
+ await notifier.notifyAsync(async () => {
425
+ const seat = await runHookSeat("permissionDenied", seatBound(false), (sig) => input.permissionDenied?.({ ...payload, signal: sig }));
426
+ if (seat.expired)
427
+ throw hookSeatExpiredError("permissionDenied", hookSeatMs, seat.cause, "the deny observation was abandoned; the deny itself is unchanged");
428
+ }, "toolGate.permissionDenied");
429
+ };
299
430
  if (preToolUse) {
300
431
  let r;
432
+ let unanswered;
301
433
  try {
302
- r = screenPreToolUseResult(await preToolUse(toolName, currentInput, hookCtx()));
434
+ const seat = await runHookSeat("preToolUse", seatBound(true), (sig) => preToolUse(toolName, currentInput, hookCtx(sig)));
435
+ if (seat.expired)
436
+ unanswered = seat.cause;
437
+ else
438
+ r = screenPreToolUseResult(seat.value);
303
439
  }
304
440
  catch (err) {
305
441
  const reason = preToolUseCrashReason(`this call to "${toolName}"`, err);
306
442
  traceHookCrash(input, err, notifier);
307
- if (input.permissionDenied) {
308
- await notifier.notifyAsync(() => input.permissionDenied?.({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason, source: "hook", ...(input.identity !== undefined ? { identity: input.identity } : {}) }), "toolGate.permissionDenied");
309
- }
443
+ await notifyPermissionDeniedSeat({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason, source: "hook", ...(input.identity !== undefined ? { identity: input.identity } : {}) });
444
+ return { block: true, reason: formatHookFeedback(reason, input.reminderMark), preToolContext };
445
+ }
446
+ if (unanswered !== undefined) {
447
+ const reason = await preToolUseUnansweredBlock(`this call to "${toolName}"`, unanswered);
448
+ await notifyPermissionDeniedSeat({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason, source: "hook", ...(input.identity !== undefined ? { identity: input.identity } : {}) });
310
449
  return { block: true, reason: formatHookFeedback(reason, input.reminderMark), preToolContext };
311
450
  }
312
451
  if (r) {
@@ -548,7 +687,7 @@ export async function runToolGate(input) {
548
687
  catch {
549
688
  return { unreadable: true };
550
689
  }
551
- return await pendingHit.then(normalizePersistedRuleHit).catch(() => ({ unreadable: true }));
690
+ return await settleOrgVerdictWithin(pendingHit.then(normalizePersistedRuleHit).catch(() => ({ unreadable: true })), { unreadable: true }, { ...(input.abortSignal !== undefined ? { signal: input.abortSignal } : {}), timeoutMs: ORG_ADJUDICATION_TIMEOUT_MS });
552
691
  })();
553
692
  const hitEntry = answer.hit;
554
693
  const hit = hitEntry?.rule;
@@ -714,8 +853,13 @@ export async function runToolGate(input) {
714
853
  }
715
854
  if (preToolUse) {
716
855
  let hr;
856
+ let hrUnanswered;
717
857
  try {
718
- hr = screenPreToolUseResult(await preToolUse(toolName, editArgs, hookCtx()));
858
+ const seat = await runHookSeat("preToolUse", seatBound(true), (sig) => preToolUse(toolName, editArgs, hookCtx(sig)));
859
+ if (seat.expired)
860
+ hrUnanswered = seat.cause;
861
+ else
862
+ hr = screenPreToolUseResult(seat.value);
719
863
  }
720
864
  catch (err) {
721
865
  traceHookCrash(input, err, notifier);
@@ -723,6 +867,11 @@ export async function runToolGate(input) {
723
867
  denySource = "hook";
724
868
  break;
725
869
  }
870
+ if (hrUnanswered !== undefined) {
871
+ editDenied = { action: "deny", message: await preToolUseUnansweredBlock(`the approved edit for "${toolName}"`, hrUnanswered) };
872
+ denySource = "hook";
873
+ break;
874
+ }
726
875
  if (hr) {
727
876
  if (hr.additionalContext)
728
877
  preToolContext.push(hr.additionalContext);
@@ -828,9 +977,7 @@ export async function runToolGate(input) {
828
977
  currentInput = decision.updatedInput;
829
978
  }
830
979
  const denyResolution = askDenyResolution ?? coreMintedResolutionOf(decision, { toolCallId, toolName });
831
- if (input.permissionDenied) {
832
- await notifier.notifyAsync(() => input.permissionDenied?.({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason: denyReason, source: denySource, ...(denyResolution !== undefined ? { resolution: denyResolution } : {}), ...(input.identity !== undefined ? { identity: input.identity } : {}) }), "toolGate.permissionDenied");
833
- }
980
+ await notifyPermissionDeniedSeat({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason: denyReason, source: denySource, ...(denyResolution !== undefined ? { resolution: denyResolution } : {}), ...(input.identity !== undefined ? { identity: input.identity } : {}) });
834
981
  const denySettledBy = decision.settledBy;
835
982
  const denyApprover = denySettledBy !== undefined ? resolvedApprover : undefined;
836
983
  return {
@@ -4,8 +4,13 @@
4
4
  * One renderer covers every human-input carrier: the core-side five (objective / live steer /
5
5
  * nextTurn / the resume tail's parked-steer frames / the wake message) and a serving layer's own
6
6
  * carriers through the same contract. The division of labor is fixed (ruled 2026-08-05): the INGRESS
7
- * sanitizes text and mints the {@link ActorAssertion}; this module only PROJECTS — it renders the
8
- * speaker label ahead of the text, and it never sanitizes, truncates, or rewrites the text itself.
7
+ * sanitizes text and mints the {@link ActorAssertion}; `projectHumanInput` only PROJECTS — it renders
8
+ * the speaker label ahead of the text, and it never sanitizes, truncates, or rewrites the text itself.
9
+ *
10
+ * The module also owns the DELIVERY frame that tells the model where a human frame arrived
11
+ * ({@link frameMidTurnUserInput}, backlog #389). That is a separate concern from attribution and runs
12
+ * AFTER projection (the speaker label belongs to the text; the delivery frame belongs around it) — it is
13
+ * housed here so every model-facing wrapper for human input has one home.
9
14
  *
10
15
  * Envelope contract:
11
16
  * - no `actor` ⇒ BYTE-IDENTICAL passthrough. Every pre-171 caller and every single-user host
@@ -35,6 +40,19 @@ export interface HumanInputFrame {
35
40
  actor?: ActorAssertion;
36
41
  source: HumanInputSource;
37
42
  }
43
+ /**
44
+ * backlog #389 伴生 (V-3 of the steer/interrupt/lifecycle anchor audit) — wrap ONE live caller steer in
45
+ * the mid-turn delivery frame.
46
+ *
47
+ * Scope, deliberately: the UNTRUSTED caller lane only. A `trusted` steer already rides a
48
+ * `<system-reminder>` (supervisor authority, CC's own non-user-source framing), and a delegated child's
49
+ * operator steer carries its own explicit operator frame — the bare lane was exactly the most-travelled
50
+ * one: a person typing while the run works.
51
+ *
52
+ * Static strings (no per-call interpolation), and a blank projection is returned VERBATIM so the
53
+ * harness's empty-injection no-op stays a no-op — a frame must never materialize from nothing.
54
+ */
55
+ export declare function frameMidTurnUserInput(projected: string): string;
38
56
  /**
39
57
  * Render the speaker envelope for one human-input frame. Pure; see the module contract above.
40
58
  * Call BEFORE any trust framing (`formatHookFeedback` / `delimitUntrusted`) so the label stays
@@ -2,6 +2,15 @@ import { snapshotActorAssertion } from "../internal/llm.js";
2
2
  import { uuidv7 } from "../internal/harness.js";
3
3
  import { inlineUntrusted } from "./untrusted-text.js";
4
4
  import { attrEscape, EXTERNAL_SOURCE_MAX } from "./task-notification.js";
5
+ const MID_TURN_USER_INPUT_HEAD = "The user sent a new message while you were working:\n";
6
+ const MID_TURN_USER_INPUT_TAIL = "\n\nThis is how a message sent mid-turn is surfaced: within the turn that is still running, often " +
7
+ "alongside the next tool result, rather than as a separate conversation turn. Address the message " +
8
+ "above as you continue this turn.";
9
+ export function frameMidTurnUserInput(projected) {
10
+ if (projected.trim().length === 0)
11
+ return projected;
12
+ return `${MID_TURN_USER_INPUT_HEAD}${projected}${MID_TURN_USER_INPUT_TAIL}`;
13
+ }
5
14
  export function projectHumanInput(frame) {
6
15
  if (frame.actor === undefined || frame.source === "system")
7
16
  return frame.text;
@@ -1,19 +1,3 @@
1
- /**
2
- * design/121 — LSP diagnostics registry + model-facing formatting (CC 2.1.198 parity).
3
- *
4
- * CC's two diagnostics sources (IDE MCP baseline/diff + passive LSP publishDiagnostics) collapse to
5
- * ONE in sema: the passive registry. The "only NEW diagnostics" semantics CC gets from per-file
6
- * baselines falls out of the delivered-set here — a diagnostic is injected at most once PER RUN, and a
7
- * run editing a file clears its delivered set so a persisting problem can resurface (CC `Fjn` same
8
- * behavior). The registry object itself is deployment-scoped, so "per run" is a keyed fact, not an
9
- * object lifetime; see {@link LspDiagnosticsRegistry}.
10
- *
11
- * Volumes and wire format are CC-exact (198:320661 `Njn=10, nqa=30`; 198:320480-320505 summary +
12
- * 4000-char cap; severity symbols ✖/⚠/ℹ/★).
13
- *
14
- * NOT durable: diagnostics regenerate from the language server on the next edit; a suspend/resume
15
- * simply starts empty (recorded in design/121 §2).
16
- */
17
1
  /** One LSP diagnostic, the subset the model/shell needs (LSP `Diagnostic` narrowed). */
18
2
  export interface LspDiagnostic {
19
3
  message: string;
@@ -102,7 +86,25 @@ export declare class LspDiagnosticsRegistry {
102
86
  * {symbol} [Line {line+1}:{col+1}] {message}[ [{code}]][ ({source})]
103
87
  * ```
104
88
  * capped at 4000 chars with an honest `…[truncated]` tail.
89
+ *
90
+ * ENVELOPE CONTAINMENT (untrusted-text.ts `ENGINE_ENVELOPES`, the `new-diagnostics` row): the
91
+ * per-diagnostic strings (`message`, `code`, `source`) and the file basename come from a LANGUAGE
92
+ * SERVER — a process the engine launches over repository content, i.e. the same trust class as the
93
+ * repository. They land inside the `<new-diagnostics>` envelope, and (since this block is now
94
+ * delivered inside a marked reminder shell) inside an authority-marked reminder too, so a diagnostic
95
+ * message carrying `</new-diagnostics>` closed the envelope and placed server text at the top level
96
+ * of engine-authored authority. CC 223 does not neutralize here either — a deliberate, disclosed
97
+ * hardening divergence: CC ships the same block inside the same reminder shell, so the hole exists on
98
+ * both sides and only the fix is ours. Neutralization runs BEFORE the join, so the 4000-char cap
99
+ * still bounds the FINAL text and can never cut inside a neutralized marker's own bytes.
105
100
  */
106
101
  export declare function formatDiagnosticsSummary(files: LspFileDiagnostics[]): string;
107
- /** CC 198 model-facing injection block (198:320500-320503), verbatim framing. */
102
+ /** CC 198 model-facing injection block (198:320500-320503), verbatim framing.
103
+ *
104
+ * DELIVERY (design/319 sibling work): the runner shells this block in a MARKED `<system-reminder>`
105
+ * before steering it — CC 223 `case "diagnostics"` does the same (`ih([Vr({content: …})])` → the
106
+ * reminder wrap), and until that was matched this was the engine's only authority text reaching the
107
+ * model as a bare block with no provenance mark at all. The block's own bytes are unchanged; the
108
+ * shell is applied at the delivery site (runtask), not baked in here, so this exported renderer keeps
109
+ * serving callers that place the block themselves. */
108
110
  export declare function formatDiagnosticsBlock(files: LspFileDiagnostics[]): string;
@@ -1,3 +1,4 @@
1
+ import { sanitizeUntrustedText, SHELLED_BODY_ENVELOPE_TAGS } from "./untrusted-text.js";
1
2
  const MAX_PER_FILE = 10;
2
3
  const MAX_TOTAL = 30;
3
4
  const MAX_SUMMARY_CHARS = 4000;
@@ -79,14 +80,19 @@ function basenameOfUri(uri) {
79
80
  return idx >= 0 ? path.slice(idx + 1) : path;
80
81
  }
81
82
  export function formatDiagnosticsSummary(files) {
83
+ const safe = (t) => sanitizeUntrustedText(t, SHELLED_BODY_ENVELOPE_TAGS);
82
84
  const lines = [];
83
85
  for (const f of files) {
84
- lines.push(`${basenameOfUri(f.uri)}:`);
86
+ lines.push(`${safe(basenameOfUri(f.uri))}:`);
85
87
  for (const d of f.diagnostics) {
86
- const pos = d.range ? `[Line ${d.range.start.line + 1}:${d.range.start.character + 1}] ` : "";
87
- const code = d.code !== undefined ? ` [${d.code}]` : "";
88
- const source = d.source ? ` (${d.source})` : "";
89
- lines.push(` ${severitySymbol(d.severity)} ${pos}${d.message}${code}${source}`);
88
+ const startLine = d.range?.start?.line;
89
+ const startChar = d.range?.start?.character;
90
+ const pos = typeof startLine === "number" && Number.isFinite(startLine) && typeof startChar === "number" && Number.isFinite(startChar)
91
+ ? `[Line ${Math.trunc(startLine) + 1}:${Math.trunc(startChar) + 1}] `
92
+ : "";
93
+ const code = d.code !== undefined ? ` [${safe(String(d.code))}]` : "";
94
+ const source = d.source ? ` (${safe(String(d.source))})` : "";
95
+ lines.push(` ${severitySymbol(d.severity)} ${pos}${safe(String(d.message))}${code}${source}`);
90
96
  }
91
97
  }
92
98
  const summary = lines.join("\n");
@@ -488,6 +488,30 @@ interface McpContentItem {
488
488
  };
489
489
  }
490
490
  export declare function mapContent(content: Array<McpContentItem>, serverName?: string, imageResizer?: McpImageResizer): Promise<Array<TextContent | ImageContent>>;
491
+ /**
492
+ * Connect to each MCP server, list its tools, and wrap them as AgentTools.
493
+ * Tools are namespaced `mcp__<server>__<tool>` (CC parity) to avoid collisions. The model sees each
494
+ * tool's real JSON-Schema (`inputSchema`), which the agent loop validates natively.
495
+ *
496
+ * Fail-open (design/29): a server that fails to connect or list tools is SKIPPED — its error is
497
+ * returned in `warnings`, the healthy servers still materialize. A single misconfigured server
498
+ * (bad stdio command, unreachable URL) never bricks the whole task.
499
+ *
500
+ * Call `dispose()` when the task finishes — connections are task-scoped, never persisted.
501
+ */
502
+ /**
503
+ * Invisible / format / private-use / unassigned characters stripped out of every model-facing string a
504
+ * server advertises — CC `MZg` :144247. CC spells this as the property class BELOW followed by five
505
+ * explicit ranges (zero-width U+200B–U+200F, bidi embedding U+202A–U+202E, bidi isolates U+2066–U+2069,
506
+ * the BOM, and the BMP private-use area); on this engine's Unicode data every one of those code points
507
+ * is already `Cf` or `Co`, so the class alone is byte-equivalent to CC's expression and the source stays
508
+ * free of literal invisible characters (a file carrying them is a grep blind spot, and this repo has
509
+ * paid for that once already). The equivalence is asserted by a pin, not assumed — a Unicode-data change
510
+ * that moved any of those ranges out of the class would open exactly the hole this strips.
511
+ *
512
+ * `\p{Cc}` is deliberately NOT here: newlines and tabs are legitimate description formatting.
513
+ */
514
+ export declare const MCP_INVISIBLE_TEXT_RE: RegExp;
491
515
  /** One tool's schema-normalization outcome (CC 220 `zyo` @336616-336654's return shape). */
492
516
  export type McpSchemaNormalizeResult = {
493
517
  outcome: "unchanged";
@@ -537,6 +561,28 @@ export declare function normalizeMcpToolSchema(schema: unknown): McpSchemaNormal
537
561
  * to catch, and this gate alone does not catch it (no type key ⇒ passes the root-type check; the
538
562
  * combinator's own structure is legal JSON Schema ⇒ passes validateJsonSchemaShape too).
539
563
  */
564
+ /**
565
+ * A schema defect the operator must HEAR about but that must not cost the tool its mount — returns a
566
+ * core-authored sentence, or `undefined` when there is nothing to say.
567
+ *
568
+ * Today that is exactly one thing: a TOP-LEVEL parameter name outside `[a-zA-Z0-9_.-]{1,64}`. A name
569
+ * like `"user name"` is rejected at the Anthropic wire, and because the tool table rides EVERY request
570
+ * it takes the whole request down with it — while the operator previously got nothing at all pointing
571
+ * at the cause (no warning, no drop record, just a task that stopped working). CC checks the identical
572
+ * names against the identical expression (`EX_`/`_xo` :325793/:325824, reported as `check:"propertyKey"`).
573
+ *
574
+ * WHY AN ADVISORY AND NOT A DROP, stated because the sibling structural gate above does drop: CC's own
575
+ * disposition here is keep-and-warn — its drop arm sits behind a rollout flag that ships OFF (`Xyd` /
576
+ * `Yyd` :354641/:354622, an empty remote-config list ⇒ false), so CC's shipped behavior is to keep the
577
+ * tool and warn "requests that include it may fail". The difference from the structural half is real
578
+ * and not a technicality: a schema with no object root is unusable everywhere, whereas this charset is
579
+ * ONE provider's rule, and this engine is bring-your-own-model. Dropping here would delete a working
580
+ * tool from a deployment whose provider accepts the name — a Chinese- or Japanese-named parameter is
581
+ * the ordinary case, not a hostile one. Detection was the gap; removal was never the mandate.
582
+ *
583
+ * TOP-LEVEL only, like CC: a nested property name is not what the provider validates.
584
+ */
585
+ export declare function mcpToolSchemaAdvisory(schema: unknown): string | undefined;
540
586
  export declare function mcpToolSchemaProblem(schema: unknown): string | undefined;
541
587
  export declare function materializeMcpTools(specs: McpServerSpec[], principal?: string, onElicit?: OnElicit, imageResizer?: McpImageResizer, // design/116 CONFIRM-1 seam: deployment-injected; default = auto-detected sharp
542
588
  reminderDisclosure?: {
package/dist/core/mcp.js CHANGED
@@ -537,6 +537,82 @@ export async function mapContent(content, serverName, imageResizer) {
537
537
  }
538
538
  return out;
539
539
  }
540
+ export const MCP_INVISIBLE_TEXT_RE = /[\p{Cf}\p{Co}\p{Cn}]/gu;
541
+ const MCP_SANITIZE_MAX_ROUNDS = 10;
542
+ function sanitizeMcpModelFacingText(text) {
543
+ let out = text;
544
+ for (let round = 0; round < MCP_SANITIZE_MAX_ROUNDS; round++) {
545
+ const next = out.normalize("NFKC").replace(MCP_INVISIBLE_TEXT_RE, "");
546
+ if (next === out)
547
+ return out;
548
+ out = next;
549
+ }
550
+ return out.replace(MCP_INVISIBLE_TEXT_RE, "");
551
+ }
552
+ const MCP_SCHEMA_PROSE_KEYS = new Set(["description", "title", "$comment"]);
553
+ const MCP_SCHEMA_CHILD_SEAT = new Map([
554
+ ...["properties", "definitions", "$defs", "dependentSchemas"].map((k) => [k, "properties"]),
555
+ ...["patternProperties"].map((k) => [k, "schemaMap"]),
556
+ ...["items", "prefixItems", "additionalItems", "additionalProperties", "unevaluatedItems", "unevaluatedProperties", "contains", "propertyNames", "contentSchema", "not", "if", "then", "else", "allOf", "anyOf", "oneOf"].map((k) => [k, "schema"]),
557
+ ...["required", "dependentRequired", "dependencies", "$ref", "$dynamicRef", "$id", "$anchor", "$dynamicAnchor"].map((k) => [k, "nameRefs"]),
558
+ ]);
559
+ class McpPayloadKeyCollision extends Error {
560
+ }
561
+ function sanitizeMcpSchemaNameRefs(value) {
562
+ if (typeof value === "string")
563
+ return sanitizeMcpModelFacingText(value);
564
+ if (Array.isArray(value))
565
+ return value.map(sanitizeMcpSchemaNameRefs);
566
+ if (value !== null && typeof value === "object") {
567
+ const out = Object.create(null);
568
+ for (const [key, v] of Object.entries(value)) {
569
+ const neutralized = sanitizeMcpModelFacingText(key);
570
+ if (Object.hasOwn(out, neutralized))
571
+ throw new McpPayloadKeyCollision(neutralized);
572
+ out[neutralized] = Array.isArray(v) ? sanitizeMcpSchemaNameRefs(v) : sanitizeMcpModelFacingValue(v, "schema");
573
+ }
574
+ return out;
575
+ }
576
+ return value;
577
+ }
578
+ function sanitizeMcpModelFacingValue(value, seat = "schema") {
579
+ if (seat === "data")
580
+ return value;
581
+ if (seat === "nameRefs")
582
+ return sanitizeMcpSchemaNameRefs(value);
583
+ if (Array.isArray(value))
584
+ return value.map((v) => sanitizeMcpModelFacingValue(v, seat === "schema" ? "schema" : "data"));
585
+ if (value !== null && typeof value === "object") {
586
+ const out = Object.create(null);
587
+ for (const [key, v] of Object.entries(value)) {
588
+ if (seat === "schemaMap") {
589
+ out[key] = sanitizeMcpModelFacingValue(v, "schema");
590
+ continue;
591
+ }
592
+ const neutralized = sanitizeMcpModelFacingText(key);
593
+ if (Object.hasOwn(out, neutralized))
594
+ throw new McpPayloadKeyCollision(neutralized);
595
+ if (seat === "properties") {
596
+ out[neutralized] = sanitizeMcpModelFacingValue(v, "schema");
597
+ continue;
598
+ }
599
+ if (MCP_SCHEMA_PROSE_KEYS.has(neutralized)) {
600
+ out[neutralized] = typeof v === "string" ? sanitizeMcpModelFacingText(v) : v;
601
+ continue;
602
+ }
603
+ out[neutralized] = sanitizeMcpModelFacingValue(v, MCP_SCHEMA_CHILD_SEAT.get(neutralized) ?? "data");
604
+ }
605
+ return out;
606
+ }
607
+ return value;
608
+ }
609
+ const MCP_TOOL_DESCRIPTION_MAX_CHARS = 2048;
610
+ const MCP_TOOL_DESCRIPTION_TRUNCATION_MARK = "… [truncated]";
611
+ function capMcpToolDescription(description) {
612
+ if (description.length <= MCP_TOOL_DESCRIPTION_MAX_CHARS)
613
+ return description;
614
+ return sliceHeadSafe(description, MCP_TOOL_DESCRIPTION_MAX_CHARS) + MCP_TOOL_DESCRIPTION_TRUNCATION_MARK;
615
+ }
540
616
  const MCP_SCHEMA_COMBINATOR_KEYS = ["anyOf", "oneOf", "allOf"];
541
617
  const MCP_SCHEMA_PROP_NAME_RE = /^[a-zA-Z0-9_.-]{1,64}$/;
542
618
  const MCP_SCHEMA_CARRY_KEYS = ["$defs", "definitions", "$schema", "additionalProperties", "description", "title"];
@@ -643,6 +719,19 @@ export function normalizeMcpToolSchema(schema) {
643
719
  return { outcome: "drop", reason: `input schema uses top-level ${combinatorsPresent.join("/")} and could not be normalized` };
644
720
  }
645
721
  }
722
+ export function mcpToolSchemaAdvisory(schema) {
723
+ if (schema === undefined || !isPlainSchemaObject(schema))
724
+ return undefined;
725
+ const properties = schema.properties;
726
+ if (!isPlainSchemaObject(properties))
727
+ return undefined;
728
+ for (const key of Object.keys(properties)) {
729
+ if (!MCP_SCHEMA_PROP_NAME_RE.test(key)) {
730
+ return `its parameter name ${inlineUntrusted(JSON.stringify(key), 80)} is outside the character set some providers accept for tool parameters (${MCP_SCHEMA_PROP_NAME_RE.source}); requests carrying this tool may be rejected by such a provider`;
731
+ }
732
+ }
733
+ return undefined;
734
+ }
646
735
  export function mcpToolSchemaProblem(schema) {
647
736
  if (schema === undefined)
648
737
  return undefined;
@@ -741,6 +830,11 @@ export async function materializeMcpTools(specs, principal, onElicit, imageResiz
741
830
  resourceServers.push(s.resourceServer);
742
831
  for (const d of s.dropped)
743
832
  droppedTools.push({ server: inlineUntrusted(spec.name, 160), ...d });
833
+ for (const a of s.schemaAdvisories)
834
+ warnings.push(schemaAdvisoryWarning(spec.name, a.tool, a.reason));
835
+ if (s.toolsCapabilityAbsent === true && spec.allowTools !== undefined && spec.allowTools.length > 0) {
836
+ warnings.push(toolsCapabilityAbsentWarning(spec.name));
837
+ }
744
838
  if (s.listingIncomplete)
745
839
  warnings.push(listingIncompleteWarning(spec.name, s.listingIncomplete));
746
840
  statuses.push(s.status);
@@ -1329,6 +1423,8 @@ export function parseCallToolResultLenient(data) {
1329
1423
  }
1330
1424
  const LENIENT_CALL_TOOL_RESULT_SCHEMA = { safeParse: parseCallToolResultLenient };
1331
1425
  async function listToolsLenient(client, options) {
1426
+ if (!client.getServerCapabilities()?.tools)
1427
+ return { tools: [], toolsCapabilityAbsent: true };
1332
1428
  const walk = await walkMcpListPages(async (cursor, remainingMs) => {
1333
1429
  const page = await client.request({ method: "tools/list", params: cursor === undefined ? {} : { cursor } }, LenientListToolsResultSchema, { ...options, timeout: remainingMs });
1334
1430
  return {
@@ -1379,7 +1475,7 @@ async function connectServer(spec, principal, onElicit, imageResizer, reminderDi
1379
1475
  };
1380
1476
  const listed = await listToolsLenient(client, startupOpts);
1381
1477
  cacheMcpToolMetadata(client, listed.tools);
1382
- const { serverTools, serverAxes, dropped } = intakeListedTools(listed, spec, client, health, imageResizer, reminderDisclosure, isServerRevoked);
1478
+ const { serverTools, serverAxes, dropped, advisories } = intakeListedTools(listed, spec, client, health, imageResizer, reminderDisclosure, isServerRevoked);
1383
1479
  const caps = client.getServerCapabilities();
1384
1480
  const resourceInfo = caps?.resources
1385
1481
  ? {
@@ -1403,6 +1499,8 @@ async function connectServer(spec, principal, onElicit, imageResizer, reminderDi
1403
1499
  tools: serverTools,
1404
1500
  axes: serverAxes,
1405
1501
  dropped,
1502
+ schemaAdvisories: advisories,
1503
+ ...(listed.toolsCapabilityAbsent === true ? { toolsCapabilityAbsent: true } : {}),
1406
1504
  listedTools: listed.tools,
1407
1505
  ...(instructions ? { instructions } : {}),
1408
1506
  ...(listed.incomplete !== undefined ? { listingIncomplete: listed.incomplete } : {}),
@@ -1418,14 +1516,28 @@ function intakeListedTools(listed, spec, client, health, imageResizer, reminderD
1418
1516
  const serverTools = [];
1419
1517
  const serverAxes = [];
1420
1518
  const dropped = [];
1519
+ const advisories = [];
1421
1520
  const mintedNames = new Map();
1422
1521
  for (const t of listed.tools) {
1423
1522
  if (spec.allowTools && !spec.allowTools.includes(t.name)) {
1424
1523
  continue;
1425
1524
  }
1426
- const normalized = normalizeMcpToolSchema(t.inputSchema);
1427
- let effectiveInputSchema = t.inputSchema;
1428
- let effectiveDescription = t.description !== undefined ? sanitizeUntrustedText(t.description) : undefined;
1525
+ let modelFacingSchema;
1526
+ let sanitizedDescription;
1527
+ try {
1528
+ modelFacingSchema = sanitizeMcpModelFacingValue(t.inputSchema);
1529
+ sanitizedDescription = t.description !== undefined ? sanitizeMcpModelFacingText(t.description) : undefined;
1530
+ }
1531
+ catch (err) {
1532
+ const reason = err instanceof McpPayloadKeyCollision
1533
+ ? `two of its advertised names neutralize to the same model-facing spelling ${inlineUntrusted(JSON.stringify(err.message), 80)}; one parameter would have vanished from the schema the model is given while the server still expects it`
1534
+ : "advertised tool payload could not be neutralized for model-facing display (pathological nesting)";
1535
+ dropped.push({ tool: inlineUntrusted(t.name), reason: inlineUntrusted(reason, 240) });
1536
+ continue;
1537
+ }
1538
+ const normalized = normalizeMcpToolSchema(modelFacingSchema);
1539
+ let effectiveInputSchema = modelFacingSchema;
1540
+ let effectiveDescription = sanitizedDescription !== undefined ? sanitizeUntrustedText(sanitizedDescription) : undefined;
1429
1541
  if (normalized.outcome === "drop") {
1430
1542
  dropped.push({ tool: inlineUntrusted(t.name), reason: inlineUntrusted(normalized.reason, 240) });
1431
1543
  continue;
@@ -1439,6 +1551,10 @@ function intakeListedTools(listed, spec, client, health, imageResizer, reminderD
1439
1551
  dropped.push({ tool: inlineUntrusted(t.name), reason: inlineUntrusted(schemaProblem, 240) });
1440
1552
  continue;
1441
1553
  }
1554
+ const schemaAdvisory = mcpToolSchemaAdvisory(effectiveInputSchema);
1555
+ if (schemaAdvisory !== undefined) {
1556
+ advisories.push({ tool: inlineUntrusted(t.name), reason: inlineUntrusted(schemaAdvisory, 240) });
1557
+ }
1442
1558
  const remoteName = t.name;
1443
1559
  const namespacedName = mintNamespacedToolName(MCP_NAMESPACE, spec.name, remoteName);
1444
1560
  const mintedBy = mintedNames.get(namespacedName);
@@ -1462,7 +1578,7 @@ function intakeListedTools(listed, spec, client, health, imageResizer, reminderD
1462
1578
  const mcpAlwaysLoad = mcpToolMeta?.["anthropic/alwaysLoad"] === true;
1463
1579
  serverTools.push({
1464
1580
  name: namespacedName,
1465
- description: effectiveDescription ?? `MCP tool ${remoteName} from ${spec.name}`,
1581
+ description: capMcpToolDescription(effectiveDescription ?? `MCP tool ${inlineUntrusted(sanitizeMcpModelFacingText(remoteName))} from ${spec.name}`),
1466
1582
  label: `${spec.name}:${remoteName}`,
1467
1583
  parameters: (effectiveInputSchema ?? { type: "object" }),
1468
1584
  ...(mcpMaxResultSizeChars !== undefined ? { mcpMaxResultSizeChars } : {}),
@@ -1564,13 +1680,23 @@ function intakeListedTools(listed, spec, client, health, imageResizer, reminderD
1564
1680
  },
1565
1681
  });
1566
1682
  }
1567
- return { serverTools, serverAxes, dropped };
1683
+ return { serverTools, serverAxes, dropped, advisories };
1568
1684
  }
1569
1685
  function asServerWarning(spec, err) {
1570
1686
  const warning = new Error(`mcp: server "${spec.name}" failed to connect — skipped (${namedMcpFailureText(err)})`, { cause: err });
1571
1687
  warning.code = "mcp.server_unavailable";
1572
1688
  return warning;
1573
1689
  }
1690
+ function schemaAdvisoryWarning(server, tool, reason) {
1691
+ const warning = new Error(`mcp: server "${inlineUntrusted(server, 160)}" tool "${tool}" is mounted, but ${reason}.`);
1692
+ warning.code = "mcp.tool_schema_advisory";
1693
+ return warning;
1694
+ }
1695
+ function toolsCapabilityAbsentWarning(server) {
1696
+ const warning = new Error(`mcp: server "${inlineUntrusted(server, 160)}" declared no "tools" capability, so no tool listing was requested and none of the tools named in this server's allowTools can be mounted. If the server does serve tools, it must declare the capability at initialize.`);
1697
+ warning.code = "mcp.tools_capability_absent";
1698
+ return warning;
1699
+ }
1574
1700
  function listingIncompleteWarning(server, flag) {
1575
1701
  const warning = new Error(`mcp: server "${inlineUntrusted(server, 160)}" listed its tools INCOMPLETELY — ${listingIncompleteNote(flag)}. The tools beyond the ${flag.pages} page${flag.pages === 1 ? "" : "s"} retrieved are NOT mounted for this task.`);
1576
1702
  warning.code = "mcp.listing_incomplete";