@sema-agent/core 5.55.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 (42) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/dist/agents/send-message-tool.js +48 -2
  3. package/dist/agents/subagent.js +250 -89
  4. package/dist/core/auto-compaction.d.ts +17 -4
  5. package/dist/core/auto-compaction.js +3 -0
  6. package/dist/core/context-edit.d.ts +55 -6
  7. package/dist/core/context-edit.js +12 -1
  8. package/dist/core/hooks.d.ts +293 -11
  9. package/dist/core/hooks.js +158 -11
  10. package/dist/core/human-input-projection.d.ts +20 -2
  11. package/dist/core/human-input-projection.js +9 -0
  12. package/dist/core/permission-rules.d.ts +23 -15
  13. package/dist/core/permission-rules.js +40 -31
  14. package/dist/core/runner/prepare-task.d.ts +8 -0
  15. package/dist/core/runner/prepare-task.js +34 -23
  16. package/dist/core/runner/runtask.js +158 -21
  17. package/dist/core/runner/session-rule-policy.js +5 -5
  18. package/dist/core/session-reconcile.d.ts +32 -0
  19. package/dist/core/session-reconcile.js +15 -0
  20. package/dist/core/task-notification.d.ts +34 -7
  21. package/dist/core/task-notification.js +11 -1
  22. package/dist/core/task-registry-agent.d.ts +20 -3
  23. package/dist/core/task-registry-agent.js +31 -2
  24. package/dist/core/tool-policy.d.ts +14 -9
  25. package/dist/core/tool-policy.js +27 -22
  26. package/dist/core/types.d.ts +37 -11
  27. package/dist/core/untrusted-text.js +8 -0
  28. package/dist/engine/compaction/compaction.d.ts +77 -7
  29. package/dist/engine/compaction/compaction.js +98 -9
  30. package/dist/engine/compaction/utils.d.ts +4 -0
  31. package/dist/engine/compaction/utils.js +6 -0
  32. package/dist/engine/harness/agent-harness.d.ts +84 -0
  33. package/dist/engine/harness/agent-harness.js +88 -12
  34. package/dist/engine/harness/messages.d.ts +4 -2
  35. package/dist/engine/harness/messages.js +7 -2
  36. package/dist/engine/harness/types.d.ts +11 -5
  37. package/dist/engine/loop/types.d.ts +7 -0
  38. package/dist/engine/session/import-validate.js +10 -0
  39. package/dist/engine/session/session.js +2 -2
  40. package/dist/orchestration/run-spec.js +8 -1
  41. package/dist/prompts/default.d.ts +10 -4
  42. package/package.json +1 -1
@@ -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;
@@ -384,12 +384,17 @@ export interface ToolPolicyProjection {
384
384
  readonly requiresLiveRemainder: boolean;
385
385
  }
386
386
  /**
387
- * The entries of a tool-NAME list whose reach is a set of names rather than one name — the MCP
388
- * covering spellings (`mcp__<server>`, `mcp__<server>__<glob>`) that CC's rule matcher resolves and
389
- * an exact-membership test silently cannot. Every name-keyed lane in this file consults these
390
- * ALONGSIDE its exact set: a covering spelling can never equal a minted tool name (`*` is outside the
391
- * minted charset and a minted name always carries a tool segment), so leaving it in the exact set too
392
- * costs nothing and keeps the projection/audit faces reporting the operator's own spelling.
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.
393
398
  *
394
399
  * The list is usually empty, which is why every call site tests it before scanning.
395
400
  *
@@ -398,9 +403,9 @@ export interface ToolPolicyProjection {
398
403
  * list these): all four publishers of {@link ToolPolicyNameSets} must resolve a covering entry the same
399
404
  * way, because the prepare-time audit's exemption for these spellings speaks for all of them at once.
400
405
  */
