@sema-agent/core 7.19.0 → 7.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/CHANGELOG.md +183 -2
  2. package/dist/agents/subagent.js +12 -20
  3. package/dist/agents/teacher.js +2 -5
  4. package/dist/agents/verify.js +21 -10
  5. package/dist/brain/anthropic.js +3 -1
  6. package/dist/brain/open-responses.js +4 -2
  7. package/dist/brain/openai.js +3 -1
  8. package/dist/brain/stream-engine.d.ts +4 -0
  9. package/dist/brain/stream-engine.js +4 -0
  10. package/dist/core/auto-mode.d.ts +37 -18
  11. package/dist/core/auto-mode.js +1 -1
  12. package/dist/core/child-clamp-seats.d.ts +79 -0
  13. package/dist/core/child-clamp-seats.js +29 -0
  14. package/dist/core/engine-notice.d.ts +67 -49
  15. package/dist/core/fs-write-gate-policy.d.ts +17 -9
  16. package/dist/core/fs-write-gate-policy.js +51 -4
  17. package/dist/core/gate-bounded-await.d.ts +38 -0
  18. package/dist/core/gate-bounded-await.js +53 -0
  19. package/dist/core/gate-fold.js +16 -48
  20. package/dist/core/gate-lanes.js +11 -3
  21. package/dist/core/gate-outcome.d.ts +9 -6
  22. package/dist/core/governance-codes.d.ts +16 -1
  23. package/dist/core/governance-codes.js +12 -0
  24. package/dist/core/hooks.d.ts +8 -8
  25. package/dist/core/memory-engine/engine.d.ts +14 -9
  26. package/dist/core/memory-engine/engine.js +21 -8
  27. package/dist/core/memory-engine/layout.d.ts +31 -0
  28. package/dist/core/memory-engine/layout.js +68 -3
  29. package/dist/core/memory-engine/types.d.ts +24 -0
  30. package/dist/core/memory.d.ts +19 -0
  31. package/dist/core/memory.js +12 -0
  32. package/dist/core/remote-env.d.ts +32 -0
  33. package/dist/core/remote-env.js +4 -1
  34. package/dist/core/runner/advertised-writable-dirs.d.ts +59 -1
  35. package/dist/core/runner/advertised-writable-dirs.js +51 -3
  36. package/dist/core/runner/announce-once-ledger.d.ts +15 -0
  37. package/dist/core/runner/announce-once-ledger.js +8 -0
  38. package/dist/core/runner/classifier-ladder.d.ts +5 -0
  39. package/dist/core/runner/classifier-ladder.js +13 -2
  40. package/dist/core/runner/contracts.d.ts +44 -1
  41. package/dist/core/runner/denial-limit-arms.d.ts +14 -1
  42. package/dist/core/runner/denial-limit-arms.js +5 -4
  43. package/dist/core/runner/env-capability-census.d.ts +30 -0
  44. package/dist/core/runner/env-capability-census.js +38 -0
  45. package/dist/core/runner/forward-sink.d.ts +32 -0
  46. package/dist/core/runner/forward-sink.js +41 -0
  47. package/dist/core/runner/gate-exit.d.ts +4 -0
  48. package/dist/core/runner/gate-exit.js +4 -1
  49. package/dist/core/runner/git-status-frame.d.ts +0 -9
  50. package/dist/core/runner/listing-fold.d.ts +57 -0
  51. package/dist/core/runner/listing-fold.js +75 -0
  52. package/dist/core/runner/prepare-execution-env.d.ts +1 -1
  53. package/dist/core/runner/prepare-execution-env.js +2 -0
  54. package/dist/core/runner/prepare-gate-stations.d.ts +8 -0
  55. package/dist/core/runner/prepare-gate-stations.js +6 -2
  56. package/dist/core/runner/prepare-hands-readface.d.ts +4 -0
  57. package/dist/core/runner/prepare-hands-readface.js +3 -5
  58. package/dist/core/runner/prepare-listings.d.ts +3 -0
  59. package/dist/core/runner/prepare-listings.js +4 -2
  60. package/dist/core/runner/prepare-memory.js +7 -1
  61. package/dist/core/runner/prepare-policy-chain.js +5 -5
  62. package/dist/core/runner/prepare-prompt-inputs.d.ts +2 -2
  63. package/dist/core/runner/prepare-run-refs.d.ts +4 -2
  64. package/dist/core/runner/prepare-run-refs.js +5 -4
  65. package/dist/core/runner/prepare-task.js +7 -5
  66. package/dist/core/runner/run-clock-and-content.js +3 -7
  67. package/dist/core/runner/run-harness-handlers.js +10 -10
  68. package/dist/core/runner/run-leg.js +28 -19
  69. package/dist/core/runner/run-settle-and-teardown.js +2 -5
  70. package/dist/core/runner/run-turn-boundary.js +44 -33
  71. package/dist/core/runner/runtask.js +2 -1
  72. package/dist/core/runner/synthetic-tools.d.ts +13 -39
  73. package/dist/core/runner/synthetic-tools.js +15 -59
  74. package/dist/core/runner/task-root-canonical.d.ts +45 -0
  75. package/dist/core/runner/task-root-canonical.js +18 -0
  76. package/dist/core/runner/turn-attachments.d.ts +125 -49
  77. package/dist/core/runner/turn-attachments.js +124 -66
  78. package/dist/core/safe-notify.d.ts +5 -0
  79. package/dist/core/safe-notify.js +24 -2
  80. package/dist/core/sensitive-path-policy.d.ts +53 -27
  81. package/dist/core/sensitive-path-policy.js +16 -26
  82. package/dist/core/skill-listing-budget.d.ts +34 -40
  83. package/dist/core/skill-listing-budget.js +25 -26
  84. package/dist/core/skills-directory.js +17 -10
  85. package/dist/core/task-registry.js +2 -1
  86. package/dist/core/task-spec.d.ts +8 -7
  87. package/dist/core/tool-policy.d.ts +12 -1
  88. package/dist/core/tool-policy.js +39 -25
  89. package/dist/core/write-protect.d.ts +98 -18
  90. package/dist/core/write-protect.js +88 -34
  91. package/dist/index.d.ts +6 -5
  92. package/dist/index.js +5 -4
  93. package/dist/orchestration/workflow-primitives.js +10 -20
  94. package/dist/stores/file/memory-store.d.ts +5 -2
  95. package/dist/stores/file/memory-store.js +8 -4
  96. package/dist/tools/fs/bash-readonly-classifier.d.ts +13 -2
  97. package/dist/tools/fs/bash-readonly-classifier.js +317 -35
  98. package/dist/tools/fs/fs-bash.d.ts +1 -1
  99. package/dist/tools/fs/fs-bash.js +12 -25
  100. package/dist/tools/fs/fs-shared.d.ts +22 -0
  101. package/dist/tools/fs/fs-shared.js +30 -0
  102. package/dist/tools/fs/index.d.ts +1 -1
  103. package/dist/tools/fs/index.js +1 -1
  104. package/package.json +2 -1
  105. package/test/export-surface.snapshot.json +19 -3
  106. package/wire-changes/core@7.20.0.json +568 -0
