@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,71 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.56.0 — 2026-08-23
4
+
5
+ ### Fixed
6
+ - **A2A covering rules join the mcp form** (#410, ruled): `a2a__peer` / `a2a__peer__*` deny, ask
7
+ and allow now fire in all five name-keyed lanes (rule DSL, allow/deny policy, approval policy,
8
+ frozen projection replay, persisted session rules) via the protocol-table-driven
9
+ `namespacedRuleNameCovers`. **Narrowings**: a frozen projection replayed under 5.56 honors
10
+ covering spellings it previously read as inert (heaviest); covering-name parenthesised rules
11
+ refuse (`unsupported.covering_paren`, additive union member); empty-peer spellings refuse at
12
+ construction. **Widening / false-refusal repair**: real minted names with separator-bearing tool
13
+ segments (`mcp__srv____foo` and twins) that previously THREW at construction now compile as
14
+ exact entries (the name-set screen judges the peer boundary only).
15
+ - **Delegation entry caps cover every local spawn leg** (#385, anchor campaign): sync spawn,
16
+ sync fork and background fork now pass the same concurrency/cumulative gate as background
17
+ spawns (one pool, four admit sites; in-flight reservations returned on settle). Sync legs mint
18
+ `delegation.concurrency_cap` / `delegation.session_cap` under the `Sub-agent not started: `
19
+ prefix (new code+prefix combination); counted nouns drop the word "background";
20
+ `AgentDefinition.background: true` is honored (previously a dead field); no-principal mounts
21
+ fold to the engine default pool instead of skipping the gate.
22
+ - **Every deployment hook seat is time-bounded** (#388, anchor campaign): the ten `Hooks` seats
23
+ ride one bounded family (default 600s; `Hooks.timeoutMs` knob, bad values refused loudly).
24
+ Decision seats fail closed on expiry (preToolUse → deny + permissionDenied(source "hook");
25
+ userPromptSubmit → block; stop → run ends); observation seats discard and shrink to a 1s grace
26
+ after task abort; hook contexts carry `signal` and live tracked `cwd`. Pre-ship rescan closures:
27
+ the runSpec hook mirror now carries `timeoutMs` (a deployment's bound was silently replaced by
28
+ the default on composed runs); stopFailure reads the deadline rather than a constant grace; the
29
+ post-abort grace caps remaining time instead of restarting the clock.
30
+ - **interrupt() keeps accepted user input; an interrupt leaves CC's transcript marker** (#389,
31
+ anchor campaign): steer queues survive `interrupt()` (one-way abort latch + in-flight slot);
32
+ a durable park migrates queued steers onto the checkpoint (idempotent CAS verb) and the
33
+ undrained account announces only the remainder; an explicit interrupt that won the abort mints
34
+ CC's verbatim `[Request interrupted by user]` marker (double-gated); unknown
35
+ `SystemInjectionPriority` values refuse and the unimplemented `"now"` ladder is disclosed once
36
+ per run (`task.injection_priority_unimplemented`); the untrusted caller steer lane gains CC's
37
+ mid-turn delivery frame.
38
+ - **A SendMessage name that changed hands refuses instead of silently redirecting** (CC's
39
+ `send_message_pin_guard` rebound arm): "Nothing was sent." plus both ways forward; a precise
40
+ task_id is never refused and re-pins; the tool description states the refusal.
41
+ - **A legacy-decided parked row converges** (#400, test P0-KPI): the live reconcile's resolved
42
+ skip now means "a claim is in flight" (read from the store row) — a checkpoint decided by the
43
+ task-level legacy lane settles the row instead of leaving a process-lifetime zombie; the
44
+ decision is re-read immediately before the verdict so a reopen CAS is never judged dead.
45
+ - **Compaction PTL retries disclose what they dropped** (#407, test P0-KPI):
46
+ `CompactionDetails.unsummarizedMessages` accumulates the true uncovered count across split
47
+ legs, with a shell-copy clause and an import-gate check. The summary prompt carries CC's
48
+ `<example>` skeleton and a compliant `<summary>` envelope is unwrapped in the persisted body
49
+ (pre-ship rescan closure: a fence-only EMPTY response is refused loudly, same class and wording
50
+ as the bare-empty arm — it could previously persist a footer-only summary).
51
+ `keepRecentToolResults` floors at 1 and refuses NaN/negative loudly.
52
+ - **#384 (keepRecentTokens default) closed as REFUTED, with evidence**: the anchor-campaign claim
53
+ that the 0 default meant "no kept tail" was disproved on a real Runner — `findCutPoint` has a
54
+ structural floor, the kept tail is never empty, and the pinned behavior matches CC's posture
55
+ via the other knob. The default is unchanged; the floor is now pinned
56
+ (test/compaction-keep-tail-floor.test.ts) and four stale anchor comments were corrected.
57
+
58
+ ### Notes
59
+ - Anchor-campaign satellite dispositions ride along (A3/A4/A8/A14 candidate tables in the
60
+ respective cars' receipts): truthfulness rewrites, registry entries, and the
61
+ PermissionDenied trigger-surface divergence registered.
62
+ - Erratum owed to sibling repos (also stated in src/core/hooks.ts): "CC fails open on hook
63
+ faults except exit 2" is true only of the command(shell) hook channel; CC's in-process
64
+ callback channel is fail-closed with dedicated copy — cited as a fail-open precedent it is
65
+ the wrong channel.
66
+ - Residuals filed, not shipped silently: #417-#423 (auth seats, Agent(<type>) governance,
67
+ lifecycle legs, postToolUse payload asymmetry, reopen fence, classic-arm Trust-but-verify).
68
+
3
69
  ## 5.55.0 — 2026-08-23
4
70
 
5
71
  ### BREAKING
@@ -5,6 +5,7 @@ import { announceTranscriptIntegrityGapOnce, canAccessAgentRecord, clearRevivedR
5
5
  import { uuidv7 } from "../internal/harness.js";
6
6
  import { MAILBOX_TOMBSTONED_RECIPIENT_CODE } from "../core/mailbox-store.js";
7
7
  import { escapeAttributeValue, escapeEnvelopeTag, isObserverTaskId, OBSERVER_SENDMESSAGE_SENDER_REFUSAL, OBSERVER_SENDMESSAGE_TARGET_REFUSAL, } from "./observer.js";
8
+ import { inlineUntrusted } from "../core/untrusted-text.js";
8
9
  import { SUBAGENT_RESUME_CAP, SubagentRetainLedger, getSessionRetainLedger } from "./retain-ledger.js";
9
10
  import { createSubagentResume } from "./subagent.js";
10
11
  import { appendHopToken, judgePeerAdmission, peerAxisToken, resolvePeerAdmissionConfig, PEER_MESSAGE_NOTICE, } from "./peer-admission.js";
@@ -45,6 +46,35 @@ function targetLaneKey(scope, targetId) {
45
46
  const OPERATOR_CONTINUATION_CTX = Symbol("sema.operator_continuation");
46
47
  export function createSendMessageTool(opts) {
47
48
  const tier3Capable = opts.agentStore !== undefined && opts.mailbox !== undefined && opts.reviveSpawn !== undefined;
49
+ const sendMessagePins = new Map();
50
+ const pinGuard = (targetId, targetName, rung, to) => {
51
+ if (rung === "other")
52
+ return undefined;
53
+ const pinKey = normalizeAgentName(targetName ?? "");
54
+ if (pinKey === "")
55
+ return undefined;
56
+ const pinned = sendMessagePins.get(pinKey);
57
+ if (pinned === undefined || pinned.id === targetId) {
58
+ sendMessagePins.set(pinKey, { id: targetId, name: targetName ?? to });
59
+ return undefined;
60
+ }
61
+ if (rung === "ref") {
62
+ sendMessagePins.set(pinKey, { id: targetId, name: targetName ?? to });
63
+ return undefined;
64
+ }
65
+ if (targetName !== undefined && normalizeAgentName(to) === pinKey && normalizeAgentName(pinned.name) !== pinKey) {
66
+ return undefined;
67
+ }
68
+ return {
69
+ content: `Message not sent: "${inlineUntrusted(to, 120)}" now resolves to a different agent than it did earlier in ` +
70
+ `this run — this name previously resolved to ${pinned.id.slice(0, 64)}, which it no longer reaches. Nothing was sent.\n` +
71
+ `It now resolves to ${targetId.slice(0, 64)}. To message that agent, re-send with its task_id: ` +
72
+ `{"to": "${targetId.slice(0, 64)}", …}. To reach the earlier agent, use its task_id ` +
73
+ `${pinned.id.slice(0, 64)} from its spawn result.`,
74
+ details: { error: "rebound", to, taskId: targetId, previousTaskId: pinned.id },
75
+ isError: true,
76
+ };
77
+ };
48
78
  return defineTool({
49
79
  name: SEND_MESSAGE_TOOL_NAME,
50
80
  contract: { contractId: "core.send_message@1", implementationRevision: "1" },
@@ -54,8 +84,9 @@ export function createSendMessageTool(opts) {
54
84
  `'to' is the agent's name (set at spawn via Agent({name})) or its task_id (a…). A RUNNING agent receives the ` +
55
85
  `message at its next turn (queued — never interrupts its current work); a FINISHED agent resumes as a new ` +
56
86
  `background run with its full prior conversation preserved, so don't re-explain what it already knows. Names ` +
57
- `keep working after an agent completes; when a newer agent took the name, the newest wins use the task_id to ` +
58
- `address an older one. You will be notified automatically when it completes prefer ending your turn; do not ` +
87
+ `keep working after an agent completes; but if a name you already messaged is later taken by a NEWER agent, the ` +
88
+ `send is refused rather than silently redirected re-send with the task_id of whichever agent you meant. ` +
89
+ `You will be notified automatically when it completes — prefer ending your turn; do not ` +
59
90
  `poll. ` +
60
91
  (tier3Capable
61
92
  ? `Continuing a finished agent works for agents with a durable record — by name or task_id, even across restarts — and for runs that retain sub-agent sessions; when neither covers it you get an honest error and should launch a new agent with the needed context instead.`
@@ -523,6 +554,11 @@ export function createSendMessageTool(opts) {
523
554
  }
524
555
  }
525
556
  if (rosterHit !== undefined) {
557
+ {
558
+ const guard = pinGuard(rosterHit.agentId, to, "name", to);
559
+ if (guard !== undefined)
560
+ return guard;
561
+ }
526
562
  const revived = await tier3Revive(rosterHit.agentId);
527
563
  if (revived !== undefined)
528
564
  return revived;
@@ -563,6 +599,16 @@ export function createSendMessageTool(opts) {
563
599
  if (row.type !== "background_agent") {
564
600
  return { content: `Message not sent: ${targetId} is a ${row.type} task, not a background agent.`, details: { error: "wrong_type", to }, isError: true };
565
601
  }
602
+ {
603
+ const rung = idRow !== undefined
604
+ ? "ref"
605
+ : row.name !== undefined && (row.name === to || normalizeAgentName(row.name) === normalizeAgentName(to))
606
+ ? "name"
607
+ : "other";
608
+ const guard = pinGuard(targetId, row.name, rung, to);
609
+ if (guard !== undefined)
610
+ return guard;
611
+ }
566
612
  if (row.status === "running" || row.status === "pending") {
567
613
  return await withTargetLane(targetLaneKey(access.scope, targetId), async () => {
568
614
  const liveRecipientKey = peerAxisToken(resolvedAccess.scope, "h", targetId);
@@ -1032,6 +1032,134 @@ export function delegationEntryLedgerFootprint(registry) {
1032
1032
  handles += s.size;
1033
1033
  return { keys: byKey.size, handles };
1034
1034
  }
1035
+ const localDelegationInFlight = new WeakMap();
1036
+ function localDelegationInFlightCount(anchor, key) {
1037
+ return localDelegationInFlight.get(anchor)?.get(key) ?? 0;
1038
+ }
1039
+ function chargeLocalDelegationInFlight(anchor, key) {
1040
+ let byKey = localDelegationInFlight.get(anchor);
1041
+ if (byKey === undefined) {
1042
+ byKey = new Map();
1043
+ localDelegationInFlight.set(anchor, byKey);
1044
+ }
1045
+ byKey.set(key, (byKey.get(key) ?? 0) + 1);
1046
+ let released = false;
1047
+ return () => {
1048
+ if (released)
1049
+ return;
1050
+ released = true;
1051
+ const live = localDelegationInFlight.get(anchor);
1052
+ if (live === undefined)
1053
+ return;
1054
+ const n = (live.get(key) ?? 0) - 1;
1055
+ if (n > 0)
1056
+ live.set(key, n);
1057
+ else
1058
+ live.delete(key);
1059
+ };
1060
+ }
1061
+ const spawnAbortedResult = (wt) => ({
1062
+ isError: true,
1063
+ content: `Sub-agent not started: the delegating call was aborted.${wt ? `\n${wt}` : ""}`,
1064
+ details: { error: "aborted" },
1065
+ });
1066
+ const noopRelease = () => undefined;
1067
+ const DELEGATION_POOL_DEFAULT_SCOPE = "default";
1068
+ async function untilAbort(p, signal) {
1069
+ if (signal === undefined)
1070
+ return await p;
1071
+ if (signal.aborted)
1072
+ throw new Error("delegation entry gate: aborted before the durable floor was read");
1073
+ let onAbort;
1074
+ try {
1075
+ return await Promise.race([
1076
+ p,
1077
+ new Promise((_resolve, reject) => {
1078
+ onAbort = () => reject(new Error("delegation entry gate: aborted while reading the durable floor"));
1079
+ signal.addEventListener("abort", onAbort, { once: true });
1080
+ }),
1081
+ ]);
1082
+ }
1083
+ finally {
1084
+ if (onAbort !== undefined)
1085
+ signal.removeEventListener("abort", onAbort);
1086
+ }
1087
+ }
1088
+ async function openDelegationEntryGate(input) {
1089
+ const { poolAnchor, registry, store, root, caps, revival, signal } = input;
1090
+ if (root === undefined)
1091
+ return { key: undefined, admit: () => ({ admitted: true, release: noopRelease }) };
1092
+ const scope = input.scope ?? DELEGATION_POOL_DEFAULT_SCOPE;
1093
+ const key = JSON.stringify([scope, root]);
1094
+ let storedHandles = [];
1095
+ let storedEnumerationOk = false;
1096
+ if (store !== undefined && !revival) {
1097
+ try {
1098
+ storedHandles = (await untilAbort(store.listBySession(scope, root), signal)).map((r) => r.handle);
1099
+ storedEnumerationOk = true;
1100
+ }
1101
+ catch {
1102
+ storedHandles = [];
1103
+ }
1104
+ }
1105
+ const admit = (kind) => {
1106
+ const activeFace = registry === undefined ? undefined : registry.activeDelegationHandles;
1107
+ const active = typeof activeFace === "function" ? activeFace.call(registry, scope, root) : [];
1108
+ const local = localDelegationInFlightCount(poolAnchor, key);
1109
+ const running = active.length + local;
1110
+ if (running >= caps.maxConcurrent) {
1111
+ return {
1112
+ admitted: false,
1113
+ refusal: {
1114
+ code: "delegation.concurrency_cap",
1115
+ text: `this session tree already has ${running} agents running — the concurrency cap (${caps.maxConcurrent}; RunnerDeps.delegationEntryCaps.maxConcurrent) refuses another. ${kind === "registered" ? "Wait for one to complete (you will be notified) or stop one, then relaunch." : "Wait for one of them to finish or stop one, then try again."}`,
1116
+ },
1117
+ };
1118
+ }
1119
+ if (!revival) {
1120
+ const ledger = delegationEntryLedger(poolAnchor, key);
1121
+ let retained;
1122
+ if (store !== undefined && storedEnumerationOk) {
1123
+ const keep = new Set([...storedHandles, ...active]);
1124
+ if (kind === "registered") {
1125
+ for (const h of [...ledger])
1126
+ if (!keep.has(h))
1127
+ ledger.delete(h);
1128
+ retained = new Set([...storedHandles, ...ledger]).size;
1129
+ }
1130
+ else {
1131
+ const survivors = new Set(storedHandles);
1132
+ for (const h of ledger)
1133
+ if (keep.has(h))
1134
+ survivors.add(h);
1135
+ retained = survivors.size;
1136
+ }
1137
+ }
1138
+ else {
1139
+ retained = ledger.size;
1140
+ }
1141
+ const cumulative = retained + local;
1142
+ if (cumulative >= caps.maxCumulativePerSession) {
1143
+ return {
1144
+ admitted: false,
1145
+ refusal: {
1146
+ code: "delegation.session_cap",
1147
+ text: `this session tree has already launched ${cumulative} agents in its retained window — the cumulative cap (${caps.maxCumulativePerSession}; RunnerDeps.delegationEntryCaps.maxCumulativePerSession) refuses more. Continue an existing agent (SendMessage) instead of launching new ones.`,
1148
+ },
1149
+ };
1150
+ }
1151
+ }
1152
+ return { admitted: true, release: kind === "local" ? chargeLocalDelegationInFlight(poolAnchor, key) : noopRelease };
1153
+ };
1154
+ return { key, admit };
1155
+ }
1156
+ function chargeDelegationEntryHandle(registry, key, store, handle) {
1157
+ if (handle !== undefined)
1158
+ delegationEntryLedger(registry, key).add(handle);
1159
+ bindDelegationLedgerKeyStore(registry, key, store);
1160
+ armDelegationLedgerLifecycle(registry);
1161
+ sweepDelegationLedgerRound(registry, 3);
1162
+ }
1035
1163
  export function normalizeSubagentType(value) {
1036
1164
  return value.normalize("NFKC").toLowerCase().replace(/[\p{White_Space}\p{Pd}_]+/gu, "");
1037
1165
  }
@@ -1314,7 +1442,6 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1314
1442
  prepareArguments: (args) => foldGeneralPurposeAlias(args, generalPurposeShadowed),
1315
1443
  execute: async (args, ctx) => {
1316
1444
  const a = foldGeneralPurposeAlias(args, generalPurposeShadowed);
1317
- const wantsBackground = opts.background !== undefined && a.run_in_background !== false;
1318
1445
  const reviveClaim = ctx.reviveClaim;
1319
1446
  if (reviveClaim !== undefined && opts.background === undefined) {
1320
1447
  return {
@@ -1358,6 +1485,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1358
1485
  }
1359
1486
  const omitted = rawType === "" || (!generalPurposeShadowed && rawType === GENERAL_PURPOSE_SUBAGENT_TYPE);
1360
1487
  const def = hasAgents && !wantsFork && !omitted ? agentMap.get(rawType) : undefined;
1488
+ const wantsBackground = opts.background !== undefined && (a.run_in_background !== false || def?.background === true);
1361
1489
  const spawnAgentType = wantsFork ? FORK_SUBAGENT_TYPE : omitted ? GENERAL_PURPOSE_SUBAGENT_TYPE : rawType;
1362
1490
  if (!def && !wantsFork && !omitted) {
1363
1491
  return {
@@ -1789,6 +1917,21 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1789
1917
  return { ...(recentSteps ? { recentSteps } : {}), ...(editedFiles ? { editedFiles } : {}) };
1790
1918
  };
1791
1919
  const treeScope = reviveClaim?.row.scope ?? ctx.principal ?? opts.background?.scope;
1920
+ const entryCapPoolAnchor = opts.background?.registry ?? opts;
1921
+ const entryCaps = ctx.delegationEntryCaps ?? { maxConcurrent: DELEGATION_MAX_CONCURRENT_DEFAULT, maxCumulativePerSession: DELEGATION_MAX_PER_SESSION_DEFAULT };
1922
+ const entryCapRoot = reviveClaim !== undefined
1923
+ ? (reviveClaim.row.rootSessionId ?? reviveClaim.row.parentSessionId ?? (reviveClaim.row.sessionScoped ? reviveClaim.row.owner : undefined))
1924
+ : (ctx.rootSessionId ?? ctx.sessionId);
1925
+ const openEntryGate = () => openDelegationEntryGate({
1926
+ poolAnchor: entryCapPoolAnchor,
1927
+ registry: opts.background?.registry,
1928
+ store: opts.background?.agentStore,
1929
+ scope: treeScope,
1930
+ root: entryCapRoot,
1931
+ caps: entryCaps,
1932
+ revival: reviveClaim !== undefined,
1933
+ signal: ctx.signal,
1934
+ });
1792
1935
  const provenanceRequest = ctx.delegationProvenanceForChildren?.();
1793
1936
  const childProvenanceRef = provenanceRequest !== undefined ? { current: newDelegationProvenanceAggregate() } : undefined;
1794
1937
  const childAttestation = (status) => childProvenanceRef !== undefined ? reduceDelegationAttestation(childProvenanceRef.current, { completed: status === "completed" }) : undefined;
@@ -2053,7 +2196,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2053
2196
  if (ctx.signal?.aborted) {
2054
2197
  await cancelObserver();
2055
2198
  const wt = await finishWorktree();
2056
- return { isError: true, content: `Sub-agent not started: the delegating call was aborted.${wt ? `\n${wt}` : ""}`, details: { error: "aborted" } };
2199
+ return spawnAbortedResult(wt);
2057
2200
  }
2058
2201
  if (spawnVerdict.kind === "block") {
2059
2202
  await cancelObserver();
@@ -2106,6 +2249,21 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2106
2249
  const shortDesc = `fork: ${(typeof a.description === "string" && a.description.trim() ? a.description.trim() : prompt).slice(0, 180)}`;
2107
2250
  const bgOwner = sessionScopedBg ? ctx.sessionId : ctx.taskId ?? bg.owner;
2108
2251
  const bgScope = treeScope;
2252
+ const forkEntryGate = await openEntryGate();
2253
+ {
2254
+ const admission = forkEntryGate.admit("registered");
2255
+ if (!admission.admitted) {
2256
+ dropHostAbortListener();
2257
+ try {
2258
+ await (releaseForked ? releaseForked() : opts.runner.sessions.release?.(forkedId));
2259
+ }
2260
+ catch {
2261
+ }
2262
+ await cancelObserver();
2263
+ const wt = await finishWorktree();
2264
+ return errorResult(`Sub-agent not started in background: ${admission.refusal.text}${wt ? `\n${wt}` : ""}`, { error: admission.refusal.code, code: admission.refusal.code });
2265
+ }
2266
+ }
2109
2267
  const forkSettlementSeat = ctx.delegationSettlement?.();
2110
2268
  const forkSettleId = forkSettlementSeat !== undefined ? `bg-${uuidv7()}` : undefined;
2111
2269
  if (forkSettlementSeat !== undefined && forkSettleId !== undefined) {
@@ -2195,6 +2353,8 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2195
2353
  }
2196
2354
  return { isError: true, content: `Sub-agent not started in background: ${e instanceof Error ? e.message : String(e)}${wt ? `\n${wt}` : ""}`, details: { error: "register_failed" } };
2197
2355
  }
2356
+ if (forkEntryGate.key !== undefined)
2357
+ chargeDelegationEntryHandle(bg.registry, forkEntryGate.key, bg.agentStore, taskId);
2198
2358
  childInternals.peerSelfRef?.addAxis("h", taskId);
2199
2359
  bg.registry.bindBackgroundAgentSession(taskId, forkedId);
2200
2360
  const forkBgHangAt = Date.now();
@@ -2507,6 +2667,28 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2507
2667
  const bgIgnoredNote = a.run_in_background === true
2508
2668
  ? `note: run_in_background was ignored for subagent_type "${FORK_SUBAGENT_TYPE}" — the fork ran synchronously (this delegation tool has no background surface configured).`
2509
2669
  : undefined;
2670
+ const syncForkEntryGate = await openEntryGate();
2671
+ if (ctx.signal?.aborted) {
2672
+ try {
2673
+ await (releaseForked ? releaseForked() : opts.runner.sessions.release?.(forkedId));
2674
+ }
2675
+ catch {
2676
+ }
2677
+ await cancelObserver();
2678
+ const wt = await finishWorktree();
2679
+ return spawnAbortedResult(wt);
2680
+ }
2681
+ const syncForkAdmission = syncForkEntryGate.admit("local");
2682
+ if (!syncForkAdmission.admitted) {
2683
+ try {
2684
+ await (releaseForked ? releaseForked() : opts.runner.sessions.release?.(forkedId));
2685
+ }
2686
+ catch {
2687
+ }
2688
+ await cancelObserver();
2689
+ const wt = await finishWorktree();
2690
+ return errorResult(`Sub-agent not started: ${syncForkAdmission.refusal.text}${wt ? `\n${wt}` : ""}`, { error: syncForkAdmission.refusal.code, code: syncForkAdmission.refusal.code });
2691
+ }
2510
2692
  let forkChild;
2511
2693
  try {
2512
2694
  forkChild = await opts.runner.runTask({ ...buildChildSpec(ctx.signal), sessionId: forkedId, requireExistingSession: true, objective: forkObjective }, forkInternals);
@@ -2527,6 +2709,9 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2527
2709
  const wt = await finishWorktree();
2528
2710
  return { isError: true, content: `Fork failed: the forked run threw (${e instanceof Error ? e.message : String(e)}).${wt ? `\n${wt}` : ""}`, details: { error: "fork run failed" } };
2529
2711
  }
2712
+ finally {
2713
+ syncForkAdmission.release();
2714
+ }
2530
2715
  const forkHandbackWarning = await reviewHandback({
2531
2716
  review: ctx.autoModeReview,
2532
2717
  toolCallId: ctx.toolCallId,
@@ -2601,51 +2786,15 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2601
2786
  const shortDesc = reviveRow?.description ?? String(a.description ?? "sub-agent").slice(0, 200);
2602
2787
  const bgOwner = reviveRow !== undefined ? reviveRow.owner : sessionScopedBg ? ctx.sessionId : ctx.taskId ?? bg.owner;
2603
2788
  const bgScope = treeScope;
2604
- const capScope = bgScope;
2605
- const capRoot = reviveRow !== undefined
2606
- ? (reviveRow.rootSessionId ?? reviveRow.parentSessionId ?? (reviveRow.sessionScoped ? reviveRow.owner : undefined))
2607
- : (ctx.rootSessionId ?? ctx.sessionId);
2608
- const entryCaps = ctx.delegationEntryCaps ?? { maxConcurrent: DELEGATION_MAX_CONCURRENT_DEFAULT, maxCumulativePerSession: DELEGATION_MAX_PER_SESSION_DEFAULT };
2609
- const capLedgerKey = capScope !== undefined && capRoot !== undefined ? JSON.stringify([capScope, capRoot]) : undefined;
2610
- if (capScope !== undefined && capRoot !== undefined) {
2611
- let storedHandles = [];
2612
- let storedEnumerationOk = false;
2613
- if (bg.agentStore !== undefined && reviveRow === undefined) {
2614
- try {
2615
- storedHandles = (await bg.agentStore.listBySession(capScope, capRoot)).map((r) => r.handle);
2616
- storedEnumerationOk = true;
2617
- }
2618
- catch {
2619
- storedHandles = [];
2620
- }
2621
- }
2622
- const capRefusal = async (code, text) => {
2789
+ const bgEntryGate = await openEntryGate();
2790
+ const capLedgerKey = bgEntryGate.key;
2791
+ {
2792
+ const admission = bgEntryGate.admit("registered");
2793
+ if (!admission.admitted) {
2623
2794
  dropHostAbortListener();
2624
2795
  await cancelObserver();
2625
2796
  const wt = await finishWorktree();
2626
- return errorResult(`Sub-agent not started in background: ${text}${wt ? `\n${wt}` : ""}`, { error: code, code });
2627
- };
2628
- const activeFace = bg.registry.activeDelegationHandles;
2629
- const active = typeof activeFace === "function" ? activeFace.call(bg.registry, capScope, capRoot) : [];
2630
- if (typeof activeFace === "function" && active.length >= entryCaps.maxConcurrent) {
2631
- return await capRefusal("delegation.concurrency_cap", `this session tree already has ${active.length} background agents running — the concurrency cap (${entryCaps.maxConcurrent}; RunnerDeps.delegationEntryCaps.maxConcurrent) refuses another. Wait for one to complete (you will be notified) or stop one, then relaunch.`);
2632
- }
2633
- if (reviveRow === undefined) {
2634
- const ledger = delegationEntryLedger(bg.registry, capLedgerKey);
2635
- let cumulative;
2636
- if (bg.agentStore !== undefined && storedEnumerationOk) {
2637
- const keep = new Set([...storedHandles, ...active]);
2638
- for (const h of [...ledger])
2639
- if (!keep.has(h))
2640
- ledger.delete(h);
2641
- cumulative = new Set([...storedHandles, ...ledger]).size;
2642
- }
2643
- else {
2644
- cumulative = ledger.size;
2645
- }
2646
- if (cumulative >= entryCaps.maxCumulativePerSession) {
2647
- return await capRefusal("delegation.session_cap", `this session tree has already launched ${cumulative} background agents in its retained window — the cumulative cap (${entryCaps.maxCumulativePerSession}; RunnerDeps.delegationEntryCaps.maxCumulativePerSession) refuses more. Continue an existing agent (SendMessage) instead of launching new ones.`);
2648
- }
2797
+ return errorResult(`Sub-agent not started in background: ${admission.refusal.text}${wt ? `\n${wt}` : ""}`, { error: admission.refusal.code, code: admission.refusal.code });
2649
2798
  }
2650
2799
  }
2651
2800
  const settlementSeat = ctx.delegationSettlement?.();
@@ -2770,13 +2919,8 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2770
2919
  }
2771
2920
  return { isError: true, content: `Sub-agent not started in background: ${e instanceof Error ? e.message : String(e)}${wt ? `\n${wt}` : ""}`, details: { error: "register_failed" } };
2772
2921
  }
2773
- if (capLedgerKey !== undefined && reviveRow === undefined)
2774
- delegationEntryLedger(bg.registry, capLedgerKey).add(taskId);
2775
- if (capLedgerKey !== undefined) {
2776
- bindDelegationLedgerKeyStore(bg.registry, capLedgerKey, bg.agentStore);
2777
- armDelegationLedgerLifecycle(bg.registry);
2778
- sweepDelegationLedgerRound(bg.registry, 3);
2779
- }
2922
+ if (capLedgerKey !== undefined)
2923
+ chargeDelegationEntryHandle(bg.registry, capLedgerKey, bg.agentStore, reviveRow === undefined ? taskId : undefined);
2780
2924
  childInternals.peerSelfRef?.addAxis("h", taskId);
2781
2925
  if (agentName !== undefined) {
2782
2926
  recordRosterSpawn(ctx.roster, { name: agentName, agentId: taskId, toolUseId: ctx.toolCallId, owner: bgOwner, scope: bgScope, ...((typeof childModel === "string" ? resolveModelDisplayLabel(childModel) : childModel?.id) !== undefined ? { model: typeof childModel === "string" ? childModel : childModel?.id } : {}), ...(reviveRow !== undefined ? ((reviveRow.rootSessionId ?? reviveRow.parentSessionId) !== undefined ? { rootSessionId: reviveRow.rootSessionId ?? reviveRow.parentSessionId } : {}) : (ctx.rootSessionId ?? ctx.sessionId) !== undefined ? { rootSessionId: ctx.rootSessionId ?? ctx.sessionId } : {}), ...(sessionScopedBg ? { sessionScoped: true } : {}), createdAt: reviveRow?.spawnedAt ?? Date.now() }, (err) => opts.onObserverError?.(err, { site: "roster.recordSpawn" }));
@@ -3411,49 +3555,66 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
3411
3555
  };
3412
3556
  }
3413
3557
  let child;
3558
+ const syncEntryGate = await openEntryGate();
3559
+ if (ctx.signal?.aborted) {
3560
+ await cancelObserver();
3561
+ const wt = await finishWorktree();
3562
+ return spawnAbortedResult(wt);
3563
+ }
3564
+ const syncAdmission = syncEntryGate.admit("local");
3565
+ if (!syncAdmission.admitted) {
3566
+ await cancelObserver();
3567
+ const wt = await finishWorktree();
3568
+ return errorResult(`Sub-agent not started: ${syncAdmission.refusal.text}${wt ? `\n${wt}` : ""}`, { error: syncAdmission.refusal.code, code: syncAdmission.refusal.code });
3569
+ }
3414
3570
  const syncStartedAt = Date.now();
3415
3571
  let retainEntry;
3416
- if (ctx.onSubagentSpawn || observerPairing) {
3417
- retainEntry = ctx.onSubagentSpawn ? await tryRetainChild(ctx.subagentRetain) : undefined;
3418
- if (retainEntry)
3419
- stepRecorder.lockTo(retainEntry.childSessionId);
3420
- const stream = opts.runner.runTaskStream({ ...buildChildSpec(ctx.signal), ...(retainEntry ? { sessionId: retainEntry.childSessionId } : {}) }, undefined, childInternals);
3421
- observedSteerRef.steer = (text, o) => stream.steer(text, o);
3422
- let settleResolve;
3423
- const settled = new Promise((r) => { settleResolve = r; });
3424
- if (ctx.onSubagentSpawn) {
3425
- const resume = createSubagentResume({
3426
- ledger: ctx.subagentRetain,
3427
- parentToolCallId: ctx.toolCallId,
3428
- runner: opts.runner,
3429
- ...(opts.background?.notify ? { notify: opts.background.notify } : {}),
3430
- sink: ctx.onSubagentSpawn,
3431
- ...(opts.background ? { registry: opts.background.registry } : {}),
3432
- ...(opts.onObserverError !== undefined ? { onNotifyError: (f) => opts.onObserverError?.(f.error, { site: f.site }) } : {}),
3433
- ...(ctx.onQuestion !== undefined ? { currentOnQuestion: ctx.onQuestion } : {}),
3434
- ...(ctx.autoModeReview !== undefined ? { currentAutoModeReview: ctx.autoModeReview } : {}),
3435
- });
3436
- notifier.notify(() => ctx.onSubagentSpawn?.(createSteerHandle(stream, ctx.toolCallId, childAgentName, settled, {
3437
- resume,
3438
- ...(retainEntry ? { childSessionId: retainEntry.childSessionId } : {}),
3439
- })), "subagent.onSubagentSpawn");
3440
- }
3441
- try {
3442
- for await (const ev of stream)
3443
- observerTap?.record(ev);
3444
- child = await stream.result();
3445
- }
3446
- catch (e) {
3447
- await settleObserver("failed");
3448
- throw e;
3572
+ try {
3573
+ if (ctx.onSubagentSpawn || observerPairing) {
3574
+ retainEntry = ctx.onSubagentSpawn ? await tryRetainChild(ctx.subagentRetain) : undefined;
3575
+ if (retainEntry)
3576
+ stepRecorder.lockTo(retainEntry.childSessionId);
3577
+ const stream = opts.runner.runTaskStream({ ...buildChildSpec(ctx.signal), ...(retainEntry ? { sessionId: retainEntry.childSessionId } : {}) }, undefined, childInternals);
3578
+ observedSteerRef.steer = (text, o) => stream.steer(text, o);
3579
+ let settleResolve;
3580
+ const settled = new Promise((r) => { settleResolve = r; });
3581
+ if (ctx.onSubagentSpawn) {
3582
+ const resume = createSubagentResume({
3583
+ ledger: ctx.subagentRetain,
3584
+ parentToolCallId: ctx.toolCallId,
3585
+ runner: opts.runner,
3586
+ ...(opts.background?.notify ? { notify: opts.background.notify } : {}),
3587
+ sink: ctx.onSubagentSpawn,
3588
+ ...(opts.background ? { registry: opts.background.registry } : {}),
3589
+ ...(opts.onObserverError !== undefined ? { onNotifyError: (f) => opts.onObserverError?.(f.error, { site: f.site }) } : {}),
3590
+ ...(ctx.onQuestion !== undefined ? { currentOnQuestion: ctx.onQuestion } : {}),
3591
+ ...(ctx.autoModeReview !== undefined ? { currentAutoModeReview: ctx.autoModeReview } : {}),
3592
+ });
3593
+ notifier.notify(() => ctx.onSubagentSpawn?.(createSteerHandle(stream, ctx.toolCallId, childAgentName, settled, {
3594
+ resume,
3595
+ ...(retainEntry ? { childSessionId: retainEntry.childSessionId } : {}),
3596
+ })), "subagent.onSubagentSpawn");
3597
+ }
3598
+ try {
3599
+ for await (const ev of stream)
3600
+ observerTap?.record(ev);
3601
+ child = await stream.result();
3602
+ }
3603
+ catch (e) {
3604
+ await settleObserver("failed");
3605
+ throw e;
3606
+ }
3607
+ finally {
3608
+ settleResolve();
3609
+ ctx.subagentRetain?.markSettled(ctx.toolCallId);
3610
+ }
3449
3611
  }
3450
- finally {
3451
- settleResolve();
3452
- ctx.subagentRetain?.markSettled(ctx.toolCallId);
3612
+ else {
3613
+ child = await opts.runner.runTask(buildChildSpec(ctx.signal), childInternals);
3453
3614
  }
3454
3615
  }
3455
- else {
3456
- child = await opts.runner.runTask(buildChildSpec(ctx.signal), childInternals);
3616
+ finally {
3617
+ syncAdmission.release();
3457
3618
  }
3458
3619
  await settleObserver(child.status);
3459
3620
  const handbackWarning = await reviewHandback({
@@ -383,7 +383,12 @@ export interface MaybeCompactOptions {
383
383
  * compaction is FORCED through the real `generateSummary` LLM path even if the provider would hit — this
384
384
  * bounds summary drift from indefinite reuse. The caller owns the counter via {@link onCompaction}
385
385
  * (`reused`) and feeds it back via this option; core treats `summaryProvider` as if absent for that one
386
- * boundary when `consecutiveProviderReuse >= maxConsecutiveProviderReuse`. Default `3` (CC parity).
386
+ * boundary when `consecutiveProviderReuse >= maxConsecutiveProviderReuse`. Default `3`, a
387
+ * SEMA choice — the earlier "(CC parity)" label was unsupported and is withdrawn (anchoring
388
+ * re-check 2026-08-23: CC 2.1.223 has no external summary provider and no reuse concept at all;
389
+ * its three 3s are the PTL retry cap `b$d`, the consecutive-compaction-failure breaker `C$d` and
390
+ * the rapid-refill trip `Q3u`, none of them this). 3 is picked for the same reason those are: a
391
+ * small bound on how long a degraded path may keep answering before the real one is forced.
387
392
  * `0`/undefined with no `consecutiveProviderReuse` = provider always consulted (no forced refresh).
388
393
  */
389
394
  maxConsecutiveProviderReuse?: number;
@@ -618,9 +623,17 @@ export declare function maybeCompact(opts: MaybeCompactOptions): Promise<{
618
623
  * (clamped ≥0, so ≤0 means "freed nothing") — orthogonal to rule 2/3's threshold test, which
619
624
  * misses this shape because the bloated post (44609) can still sit UNDER the threshold (44800),
620
625
  * so rule 3 alone would re-enable the force and repeat the negative-yield pass every boundary.
621
- * SEMA-ONLY DEFENSE, not a CC port: CC's full compact keeps tail 0 and monotonically shrinks
622
- * (capture §A4), and CC has no request-layer trim, so this regime cannot exist there it is a
623
- * derived interaction of our trim seam × keep-tail × attachment mass. Scoped to trim-forced
626
+ * SEMA-ONLY DEFENSE, not a CC port but the ORIGINAL justification for that label was wrong
627
+ * on both of its premises, and the corrected pair still supports it. (a) "CC's full compact
628
+ * keeps tail 0 and monotonically shrinks" describes the `GNo` branch, which a LOCAL threshold
629
+ * compaction never reaches (CC 2.1.223 `y9s` :432704 routes local passes to the reactive
630
+ * pipeline, which preserves the last group verbatim — `YMo` :399971, `s = 1` :399982); CC's
631
+ * default posture therefore has a keep-tail, exactly as ours does. (b) "CC has no
632
+ * request-layer trim" holds only for the MAIN conversation request: CC does own a
633
+ * keep-the-newest-groups-that-fit primitive (`UPb` :645223), whose only caller is the
634
+ * prompt-hook evaluator's own transcript window, not a main-lane request. What actually makes
635
+ * this regime ours is the third factor — the trim seam feeding a deceived usage anchor, and
636
+ * attachment mass re-entering after the fold — not the absence of a keep-tail. Scoped to trim-forced
624
637
  * passes only (`trimForced`), so Seam C reused-summary landings (freed≈0 by design) on the
625
638
  * natural/manual paths never trip it. Release path unchanged: backoff gates only the FORCE, a
626
639
  * later natural landing that posts under threshold (rule 3, non-trim-forced) turns it back off.
@@ -196,6 +196,9 @@ export async function maybeCompact(opts) {
196
196
  if (prep.value.elidedMessages !== undefined && prep.value.elidedMessages > 0) {
197
197
  details.elidedMessages = prep.value.elidedMessages;
198
198
  }
199
+ if (prep.value.carriedUnsummarizedMessages !== undefined && prep.value.carriedUnsummarizedMessages > 0) {
200
+ details.unsummarizedMessages = prep.value.carriedUnsummarizedMessages;
201
+ }
199
202
  }
200
203
  else {
201
204
  let summaryModel = opts.compactionModel ?? opts.model;