@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
@@ -1,5 +1,5 @@
1
1
  import { PRESENT_PLAN_TOOL_NAME } from "../present-plan-tool.js";
2
- import { defuseFenceMarkers, delimitUntrusted, sanitizeUntrustedText } from "../untrusted-text.js";
2
+ import { delimitUntrustedWithClip, sanitizeUntrustedText, SHELLED_BODY_ENVELOPE_TAGS } from "../untrusted-text.js";
3
3
  import { protocolOf } from "../protocol-table.js";
4
4
  import { buildSkillsBlock, skillListingLine } from "./synthetic-tools.js";
5
5
  import { TOOL_SEARCH_NAME as TOOL_SEARCH_TOOL_NAME } from "./tool-disclosure.js";
@@ -20,6 +20,11 @@ export const CHANGED_FILES_MTIME_EPS_MS = 2000;
20
20
  export const ATTACHMENT_BYTE_CAP = 8 * 1024;
21
21
  const PROJECTION_ITEMS_MAX = 50;
22
22
  const PROJECTION_CONTENT_MAX = 80;
23
+ const ATTACHMENT_TAGS_DEFAULT = [...SHELLED_BODY_ENVELOPE_TAGS];
24
+ const ATTACHMENT_TAGS_SKILLS_OWNER = SHELLED_BODY_ENVELOPE_TAGS.filter((t) => t !== "skills");
25
+ export function attachmentEnvelopeTags(source) {
26
+ return source === "skills_listing" ? ATTACHMENT_TAGS_SKILLS_OWNER : ATTACHMENT_TAGS_DEFAULT;
27
+ }
23
28
  export const INSTRUCTIONS_CHANGE_BYTE_CAP = 512;