401
- export declare function mcpCoveringEntries(entries: readonly string[] | undefined): readonly string[];
402
- /** Does any covering entry reach `toolName`? See {@link mcpCoveringEntries}. */
403
- export declare function mcpCoveringHit(covering: readonly string[], toolName: string): boolean;
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;
404
409
  /**
405
410
  * Execute a persisted {@link ToolPolicyProjection} against a call (F-012 L1): returns the first
406
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 { isMcpCoveringRuleName, mcpRuleNameCovers, 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,22 +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
  }
39
- export function mcpCoveringEntries(entries) {
40
- return (entries ?? []).filter(isMcpCoveringRuleName);
40
+ export function namespacedCoveringEntries(entries) {
41
+ return (entries ?? []).filter(isNamespacedCoveringRuleName);
41
42
  }
42
- export function mcpCoveringHit(covering, toolName) {
43
- return covering.length > 0 && covering.some((e) => mcpRuleNameCovers(e, toolName));
43
+ export function namespacedCoveringHit(covering, toolName) {
44
+ return covering.length > 0 && covering.some((e) => namespacedRuleNameCovers(e, toolName));
44
45
  }
45
46
  export function checkToolPolicyProjection(projection, req) {
46
47
  for (const c of projection.components) {
47
48
  if (c.kind === "tool_deny") {
48
- if (c.names.includes(req.toolName) || mcpCoveringHit(mcpCoveringEntries(c.names), req.toolName)) {
49
+ if (c.names.includes(req.toolName) || namespacedCoveringHit(namespacedCoveringEntries(c.names), req.toolName)) {
49
50
  return { action: "deny", message: `tool "${req.toolName}" is denied by a frozen inherited policy projection` };
50
51
  }
51
52
  continue;
52
53
  }
53
54
  if (c.kind === "tool_allowlist") {
54
- if (!c.names.includes(req.toolName) && !mcpCoveringHit(mcpCoveringEntries(c.names), req.toolName)) {
55
+ if (!c.names.includes(req.toolName) && !namespacedCoveringHit(namespacedCoveringEntries(c.names), req.toolName)) {
55
56
  return { action: "deny", message: `tool "${req.toolName}" is not in a frozen inherited policy projection's allowlist` };
56
57
  }
57
58
  continue;
@@ -179,11 +180,15 @@ export function createAllowDenyPolicy(opts) {
179
180
  return undefined;
180
181
  const kept = [];
181
182
  for (const entry of entries) {
182
- if (entry.startsWith("mcp__")) {
183
- const segments = entry.slice("mcp__".length).split("__");
184
- if (segments.some((seg) => seg.length === 0)) {
185
- const lesson = `a malformed MCP tool name (empty segment) — it can never match any mounted tool. ` +
186
- `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.`;
187
192
  invalid.push({ entry, list, message: `"${entry}" is ${lesson}`, lesson });
188
193
  continue;
189
194
  }
@@ -224,8 +229,8 @@ export function createAllowDenyPolicy(opts) {
224
229
  opts = { ...opts, ...(screenedAllow ? { allow: screenedAllow } : {}), ...(screenedDeny ? { deny: screenedDeny } : {}) };
225
230
  const allow = opts.allow ? new Set(opts.allow) : undefined;
226
231
  const deny = new Set(opts.deny ?? []);
227
- const denyCovering = mcpCoveringEntries(opts.deny);
228
- const allowCovering = mcpCoveringEntries(opts.allow);
232
+ const denyCovering = namespacedCoveringEntries(opts.deny);
233
+ const allowCovering = namespacedCoveringEntries(opts.allow);
229
234
  return {
230
235
  projection: {
231
236
  components: [
@@ -237,10 +242,10 @@ export function createAllowDenyPolicy(opts) {
237
242
  nameSets: [{ ...(opts.allow ? { allow: [...opts.allow] } : {}), ...(opts.deny ? { deny: [...opts.deny] } : {}) }],
238
243
  check(req) {
239
244
  const toolName = req.toolName;
240
- if (deny.has(toolName) || mcpCoveringHit(denyCovering, toolName)) {
245
+ if (deny.has(toolName) || namespacedCoveringHit(denyCovering, toolName)) {
241
246
  return { action: "deny", message: `tool "${req.toolName}" is denied by policy` };
242
247
  }
243
- if (allow && !allow.has(toolName) && !mcpCoveringHit(allowCovering, toolName)) {
248
+ if (allow && !allow.has(toolName) && !namespacedCoveringHit(allowCovering, toolName)) {
244
249
  return { action: "deny", message: `tool "${req.toolName}" is not in the allowlist` };
245
250
  }
246
251
  return ALLOW;
@@ -257,9 +262,9 @@ export function createApprovalPolicy(opts) {
257
262
  const need = new Set(opts.requireApproval);
258
263
  const deny = new Set(opts.deny ?? []);
259
264
  const auto = new Set(opts.autoAllow ?? []);
260
- const needCovering = mcpCoveringEntries(opts.requireApproval);
261
- const denyCovering = mcpCoveringEntries(opts.deny);
262
- const autoCovering = mcpCoveringEntries(opts.autoAllow);
265
+ const needCovering = namespacedCoveringEntries(opts.requireApproval);
266
+ const denyCovering = namespacedCoveringEntries(opts.deny);
267
+ const autoCovering = namespacedCoveringEntries(opts.autoAllow);
263
268
  return {
264
269
  projection: {
265
270
  components: [
@@ -277,10 +282,10 @@ export function createApprovalPolicy(opts) {
277
282
  ],
278
283
  async check(req, signal) {
279
284
  const toolName = req.toolName;
280
- if (deny.has(toolName) || mcpCoveringHit(denyCovering, toolName)) {
285
+ if (deny.has(toolName) || namespacedCoveringHit(denyCovering, toolName)) {
281
286
  return { action: "deny", message: `tool "${req.toolName}" is denied by policy` };
282
287
  }
283
- if (need.has(toolName) || mcpCoveringHit(needCovering, toolName)) {
288
+ if (need.has(toolName) || namespacedCoveringHit(needCovering, toolName)) {
284
289
  if (signal?.aborted) {
285
290
  return withCoreMintedResolution({ action: "deny", message: `approval aborted for "${req.toolName}" (task ended)`, settledBy: "aborted" }, "task_aborted", req);
286
291
  }
@@ -314,7 +319,7 @@ export function createApprovalPolicy(opts) {
314
319
  }
315
320
  return withCoreMintedResolution({ action: "deny", message: `approval denied for "${req.toolName}"`, settledBy: "human" }, "human_refused", req);
316
321
  }
317
- if (opts.denyByDefault && !auto.has(toolName) && !mcpCoveringHit(autoCovering, toolName)) {
322
+ if (opts.denyByDefault && !auto.has(toolName) && !namespacedCoveringHit(autoCovering, toolName)) {
318
323
  return { action: "deny", message: `tool "${req.toolName}" requires explicit allow` };
319
324
  }
320
325
  return ALLOW;
@@ -657,8 +657,9 @@ export interface ToolExecuteContext {
657
657
  /**
658
658
  * Subagent transcript persistence — the RESOLVED delegation entry caps for this run
659
659
  * ({@link RunnerDeps.delegationEntryCaps} after prepare's loud validation; both members always
660
- * present). Runner-filled trusted seat, never a model argument — the Agent tool's background lane
661
- * reads it at its registration point. Undefined outside a Runner task (the lane then applies the
660
+ * present). Runner-filled trusted seat, never a model argument — the Agent tool's spawn gate reads
661
+ * it on EVERY local lane (background, background-fork, sync fork, sync spawn), each at its own
662
+ * launch point. Undefined outside a Runner task (the lane then applies the
662
663
  * exported defaults itself, so a directly-driven tool is bounded too).
663
664
  */