@@ -2,7 +2,8 @@ import { PRESENT_PLAN_TOOL_NAME } from "../present-plan-tool.js";
2
2
  import { delimitUntrustedWithClip, sanitizeUntrustedText, SHELLED_BODY_ENVELOPE_TAGS } from "../untrusted-text.js";
3
3
  import { protocolOf } from "../protocol-table.js";
4
4
  import { renderSkillLinesWithin, renderSkillsBlock } from "./synthetic-tools.js";
5
- import { SKILL_LISTING_DESC_MAX_CHARS_DEFAULT, SKILLS_BLOCK_MAX_BYTES } from "../skill-listing-budget.js";
5
+ import { SKILL_LISTING_DEFAULT_BUDGET } from "../skill-listing-budget.js";
6
+ import { foldListingWithin, foldRemovalArmWithin, removalArmFloor } from "./listing-fold.js";
6
7
  import { TOOL_SEARCH_NAME as TOOL_SEARCH_TOOL_NAME } from "./tool-disclosure.js";
7
8
  export const TODO_REMINDER_CONFIG = {
8
9
  TURNS_SINCE_WRITE: 10,
@@ -24,6 +25,33 @@ export const CHANGED_FILES_MTIME_EPS_MS = 2000;
24
25
  export const ATTACHMENT_BYTE_CAP = 8 * 1024;
25
26
  const PROJECTION_ITEMS_MAX = 50;
26
27
  const PROJECTION_CONTENT_MAX = 80;
28
+ const LISTING_FRAME_FLOOR_BYTES = 768;
29
+ const DATE_CHANGE_MAX_BYTES = 160;
30
+ const WORKFLOW_SIZE_GUIDELINE_CHANGE_MAX_BYTES = 256;
31
+ export const ATTACHMENT_DELIVERY_CLASS = {
32
+ todo_reminder: { class: "clippable" },
33
+ task_reminder: { class: "clippable" },
34
+ tool_search_usage_reminder: { class: "clippable" },
35
+ changed_files: { class: "clippable" },
36
+ plan_mode: { class: "clippable" },
37
+ budget_usd: { class: "clippable" },
38
+ total_tokens_reminder: { class: "clippable" },
39
+ background_tasks: { class: "clippable" },
40
+ tools_delta: { class: "intact", bound: (inp) => toolsDeltaBound(inp) },
41
+ agent_listing: { class: "intact", bound: (inp) => listingFrameBound(inp.listingBudget) + (inp.agentModels !== undefined ? Buffer.byteLength(modelsAvailableLine(inp.agentModels), "utf8") : 0) },
42
+ skills_listing: { class: "intact", bound: (inp) => listingFrameBound(inp.listingBudget) },
43
+ mcp_instructions: { class: "intact", bound: (inp) => mcpInstructionsBound(inp) },
44
+ mcp_dropped_tools: { class: "intact", bound: (inp) => mcpDroppedToolsBound(inp) },
45
+ date_change: { class: "intact", bound: () => DATE_CHANGE_MAX_BYTES },
46
+ instructions_change: { class: "intact", bound: () => INSTRUCTIONS_CHANGE_BYTE_CAP },
47
+ workflow_size_guideline_change: { class: "intact", bound: () => WORKFLOW_SIZE_GUIDELINE_CHANGE_MAX_BYTES },
48
+ };
49
+ export function isIntactSource(source) {
50
+ return ATTACHMENT_DELIVERY_CLASS[source].class === "intact";
51
+ }
52
+ function listingFrameBound(budget) {
53
+ return Math.max((budget ?? SKILL_LISTING_DEFAULT_BUDGET).blockMaxBytes, LISTING_FRAME_FLOOR_BYTES);
54
+ }
27
55
  const ATTACHMENT_TAGS_DEFAULT = [...SHELLED_BODY_ENVELOPE_TAGS];
28
56
  const ATTACHMENT_TAGS_OWNED = {
29
57
  skills_listing: SHELLED_BODY_ENVELOPE_TAGS.filter((t) => t !== "skills"),
@@ -208,23 +236,15 @@ export function collectDueAttachments(state, inp) {
208
236
  (out ??= []).push({ source: "tools_delta", body });
209
237
  }
210
238
  const agentListingBody = inp.config.agentListing && inp.agentListing !== undefined
211
- ? renderAgentListingDelta(state, inp.agentListing, inp.agentToolName ?? "Agent", inp.agentModels)
239
+ ? renderAgentListingDelta(state, inp.agentListing, inp.agentToolName ?? "Agent", inp.agentModels, inp.listingBudget)
212
240
  : undefined;
241
+ if (agentListingBody !== undefined)
242
+ (out ??= []).push({ source: "agent_listing", body: agentListingBody });
213
243
  const skillsListingBody = inp.config.skillsListing && inp.skillsListing !== undefined
214
- ? renderSkillsListingDelta(state, inp.skillsListing, inp.skillsListingBudget)
244
+ ? renderSkillsListingDelta(state, inp.skillsListing, inp.listingBudget)
215
245
  : undefined;
216
- const listingPair = [];
217
- if (agentListingBody !== undefined)
218
- listingPair.push({ source: "agent_listing", body: agentListingBody });
219
- if (skillsListingBody !== undefined) {
220
- const att = { source: "skills_listing", body: skillsListingBody };
221
- if (state.skillsListingStarved === true)
222
- listingPair.unshift(att);
223
- else
224
- listingPair.push(att);
225
- }
226
- for (const att of listingPair)
227
- (out ??= []).push(att);
246
+ if (skillsListingBody !== undefined)
247
+ (out ??= []).push({ source: "skills_listing", body: skillsListingBody });
228
248
  if (inp.config.mcpInstructions && inp.mcpInstructions !== undefined) {
229
249
  const body = renderMcpInstructionsDelta(inp.mcpInstructions.added, inp.mcpInstructions.removed);
230
250
  if (body !== undefined)
@@ -244,9 +264,6 @@ export function collectDueAttachments(state, inp) {
244
264
  if (out === undefined)
245
265
  return EMPTY;
246
266
  const capped = capAttachments(out);
247
- if (skillsListingBody !== undefined) {
248
- state.skillsListingStarved = !capped.some((a) => a.source === "skills_listing" && a.body === skillsListingBody);
249
- }
250
267
  if (pendingSurfaced !== undefined && capped.some((a) => a.source === "changed_files")) {
251
268
  for (const c of pendingSurfaced)
252
269
  state.surfacedMtime.set(c.path, c.mtimeMs);
@@ -434,15 +451,19 @@ export function renderToolsDelta(input) {
434
451
  const added = input.added ?? [];
435
452
  if (added.length > 0) {
436
453
  const swapped = input.staticFace === true ? (input.swappedUnderStatic ?? []).filter((n) => added.includes(n)) : [];
437
- blocks.push((input.staticFace === true
438
- ? "The following deferred tools are now active — call them directly. The tools list keeps compact " +
439
- 'placeholder entries, so re-run ToolSearch ("select:<name>") if you need a parameter schema:\n'
440
- : "The following deferred tools are now available. Their full schemas are loaded — call them " +
441
- "directly like any other tool:\n") +
442
- added.map((n) => `- ${n}`).join("\n") +
443
- (swapped.length > 0
444
- ? `\nException: ${swapped.join(", ")} — too large to inline, so the full declaration is in the tools list instead.`
445
- : ""));
454
+ const grouped = added.length > TOOLS_DELTA_LIST_MAX;
455
+ const exception = swapped.length > 0
456
+ ? grouped
457
+ ? `\nException: ${swapped.length} of them were too large to inline, so their full declarations are in the tools list instead.`
458
+ : `\nException: ${swapped.join(", ")} — too large to inline, so the full declaration is in the tools list instead.`
459
+ : "";
460
+ blocks.push((grouped ? `${added.length} deferred tools are now ${input.staticFace === true ? "active" : "available"}: ${groupByMcpServer(added)}. ` : "") +
461
+ (input.staticFace === true
462
+ ? (grouped ? "Call them directly. " : "The following deferred tools are now active — call them directly. ") +
463
+ `The tools list keeps compact placeholder entries, so re-run ToolSearch ("select:<name>") if you need a parameter schema${grouped ? "." : ":\n"}`
464
+ : (grouped ? "" : "The following deferred tools are now available. ") + `Their full schemas are loaded — call them directly like any other tool${grouped ? "." : ":\n"}`) +
465
+ (grouped ? "" : added.map((n) => `- ${n}`).join("\n")) +
466
+ exception);
446
467
  }
447
468
  const readded = input.readded ?? [];
448
469
  if (readded.length > 0) {
@@ -530,22 +551,35 @@ function listingIdentity(name) {
530
551
  export function agentListingDeltaHeader(toolName) {
531
552
  return `New agent types are now available for the ${sanitizeUntrustedText(toolName, SHELLED_BODY_ENVELOPE_TAGS)} tool:`;
532
553
  }
533
- export function renderAgentListingDelta(state, entries, toolName, models) {
554
+ export function renderAgentListingFrame(state, entries, toolName, models, budget = SKILL_LISTING_DEFAULT_BUDGET) {
534
555
  const safe = (t) => sanitizeUntrustedText(t, SHELLED_BODY_ENVELOPE_TAGS);
535
556
  const line = (e) => {
536
557
  const base = e.description ? `- ${safe(e.name)}: ${safe(e.description)}` : `- ${safe(e.name)}`;
537
558
  return `${base} (Tools: ${safe(e.tools ?? AGENT_TOOLS_NOTE_DEFAULT)})`;
538
559
  };
560
+ const bytes = (s) => Buffer.byteLength(s, "utf8");
561
+ const joiner = bytes(AGENT_DELTA_BLOCK_JOINER);
562
+ const fold = (items, header, maxBytes) => foldListingWithin(items, {
563
+ blockMaxBytes: maxBytes,
564
+ full: line,
565
+ nameOnly: (e) => `- ${safe(e.name)}`,
566
+ nameOf: (e) => e.name,
567
+ capMarker: AGENT_LISTING_CAP_MARKER,
568
+ overflowLine: (n) => `(… +${n} more agent types not listed)`,
569
+ frame: (lines) => [header, ...lines].join("\n"),
570
+ });
539
571
  const announced = state.announcedAgentTypes;
540
572
  if (announced === undefined) {
541
573
  if (entries.length === 0)
542
574
  return undefined;
543
- const blocks = [`${agentListingInitialHeader(toolName)}\n${entries.map(line).join("\n")}`];
575
+ const tail = [];
544
576
  if (entries.length > 1)
545
- blocks.push(AGENT_CONCURRENCY_NOTE);
577
+ tail.push(AGENT_CONCURRENCY_NOTE);
546
578
  if (models !== undefined && models.length > 0)
547
- blocks.push(modelsAvailableLine(models));
548
- return blocks.join("\n\n");
579
+ tail.push(modelsAvailableLine(models));
580
+ const tailCost = tail.reduce((acc, t) => acc + joiner + bytes(t), 0);
581
+ const roster = fold(entries, agentListingInitialHeader(toolName), budget.blockMaxBytes - tailCost);
582
+ return { text: [roster.text, ...tail].join(AGENT_DELTA_BLOCK_JOINER), nameOnly: roster.nameOnly, dropped: roster.dropped, listed: entries.length };
549
583
  }
550
584
  const announcedIds = new Set([...announced.keys()].map(listingIdentity));
551
585
  const entryIds = new Set(entries.map((e) => listingIdentity(e.name)));
@@ -557,17 +591,26 @@ export function renderAgentListingDelta(state, entries, toolName, models) {
557
591
  state.announcedModels.some((m, i) => listingIdentity(m) !== listingIdentity(models[i])));
558
592
  if (added.length === 0 && removed.length === 0 && !modelsDrifted)
559
593
  return undefined;
560
- const blocks = [];
561
- if (added.length > 0)
562
- blocks.push(`${agentListingDeltaHeader(toolName)}\n${added.map(line).join("\n")}`);
594
+ const modelsLine = modelsDrifted ? modelsAvailableLine(models) : undefined;
595
+ const modelsCost = modelsLine !== undefined ? joiner + bytes(modelsLine) : 0;
596
+ const ambientCost = removed.length > 0 ? joiner + bytes(AMBIENT_CONTEXT_NOTE) : 0;
597
+ const removalReserve = removed.length > 0 ? joiner + bytes(removalArmFloor(AGENT_LISTING_REMOVED_HEADER, removed.length)) : 0;
598
+ const addArm = added.length > 0 ? fold(added, agentListingDeltaHeader(toolName), budget.blockMaxBytes - modelsCost - ambientCost - removalReserve) : undefined;
599
+ const addCost = addArm === undefined ? 0 : bytes(addArm.text) + joiner;
600
+ const blocks = addArm === undefined ? [] : [addArm.text];
563
601
  if (removed.length > 0) {
564
- blocks.push(`${AGENT_LISTING_REMOVED_HEADER}\n${removed.map((n) => `- ${safe(n)}`).join("\n")}`);
602
+ blocks.push(foldRemovalArmWithin(removed, AGENT_LISTING_REMOVED_HEADER, budget.blockMaxBytes - modelsCost - ambientCost - addCost));
565
603
  blocks.push(AMBIENT_CONTEXT_NOTE);
566
604
  }
567
- if (modelsDrifted)
568
- blocks.push(modelsAvailableLine(models));
569
- return blocks.join("\n\n");
605
+ if (modelsLine !== undefined)
606
+ blocks.push(modelsLine);
607
+ return { text: blocks.join(AGENT_DELTA_BLOCK_JOINER), nameOnly: addArm?.nameOnly ?? [], dropped: addArm?.dropped ?? [], listed: added.length };
608
+ }
609
+ export function renderAgentListingDelta(state, entries, toolName, models, budget) {
610
+ return renderAgentListingFrame(state, entries, toolName, models, budget)?.text;
570
611
  }
612
+ const AGENT_LISTING_CAP_MARKER = "(listing budget reached — lower-priority agent types are listed name-only)";
613
+ const AGENT_DELTA_BLOCK_JOINER = "\n\n";
571
614
  export const MODELS_AVAILABLE_PREFIX = "Models available for the 'model' parameter: ";
572
615
  function modelsAvailableLine(models) {
573
616
  const named = models.map((m) => sanitizeUntrustedText(m, SHELLED_BODY_ENVELOPE_TAGS));
@@ -597,28 +640,7 @@ export function commitAgentListing(state, entries, models) {
597
640
  export const SKILLS_LISTING_DELTA_HEADER = "New skills are now available:";
598
641
  export const SKILLS_LISTING_REMOVED_HEADER = "The following skills are no longer available:";
599
642
  const SKILLS_DELTA_BLOCK_JOINER = "\n\n";
600
- function skillsRemovalArmFloor(count) {
601
- return [SKILLS_LISTING_REMOVED_HEADER, `(… +${count} more are no longer available)`].join("\n");
602
- }
603
- function renderSkillsRemovalArm(removed, maxBytes) {
604
- const bytes = (s) => Buffer.byteLength(s, "utf8");
605
- const lines = removed.map((n) => `- ${sanitizeUntrustedText(n, SHELLED_BODY_ENVELOPE_TAGS)}`);
606
- const head = bytes(SKILLS_LISTING_REMOVED_HEADER);
607
- const lineCost = (l) => bytes(l) + 1;
608
- const prefix = new Array(lines.length + 1);
609
- prefix[0] = 0;
610
- for (let i = 0; i < lines.length; i++)
611
- prefix[i + 1] = prefix[i] + lineCost(lines[i]);
612
- if (head + prefix[lines.length] <= maxBytes)
613
- return [SKILLS_LISTING_REMOVED_HEADER, ...lines].join("\n");
614
- for (let m = lines.length - 1; m >= 0; m--) {
615
- const tail = `(… +${lines.length - m} more are no longer available)`;
616
- if (head + prefix[m] + lineCost(tail) <= maxBytes)
617
- return [SKILLS_LISTING_REMOVED_HEADER, ...lines.slice(0, m), tail].join("\n");
618
- }
619
- return skillsRemovalArmFloor(lines.length);
620
- }
621
- export function renderSkillsListingFrame(state, entries, budget) {
643
+ export function renderSkillsListingFrame(state, entries, budget = SKILL_LISTING_DEFAULT_BUDGET) {
622
644
  const announced = state.announcedSkills;
623
645
  if (announced === undefined) {
624
646
  if (entries.length === 0)
@@ -634,20 +656,20 @@ export function renderSkillsListingFrame(state, entries, budget) {
634
656
  return undefined;
635
657
  const bytes = (s) => Buffer.byteLength(s, "utf8");
636
658
  const joiner = bytes(SKILLS_DELTA_BLOCK_JOINER);
637
- const frameMaxBytes = Math.min(budget?.blockMaxBytes ?? SKILLS_BLOCK_MAX_BYTES, SKILLS_BLOCK_MAX_BYTES);
659
+ const frameMaxBytes = budget.blockMaxBytes;
638
660
  const ambientCost = removed.length > 0 ? joiner + bytes(AMBIENT_CONTEXT_NOTE) : 0;
639
- const removalReserve = removed.length > 0 ? joiner + bytes(skillsRemovalArmFloor(removed.length)) : 0;
661
+ const removalReserve = removed.length > 0 ? joiner + bytes(removalArmFloor(SKILLS_LISTING_REMOVED_HEADER, removed.length)) : 0;
640
662
  const addArm = added.length > 0
641
663
  ? renderSkillLinesWithin(added, {
642
664
  blockMaxBytes: frameMaxBytes - ambientCost - removalReserve,
643
- descMaxChars: budget?.descMaxChars ?? SKILL_LISTING_DESC_MAX_CHARS_DEFAULT,
665
+ descMaxChars: budget.descMaxChars,
644
666
  frame: (lines) => [SKILLS_LISTING_DELTA_HEADER, ...lines].join("\n"),
645
667
  })
646
668
  : undefined;
647
669
  const addCost = addArm === undefined ? 0 : bytes(addArm.text) + (removed.length > 0 ? joiner : 0);
648
670
  const blocks = addArm === undefined ? [] : [addArm.text];
649
671
  if (removed.length > 0) {
650
- blocks.push(renderSkillsRemovalArm(removed, frameMaxBytes - ambientCost - addCost), AMBIENT_CONTEXT_NOTE);
672
+ blocks.push(foldRemovalArmWithin(removed, SKILLS_LISTING_REMOVED_HEADER, frameMaxBytes - ambientCost - addCost), AMBIENT_CONTEXT_NOTE);
651
673
  }
652
674
  return {
653
675
  text: blocks.join(SKILLS_DELTA_BLOCK_JOINER),
@@ -703,6 +725,37 @@ export function selectMcpDroppedBatch(entries) {
703
725
  }
704
726
  return out;
705
727
  }
728
+ const TOOLS_DELTA_FIXED_MAX_BYTES = 2048;
729
+ function toolsDeltaBound(inp) {
730
+ const bytes = (s) => Buffer.byteLength(s, "utf8");
731
+ const list = (names) => (names.length > TOOLS_DELTA_LIST_MAX ? bytes(groupByMcpServer(names)) : names.reduce((acc, n) => acc + bytes(n) + 3, 0));
732
+ const swapped = (names) => names.reduce((acc, n) => acc + bytes(n) + 2, 0);
733
+ const mcp = inp.mcpToolsDelta;
734
+ return (TOOLS_DELTA_FIXED_MAX_BYTES +
735
+ list(inp.newTools ?? []) +
736
+ swapped(inp.newToolsSwappedUnderStatic ?? []) +
737
+ bytes(groupByMcpServer(mcp?.readded ?? [])) +
738
+ swapped(inp.readdedToolsSwappedUnderStatic ?? []) +
739
+ list(mcp?.removed ?? []) +
740
+ (mcp?.failedServers ?? []).slice(0, TOOLS_DELTA_LIST_MAX).reduce((acc, f) => acc + bytes(f.name) + bytes(f.error ?? "") + 8, 0));
741
+ }
742
+ const MCP_INSTRUCTIONS_FENCE_MAX_BYTES = 512;
743
+ function mcpInstructionsBound(inp) {
744
+ const bytes = (s) => Buffer.byteLength(s, "utf8");
745
+ const m = inp.mcpInstructions;
746
+ if (m === undefined)
747
+ return 0;
748
+ return (512 +
749
+ m.added.reduce((acc, a) => acc + MCP_INSTRUCTIONS_MAX_CHARS * 4 + bytes(a.server) + MCP_INSTRUCTIONS_FENCE_MAX_BYTES, 0) +
750
+ m.removed.reduce((acc, n) => acc + bytes(n) + 1, 0));
751
+ }
752
+ const MCP_DROPPED_FIXED_MAX_BYTES = 1024;
753
+ function mcpDroppedToolsBound(inp) {
754
+ const bytes = (s) => Buffer.byteLength(s, "utf8");
755
+ const first = inp.mcpDroppedTools?.[0];
756
+ const single = first === undefined ? 0 : MCP_DROPPED_FIXED_MAX_BYTES + bytes(first.tool) + bytes(first.server) + bytes(first.reason) + 32;
757
+ return Math.max(MCP_DROPPED_BATCH_MAX_BYTES, single);
758
+ }
706
759
  const PLAN_MODE_FULL_BODY = `Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits, run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supercedes any other instructions you have received. Instead, you should: research and analyze with read-only tools, and when you have formed a concrete plan, present it with the ${PRESENT_PLAN_TOOL_NAME} tool for approval. That tool inherently requests plan approval — never ask for approval via plain text.`;
707
760
  const PLAN_MODE_SPARSE_BODY = `Plan mode still active (see full instructions earlier in conversation). Read-only: no edits, commits, or config changes. When your plan is ready, present it with the ${PRESENT_PLAN_TOOL_NAME} tool for approval. Never ask about plan approval via plain text.`;
708
761
  const TRUNCATION_MARKER = "\n…[truncated]";
@@ -710,12 +763,17 @@ const MIN_KEEP_BYTES = 160;
710
763
  function capAttachments(atts) {
711
764
  let total = 0;
712
765
  for (const a of atts)
713
- total += Buffer.byteLength(a.body, "utf8");
766
+ if (!isIntactSource(a.source))
767
+ total += Buffer.byteLength(a.body, "utf8");
714
768
  if (total <= ATTACHMENT_BYTE_CAP)
715
769
  return atts;
716
770
  let budget = ATTACHMENT_BYTE_CAP;
717
771
  const out = [];
718
772
  for (const a of atts) {
773
+ if (isIntactSource(a.source)) {
774
+ out.push(a);
775
+ continue;
776
+ }
719
777
  const len = Buffer.byteLength(a.body, "utf8");
720
778
  if (len <= budget) {
721
779
  out.push(a);
@@ -56,6 +56,11 @@ export interface SafeNotifierOptions {
56
56
  */
57
57
  readonly onError?: (failure: SafeNotifyFailure) => void;
58
58
  }
59
+ /** The sentence of a contained failure, for a consumer that puts it in front of a person. Total by the
60
+ * same rule as {@link containedError}: an error object handed in from elsewhere (a `lastErrorAt`
61
+ * reading, a rejection an adapter wrapped) may still carry a hostile `message`, and the disclosure a
62
+ * site composes from it must not be the thing that faults. */
63
+ export declare function safeErrorMessage(error: unknown): string;
59
64
  export interface SafeNotifier {
60
65
  /**
61
66
  * Run one host-callback invocation, containing any throw. Returns `true` when it completed, `false` when it
@@ -1,9 +1,30 @@
1
+ function containedError(thrown) {
2
+ try {
3
+ if (thrown instanceof Error && typeof thrown.message === "string")
4
+ return thrown;
5
+ return new Error(String(thrown));
6
+ }
7
+ catch {
8
+ return new Error(`a thrown value of type ${typeof thrown} that cannot be inspected or converted to text`);
9
+ }
10
+ }
11
+ export function safeErrorMessage(error) {
12
+ try {
13
+ const m = error?.message;
14
+ if (typeof m === "string")
15
+ return m;
16
+ return String(error);
17
+ }
18
+ catch {
19
+ return "a failure whose message could not be read";
20
+ }
21
+ }
1
22
  export function createSafeNotifier(opts) {
2
23
  const onError = opts?.onError;
3
24
  let sites;
4
25
  let total = 0;
5
26
  const record = (site, thrown) => {
6
- const error = thrown instanceof Error ? thrown : new Error(String(thrown));
27
+ const error = containedError(thrown);
7
28
  total += 1;
8
29
  if (sites === undefined)
9
30
  sites = new Map();
@@ -60,8 +81,9 @@ export function createSafeNotifier(opts) {
60
81
  export function observeThenableRejection(r, notifier, site) {
61
82
  if (typeof r?.then === "function") {
62
83
  r.then(undefined, (err) => {
84
+ const error = containedError(err);
63
85
  notifier.notify(() => {
64
- throw err instanceof Error ? err : new Error(String(err));
86
+ throw error;
65
87
  }, site);
66
88
  });
67
89
  }
@@ -54,20 +54,13 @@ export declare function agentConfigDenyPatterns(): readonly string[];
54
54
  * hard refusal for every deployment that wires this list.
55
55
  *
56
56
  * SEVERITY LAYERING for the agent-configuration class: the law is {@link agentConfigDenyPatterns} —
57
- * configuration FILES here, the DIRECTORY one step down on {@link RECOMMENDED_SENSITIVE_ASK_PATTERNS}
58
- * (same judge, so the severity drops and the reach does not); a directory also holds the person's own
59
- * material, and the directory-wide entry turned an explicit instruction to edit `.claude/projects/…`
60
- * into a refusal nobody could lift (a deny outranks an ask under the fold).
57
+ * configuration FILES here, the DIRECTORY one step down on the always-on write-protection table
58
+ * (`WRITE_PROTECTED_DEFAULT_TABLE`'s `.claude` / `.sema` rows — the same reach, since that judge sees the
59
+ * real target too, {@link import("./write-protect.js").WriteProtectionMatcher.judge}); a directory also
60
+ * holds the person's own material, and a directory-wide DENY entry turned an explicit instruction to edit
61
+ * `.claude/projects/…` into a refusal nobody could lift (a deny outranks an ask under the fold).
61
62
  */
62
63
  export declare const RECOMMENDED_SENSITIVE_PATTERNS: readonly string[];
63
- /**
64
- * The ASK-severity half of the same guard: the agent-configuration DIRECTORIES, on the SAME judge
65
- * ({@link createSensitivePathPolicy}'s `askPatterns` seat, whose ABSENCE reads as this list) — only the
66
- * directories, since the configuration FILES inside them are deny material above. It lives HERE and not
67
- * only on `WRITE_PROTECTED_DEFAULT_TABLE`, which names the same segments but judges LEXICALLY: a
68
- * symlink with an innocent name walks past that one, so demoting to it alone trades severity for REACH.
69
- */
70
- export declare const RECOMMENDED_SENSITIVE_ASK_PATTERNS: readonly string[];
71
64
  /**
72
65
  * HRD-PRM-10 — the engine data root, read from the same knob its two consumers read
73
66
  * (`$AGENT_DATA_DIR ?? ~/.ai-agent`, off the HOST at wiring time, NFC-normalized) and returned in
@@ -85,16 +78,53 @@ export declare const RECOMMENDED_SENSITIVE_ASK_PATTERNS: readonly string[];
85
78
  */
86
79
  export declare function dataRootSpellings(explicit: string | undefined): string[];
87
80
  /**
88
- * Build a composable policy that judges writes (via the guarded fs tools) by their CANONICAL target,
89
- * at TWO severities from ONE judge. `patterns` is the DENY set — deployment-supplied, no default list
90
- * is shipped; `askPatterns` is the ASK set, judged the same way and consulted only where the deny set
91
- * misses, so a path on both denies. Both empty = a no-op allow.
81
+ * The data root's identity in the SAME namespace a judged key lives in: every declared spelling
82
+ * ({@link dataRootSpellings}) run through the SAME `canonicalizeTarget` that mints the target key. Not a
83
+ * second canonicalization — the same one, on the other operand, which is what lets
84
+ * {@link relativeUnderRoot}'s comparison be exact.
85
+ *
86
+ * Resolved PER CALL, deliberately not cached. A cached canonical root is a snapshot of the symlink
87
+ * topology at first use: with a data root reached through a symlink, retargeting it left the policy
88
+ * still exempting the OLD target for the life of the run. The module header already accepts paying
89
+ * `canonicalizeTarget` twice for a guarded write as the price of a decoupled gate; this is the same
90
+ * bargain on the other operand, and a request-scoped cache is the same note's answer if profiling
91
+ * ever asks for one. A spelling that cannot be resolved contributes NO exemption (the guard's side wins).
92
+ *
93
+ * ONE resolver for both write judges (the deny set here, the always-on table's target view) — an
94
+ * engine-internal seam, like the two neighbours above.
95
+ */
96
+ export declare function canonicalDataRootKeys(env: ExecutionEnv, spellings: readonly string[], signal?: AbortSignal): Promise<string[]>;
97
+ /**
98
+ * Is `targetKey` inside the data root `rootKey`, and if so what is its path relative to it? Returns
99
+ * the relative sub-path (`""` when the target IS the root), or `null` for everything else.
100
+ *
101
+ * 🔴 EXACT, and deliberately nothing else. Both operands are minted by the SAME canonicalizer, so
102
+ * every transformation this comparison could apply — a case fold, an NFC pass, splitting a foreign
103
+ * separator family, unioning several readings of the root — can only ADD matches. On the DENY side of
104
+ * this file that is the safe direction and several of those transforms are applied on purpose (a
105
+ * spelling filter is not a path filter). Here the direction inverts: this test grants an EXEMPTION, so
106
+ * an added match is not a wider guard, it is a bypass of one — each of case folding, an NFC pass and a
107
+ * foreign-family read was measured leaking an unrelated sibling into the exemption (their pins name the
108
+ * shapes). The rule: compare what the canonicalizer produced, byte for byte, every uncertainty falling
109
+ * to the deny side.
110
+ *
111
+ * The separator family comes from the ROOT key (a backslash is an ordinary filename character on
112
+ * POSIX, so a root `/x/.sema/data` must not swallow the sibling FILE `/x/.sema/data\evil`), and a root
113
+ * that reduces to nothing exempts nothing rather than exempting the filesystem.
92
114
  *
93
- * @contract sensitive_paths.two_severities_one_judge — ONE canonicalizing judge answers both
94
- * severities: a deny is a refusal no approver and no persisted allow rule can lift, an ask is one
95
- * explicit look that either of them may clear. Lowering a path's severity here never lowers its REACH
96
- * — both tiers see the real target (symlinks resolved, relative spellings resolved against the call's
97
- * live cwd), which the synchronous write-protection TABLE deliberately does not.
115
+ * Exported as an ENGINE-INTERNAL seam (not re-exported from `src/index.ts`), like {@link dataRootSpellings}:
116
+ * the always-on write-protection judge applies the same exemption on its target view, and a second
117
+ * containment test would be a second place for the two judges to disagree about what "inside" means.
118
+ */
119
+ export declare function relativeUnderRoot(targetKey: string, rootKey: string): string | null;
120
+ /**
121
+ * Build a composable policy that DENIES writes (via the guarded fs tools) whose CANONICAL target lands on
122
+ * a guarded pattern. `patterns` is deployment-supplied — no default list is shipped; empty = a no-op allow.
123
+ * A deny is a refusal no approver and no persisted allow rule can lift. The ASK severity of the same class
124
+ * (the agent-configuration DIRECTORIES) is not a tier here: it is the always-on write-protection table's,
125
+ * which judges the same real target ({@link import("./write-protect.js").WriteProtectionMatcher.judge} —
126
+ * its contract sentence `write_protection.judges_the_real_target` says the two differ in vocabulary and
127
+ * severity, never in reach).
98
128
  *
99
129
  * `env` is the same ExecutionEnv the hands run against (inject the one you pass to the Runner), so
100
130
  * canonicalization sees the task's real filesystem (a remote/E2B task resolves in its container).
@@ -104,17 +134,13 @@ export declare function dataRootSpellings(explicit: string | undefined): string[
104
134
  *
105
135
  * Fail-open by design: a tool with no string `path` returns `allow` here — this guard adds friction on
106
136
  * top of the other gates, it is not the only line of defense, and it must not turn an unrelated tool
107
- * call into a spurious verdict. The verdicts it owns are narrow and certain: "the real target IS a
108
- * guarded path → deny / ask". (A target it could not RESOLVE is the one fail-CLOSED arm, below.)
137
+ * call into a spurious verdict. The verdict it owns is narrow and certain: "the real target IS a
138
+ * guarded path → deny". (A target it could not RESOLVE is the one fail-CLOSED arm, below.)
109
139
  */
110
140
  export declare function createSensitivePathPolicy(opts: {
111
141
  env: ExecutionEnv;
112
142
  /** `readonly` accepted so {@link RECOMMENDED_SENSITIVE_PATTERNS} can be passed as-is (widening, non-breaking). */
113
143
  patterns: readonly string[];
114
- /** The ASK-severity set, same syntax and same judge as `patterns`, consulted only where the deny set
115
- * misses. ABSENT ⇒ {@link RECOMMENDED_SENSITIVE_ASK_PATTERNS}, so a deployment already wiring this
116
- * guard keeps its reach over those directories without a code change; `[]` = no ask tier. */
117
- askPatterns?: readonly string[];
118
144
  rootPath?: string;
119
145
  /**
120
146
  * HRD-PRM-10 — the engine data root this deployment runs on. Default
@@ -50,7 +50,6 @@ export const RECOMMENDED_SENSITIVE_PATTERNS = [
50
50
  ".bash_history",
51
51
  ".zsh_history",
52
52
  ];
53
- export const RECOMMENDED_SENSITIVE_ASK_PATTERNS = [".claude", ".sema"];
54
53
  const CASE_INSENSITIVE_FS = process.platform === "darwin" || process.platform === "win32";
55
54
  export function dataRootSpellings(explicit) {
56
55
  const raw = explicit ?? process.env.AGENT_DATA_DIR ?? join(homedir(), ".ai-agent");
@@ -65,7 +64,20 @@ export function dataRootSpellings(explicit) {
65
64
  }
66
65
  return out;
67
66
  }
68
- function relativeUnderRoot(targetKey, rootKey) {
67
+ export async function canonicalDataRootKeys(env, spellings, signal) {
68
+ const keys = [];
69
+ for (const spelling of spellings) {
70
+ try {
71
+ const r = await canonicalizeTarget(env, spelling, signal, pathFamilyOf({ root: spelling }));
72
+ if (r.ok && !keys.includes(r.key))
73
+ keys.push(r.key);
74
+ }
75
+ catch {
76
+ }
77
+ }
78
+ return keys;
79
+ }
80
+ export function relativeUnderRoot(targetKey, rootKey) {
69
81
  const seps = isWinFormPath(rootKey) ? ["\\", "/"] : ["/"];
70
82
  let root = rootKey;
71
83
  while (root.length > 0 && seps.includes(root[root.length - 1]))
@@ -96,25 +108,11 @@ function matchSensitive(canonicalKey, compiled, aliasResolved) {
96
108
  }
97
109
  export function createSensitivePathPolicy(opts) {
98
110
  const compiled = compilePatterns(opts.patterns);
99
- const compiledAsk = compilePatterns(opts.askPatterns ?? RECOMMENDED_SENSITIVE_ASK_PATTERNS);
100
111
  const dataRootPaths = dataRootSpellings(opts.dataRoot);
101
- const dataRootKeys = async (signal) => {
102
- const keys = [];
103
- for (const spelling of dataRootPaths) {
104
- try {
105
- const r = await canonicalizeTarget(opts.env, spelling, signal, pathFamilyOf({ root: spelling }));
106
- if (r.ok && !keys.includes(r.key))
107
- keys.push(r.key);
108
- }
109
- catch {
110
- }
111
- }
112
- return keys;
113
- };
114
112
  const guardedByName = opts.tools !== undefined ? new Set(opts.tools) : undefined;
115
113
  return {
116
114
  async check(req, signal) {
117
- if (compiled.length === 0 && compiledAsk.length === 0)
115
+ if (compiled.length === 0)
118
116
  return { action: "allow" };
119
117
  if (guardedByName !== undefined ? !guardedByName.has(req.toolName) : !isProtectedWrite(req))
120
118
  return { action: "allow" };
@@ -133,7 +131,7 @@ export function createSensitivePathPolicy(opts) {
133
131
  };
134
132
  }
135
133
  let relative = null;
136
- for (const rootKey of await dataRootKeys(signal)) {
134
+ for (const rootKey of await canonicalDataRootKeys(opts.env, dataRootPaths, signal)) {
137
135
  relative = relativeUnderRoot(canon.key, rootKey);
138
136
  if (relative !== null)
139
137
  break;
@@ -149,14 +147,6 @@ export function createSensitivePathPolicy(opts) {
149
147
  decisionReason: "safety",
150
148
  };
151
149
  }
152
- const askHit = matchSensitive(judged, compiledAsk, canon.aliasResolved === true);
153
- if (askHit) {
154
- return {
155
- action: "ask",
156
- message: `this write's real target resolves inside the guarded directory "${askHit}" — explicit approval required`,
157
- decisionReason: "safety",
158
- };
159
- }
160
150
  return { action: "allow" };
161
151
  },
162
152
  };