24
29
  export function createAttachmentState() {
25
30
  return {
@@ -358,8 +363,9 @@ export function renderBudgetUsd(used, total) {
358
363
  return `USD budget: $${used}/$${total}; $${total - used} remaining`;
359
364
  }
360
365
  export function renderOrphanedBackgroundTasks(tasks) {
366
+ const safe = (t) => sanitizeUntrustedText(t, SHELLED_BODY_ENVELOPE_TAGS);
361
367
  return (`The container was restarted. The following background tasks were running and are now stopped:\n` +
362
- tasks.map((t) => `- ${t.description || "(no description)"} (task ${t.id})`).join("\n") +
368
+ tasks.map((t) => `- ${safe(t.description || "(no description)")} (task ${safe(t.id)})`).join("\n") +
363
369
  `\nRe-create them if still needed.`);
364
370
  }
365
371
  function renderChangedFiles(paths) {
@@ -449,7 +455,7 @@ export const AGENT_TOOLS_NOTE_DEFAULT = "All tools";
449
455
  export const AGENT_CONCURRENCY_NOTE = "When you launch multiple agents for independent work, send them in a single message with multiple tool uses so they run concurrently.";
450
456
  export const AMBIENT_CONTEXT_NOTE = "This is ambient context — do not narrate it to the user unless they ask or it is directly relevant to their request.";
451
457
  export function agentListingInitialHeader(toolName) {
452
- return `Available agent types for the ${toolName} tool:`;
458
+ return `Available agent types for the ${sanitizeUntrustedText(toolName, SHELLED_BODY_ENVELOPE_TAGS)} tool:`;
453
459
  }
454
460
  export function replayAnnouncedListing(texts, headers) {
455
461
  let announced;
@@ -492,13 +498,17 @@ export function replayAnnouncedListing(texts, headers) {
492
498
  return announced;
493
499
  }
494
500
  export const AGENT_LISTING_REMOVED_HEADER = "The following agent types are no longer available:";
501
+ function listingIdentity(name) {
502
+ return sanitizeUntrustedText(name, SHELLED_BODY_ENVELOPE_TAGS);
503
+ }
495
504
  export function agentListingDeltaHeader(toolName) {
496
- return `New agent types are now available for the ${toolName} tool:`;
505
+ return `New agent types are now available for the ${sanitizeUntrustedText(toolName, SHELLED_BODY_ENVELOPE_TAGS)} tool:`;
497
506
  }
498
507
  export function renderAgentListingDelta(state, entries, toolName, models) {
508
+ const safe = (t) => sanitizeUntrustedText(t, SHELLED_BODY_ENVELOPE_TAGS);
499
509
  const line = (e) => {
500
- const base = e.description ? `- ${e.name}: ${e.description}` : `- ${e.name}`;
501
- return `${base} (Tools: ${e.tools ?? AGENT_TOOLS_NOTE_DEFAULT})`;
510
+ const base = e.description ? `- ${safe(e.name)}: ${safe(e.description)}` : `- ${safe(e.name)}`;
511
+ return `${base} (Tools: ${safe(e.tools ?? AGENT_TOOLS_NOTE_DEFAULT)})`;
502
512
  };
503
513
  const announced = state.announcedAgentTypes;
504
514
  if (announced === undefined) {
@@ -511,18 +521,22 @@ export function renderAgentListingDelta(state, entries, toolName, models) {
511
521
  blocks.push(modelsAvailableLine(models));
512
522
  return blocks.join("\n\n");
513
523
  }
514
- const added = entries.filter((e) => !announced.has(e.name)).toSorted((a, b) => a.name.localeCompare(b.name));
515
- const removed = [...announced.keys()].filter((n) => !entries.some((e) => e.name === n)).sort();
524
+ const announcedIds = new Set([...announced.keys()].map(listingIdentity));
525
+ const added = entries.filter((e) => !announcedIds.has(listingIdentity(e.name))).toSorted((a, b) => a.name.localeCompare(b.name));
526
+ const removed = [...announced.keys()]
527
+ .filter((n) => !entries.some((e) => listingIdentity(e.name) === listingIdentity(n)))
528
+ .sort();
516
529
  const modelsDrifted = state.announcedModels !== undefined &&
517
530
  models !== undefined &&
518
- (state.announcedModels.length !== models.length || state.announcedModels.some((m, i) => m !== models[i]));
531
+ (state.announcedModels.length !== models.length ||
532
+ state.announcedModels.some((m, i) => listingIdentity(m) !== listingIdentity(models[i])));
519
533
  if (added.length === 0 && removed.length === 0 && !modelsDrifted)
520
534
  return undefined;
521
535
  const blocks = [];
522
536
  if (added.length > 0)
523
537
  blocks.push(`${agentListingDeltaHeader(toolName)}\n${added.map(line).join("\n")}`);
524
538
  if (removed.length > 0) {
525
- blocks.push(`${AGENT_LISTING_REMOVED_HEADER}\n${removed.map((n) => `- ${n}`).join("\n")}`);
539
+ blocks.push(`${AGENT_LISTING_REMOVED_HEADER}\n${removed.map((n) => `- ${safe(n)}`).join("\n")}`);
526
540
  blocks.push(AMBIENT_CONTEXT_NOTE);
527
541
  }
528
542
  if (modelsDrifted)
@@ -531,7 +545,8 @@ export function renderAgentListingDelta(state, entries, toolName, models) {
531
545
  }
532
546
  export const MODELS_AVAILABLE_PREFIX = "Models available for the 'model' parameter: ";
533
547
  function modelsAvailableLine(models) {
534
- return `${MODELS_AVAILABLE_PREFIX}${models.length > 0 ? models.join(", ") : "(none)"}`;
548
+ const named = models.map((m) => sanitizeUntrustedText(m, SHELLED_BODY_ENVELOPE_TAGS));
549
+ return `${MODELS_AVAILABLE_PREFIX}${named.length > 0 ? named.join(", ") : "(none)"}`;
535
550
  }
536
551
  export function replayAnnouncedModels(texts) {
537
552
  let last;
@@ -563,15 +578,16 @@ export function renderSkillsListingDelta(state, entries) {
563
578
  return undefined;
564
579
  return buildSkillsBlock(entries);
565
580
  }
566
- const added = entries.filter((e) => !announced.has(e.name));
567
- const removed = [...announced.keys()].filter((n) => !entries.some((e) => e.name === n));
581
+ const announcedIds = new Set([...announced.keys()].map(listingIdentity));
582
+ const added = entries.filter((e) => !announcedIds.has(listingIdentity(e.name)));
583
+ const removed = [...announced.keys()].filter((n) => !entries.some((e) => listingIdentity(e.name) === listingIdentity(n)));
568
584
  if (added.length === 0 && removed.length === 0)
569
585
  return undefined;
570
586
  const blocks = [];
571
587
  if (added.length > 0)
572
588
  blocks.push(`${SKILLS_LISTING_DELTA_HEADER}\n${added.map((e) => skillListingLine(e)).join("\n")}`);
573
589
  if (removed.length > 0) {
574
- blocks.push(`${SKILLS_LISTING_REMOVED_HEADER}\n${removed.map((n) => `- ${n}`).join("\n")}`);
590
+ blocks.push(`${SKILLS_LISTING_REMOVED_HEADER}\n${removed.map((n) => `- ${sanitizeUntrustedText(n, SHELLED_BODY_ENVELOPE_TAGS)}`).join("\n")}`);
575
591
  blocks.push(AMBIENT_CONTEXT_NOTE);
576
592
  }
577
593
  return blocks.join("\n\n");
@@ -581,12 +597,10 @@ export function commitSkillsListing(state, entries) {
581
597
  }
582
598
  export const MCP_INSTRUCTIONS_MAX_CHARS = 8 * 1024;
583
599
  export function fenceMcpServerInstructions(server, text) {
584
- const neutralized = defuseFenceMarkers(sanitizeUntrustedText(text));
585
- const clipped = [...neutralized].length > MCP_INSTRUCTIONS_MAX_CHARS;
586
- const fenced = delimitUntrusted(`MCP server "${server}" instructions`, neutralized, MCP_INSTRUCTIONS_MAX_CHARS);
587
- return clipped
588
- ? `${fenced}\n(Truncated by the agent runtime: this server's instructions exceeded ${MCP_INSTRUCTIONS_MAX_CHARS} characters.)`
589
- : fenced;
600
+ const fence = delimitUntrustedWithClip(`MCP server "${server}" instructions`, text, MCP_INSTRUCTIONS_MAX_CHARS);
601
+ return fence.clipped
602
+ ? `${fence.text}\n(Truncated by the agent runtime: this server's instructions exceeded ${MCP_INSTRUCTIONS_MAX_CHARS} characters.)`
603
+ : fence.text;
590
604
  }
591
605
  export function renderMcpInstructionsDelta(added, removed) {
592
606
  const blocks = [];
@@ -84,6 +84,38 @@ export interface ReconcileReport {
84
84
  * `runner.resume()` uses a checkpoint-aware entry that bypasses this reconcile entirely for those calls.
85
85
  */
86
86
  export declare function findOrphanToolCalls(messages: AgentMessage[], suspendedBatch?: ReadonlySet<string>): OrphanToolCall[];
87
+ /**
88
+ * backlog #389 伴生 (D-2) — CC 2.1.223's interruption markers, VERBATIM (`$U`/`CR` @ `CC:151120-151121`,
89
+ * minted as a USER message by `Jce` @ `CC:640141-640154`). CC mints one on every abort whose reason is
90
+ * outside `{"interrupt","refusal-fallback-edit"}` — and the interactive Esc / remote cancel, which is
91
+ * what `TaskStream.interrupt()` corresponds to, is precisely on the minting side (`CC:1033698-1033733`).
92
+ * The suppressed reason is the one case where the user's own replacement message is already the context.
93
+ *
94
+ * Taken verbatim rather than reworded: this string is an INPUT to later reasoning in CC (its own
95
+ * "interrupted then immediately retried the same action" rule reads it back), and the constitution's
96
+ * standing rule is that a question CC has answered is answered in CC's form.
97
+ */
98
+ export declare const INTERRUPTED_BY_USER_MARKER = "[Request interrupted by user]";
99
+ /** backlog #389 伴生 — the tool-use variant (`CR`): the run was cut while a tool batch was in flight. */
100
+ export declare const INTERRUPTED_BY_USER_FOR_TOOL_USE_MARKER = "[Request interrupted by user for tool use]";
101
+ /**
102
+ * backlog #389 伴生 (D-2) — append the interruption marker so the SESSION records that a person stopped
103
+ * this run.
104
+ *
105
+ * Without it, an interrupt that lands on the model stream (no tool call in flight) leaves literally no
106
+ * trace: the orphan reconcile has nothing to close, and the empty aborted assistant is deliberately not
107
+ * persisted (`isEmptyFailureAssistant`). The next run on that session then reads a transcript in which
108
+ * the half-finished work simply stops, and continues as if it had ended by itself.
109
+ *
110
+ * MUST be called AFTER {@link reconcileInterruptedSession} on the same interruption: a user message
111
+ * placed between an assistant's tool calls and their results is exactly the invalid sequence the
112
+ * reconcile exists to prevent.
113
+ *
114
+ * Returns the persisted entry id so the caller can mint its `message_committed` frame.
115
+ */
116
+ export declare function appendInterruptionMarker(session: Session, opts: {
117
+ toolUseInFlight: boolean;
118
+ }): Promise<string>;
87
119
  /**
88
120
  * Reconcile a resumed session's active branch: close any orphan tool calls with a synthetic
89
121
  * interrupted `toolResult` (never re-running the tool). Returns what was recovered.
@@ -81,6 +81,21 @@ export function findOrphanToolCalls(messages, suspendedBatch) {
81
81
  });
82
82
  return orphans;
83
83
  }
84
+ export const INTERRUPTED_BY_USER_MARKER = "[Request interrupted by user]";
85
+ export const INTERRUPTED_BY_USER_FOR_TOOL_USE_MARKER = "[Request interrupted by user for tool use]";
86
+ export async function appendInterruptionMarker(session, opts) {
87
+ return await session.appendMessage({
88
+ role: "user",
89
+ content: [
90
+ {
91
+ type: "text",
92
+ text: opts.toolUseInFlight ? INTERRUPTED_BY_USER_FOR_TOOL_USE_MARKER : INTERRUPTED_BY_USER_MARKER,
93
+ },
94
+ ],
95
+ provenance: "engine-note",
96
+ timestamp: Date.now(),
97
+ });
98
+ }
84
99
  export async function reconcileInterruptedSession(session, toolEffects, suspendedBatch, startedToolCallIds) {
85
100
  const { messages } = await session.buildContext();
86
101
  const orphans = findOrphanToolCalls(messages, suspendedBatch).filter((o) => o.kind !== "result");
@@ -2,6 +2,16 @@
2
2
  * lines from a still-running watch). Everything else is a terminal completion notification. */
3
3
  export type TaskNotificationStatus = "completed" | "failed" | "killed" | "cancelled" | "event";
4
4
  export type SystemInjectionPriority = "now" | "next" | "later";
5
+ /** The closed value set of {@link SystemInjectionPriority}, as data — the wire-facing entries validate
6
+ * against THIS rather than against three inline string literals, so widening the type widens the guard.
7
+ * (Names taken verbatim from CC 2.1.223's `UYo` @ `CC:644918`; see the semantics note on
8
+ * {@link SystemInjection.priority} for which of CC's behaviors this engine implements.) */
9
+ export declare const SYSTEM_INJECTION_PRIORITIES: readonly SystemInjectionPriority[];
10
+ /** backlog #389 伴生 (D-3, bad-value loudness) — is this a priority this engine knows at all? A value
11
+ * outside the set is a caller bug that must be refused, never silently folded into a default: the
12
+ * no-subscriber retain path indexes `queues[priority]` and the park path stores it verbatim, so an
13
+ * unknown value used to travel until something downstream failed on it (or didn't, and lied). */
14
+ export declare function isSystemInjectionPriority(value: unknown): value is SystemInjectionPriority;
5
15
  export interface TaskNotificationPayload {
6
16
  task_id: string;
7
17
  /** design/144 §2: `"external"` is CORE-MINTED ONLY — `TaskStream.notify()` casts it onto every
@@ -137,13 +147,30 @@ export interface ExternalNotificationInput {
137
147
  }
138
148
  export interface SystemInjection<TPayload = unknown> {
139
149
  kind: "task_notification";
140
- /** design/116 §7, re-anchored 2026-08-05 (CC 2.1.221): EVERY priority delivers at the NEXT turn
141
- * boundary via harness.steer(), mid-work included CC's queued task-notification inputs are
142
- * unconditionally deliverable at the boundary, and the old later→followUp mapping (deliver only
143
- * when the agent would otherwise stop) starved busy models of the very completion they were told
144
- * not to poll for. Delivery is ARRIVAL order (consecutive frames batch); `priority` affects only the park/uplink
145
- * path. A delivery that races the agent going idle parks on PendingSessionNotifications for the
146
- * session's next run. `drain()` serves that parked lane. */
150
+ /**
151
+ * design/116 §7 in THIS engine every priority delivers at the NEXT turn boundary via
152
+ * `harness.steer()`, mid-work included, in ARRIVAL order (consecutive frames batch); `priority`
153
+ * affects only the park/uplink path. A delivery that races the agent going idle parks on
154
+ * PendingSessionNotifications for the session's next run; `drain()` serves that parked lane.
155
+ *
156
+ * **Anchor correction (backlog #389 伴生 / hallucination audit H-1).** The 2026-08-05 re-anchor
157
+ * justified flattening the ladder with "CC's queued task-notification inputs are UNCONDITIONALLY
158
+ * deliverable at the boundary (CC 2.1.221)". That sentence is FALSE as a statement about CC, on
159
+ * 221 and 223 alike: the mid-turn fold is gated at `getCommandsByMaxPriority("next")`
160
+ * (`pretty221.js:449195` / `pretty223.js:415586`), which admits `now`+`next` and EXCLUDES `later`
161
+ * — and `enqueuePendingNotification` defaults to `later`. CC's background-completion notices fold
162
+ * mid-turn because they explicitly say `priority:"next"`; its ultraplan/artifact notices take the
163
+ * default and deliberately do NOT. So all three of CC's values carry live delivery semantics
164
+ * (`now` = abort the running turn, `next` = fold into it, `later` = wait for the next one).
165
+ *
166
+ * The engineering conclusion the re-anchor reached — a background completion must reach a busy
167
+ * model at the boundary rather than starve behind a "deliver only when it would otherwise stop"
168
+ * rule — stands on its own. What does not stand is the claim that CC has no ladder. Restoring the
169
+ * `later` = "do not fold into the running turn" arm is a behavior-face change and `now` = "abort
170
+ * the running turn" is a new capability; both are ruled changes, not silent ones. Until then the
171
+ * gap is DISCLOSED at the injection funnel rather than left as a silently inert knob (`now` is
172
+ * announced, an unknown value is refused) — the bad-value loudness rule.
173
+ */
147
174
  priority: SystemInjectionPriority;
148
175
  dedupKey: string;
149
176
  payload: TPayload;
@@ -1,4 +1,8 @@
1
1
  import { inlineUntrusted, sanitizeUntrustedText } from "./untrusted-text.js";
2
+ export const SYSTEM_INJECTION_PRIORITIES = ["now", "next", "later"];
3
+ export function isSystemInjectionPriority(value) {
4
+ return typeof value === "string" && SYSTEM_INJECTION_PRIORITIES.includes(value);
5
+ }
2
6
  const TASK_NOTIFICATION_TAGS = [
3
7
  "task-notification",
4
8
  "task-id",
@@ -214,6 +218,9 @@ export class SystemInjectionQueue {
214
218
  listeners = new Set();
215
219
  droppedOldest = 0;
216
220
  enqueue(item) {
221
+ if (!isSystemInjectionPriority(item.priority)) {
222
+ throw new Error(`SystemInjectionQueue: unknown priority ${JSON.stringify(item.priority)} — expected one of ${SYSTEM_INJECTION_PRIORITIES.join("/")}`);
223
+ }
217
224
  if (this.dedup.has(item.dedupKey))
218
225
  return false;
219
226
  this.dedup.add(item.dedupKey);
@@ -239,7 +246,10 @@ export class SystemInjectionQueue {
239
246
  return true;
240
247
  }
241
248
  drain(priority) {
242
- const priorities = priority ? [priority] : ["now", "next", "later"];
249
+ if (priority !== undefined && !isSystemInjectionPriority(priority)) {
250
+ throw new Error(`SystemInjectionQueue: unknown priority ${JSON.stringify(priority)} — expected one of ${SYSTEM_INJECTION_PRIORITIES.join("/")}`);
251
+ }
252
+ const priorities = priority ? [priority] : [...SYSTEM_INJECTION_PRIORITIES];
243
253
  const out = [];
244
254
  for (const p of priorities) {
245
255
  const q = this.queues[p];
@@ -166,11 +166,23 @@ export declare function parkBackgroundAgentLane(core: DurableAgentCore, id: stri
166
166
  * design/153 §7.1 (件1 codex HIGH split-brain fence) — the registry-aware reconciliation entry an
167
167
  * IN-PROCESS deployment calls on its retention cadence. Two halves, one call:
168
168
  * 1. LIVE half: this instance's own parked handles are probed against their checkpoint and, on
169
- * expired/missing, settled failed THROUGH the handle (poll/stop/durable row stay coherent —
170
- * quiescence poked, arbitration closure dropped, durable clear rides the settle write).
169
+ * expired/missing/DECIDED-ELSEWHERE, settled failed THROUGH the handle (poll/stop/durable row
170
+ * stay coherent — quiescence poked, arbitration closure dropped, durable clear rides the settle
171
+ * write).
171
172
  * 2. STORE half: delegates to the store-level {@link reconcileParkedAgents} with
172
173
  * `excludeWriterId` = this instance's writerId, so foreign (dead-process) rows are cleaned
173
174
  * without ever touching a row this process is live-managing.
175
+ *
176
+ * backlog #400 — the live half used to skip EVERY non-expired/missing state, `resolved` included, and
177
+ * that was the whole zombie: a parked row whose checkpoint was decided by the task-level
178
+ * `resumeCheckpoint` leg (a serving layer's documented fallback when its parked-row discriminator
179
+ * misses) is decided but still says `parked`, its side effects already executed by a path that never
180
+ * touched this registry. Nothing else could reach it either — a re-claim answers
181
+ * `checkpoint_not_pending`, and the store half excludes exactly the rows this process live-manages —
182
+ * so it stayed `parked` for the life of the process. It now converges here. The one `resolved` case
183
+ * that must still be LET THROUGH is the registry's own claim window (reservation → consume flip): that
184
+ * skip is load-bearing, and it is now expressed as what it actually is — a claim in flight — instead
185
+ * of as a blanket state filter that also swallowed the case with no claimant at all.
174
186
  */
175
187
  export declare function reconcileParkedAgentsLane(core: DurableAgentCore, stores: {
176
188
  agentStore: import("./background-agent-store.js").BackgroundAgentStore;
@@ -275,7 +287,12 @@ export declare function abortBackgroundAgentsForOwnerLane(core: DurableAgentCore
275
287
  * SendMessage consume, so the two verbs can never drift. Ladder:
276
288
  * 0a. verbatim `handle.name` match — LATEST-WINS (CC SendMessage.md: "a newer agent took the name
277
289
  * (latest wins)"; older rows stay task_id-addressable), any status (names keep working after
278
- * completion — the resume face is the CC "resumes it from its transcript" counterpart);
290
+ * completion — the resume face is the CC "resumes it from its transcript" counterpart).
291
+ * **Latest-wins is RESOLUTION, not permission to redirect a conversation**: CC pairs the same
292
+ * resolver with a per-conversation pin guard (`send_message_pin_guard`), and SendMessage REFUSES
293
+ * a send whose name has changed hands since this caller last used it. That guard lives at the
294
+ * SendMessage face (`createSendMessageTool`), exactly as it does upstream — this resolver stays
295
+ * a pure resolver, and TaskStop keeps its own (preferRunning) posture untouched;
279
296
  * 0b. normalized `handle.name` match — same latest-wins (the spawn regex bans whitespace, so
280
297
  * normalization here is effectively NFKC+lowercase: "Builder"/"builder" share the name pool);
281
298
  * 1+. the pre-[c209] description layers, UNCHANGED (exact → normalized, running-preferred +
@@ -484,12 +484,41 @@ export async function reconcileParkedAgentsLane(core, stores, scope, now, opts)
484
484
  state = "unreadable";
485
485
  }
486
486
  }
487
- if (state !== "expired" && state !== "missing")
487
+ let error;
488
+ if (state === "expired" || state === "missing") {
489
+ error = `approval checkpoint ${state} while parked (reconciled)`;
490
+ }
491
+ else if (state === "resolved") {
492
+ let row;
493
+ try {
494
+ row = await stores.agentStore.get(handle.id, scope);
495
+ }
496
+ catch {
497
+ continue;
498
+ }
499
+ if (row === null || row.status !== "parked" || row.parkClaimId !== undefined)
500
+ continue;
501
+ try {
502
+ const reread = (await stores.checkpointStore.get(token));
503
+ if (reread === null || reread.status !== "resolved")
504
+ continue;
505
+ }
506
+ catch {
507
+ continue;
508
+ }
509
+ error =
510
+ "approval checkpoint resolved outside the registry's claim path while parked — the decision was " +
511
+ "redeemed by another leg and this row's outcome is unknown (reconciled)";
512
+ }
513
+ else {
488
514
  continue;
515
+ }
489
516
  if (handle.status !== "parked")
490
517
  continue;
518
+ if (handle.parkedCheckpointToken !== token)
519
+ continue;
491
520
  handle.status = "failed";
492
- handle.error = `approval checkpoint ${state} while parked (reconciled)`;
521
+ handle.error = error;
493
522
  handle.parkedCheckpointToken = undefined;
494
523
  handle.resolveParkedStop = undefined;
495
524
  handle.updatedAt = now;
@@ -383,6 +383,29 @@ export interface ToolPolicyProjection {
383
383
  */
384
384
  readonly requiresLiveRemainder: boolean;
385
385
  }
386
+ /**
387
+ * The entries of a tool-NAME list whose reach is a set of names rather than one name — the covering
388
+ * spellings (`<ns>__<peer>`, `<ns>__<peer>__<glob>`) of ANY protocol namespace, which CC's rule
389
+ * matcher resolves and an exact-membership test silently cannot. Every name-keyed lane in this file
390
+ * consults these ALONGSIDE its exact set: a covering spelling can never equal a minted tool name (`*`
391
+ * is outside the minted charset and a minted name always carries a tool segment), so leaving it in the
392
+ * exact set too costs nothing and keeps the projection/audit faces reporting the operator's own spelling.
393
+ *
394
+ * Namespace-generic on purpose: the covering grammar is a property of the shared `<prefix><peer>__<tool>`
395
+ * name shape, not of MCP, and keying it to one prefix made every sibling-namespace covering rule
396
+ * (`a2a__<peer>__*`) compile clean and match nothing — inert in the deny half, and in an ALLOW list the
397
+ * removal of that peer's whole tool set.
398
+ *
399
+ * The list is usually empty, which is why every call site tests it before scanning.
400
+ *
401
+ * Exported for the OTHER name-keyed lane in this engine — the persisted session/ancestor rules in
402
+ * `session-rule-policy.ts`. Not part of the public API (`src/index.ts` re-exports by name and does not
403
+ * list these): all four publishers of {@link ToolPolicyNameSets} must resolve a covering entry the same
404
+ * way, because the prepare-time audit's exemption for these spellings speaks for all of them at once.
405
+ */
406
+ export declare function namespacedCoveringEntries(entries: readonly string[] | undefined): readonly string[];
407
+ /** Does any covering entry reach `toolName`? See {@link namespacedCoveringEntries}. */
408
+ export declare function namespacedCoveringHit(covering: readonly string[], toolName: string): boolean;
386
409
  /**
387
410
  * Execute a persisted {@link ToolPolicyProjection} against a call (F-012 L1): returns the first
388
411
  * component's deny, or `undefined` when the projection has no opinion (it is deny-only by
@@ -5,7 +5,8 @@ import { join, normalize as normalizePath, posix as posixPath, sep, win32 as win
5
5
  import { BASH_READONLY_DEFAULT_ALLOW, parseLeadingCommandName } from "../tools/fs/index.js";
6
6
  import { boundInputHashOf } from "./canonical-json.js";
7
7
  import { delimitUntrusted, inlineUntrusted, REVIEWER_NOTE_MAX_BODY } from "./untrusted-text.js";
8
- import { parsePermissionRule } from "./permission-rules.js";
8
+ import { isNamespacedCoveringRuleName, namespacedRuleNameCovers, parsePermissionRule } from "./permission-rules.js";
9
+ import { protocolOf } from "./protocol-table.js";
9
10
  import { isAbsolutePathForm, isWinFormPath, writeTargetPath } from "../tools/fs/safety.js";
10
11
  const DECISION_REASONS = ["rule", "mode", "hook", "safety", "classifier", "persisted_rule", "sandbox", "org_rule", "org_unavailable"];
11
12
  const DECISION_REASON_SET = new Set(DECISION_REASONS);
@@ -36,16 +37,22 @@ export const ASK_EVIDENCE_ABSENCE_VALUES = ["not_wired", "not_adjudicated", "una
36
37
  export function decisionText(d) {
37
38
  return d.message;
38
39
  }
40
+ export function namespacedCoveringEntries(entries) {
41
+ return (entries ?? []).filter(isNamespacedCoveringRuleName);
42
+ }
43
+ export function namespacedCoveringHit(covering, toolName) {
44
+ return covering.length > 0 && covering.some((e) => namespacedRuleNameCovers(e, toolName));
45
+ }
39
46
  export function checkToolPolicyProjection(projection, req) {
40
47
  for (const c of projection.components) {
41
48
  if (c.kind === "tool_deny") {
42
- if (c.names.includes(req.toolName)) {
49
+ if (c.names.includes(req.toolName) || namespacedCoveringHit(namespacedCoveringEntries(c.names), req.toolName)) {
43
50
  return { action: "deny", message: `tool "${req.toolName}" is denied by a frozen inherited policy projection` };
44
51
  }
45
52
  continue;
46
53
  }
47
54
  if (c.kind === "tool_allowlist") {
48
- if (!c.names.includes(req.toolName)) {
55
+ if (!c.names.includes(req.toolName) && !namespacedCoveringHit(namespacedCoveringEntries(c.names), req.toolName)) {
49
56
  return { action: "deny", message: `tool "${req.toolName}" is not in a frozen inherited policy projection's allowlist` };
50
57
  }
51
58
  continue;
@@ -173,11 +180,15 @@ export function createAllowDenyPolicy(opts) {
173
180
  return undefined;
174
181
  const kept = [];
175
182
  for (const entry of entries) {
176
- if (entry.startsWith("mcp__")) {
177
- const segments = entry.slice("mcp__".length).split("__");
178
- if (segments.some((seg) => seg.length === 0)) {
179
- const lesson = `a malformed MCP tool name (empty segment) — it can never match any mounted tool. ` +
180
- `Use \`mcp__<server>\` for every tool of a server, or \`mcp__<server>__<tool>\` for one tool.`;
183
+ const ns = protocolOf(entry);
184
+ if (ns !== undefined) {
185
+ const suffix = entry.slice(ns.prefix.length);
186
+ const sep = suffix.indexOf("__");
187
+ const malformed = suffix.length === 0 || sep === 0 || (sep > 0 && suffix.length === sep + 2);
188
+ if (malformed) {
189
+ const family = ns.id.toUpperCase();
190
+ const lesson = `a malformed ${family} tool name (empty peer or tool segment) — it can never match any mounted tool. ` +
191
+ `Use \`${ns.prefix}<peer>\` for every tool of a peer, or \`${ns.prefix}<peer>__<tool>\` for one tool.`;
181
192
  invalid.push({ entry, list, message: `"${entry}" is ${lesson}`, lesson });
182
193
  continue;
183
194
  }
@@ -218,6 +229,8 @@ export function createAllowDenyPolicy(opts) {
218
229
  opts = { ...opts, ...(screenedAllow ? { allow: screenedAllow } : {}), ...(screenedDeny ? { deny: screenedDeny } : {}) };
219
230
  const allow = opts.allow ? new Set(opts.allow) : undefined;
220
231
  const deny = new Set(opts.deny ?? []);
232
+ const denyCovering = namespacedCoveringEntries(opts.deny);
233
+ const allowCovering = namespacedCoveringEntries(opts.allow);
221
234
  return {
222
235
  projection: {
223
236
  components: [
@@ -229,10 +242,10 @@ export function createAllowDenyPolicy(opts) {
229
242
  nameSets: [{ ...(opts.allow ? { allow: [...opts.allow] } : {}), ...(opts.deny ? { deny: [...opts.deny] } : {}) }],
230
243
  check(req) {
231
244
  const toolName = req.toolName;
232
- if (deny.has(toolName)) {
245
+ if (deny.has(toolName) || namespacedCoveringHit(denyCovering, toolName)) {
233
246
  return { action: "deny", message: `tool "${req.toolName}" is denied by policy` };
234
247
  }
235
- if (allow && !allow.has(toolName)) {
248
+ if (allow && !allow.has(toolName) && !namespacedCoveringHit(allowCovering, toolName)) {
236
249
  return { action: "deny", message: `tool "${req.toolName}" is not in the allowlist` };
237
250
  }
238
251
  return ALLOW;
@@ -249,6 +262,9 @@ export function createApprovalPolicy(opts) {
249
262
  const need = new Set(opts.requireApproval);
250
263
  const deny = new Set(opts.deny ?? []);
251
264
  const auto = new Set(opts.autoAllow ?? []);
265
+ const needCovering = namespacedCoveringEntries(opts.requireApproval);
266
+ const denyCovering = namespacedCoveringEntries(opts.deny);
267
+ const autoCovering = namespacedCoveringEntries(opts.autoAllow);
252
268
  return {
253
269
  projection: {
254
270
  components: [
@@ -266,10 +282,10 @@ export function createApprovalPolicy(opts) {
266
282
  ],
267
283
  async check(req, signal) {
268
284
  const toolName = req.toolName;
269
- if (deny.has(toolName)) {
285
+ if (deny.has(toolName) || namespacedCoveringHit(denyCovering, toolName)) {
270
286
  return { action: "deny", message: `tool "${req.toolName}" is denied by policy` };
271
287
  }
272
- if (need.has(toolName)) {
288
+ if (need.has(toolName) || namespacedCoveringHit(needCovering, toolName)) {
273
289
  if (signal?.aborted) {
274
290
  return withCoreMintedResolution({ action: "deny", message: `approval aborted for "${req.toolName}" (task ended)`, settledBy: "aborted" }, "task_aborted", req);
275
291
  }
@@ -303,7 +319,7 @@ export function createApprovalPolicy(opts) {
303
319
  }
304
320
  return withCoreMintedResolution({ action: "deny", message: `approval denied for "${req.toolName}"`, settledBy: "human" }, "human_refused", req);
305
321
  }
306
- if (opts.denyByDefault && !auto.has(toolName)) {
322
+ if (opts.denyByDefault && !auto.has(toolName) && !namespacedCoveringHit(autoCovering, toolName)) {
307
323
  return { action: "deny", message: `tool "${req.toolName}" requires explicit allow` };
308
324
  }
309
325
  return ALLOW;