664
665
  delegationEntryCaps?: {
@@ -2543,9 +2544,12 @@ export interface TaskSpec {
2543
2544
  * Override auto-compaction thresholds for this task's session. Compaction (LLM summary, persisted
2544
2545
  * to the session) runs in two places sharing these settings: at every within-task turn boundary
2545
2546
  * (design/64 §25 (A) — the long-single-task case) and once at task end (the between-task case).
2546
- * Defaults: enabled, reserveTokens 16384, keepRecentTokens 0 (design/169-C, CC full-compaction
2547
- * form: the summary replaces the compacted region entirely by default set `keepRecentTokens`
2548
- * explicitly to keep a verbatim recent tail; 20000 was the pre-169 default) — repaired
2547
+ * Defaults: enabled, reserveTokens 16384, keepRecentTokens 0 (design/169-C; 20000 was the
2548
+ * pre-169 default). A zero budget is NOT "no verbatim tail": the cut-point search has a
2549
+ * structural floor under the budget, so the default already leaves the last exchange verbatim
2550
+ * after the summary (a trailing tool-result batch keeps its emitting assistant with it) — the
2551
+ * same one-group posture CC's reactive compaction ships. Set `keepRecentTokens` explicitly only
2552
+ * to keep a WIDER tail than that. Repaired
2549
2553
  * automatically when
2550
2554
  * pathological for the model's window: threshold ≤ 0, keepRecent ≥ threshold, or a threshold above
2551
2555
  * the clearStale point (0.7×window — the prefix-cache "death band", design/64 §26.4) is clamped
@@ -4862,6 +4866,24 @@ export interface EngineNotice {
4862
4866
  * #257 contract's "accepted = enqueued, not consumed" sentence;
4863
4867
  * `detail: { steer, taskId? }` / `{ followUp, taskId? }`. Per-run, at most once per family
4864
4868
  * (the terminal sweep is a single site).
4869
+ * **#389 (two corrections).** ① The family now fires on the INTERRUPT path too: `abort()` used
4870
+ * to empty both queues before agent_end could count them, so the one loss path an operator most
4871
+ * needs to hear about was the one path that stayed silent. ② On a DURABLE PARK the verdict is
4872
+ * settled at the run tail instead of at agent_end, because the tail first carries the still-
4873
+ * undelivered input onto the committed checkpoint's parked-steer queue (the resume delivers it)
4874
+ * — what is announced is the remainder that could NOT be carried, so a fully-migrated park says
4875
+ * nothing at all and a queue-full / no-longer-pending row still says exactly what was lost.
4876
+ *
4877
+ * - `"task.injection_priority_unimplemented"` (#389 伴生, D-3) — a notification was injected with
4878
+ * `priority: "now"`. The three values are CC's names (`now`/`next`/`later`) but this engine
4879
+ * delivers all three identically at the next turn boundary: there is no arm that aborts the
4880
+ * running turn, so a caller writing `"now"` and expecting an interruption gets a plain queued
4881
+ * delivery. The frame IS delivered (this is disclosure, not a refusal — dropping an accepted
4882
+ * notification would be the worse error); what is announced is that the knob's promise is not
4883
+ * honored, so an operator can stop building on it. Once per run (the injection funnel is a
4884
+ * single site, and a busy lane must not narrate the same gap once per frame);
4885
+ * `detail: { priority, taskId? }`. An UNKNOWN priority value is a different fact with a
4886
+ * different posture — `TaskStream.notify` refuses it typed (`notify.invalid_payload`).
4865
4887
  *
4866
4888
  * - `"memory.session_polluted"` (design/178 §3, #324a; message mode-aware since design/336) —
4867
4889
  * this session's memory crossed into the one-way externally-exposed state (a tool classified
@@ -5498,12 +5520,16 @@ export interface RunnerDeps {
5498
5520
  * Subagent transcript persistence — the delegation ENTRY caps (CC parity values: 20 concurrent /
5499
5521
  * 200 cumulative per session tree; defaults exported as `DELEGATION_MAX_CONCURRENT_DEFAULT` /
5500
5522
  * `DELEGATION_MAX_PER_SESSION_DEFAULT`). Key = `(scope, rootSessionId)`, full depth (grandchildren
5501
- * share the tree's pool). `maxConcurrent` bounds running/pending a* handles in this process's
5502
- * registry (parked does not burn a slot a suspension is not concurrency; a revival claim counts
5503
- * like a spawn); `maxCumulativePerSession` bounds the RETAINED-WINDOW cumulative count (registry-
5504
- * retained + store-retained rows — a reaped row returns its quota; deliberately NOT CC's lifetime-
5505
- * monotonic session counter, which would require a persistent counting surface this economic bound
5506
- * does not justify registered divergence). Refusals are coded (`delegation.concurrency_cap` /
5523
+ * share the tree's pool). `maxConcurrent` bounds the tree's RUNNING children of every local lane:
5524
+ * running/pending a* handles in this process's registry PLUS the synchronous and forked children in
5525
+ * flight in this process (parked does not burn a slot a suspension is not concurrency; a revival
5526
+ * claim counts like a spawn); `maxCumulativePerSession` bounds the RETAINED-WINDOW cumulative count
5527
+ * (registry-retained + store-retained rows, plus those same in-flight local children a reaped row
5528
+ * returns its quota, and a synchronous child retains nothing so it leaves the window when it
5529
+ * settles; deliberately NOT CC's lifetime-monotonic session counter, which would require a
5530
+ * persistent counting surface this economic bound does not justify — registered divergence). One
5531
+ * consequence, stated rather than left to be discovered: a tree that only ever spawns synchronously
5532
+ * is bounded by the concurrency cap alone. Refusals are coded (`delegation.concurrency_cap` /
5507
5533
  * `delegation.session_cap`) with the current value and this knob's name in the text.
5508
5534
  *
5509
5535
  * BAD VALUES REFUSE LOUDLY at prepare (`config.delegation_entry_caps`, the #123 posture): a
@@ -240,6 +240,14 @@ export const ENGINE_ENVELOPES = Object.freeze([
240
240
  fenced: false,
241
241
  disclosed: false,
242
242
  },
243
+ {
244
+ tag: "example",
245
+ kind: "not-an-envelope",
246
+ mint: "engine/compaction SUMMARIZATION_PROMPT (CC 2.1.223 `qby` verbatim — the output skeleton shown to the summarizer)",
247
+ guard: "no untrusted bytes reach it: the block is a CONSTANT that frames placeholder text (`[Concept 1]`), it wraps nothing, and it is never emitted back — the summarizer's own answer is read through the `<summary>` envelope, not this one",
248
+ fenced: false,
249
+ disclosed: false,
250
+ },
243
251
  {
244
252
  tag: "task-id",
245
253
  kind: "not-an-envelope",
@@ -35,6 +35,22 @@ export interface CompactionDetails {
35
35
  * on pre-field entries (⇒ no disclosure, byte-identical legacy wrapper).
36
36
  */
37
37
  elidedMessages?: number;
38
+ /**
39
+ * PTL-shortfall accounting: CUMULATIVE count of folded messages the summary does NOT cover — the ones a reactive
40
+ * prompt-too-long retry shed from the summarization input (`summarizeWithPtlRetry`'s group
41
+ * rollback) after `prepareCompaction` had already committed the cut. Those messages leave the
42
+ * transcript with everyone else (so {@link elidedMessages} stays the honest FOLD count) but
43
+ * nothing in the summary stands in for them, and the wrapper used to claim otherwise.
44
+ *
45
+ * Deliberately a SECOND number rather than a correction of the first: lowering `elidedMessages`
46
+ * to the covered count would under-report the fold scale, which is the exact judgment that field
47
+ * exists to inform ("was a whole investigation folded, or three turns?"). Same cumulative +
48
+ * write-side + optional-absent discipline as its sibling; a lossless pass omits it entirely, so
49
+ * details written by the common path stay byte-identical. Counted in the summarization-input
50
+ * coordinate — a LOWER BOUND on uncovered transcript messages (see
51
+ * {@link SummarizationInputTruncation.droppedMessages}).
52
+ */
53
+ unsummarizedMessages?: number;
38
54
  /**
39
55
  * RB-398-c (blackboard [2105]): `<persisted-output ref="…">` handles found in the COMPACTED range
40
56
  * (merged over the previous compaction's retained set, oldest-first, capped at
@@ -105,12 +121,37 @@ export interface CompactionSettings {
105
121
  * AND DISCLOSED (`compaction.clamp_disclosure`), not "lossless" — the honest wording is in the
106
122
  * design doc. Above this, a cheap-model summary is judged too lossy and the main model takes over. */
107
123
  export declare const DEFAULT_CLAMP_TOLERANCE = 0.1;
108
- /** Default compaction settings used by the harness.
109
- * design/169-C (CC 220 :516151 `messagesToKeep: []` parity): the DEFAULT compaction is a FULL
110
- * compaction — `keepRecentTokens: 0` keeps no verbatim token budget; the summary replaces the
111
- * region entirely (the cut still lands on the nearest valid floor, so tool pairs and the
112
- * in-progress turn's suffix stay intact). Keeping a verbatim tail is the reactive/partial posture
113
- * and remains available as the explicit `keepRecentTokens` knob (the pre-169 default was 20000). */
124
+ /**
125
+ * Default compaction settings used by the harness.
126
+ *
127
+ * `keepRecentTokens: 0` is a ZERO BUDGET, not "keep nothing" {@link findCutPoint} has a
128
+ * structural floor underneath the budget (see its doc), so the cut still lands on the last valid
129
+ * cut point and the summary is followed by the last exchange VERBATIM. Measured on the real
130
+ * Runner: the first request after an auto-compaction is `[summary wrapper][last assistant turn]
131
+ * [new user]`, and a within-turn boundary keeps `[emitting assistant][its tool results]` because
132
+ * a trailing tool-result batch pulls the floor back to its call site.
133
+ *
134
+ * That is the CC posture, arrived at through a different knob. CC 2.1.223's LOCAL threshold
135
+ * compaction always routes to the reactive pipeline (`y9s` :432704 — the `GNo` full-compaction
136
+ * branch with `messagesToKeep: []` :431788 is reached only for a remote/in-process runner, and a
137
+ * `source:"auto"` window makes the local threshold not fire at all, `hky` :432687 / `aYe` :242816).
138
+ * Reactive splits the session into GROUPS (`Ubr` :399459 — a new assistant `message.id` starts a
139
+ * group and its tool results ride with it) and preserves the last `s = 1` of them verbatim
140
+ * (`YMo` :399971 / :399982), zeroing the preserved assistants' usage on the way out (`ryt` :401285
141
+ * — our `buildSessionContext` kept-tail `stripAssistantUsage`, design/123 D1, is the same act).
142
+ * `s` only grows on ITS retry path, so one group is the default posture on both sides.
143
+ *
144
+ * design/169-C moved this default from 20000 to 0 and anchored the change on `messagesToKeep: []`;
145
+ * the anchor names the minority path, but the VALUE lands on the majority path's behavior, and
146
+ * raising it again would keep MORE than CC does (measured: a 2000-token budget keeps two whole
147
+ * exchanges). A positive `keepRecentTokens` remains the explicit knob for callers that want a
148
+ * wider verbatim tail than either engine's default.
149
+ *
150
+ * Known residual (recorded, not repaired): when the last entry is a USER message the floor takes
151
+ * it alone, where CC's group would also carry the assistant before it. The runner's lanes evaluate
152
+ * compaction at turn boundaries and at finish — after the assistant/tool results, before the next
153
+ * prompt — so that shape belongs to direct `maybeCompact` callers.
154
+ */
114
155
  export declare const DEFAULT_COMPACTION_SETTINGS: CompactionSettings;
115
156
  /**
116
157
  * design/123 D2 — default structural coefficient (chars per token) for every structural token
@@ -166,7 +207,19 @@ export interface CutPointResult {
166
207
  }
167
208
  /** Find the compaction cut point that keeps approximately the requested recent-token budget.
168
209
  * `charsPerToken` (design/123 D2): the keep-recent accumulation must live in the SAME structural
169
- * coordinate as the trigger estimate, or the kept tail is systematically over/under-sized. */
210
+ * coordinate as the trigger estimate, or the kept tail is systematically over/under-sized.
211
+ *
212
+ * STRUCTURAL FLOOR — the budget can raise the kept tail, never empty it. The backward scan breaks
213
+ * at the FIRST message it visits when the budget is already satisfied (a budget of 0 is satisfied
214
+ * immediately), and then lands on a valid cut point, so the kept tail is never smaller than
215
+ * "the last cut point onward". Two shapes follow, and both are load-bearing:
216
+ * - the tail ends on an assistant/user message ⇒ that message is itself a cut point and is kept;
217
+ * - the tail ends inside a tool-result batch ⇒ tool results are not cut points, so the `!found`
218
+ * fallback below pulls the floor back to the LARGEST cut point ≤ i, i.e. the emitting
219
+ * assistant, and the whole batch is kept with it.
220
+ * Reading `keepRecentTokens: 0` as "no verbatim tail" is therefore wrong at both the name and the
221
+ * behavior (see {@link DEFAULT_COMPACTION_SETTINGS} for the measured shape and the CC coordinates
222
+ * it matches). The pins live in test/compaction-keep-tail-floor.test.ts. */
170
223
  export declare function findCutPoint(entries: SessionTreeEntry[], startIndex: number, endIndex: number, keepRecentTokens: number, charsPerToken?: number): CutPointResult;
171
224
  export declare const SUMMARIZATION_SYSTEM_PROMPT = "You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified.\n\nDo NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.";
172
225
  /**
@@ -250,6 +303,18 @@ export interface SummarizationInputTruncation {
250
303
  label: "history" | "turn_prefix";
251
304
  droppedChars: number;
252
305
  keptChars: number;
306
+ /**
307
+ * How many WHOLE messages this drop removed from the summarization input, when the drop was the
308
+ * PTL retry's group rollback (`summarizeWithPtlRetry`) rather than the proactive head+tail clamp.
309
+ * The clamp cuts a mid-section of TEXT and leaves every message present, so it reports chars only
310
+ * and omits this field; the rollback shifts entire assistant-turn groups off the front, and those
311
+ * messages are folded out of the transcript by the same pass WITHOUT ever reaching the summarizer
312
+ * — the fact the wrapper's scale disclosure has to state (see
313
+ * {@link CompactionDetails.unsummarizedMessages}). Counted in the summarization-INPUT coordinate
314
+ * (messages actually handed to the summarizer, i.e. post-`convertToLlm`), so it is a LOWER BOUND
315
+ * on the transcript messages the summary does not cover.
316
+ */
317
+ droppedMessages?: number;
253
318
  }
254
319
  /** design/145 §1 — result of a window-safety clamp DRY RUN (see {@link dryRunSummarizationClamp}). */
255
320
  export interface SummarizationClampDryRun {
@@ -313,6 +378,11 @@ export interface CompactionPreparation {
313
378
  /** RB-398-b①: cumulative folded-message count (this range + the previous carrier's count) —
314
379
  * ready to persist as `CompactionDetails.elidedMessages`. Optional (same rule as above). */
315
380
  elidedMessages?: number;
381
+ /** PTL-shortfall accounting: the previous carrier's uncovered-message count, CARRIED FORWARD only — this pass's own
382
+ * uncovered count is not knowable at prepare time (it is produced by the summary leg's PTL
383
+ * rollback), so `compact()` adds its own drops on top before persisting
384
+ * `CompactionDetails.unsummarizedMessages`. Absent = nothing carried. */
385
+ carriedUnsummarizedMessages?: number;
316
386
  /** Settings used to prepare compaction. */
317
387
  settings: CompactionSettings;
318
388
  }