@animalabs/agent-framework 0.29.0 → 0.30.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 (104) hide show
  1. package/dist/src/admission/admit.d.ts +90 -0
  2. package/dist/src/admission/admit.d.ts.map +1 -0
  3. package/dist/src/admission/admit.js +43 -0
  4. package/dist/src/admission/admit.js.map +1 -0
  5. package/dist/src/agent.d.ts +21 -0
  6. package/dist/src/agent.d.ts.map +1 -1
  7. package/dist/src/agent.js +43 -6
  8. package/dist/src/agent.js.map +1 -1
  9. package/dist/src/author-presentation.d.ts +50 -0
  10. package/dist/src/author-presentation.d.ts.map +1 -0
  11. package/dist/src/author-presentation.js +58 -0
  12. package/dist/src/author-presentation.js.map +1 -0
  13. package/dist/src/dendrite/index.d.ts +6 -0
  14. package/dist/src/dendrite/index.d.ts.map +1 -0
  15. package/dist/src/dendrite/index.js +3 -0
  16. package/dist/src/dendrite/index.js.map +1 -0
  17. package/dist/src/dendrite/presets.d.ts +60 -0
  18. package/dist/src/dendrite/presets.d.ts.map +1 -0
  19. package/dist/src/dendrite/presets.js +117 -0
  20. package/dist/src/dendrite/presets.js.map +1 -0
  21. package/dist/src/dendrite/registry.d.ts +260 -0
  22. package/dist/src/dendrite/registry.d.ts.map +1 -0
  23. package/dist/src/dendrite/registry.js +742 -0
  24. package/dist/src/dendrite/registry.js.map +1 -0
  25. package/dist/src/dendrite/types.d.ts +392 -0
  26. package/dist/src/dendrite/types.d.ts.map +1 -0
  27. package/dist/src/dendrite/types.js +24 -0
  28. package/dist/src/dendrite/types.js.map +1 -0
  29. package/dist/src/framework.d.ts +684 -33
  30. package/dist/src/framework.d.ts.map +1 -1
  31. package/dist/src/framework.js +2998 -350
  32. package/dist/src/framework.js.map +1 -1
  33. package/dist/src/gate/event-gate.d.ts +12 -4
  34. package/dist/src/gate/event-gate.d.ts.map +1 -1
  35. package/dist/src/gate/event-gate.js +25 -5
  36. package/dist/src/gate/event-gate.js.map +1 -1
  37. package/dist/src/host-presentation.d.ts +4 -0
  38. package/dist/src/host-presentation.d.ts.map +1 -1
  39. package/dist/src/host-presentation.js +7 -3
  40. package/dist/src/host-presentation.js.map +1 -1
  41. package/dist/src/index.d.ts +5 -2
  42. package/dist/src/index.d.ts.map +1 -1
  43. package/dist/src/index.js +6 -1
  44. package/dist/src/index.js.map +1 -1
  45. package/dist/src/mcpl/channel-registry.d.ts +84 -9
  46. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  47. package/dist/src/mcpl/channel-registry.js +136 -58
  48. package/dist/src/mcpl/channel-registry.js.map +1 -1
  49. package/dist/src/mcpl/push-coalescer.d.ts +9 -0
  50. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  51. package/dist/src/mcpl/push-coalescer.js +21 -0
  52. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  53. package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
  54. package/dist/src/mcpl/toolsets-module.js +7 -0
  55. package/dist/src/mcpl/toolsets-module.js.map +1 -1
  56. package/dist/src/notice-tags.d.ts +2 -0
  57. package/dist/src/notice-tags.d.ts.map +1 -1
  58. package/dist/src/notice-tags.js +2 -0
  59. package/dist/src/notice-tags.js.map +1 -1
  60. package/dist/src/silent-heartbeat.d.ts +4 -2
  61. package/dist/src/silent-heartbeat.d.ts.map +1 -1
  62. package/dist/src/silent-heartbeat.js +7 -4
  63. package/dist/src/silent-heartbeat.js.map +1 -1
  64. package/dist/src/stored-injections.d.ts +4 -0
  65. package/dist/src/stored-injections.d.ts.map +1 -1
  66. package/dist/src/stored-injections.js +6 -0
  67. package/dist/src/stored-injections.js.map +1 -1
  68. package/dist/src/tune-out/coordinator.d.ts +379 -9
  69. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  70. package/dist/src/tune-out/coordinator.js +1080 -97
  71. package/dist/src/tune-out/coordinator.js.map +1 -1
  72. package/dist/src/tune-out/tools.d.ts +101 -5
  73. package/dist/src/tune-out/tools.d.ts.map +1 -1
  74. package/dist/src/tune-out/tools.js +55 -18
  75. package/dist/src/tune-out/tools.js.map +1 -1
  76. package/dist/src/types/agent.d.ts +25 -0
  77. package/dist/src/types/agent.d.ts.map +1 -1
  78. package/dist/src/types/framework.d.ts +99 -0
  79. package/dist/src/types/framework.d.ts.map +1 -1
  80. package/dist/src/types/trace.d.ts +136 -0
  81. package/dist/src/types/trace.d.ts.map +1 -1
  82. package/package.json +3 -3
  83. package/src/admission/admit.ts +113 -0
  84. package/src/agent.ts +48 -6
  85. package/src/author-presentation.ts +81 -0
  86. package/src/dendrite/index.ts +32 -0
  87. package/src/dendrite/presets.ts +149 -0
  88. package/src/dendrite/registry.ts +808 -0
  89. package/src/dendrite/types.ts +397 -0
  90. package/src/framework.ts +3239 -369
  91. package/src/gate/event-gate.ts +31 -5
  92. package/src/host-presentation.ts +10 -3
  93. package/src/index.ts +15 -2
  94. package/src/mcpl/channel-registry.ts +170 -60
  95. package/src/mcpl/push-coalescer.ts +19 -0
  96. package/src/mcpl/toolsets-module.ts +7 -0
  97. package/src/notice-tags.ts +2 -0
  98. package/src/silent-heartbeat.ts +7 -4
  99. package/src/stored-injections.ts +7 -0
  100. package/src/tune-out/coordinator.ts +1229 -98
  101. package/src/tune-out/tools.ts +152 -11
  102. package/src/types/agent.ts +28 -0
  103. package/src/types/framework.ts +102 -0
  104. package/src/types/trace.ts +150 -0
package/src/framework.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ToolPresentation, presentationTools, isPresentationTool, renderCatalogue, type PresentationSnapshot } from "./tool-presentation.js";
2
+ import { presentAuthor } from './author-presentation.js';
2
3
  import { constants as bufferConstants } from 'node:buffer';
3
4
  import { dirname, join } from 'node:path';
4
5
  import { EXPIRY_REASON, REFERENCE_LIMITS, claimedExpiry, classifyBlock, isReferenceExpired, referenceBlockForScript, referenceRegistry, sanitizeLabel, type RegistryJournal, type ResultClaims, type ScriptLine, type ServerAddress } from './mcpl/references.js';
@@ -14,8 +15,16 @@ import { classifyFailure, retryDecision, MEMBRANE_RETRY_SETTINGS, type RetrySett
14
15
  import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy, OverBudgetError, UncoveredDropError, stripUnpairedToolBlocks, continuesBody, MalformedContentError, malformedContentPlaces, CompressionWithdrawn } from '@animalabs/context-manager';
15
16
  import type { CacheWireReceipt } from './kv-unified-wire.js';
16
17
  import { SILENT_HEARTBEAT_SEPARATOR, type RequestOnlyPrompt, type RequestOnlyStamps } from './silent-heartbeat.js';
17
- import { SUBCONSCIOUS_TOOLS, SUBCONSCIOUS_TOOL_NAMES, type SubconsciousConfig } from './tune-out/tools.js';
18
- import { TuneOutCoordinator, TUNE_OUT_DEFAULTS, alreadyTunedOutText } from './tune-out/coordinator.js';
18
+ import {
19
+ SUBCONSCIOUS_TOOLS,
20
+ SUBCONSCIOUS_TOOL_NAMES,
21
+ defaultReaderFraming,
22
+ type SubconsciousConfig,
23
+ type PersistentReaderConfig,
24
+ type ForkReaderConfig,
25
+ } from './tune-out/tools.js';
26
+ import { TuneOutCoordinator, TUNE_OUT_DEFAULTS, alreadyTunedOutText, type QueuedHeldMessage, type ReaderInvocation } from './tune-out/coordinator.js';
27
+ import { admit, type InboundEnvelope, type AdmissionDecision } from './admission/admit.js';
19
28
  import type {
20
29
  MessageId,
21
30
  MessageMetadata,
@@ -106,6 +115,24 @@ import { InferenceRouter } from './mcpl/inference-router.js';
106
115
  import { ChannelRegistry, TUNE_OUT_REPLACED, connectorText, desiredStateInLog, destinationKey, journalToolDescription, NON_HOLDER_CHANNEL_DESCRIPTIONS, NO_CHANNEL_EXAMPLE, NO_PERSON_EXAMPLE, qualifiedChannelName, sameChannel, type ChannelRef, type ChannelToolOrigin, type PublishDestination, type PublishOutcome, type SelectedForOpening } from './mcpl/channel-registry.js';
107
116
  import { KEEP_IDLE_MS, KEEP_NEWEST, ProseDraftStore, draftState, uncertainAttempt, type Draft, type DraftAttempt, type DraftReason, type DraftState, type InheritedRisk } from './prose-drafts.js';
108
117
  import { ConversationRouter, CONVERSATION_ROUTING_DEPRECATION_NOTICE } from './mcpl/conversation-router.js';
118
+ import {
119
+ AgentRegistry,
120
+ conversationForkSpec,
121
+ residentSpec,
122
+ subconsciousSpec,
123
+ taskForkSpec,
124
+ workerSpec,
125
+ } from './dendrite/index.js';
126
+ import type {
127
+ ActivationRecord,
128
+ AgentEndReason,
129
+ AgentRecord,
130
+ AgentSpec,
131
+ EndOutcome,
132
+ HeldMail,
133
+ PolicyEpoch,
134
+ RegistrySnapshot,
135
+ } from './dendrite/index.js';
109
136
  import { INBOUND_SOURCE_KEY, sourceBodyDigest, type InboundAcceptanceObserver, type InboundChannelSource, type InboundLane, type InboundSource, type InboundUnscopedSource } from './mcpl/inbound-source.js';
110
137
  import { markNoticeOpenings, nameToTypeBack, quotedValue, showsAsItself, suppliedValue } from './supplied-text.js';
111
138
  import { NOTICE_TAG } from './notice-tags.js';
@@ -173,7 +200,7 @@ import { splitThinkingDrops, ThinkingLossIncidents, type PlacedDrop, type Stream
173
200
  import { asNextRequestCarries, roundRows, type RoundRow, type SentRow } from './sent-rows.js';
174
201
  import { ReasoningLossTeller } from './reasoning-loss.js';
175
202
  import { contentAsStored, nameAsStored } from './text-as-stored.js';
176
- import { STORED_INJECTION_KEY, STORED_INJECTION_KIND, asStoredRow, hasRenderedContent, isAnotherAgentsInjection, isStoredKind, storedInjectionMarkOf, unitHash, type StoredInjectionMark } from './stored-injections.js';
203
+ import { STORED_INJECTION_KEY, STORED_INJECTION_KIND, asStoredRow, hasRenderedContent, isAnotherAgentsInjection, isOwnInjection, isStoredKind, storedInjectionMarkOf, unitHash, type StoredInjectionMark } from './stored-injections.js';
177
204
 
178
205
  /** An MCPL tool call refused because its server isn't in the registry: one
179
206
  * closed for good, one removed, or one whose connect failed without
@@ -373,10 +400,11 @@ const PRIMARY_IF_UNREAD_KEY = 'primaryIfUnread';
373
400
  * a deferred write, `external` a module's own row for an outside message,
374
401
  * `openaiResponsesItems` membrane's mark of an imported Responses
375
402
  * history, `contextInjection` an agent's stored context injection
376
- * (stored-injections.ts), which every other agent's view leaves out, and
403
+ * (stored-injections.ts), which every other agent's view leaves out,
377
404
  * `primaryIfUnread` a module's write for an ephemeral run or a
378
405
  * conversation fork, which goes to the primary if its target never read it
379
- * (passesToPrimary). A
406
+ * (passesToPrimary), and `dendrite` the stamp of delivered agent mail, whose
407
+ * `mailId` releases that held mail once the row lands (releaseLandedMail). A
380
408
  * server's metadata, a channel message's `metadata` or a
381
409
  * push event's `origin`, is stored without them, and the host writes its own
382
410
  * after it: MCPL SPEC §16.6 makes metadata the producer's untrusted claims,
@@ -387,7 +415,7 @@ const PRIMARY_IF_UNREAD_KEY = 'primaryIfUnread';
387
415
  */
388
416
  const HOST_METADATA_KEYS = [
389
417
  'system', 'kind', 'draftOwner', 'draftIds', 'tuneOut', 'coalescingSubject', 'firstView',
390
- 'deferredWriteId', 'external', 'openaiResponsesItems', STORED_INJECTION_KEY, PRIMARY_IF_UNREAD_KEY,
418
+ 'deferredWriteId', 'external', 'openaiResponsesItems', STORED_INJECTION_KEY, PRIMARY_IF_UNREAD_KEY, 'dendrite',
391
419
  ] as const;
392
420
 
393
421
  /**
@@ -655,16 +683,18 @@ function mcplToolNoWordsText(serverId: string, callName: string): string {
655
683
  }
656
684
 
657
685
  /**
658
- * True when an incoming channel message explicitly addressed the agent.
659
- * Primary signal is the MCPL RFC-001 `chat:addressed` tag (mention / reply /
660
- * DM, as classified by the server); the metadata booleans are the fallback
661
- * for servers that predate tags. Addressed messages outrank ambient chatter
686
+ * True when an incoming channel message explicitly addressed the agent: the
687
+ * MCPL RFC-001 `chat:addressed` tag (mention / reply / DM, as classified by
688
+ * the server). Tag-only — there is no metadata fallback, so a server that
689
+ * does not tag never addresses. Addressed messages outrank ambient chatter
662
690
  * when picking a turn's frozen speech locus.
663
691
  */
664
- const isAddressedMessage = (
665
- tags?: string[],
666
- _metadata?: Record<string, unknown>,
667
- ): boolean => Array.isArray(tags) && tags.includes('chat:addressed');
692
+ const isAddressedMessage = (tags?: string[]): boolean =>
693
+ Array.isArray(tags) && tags.includes('chat:addressed');
694
+
695
+ /** The author a channel message names, for a notice about it. */
696
+ const authorName = (metadata: Record<string, unknown> | undefined): string =>
697
+ (metadata?.author as { name?: string } | undefined)?.name ?? 'Someone';
668
698
 
669
699
  /** A wake's `ambient` mark: only a message its server tagged `chat:ambient`
670
700
  * (RFC-001) is known to ask nothing of the agent. */
@@ -843,7 +873,7 @@ import { HostPresentation, BUILTIN_TOOLSET, type HostActTarget } from './host-pr
843
873
  import { isEmptyChange, type Presence } from './mcpl/tool-presence.js';
844
874
  import { ToolsetsModule, resolveAct, withLookHint, type TakenChange } from './mcpl/toolsets-module.js';
845
875
  import { ModernMcpConnection } from './mcpl/modern-connection.js';
846
- import { WITHHELD_TEXT, asText, credentialRedactor, redactUrl, redactServerText, resolveServerBinding, serverConfigProblems, serverCredentialPatterns, serverTextRedactor, type CredentialPattern } from './mcpl/protocol-family.js';
876
+ import { MAX_TIMER_MS, WITHHELD_TEXT, asText, credentialRedactor, redactUrl, redactServerText, resolveServerBinding, serverConfigProblems, serverCredentialPatterns, serverTextRedactor, type CredentialPattern } from './mcpl/protocol-family.js';
847
877
  import { MALFORMED_CONTENT_NOTICE, normalizeStandardToolResult } from './mcpl/tool-result-normalize.js';
848
878
  import { boundedForLog, describeCause } from './mcpl/errors.js';
849
879
  import { assertServerConfigBooleans, buildWebSocketUrl, isWebSocketTransport } from './mcpl/transport.js';
@@ -860,7 +890,7 @@ import type {
860
890
  ChannelsChangedParams,
861
891
  ChannelsIncomingParams,
862
892
  } from './mcpl/types.js';
863
- import type { ContextInjection, ThinkingBinding } from '@animalabs/context-manager';
893
+ import type { ContextInjection, ContextStrategy, ThinkingBinding } from '@animalabs/context-manager';
864
894
  import { formatZonedDateTime, resolveTimeZone } from './timezone.js';
865
895
  import { CLOCK_ROW_KIND, clockStampOf, planClock, takesClockBefore } from './clock-rows.js';
866
896
  import { firstFreeBranchName } from './branch-names.js';
@@ -902,6 +932,12 @@ import {
902
932
  } from './operator-log.js';
903
933
 
904
934
  const FRAMEWORK_STATE_ID = 'framework/state';
935
+ /** Dendrite agent registry: live records, incarnation counters, held mail. */
936
+ const AGENT_REGISTRY_STATE_ID = 'framework/dendrite';
937
+ /** Dendrite agent registry: append-only history of ended agent records. */
938
+ const AGENT_REGISTRY_ENDED_ID = 'framework/dendrite/ended';
939
+ /** How many ended agent records are reloaded for inspection at boot. */
940
+ const AGENT_REGISTRY_ENDED_RELOAD = 256;
905
941
  /** Snapshot slot mirroring context writes deferred while quiesced (#122). */
906
942
  const DEFERRED_WRITES_ID = 'framework/deferred-writes';
907
943
  /** What a write of the deferred-write queue did: wrote the record (the
@@ -1086,11 +1122,12 @@ function describeDeferredWrite(msg: { id: string; participant: string; content:
1086
1122
  /** Whether a deferred message can join a live stream at a tool boundary:
1087
1123
  * no tool blocks (the membrane's tool cycle) and not named as the agent
1088
1124
  * itself (it would render as an assistant turn, a prefill). */
1089
- function isInjectableMidTurn(msg: { participant: string; content: ContentBlock[] }, agentName: string): boolean {
1090
- // Exact, as membrane's role test is: only a participant equal to the
1091
- // request's assistantParticipant (Agent's name) renders as the assistant.
1092
- // It's the name as sent, which the boundary takes as the store keeps it.
1093
- return nameAsStored(msg.participant) !== agentName
1125
+ function isInjectableMidTurn(msg: { participant: string; content: ContentBlock[] }, agent: Pick<Agent, 'isOwnParticipant'>): boolean {
1126
+ // Exact, as membrane's role test is: a participant the request assigns
1127
+ // the assistant role (the agent's name, and for a derived agent the
1128
+ // voice it inherited) renders as the assistant. It's the name as sent,
1129
+ // which the boundary takes as the store keeps it.
1130
+ return !agent.isOwnParticipant(nameAsStored(msg.participant))
1094
1131
  && !msg.content.some((b) => b.type === 'tool_use' || b.type === 'tool_result');
1095
1132
  }
1096
1133
 
@@ -1447,6 +1484,13 @@ interface TurnRouting {
1447
1484
  * retracted, isn't there for the agent to answer.
1448
1485
  */
1449
1486
  arrivals: Arrival[];
1487
+ /**
1488
+ * Under `speech.locus: 'delivery'`, the channels the turn has already told
1489
+ * the agent it was addressed from while busy, by destinationKey (one
1490
+ * notice per channel per turn; a retry or restart continues the turn and
1491
+ * doesn't repeat it).
1492
+ */
1493
+ addressedElsewhere: Set<string>;
1450
1494
  heard?: Map<MessageId, number>;
1451
1495
  }
1452
1496
 
@@ -1455,6 +1499,8 @@ interface Arrival {
1455
1499
  messageId: MessageId;
1456
1500
  /** The channel it re-pins the locus to. */
1457
1501
  channel: ChannelRef;
1502
+ /** It addressed the agent (`chat:addressed`), rather than following up in an engaged channel. */
1503
+ addressed: boolean;
1458
1504
  }
1459
1505
 
1460
1506
  /** The last TurnRouting.turnId given out: ids increase, and are unique within the process. */
@@ -1472,6 +1518,7 @@ function freshTurnRouting(): TurnRouting {
1472
1518
  engaged: new Set(),
1473
1519
  wasIn: [],
1474
1520
  continuation: false, deliveries: [], suppressed: 0, drafts: [], keptPrivate: 0, arrivals: [], heard: new Map(),
1521
+ addressedElsewhere: new Set(),
1475
1522
  };
1476
1523
  }
1477
1524
 
@@ -2633,6 +2680,136 @@ interface EphemeralRun {
2633
2680
  inferenceStarted: boolean;
2634
2681
  lastActivity: number;
2635
2682
  toolCallsCount: number;
2683
+ /** Why the run ended, for the registry record. Unset = it failed. */
2684
+ endReason?: AgentEndReason;
2685
+ /** Who ended it, when it was stopped from outside. */
2686
+ endedBy?: string;
2687
+ }
2688
+
2689
+ /**
2690
+ * The admission record on a stored message: it was held from the residents
2691
+ * by a policy epoch (`metadata.tuneOut = { epochId }`). ONE predicate for
2692
+ * "held from the resident", read by the compiled-view filter and by the
2693
+ * mid-turn injection alike (#232 H1), so the window and the wire agree.
2694
+ */
2695
+ export const heldFromResidents = (metadata: Record<string, unknown> | undefined): boolean =>
2696
+ !!(metadata as { tuneOut?: unknown } | undefined)?.tuneOut;
2697
+
2698
+ /**
2699
+ * What a reader of the residents' shared message slot does not see: held
2700
+ * messages. Used by every resident, and by a context derived from one when
2701
+ * it is reopened.
2702
+ */
2703
+ const RESIDENT_VIEW_FILTER = (message: StoredMessage): boolean =>
2704
+ !heldFromResidents(message.metadata as Record<string, unknown> | undefined);
2705
+
2706
+ /** The context-manager surface shared inheritance needs; absent on versions without derivation. */
2707
+ interface DerivingContextManager {
2708
+ derive(options: {
2709
+ branch: string;
2710
+ strategy: ContextStrategy;
2711
+ membrane?: Membrane;
2712
+ solve?: 'reuse' | 'fresh';
2713
+ inherit?: { refusals?: boolean };
2714
+ atSequence?: number;
2715
+ viewFilter?: (message: StoredMessage) => boolean;
2716
+ debugLogContext?: boolean;
2717
+ }): Promise<ContextManager>;
2718
+ getDerivation(): ContextDerivationRecord | null;
2719
+ }
2720
+
2721
+ /** context-manager's plain-data record of a derivation (see its ContextDerivation). */
2722
+ interface ContextDerivationRecord {
2723
+ parentBranch: string;
2724
+ branch: string;
2725
+ atSequence: number;
2726
+ solve: 'reuse' | 'fresh';
2727
+ slots?: { messageNamespace: string | null; contextNamespace: string | null };
2728
+ }
2729
+
2730
+ /** `ContextManager.reopenDerived`, absent on versions without derivation. */
2731
+ type ReopenDerived = (options: {
2732
+ store: JsStore;
2733
+ derivation: ContextDerivationRecord;
2734
+ strategy?: ContextStrategy;
2735
+ membrane?: Membrane;
2736
+ viewFilter?: (message: StoredMessage) => boolean;
2737
+ debugLogContext?: boolean;
2738
+ }) => Promise<ContextManager>;
2739
+
2740
+ /** Options for {@link AgentFramework.deriveAgent}. */
2741
+ export interface DeriveAgentOptions {
2742
+ /** The new agent's name. Single-use within this framework's lifetime. */
2743
+ name: string;
2744
+ /** The live agent whose context and configuration are inherited. */
2745
+ from: string;
2746
+ /** How context is inherited. Default `'shared'`. */
2747
+ mode?: 'shared' | 'copy';
2748
+ /** `'shared'` only. Default `'reuse'`. */
2749
+ solve?: 'reuse' | 'fresh';
2750
+ /**
2751
+ * `'shared'` only. Also inherit the parent's refusal ledger (what it
2752
+ * declined to compress). Default false; set for a fork that stands in
2753
+ * for the parent's attention. Recorded as `inherit.refusals`.
2754
+ */
2755
+ inheritRefusals?: boolean;
2756
+ /**
2757
+ * A fresh strategy instance for the child. Strategy instances hold state
2758
+ * and are never shared; to reuse the parent's rendering it must be the
2759
+ * same class with compatible configuration. Default: passthrough.
2760
+ */
2761
+ strategy?: ContextStrategy;
2762
+ /** Identity and settings overrides. Everything not named is the parent's. */
2763
+ config?: Partial<Omit<AgentConfig, 'name' | 'strategy'>>;
2764
+ /**
2765
+ * The parent's tool round in flight, if any. While a tool call runs, the
2766
+ * assistant turn that made it is not in the parent's store yet. By
2767
+ * default the child's branch gets that turn verbatim (as its own, like
2768
+ * the rest of the inheritance) followed by a tool-result message: results
2769
+ * the parent has already received as they are; the call that derived the
2770
+ * child with `madeBy.result`, or a note saying it did; and any other call
2771
+ * still running with a note that its result was not available. So a fork
2772
+ * sees the call that made it and never meets a dangling `tool_use`.
2773
+ * `madeBy` defaults to the only open call when there is exactly one.
2774
+ * `include: false` leaves the round out, and the child starts from the
2775
+ * last stored message before it.
2776
+ */
2777
+ pendingRound?: {
2778
+ include?: boolean;
2779
+ madeBy?: { toolUseId: string; result: string };
2780
+ /** Results, from the child's side, for other calls still open, by tool-use id. */
2781
+ results?: Record<string, string>;
2782
+ };
2783
+ /** Ordinary messages appended to the child before it runs. */
2784
+ framing?: Array<{ participant: string; content: ContentBlock[]; metadata?: MessageMetadata }>;
2785
+ /** Who spawned it. Default: `from`. */
2786
+ spawnedBy?: string;
2787
+ /** Where its completion returns. */
2788
+ resultTo?: import('./dendrite/index.js').ResultRoute;
2789
+ /** What happens to it when its spawner ends. Default `'orphan'`. */
2790
+ onParentEnd?: import('./dendrite/index.js').ParentEndPolicy;
2791
+ deadlineMs?: number;
2792
+ idleTimeoutMs?: number;
2793
+ maxTurns?: number;
2794
+ /** Preset label for discovery. Default `'task-fork'`. */
2795
+ kind?: string;
2796
+ metadata?: Record<string, unknown>;
2797
+ }
2798
+
2799
+ /** Rejection delivered to the caller awaiting a bounded run that was ended from outside. */
2800
+ export class AgentStoppedError extends Error {
2801
+ constructor(
2802
+ readonly agentName: string,
2803
+ readonly reason: AgentEndReason,
2804
+ readonly by?: string,
2805
+ detail?: string,
2806
+ ) {
2807
+ super(
2808
+ `Agent "${agentName}" was ended (${reason}${by ? ` by ${by}` : ''})` +
2809
+ (detail ? `: ${detail}` : ''),
2810
+ );
2811
+ this.name = 'AgentStoppedError';
2812
+ }
2636
2813
  }
2637
2814
 
2638
2815
  interface SyncMoveReinit {
@@ -3370,6 +3547,13 @@ export class AgentFramework {
3370
3547
  private quiesceReason?: string;
3371
3548
  private quiescedAt?: number;
3372
3549
  private traceListeners: TraceRegistration[] = [];
3550
+ /** Traces emitted before start() — registry restore, interrupted work,
3551
+ * resumed epochs — replayed to listeners that attach afterwards, since a
3552
+ * host attaches its sinks after create() returns. Dropped at start().
3553
+ * It keeps the first 1,000, the start of the stream, and counts the rest
3554
+ * (earlyTracesDropped), which a replay ends by naming. */
3555
+ private earlyTraces: TraceEvent[] | null = [];
3556
+ private earlyTracesDropped = 0;
3373
3557
  private syncIntervalMs: number;
3374
3558
  private syncTimer: ReturnType<typeof setInterval> | null = null;
3375
3559
  private maintenanceIntervalMs: number;
@@ -3431,8 +3615,9 @@ export class AgentFramework {
3431
3615
  private midTurnInputSignals: Set<string> = new Set();
3432
3616
  /** Last outbound locus announced to each agent as a durable `[routing]`
3433
3617
  * window message. Announce-on-change only: steady state emits nothing
3434
- * (KV-safe, no per-turn chatter). In-memory — after a process restart the
3435
- * first turn's locus is announced once to re-establish the baseline. */
3618
+ * (KV-safe, no per-turn chatter). Kept in the store as well
3619
+ * (rememberAnnouncedLocus), so a restart compares with what the agent was
3620
+ * last told rather than with nothing. */
3436
3621
  private lastAnnouncedLocus: Map<string, ChannelRef | null> = new Map();
3437
3622
 
3438
3623
  // ---- Held prose drafts (src/prose-drafts.ts) ---------------------------
@@ -3519,7 +3704,22 @@ export class AgentFramework {
3519
3704
  * global fallback even after its release, or a conversation fork. */
3520
3705
  private usedEphemeralAgentNames: Map<string, 'run' | 'conversation'> = new Map();
3521
3706
  /** One-shot generation tickets minted by createEphemeralAgent. */
3522
- private ephemeralCandidates: WeakMap<Agent, ContextManager> = new WeakMap();
3707
+ private ephemeralCandidates: WeakMap<Agent, {
3708
+ contextManager: ContextManager;
3709
+ config: AgentConfig;
3710
+ spec: AgentSpec;
3711
+ /** A new incarnation of an identity that ended (see resumeAgent). */
3712
+ resume?: boolean;
3713
+ /** The incarnation it resumes: the new one is past it, though the registry's counter for an aged-out name is gone. */
3714
+ after?: number;
3715
+ }> = new WeakMap();
3716
+ /**
3717
+ * Derived agent → the agent whose tool presentation it shows. A derived
3718
+ * agent that inherits its parent's tools must advertise the identical
3719
+ * tool block (it is the front of the shared request prefix), including
3720
+ * the parent's resident-edited visibility and descriptions.
3721
+ */
3722
+ private toolSurfaceOwners: Map<string, string> = new Map();
3523
3723
  /** Every agent createEphemeralAgent made, for as long as the object lives:
3524
3724
  * its usage counts in the usage record's one shared ephemeral entry. Kept
3525
3725
  * by the object, not the run's record (ephemeralRuns), which its cleanup
@@ -3596,8 +3796,25 @@ export class AgentFramework {
3596
3796
  private subconsciousStrategy: WindowedPassthroughStrategy | null = null;
3597
3797
  /** Its config (speak_in_channel gate, voice block provenance). */
3598
3798
  private subconsciousConfig: SubconsciousConfig | null = null;
3599
- /** Tune-out coordinator (issue #77); non-null iff subconscious + channels. */
3799
+ /** Tune-out coordinator (issue #77); non-null iff a reader is configured + channels. */
3600
3800
  private tuneOutCoordinator: TuneOutCoordinator | null = null;
3801
+ /** Residents are offered `tune_out` (a reader is configured, or `tuneOut.enabled`). */
3802
+ private tuneOutOffered = false;
3803
+ /**
3804
+ * The reply anchor a coalesced delivery would set, parked until the
3805
+ * acceptance is acknowledged to the server (handleCoalescedIncoming): an
3806
+ * acceptance whose commit fails after its delivery moves no anchor, and the
3807
+ * retry the receipt answers, which delivers nothing again, commits the one
3808
+ * its first attempt parked. Keyed by server and event id; bounded.
3809
+ */
3810
+ private parkedAnchors: Map<string, { channel: ChannelRef; messageId: string; threadId?: string; tags?: string[]; firstView: boolean }> = new Map();
3811
+ /** `tuneOut.announce`: the excepted channel hears the all-except scope open and close. */
3812
+ private tuneOutAnnounce = true;
3813
+ /** `speech.locus`: where prose goes when nothing names a destination (see FrameworkConfig). */
3814
+ private speechLocus: 'delivery' | 'latest-inbound' = 'delivery';
3815
+ /** `subconscious.reader: 'forks'`: the reader is a succession of forks (Dendrite). */
3816
+ private readerForks = false;
3817
+ private readerForkCounter = 0;
3601
3818
 
3602
3819
  /** Agents an abandon could not cancel (token held, no stream) — surfaced
3603
3820
  * in HostModeStatus until the next quiesce/resume. */
@@ -3616,6 +3833,24 @@ export class AgentFramework {
3616
3833
  /** True once the cap has been met in the current run, so its line is
3617
3834
  * logged once a run, whatever began the run. */
3618
3835
  private deferredWritesCapLogged = false;
3836
+ /** The agent registry and its ended log, as recovery files beside the store
3837
+ * (nissa's #546 finding 3): Chronicle state follows the store's branch, and
3838
+ * an undo or an operator's surgery must not erase a result held for
3839
+ * delivery or the record of work that ran. The file wins where it exists;
3840
+ * the branch-local state is read only for stores that predate it. */
3841
+ private agentRegistryPath: string | undefined;
3842
+ private agentRegistryEndedPath: string | undefined;
3843
+ /** The ended log as the recovery file keeps it: the newest records, bounded. */
3844
+ private endedRecovery: AgentRecord[] = [];
3845
+ /** The branch-local ended log by name (its newest entry for each), and how far it is indexed (findEndedAgentRecord). */
3846
+ private readonly endedLogIndex = new Map<string, number>();
3847
+ /** The ended log's entries by the namespace their context holds (the latest for each). */
3848
+ private readonly endedLogNamespaceIndex = new Map<string, number>();
3849
+ /** Namespaces this process opened for an ephemeral agent's context, by owner: candidates not yet registered included. */
3850
+ private readonly namespaceOwners = new Map<string, string>();
3851
+ private endedLogIndexed = 0;
3852
+ /** The last indexed entry's mark, so a log that changed under the index is seen. */
3853
+ private endedLogTail = '';
3619
3854
  /** Branch-independent recovery files (see FrameworkConfig.hostModePath /
3620
3855
  * deferredWritesPath). Undefined → branch-local slot fallback. */
3621
3856
  private hostModePath: string | undefined;
@@ -3777,12 +4012,32 @@ export class AgentFramework {
3777
4012
  private coalescingSnapshotDirty: (() => CoalescingSnapshot) | null = null;
3778
4013
  private coalescingRecentReceipts: CoalescingReceiptRecord[] = [];
3779
4014
  /**
3780
- * Agents that read the residents' shared un-namespaced message slot: every
3781
- * ordinary resident (their context managers are not isolated) and the
3782
- * subconscious (isolated, but merging that slot read-only). A message in
3783
- * that slot is unread only if NONE of them has compiled past it. Forks and
3784
- * ephemeral agents have isolated slots and are their own only readers.
3785
- */
4015
+ * Dendrite agent registry — every agent this framework runs, with its
4016
+ * roles, lifetime and relationships. `agents` stays the map of live Agent
4017
+ * OBJECTS (the generation identity streams are checked against); the
4018
+ * registry is what says what each of them is for. Selection sites read
4019
+ * roles from here instead of excluding kinds by hand.
4020
+ */
4021
+ private readonly registry: AgentRegistry = new AgentRegistry({
4022
+ persist: (snapshot) => this.persistAgentRegistry(snapshot),
4023
+ appendEnded: (record) => this.appendEndedAgentRecord(record),
4024
+ onRollback: () => this.rewriteAgentRegistryAfterRollback(),
4025
+ findEnded: (name) => this.findEndedAgentRecord(name),
4026
+ });
4027
+ /** Tell the first adoption candidate when task work is orphaned (config.dendrite.announceOrphans). */
4028
+ private announceOrphans = true;
4029
+ /**
4030
+ * After this many derivations of one kind from one agent, that agent hears
4031
+ * a count line every so many instead of a line per fork
4032
+ * (config.dendrite.collapseDerivationNoticesAfter; 0 = never collapse).
4033
+ */
4034
+ private collapseDerivationNoticesAfter = 10;
4035
+ /** The host's say over each derivation, ephemeral spawn and resume (config.dendrite.beforeDerive). */
4036
+ private beforeDerive?: (spec: AgentSpec) => void | { refuse: string };
4037
+ /** What a source is told of a derivation (config.dendrite.derivationNotice); absent, the default line. */
4038
+ private derivationNoticeHook?: (record: AgentRecord, from: string, defaultText: string) => string | null;
4039
+ /** Per source agent, per kind: how many derivations, since when, and at which count the last note went. */
4040
+ private derivationTallies: Map<string, Map<string, { count: number; since: number }>> = new Map();
3786
4041
  private readonly sharedSlotAgents = new Set<Agent>();
3787
4042
  /** The batched-wake lines already written (placeWakeLines), so every agent a
3788
4043
  * wake was for, and every later start of it, writes each at most once. */
@@ -3791,6 +4046,7 @@ export class AgentFramework {
3791
4046
  * in the round counted by their share of the bound: such a round is
3792
4047
  * stored by its share whether or not it fits (buildStoredToolResultContent). */
3793
4048
  private readonly toolRoundsByShare = new WeakSet<CompletedToolCall[]>();
4049
+ /** Group commit: one fsync per event-loop turn, shared by every waiter. */
3794
4050
  /** The windows of forks and runs deregistered while the host was quiesced,
3795
4051
  * by agent name (releaseWindowNowOrAfterResume); resume() releases them
3796
4052
  * (runReleasesAfterResume), or stop() passes on what they left, unread or
@@ -3799,6 +4055,7 @@ export class AgentFramework {
3799
4055
  private readonly releasesAfterResume = new Map<string, Agent>();
3800
4056
  private readonly managerReleases = new WeakMap<Agent, ManagerRelease>();
3801
4057
  private readonly releasedByCleanup = new WeakSet<Agent>();
4058
+ private readonly resumesPending = new Map<string, Agent | undefined>();
3802
4059
  // context-manager 0.23.1: a strategy that keeps its context, as
3803
4060
  // AutobiographicalStrategy does (hostContext, autobiographical.ts:2897),
3804
4061
  // keeps its manager reachable while its work is pending, since the
@@ -3819,12 +4076,10 @@ export class AgentFramework {
3819
4076
  private conversationRouter: ConversationRouter | null = null;
3820
4077
  /** Agent configs by name — fork agents are built from the template's config. */
3821
4078
  private agentConfigs: Map<string, AgentConfig> = new Map();
3822
- /**
3823
- * Fork agent → its home channel. Permanent (unlike router bindings, which
3824
- * expire): publish/injection scoping must survive unbinding so the closure
3825
- * turn still lands in the right channel.
3826
- */
3827
- private conversationAgentHomes: Map<string, ChannelRef> = new Map();
4079
+ // A fork agent's home channel is its registry record's `homeChannel`
4080
+ // (homeOf). Permanent (unlike router bindings, which expire):
4081
+ // publish/injection scoping must survive unbinding so the closure turn
4082
+ // still lands in the right channel.
3828
4083
  /** Forks being spawned, by channel id, each until its spawn settles (see
3829
4084
  * routeConversation). The promise never rejects. */
3830
4085
  private readonly conversationSpawns = new Map<string, Promise<void>>();
@@ -3832,7 +4087,7 @@ export class AgentFramework {
3832
4087
  private lastConversationSweep = 0;
3833
4088
 
3834
4089
  /** Forks whose TTL closure turn has been queued — disposed (removed from
3835
- * agents/agentConfigs/conversationAgentHomes) when their stream ends. */
4090
+ * agents/agentConfigs and ended in the registry) when their stream ends. */
3836
4091
  private closingConversationAgents: Set<string> = new Set();
3837
4092
  // Client-side programmatic tool calling (`code_execution`). Null unless
3838
4093
  // config.codeExecution.enabled: its settings, and every agent's scripts,
@@ -4170,7 +4425,7 @@ export class AgentFramework {
4170
4425
  // Register framework states
4171
4426
  ensureStateRegistered(store, { id: FRAMEWORK_STATE_ID, strategy: 'snapshot' });
4172
4427
  ensureStateRegistered(store, { id: DEFERRED_WRITES_ID, strategy: 'snapshot' });
4173
- for (const id of [COALESCING_STATE_ID, COALESCING_RECENT_ID]) {
4428
+ for (const id of [COALESCING_STATE_ID, COALESCING_RECENT_ID, AGENT_REGISTRY_STATE_ID]) {
4174
4429
  ensureStateRegistered(store, { id, strategy: 'snapshot' });
4175
4430
  }
4176
4431
  ensureStateRegistered(store, {
@@ -4203,6 +4458,12 @@ export class AgentFramework {
4203
4458
  );
4204
4459
  }
4205
4460
  }
4461
+ ensureStateRegistered(store, {
4462
+ id: AGENT_REGISTRY_ENDED_ID,
4463
+ strategy: 'append_log',
4464
+ deltaSnapshotEvery: 100,
4465
+ fullSnapshotEvery: 20,
4466
+ });
4206
4467
 
4207
4468
  // Process logging config (default: disabled)
4208
4469
  const processLoggingPersist = config.processLogging?.persist === true;
@@ -4287,9 +4548,41 @@ export class AgentFramework {
4287
4548
  // subconscious disabled now may have run and left its own entry.
4288
4549
  framework.restoreUsageState([
4289
4550
  ...config.agents.map((agent) => String(agent.name)),
4290
- ...(config.subconscious ? [String(config.subconscious.name ?? 'Subconscious')] : []),
4551
+ // A forks reader has no agent of its own; its forks are bounded jobs.
4552
+ ...(config.subconscious && config.subconscious.reader !== 'forks' ? [String(config.subconscious.name ?? 'Subconscious')] : []),
4291
4553
  ]);
4292
4554
 
4555
+ framework.announceOrphans = config.dendrite?.announceOrphans ?? true;
4556
+ framework.collapseDerivationNoticesAfter = Math.max(0, Math.floor(config.dendrite?.collapseDerivationNoticesAfter ?? 10));
4557
+ framework.beforeDerive = config.dendrite?.beforeDerive;
4558
+ framework.derivationNoticeHook = config.dendrite?.derivationNotice;
4559
+ framework.speechLocus = config.speech?.locus ?? 'delivery';
4560
+ framework.tuneOutOffered = !!config.subconscious?.enabled || config.tuneOut?.enabled === true;
4561
+ framework.tuneOutAnnounce = config.tuneOut?.announce !== false;
4562
+
4563
+ // What the previous process left in the agent registry. Read before any
4564
+ // agent is declared so a restored persistent agent becomes a new
4565
+ // incarnation of the same identity rather than a stranger. The registry
4566
+ // lives in recovery files beside the store (see agentRegistryPath).
4567
+ framework.agentRegistryPath = config.agentRegistryPath
4568
+ ?? (config.storePath ? join(config.storePath, 'recovery', 'dendrite-registry.json') : undefined);
4569
+ framework.agentRegistryEndedPath = config.agentRegistryEndedPath
4570
+ ?? (config.storePath ? join(config.storePath, 'recovery', 'dendrite-ended.json') : undefined);
4571
+ if (!framework.agentRegistryPath || !framework.agentRegistryEndedPath) {
4572
+ const missing = [
4573
+ ...(framework.agentRegistryPath ? [] : ['agentRegistryPath']),
4574
+ ...(framework.agentRegistryEndedPath ? [] : ['agentRegistryEndedPath']),
4575
+ ];
4576
+ console.error(
4577
+ `[dendrite] no storePath / ${missing.join(' / ')}: the agent registry's ` +
4578
+ (framework.agentRegistryPath
4579
+ ? 'ended log persists only in the branch-local store, where a branch switch or historical rollback can erase it'
4580
+ : 'records and held results persist only in the branch-local store, where a branch switch or historical rollback can erase them') +
4581
+ ', and a crash can lose what wasn\'t synced; configure the recovery paths',
4582
+ );
4583
+ }
4584
+ const interruptedAgents = framework.restoreAgentRegistry();
4585
+
4293
4586
  // Create agents
4294
4587
  for (const agentConfig of config.agents) {
4295
4588
  await framework.createAgent(agentConfig);
@@ -4299,8 +4592,12 @@ export class AgentFramework {
4299
4592
  // The subconscious resident (issue #77) registers after the residents so
4300
4593
  // it can never become primary; it is excluded from every broadcast
4301
4594
  // fan-out and woken only by the tune-out coordinator's explicit pushes.
4302
- if (config.subconscious?.enabled === true) {
4303
- await framework.createSubconsciousAgent(config.subconscious);
4595
+ if (config.subconscious?.enabled) {
4596
+ if (config.subconscious.reader === 'forks') {
4597
+ framework.configureReaderForks(config.subconscious);
4598
+ } else {
4599
+ await framework.createSubconsciousAgent(config.subconscious);
4600
+ }
4304
4601
  }
4305
4602
 
4306
4603
  // Finish any branch-local suppression interrupted after Chronicle switched
@@ -4433,8 +4730,9 @@ export class AgentFramework {
4433
4730
  })(),
4434
4731
  });
4435
4732
  },
4436
- getAgentNames: () => [...framework.agents.keys()].filter(
4437
- (n) => n !== framework.subconsciousAgentName),
4733
+ getAgentNames: () => framework.registry.gateRecipients(),
4734
+ isHeld: (serverId, channelId) =>
4735
+ !!(serverId && channelId && framework.tuneOutCoordinator?.holdFor(serverId, channelId)),
4438
4736
  // Push events reach the gate with the adapter's raw channel id; map
4439
4737
  // them to the registered composite id the same way ingestion does
4440
4738
  // (handleMcplPushEvent registers that channel on arrival, before any
@@ -4601,6 +4899,15 @@ export class AgentFramework {
4601
4899
  framework.hostPresentation = framework.makeHostPresentation(config.storePath);
4602
4900
 
4603
4901
  // Initialize MCPL subsystems if configured
4902
+ // Configuration has now declared its agents, and host mode and the
4903
+ // deferred-write queue are restored: settle what the previous process
4904
+ // left unfinished (interrupted bounded work, persistent agents no longer
4905
+ // configured, results still held for delivery). After the restore, so a
4906
+ // quiesced host holds the restart notices and redelivered mail as it
4907
+ // holds any write, and mail an entry of the restored queue carries is
4908
+ // left to that entry's landing (nissa's #546 finding 4).
4909
+ framework.reconcileAgentRegistry(interruptedAgents);
4910
+
4604
4911
  if (config.mcplServers && config.mcplServers.length > 0) {
4605
4912
  // Check every server's config before any is brought up: its boolean
4606
4913
  // settings, and its tool prefix, which mustn't collide with a module
@@ -4653,9 +4960,14 @@ export class AgentFramework {
4653
4960
  await framework.ensureToolsetsModule();
4654
4961
  }
4655
4962
 
4656
- // Tune-out coordinator (issue #77): needs both the subconscious resident
4657
- // and the MCPL channel subsystem; created after both exist.
4658
- if (framework.subconsciousAgentName && framework.channelRegistry) {
4963
+ // Tune-out coordinator (issue #77, #232 §3.3): the hold machinery exists
4964
+ // wherever tune_out is offered — with a reader (persistent subconscious
4965
+ // or reader forks) it summarizes and wakes; with `tuneOut.enabled` alone
4966
+ // it is a silent hold, and a persisted epoch is resumed and released
4967
+ // (#232 gap 10). Where neither is configured nothing could ever cancel a
4968
+ // tune-out on record, so each one ends at the start instead
4969
+ // (endTuneOutsWithoutCoordinator), as main has it.
4970
+ if (framework.channelRegistry && framework.tuneOutOffered) {
4659
4971
  framework.tuneOutCoordinator = new TuneOutCoordinator(
4660
4972
  framework.channelRegistry,
4661
4973
  framework.mcplServerRegistry!,
@@ -4667,12 +4979,21 @@ export class AgentFramework {
4667
4979
  framework.pendingRequests.push({ agentName, reason, source, timestamp: Date.now() });
4668
4980
  },
4669
4981
  subconsciousName: () => framework.subconsciousAgentName,
4982
+ ...(framework.readerForks
4983
+ ? {
4984
+ invokeReader: (invocation: ReaderInvocation) => framework.invokeReaderFork(invocation),
4985
+ deliverFromReader: (reader: string, content: ContentBlock[], metadata: Record<string, unknown>) =>
4986
+ framework.deliverFromReader(reader, content, metadata),
4987
+ }
4988
+ : {}),
4670
4989
  primaryName: () => framework.primaryAgentName,
4671
4990
  getStoredMessages: () => {
4672
4991
  const primary = framework.primaryAgentName
4673
4992
  ? framework.agents.get(framework.primaryAgentName) : undefined;
4674
4993
  return primary ? primary.getContextManager().getAllMessages() : [];
4675
4994
  },
4995
+ commitDeliveries: () => framework.commitDeliveries(),
4996
+ historyTool: () => framework.residentHistoryTool(),
4676
4997
  getDeferredMessages: () => {
4677
4998
  const forPrimary = (m: DeferredWrite): boolean => (m.forAgent ?? framework.primaryAgentName) === framework.primaryAgentName;
4678
4999
  // Settled first, so a write that landed is in the stored rows
@@ -4680,7 +5001,7 @@ export class AgentFramework {
4680
5001
  framework.settleUnknownLandings(forPrimary);
4681
5002
  return framework.deferredMessages.filter(forPrimary)
4682
5003
  .sort(bySeq)
4683
- .map((m) => ({ participant: m.participant, content: m.content, metadata: m.metadata as Record<string, unknown> | undefined }));
5004
+ .map((m) => ({ participant: m.participant, content: m.content, metadata: m.metadata as Record<string, unknown> | undefined, writeId: m.id, heldAt: m.heldAt }));
4684
5005
  },
4685
5006
  // Every reader of the shared slot compiles the dump with its own
4686
5007
  // strategy, so the dump fits the smallest of their bounds.
@@ -4696,10 +5017,22 @@ export class AgentFramework {
4696
5017
  setSubconsciousAnchor: (sequence) =>
4697
5018
  framework.subconsciousStrategy?.setAnchor(sequence),
4698
5019
  isForkBound: (channel) =>
4699
- [...framework.conversationAgentHomes.values()].some((home) => sameChannel(home, channel)),
5020
+ framework.registry.list().some((record) => sameChannel(framework.homeOf(record.name), channel)),
4700
5021
  isPrivilegedAuthor: (authorId) =>
4701
5022
  framework.eventGate?.isPrivilegedUser(authorId) ?? false,
4702
5023
  allowChannelSpeech: () => framework.subconsciousConfig?.allowChannelSpeech === true,
5024
+ holdWithoutReader: () => framework.tuneOutOffered,
5025
+ ...(framework.tuneOutAnnounce
5026
+ ? {
5027
+ // The excepted channel on its server: a bare id two servers
5028
+ // register is refused (publishForAgent).
5029
+ announce: async (serverId: string, channelId: string, text: string) => {
5030
+ const r = await framework.channelRegistry!.publishForAgent(
5031
+ { serverId, channelId }, text, framework.primaryAgentName ?? 'resident');
5032
+ return { success: r.success, ...(r.error ? { error: r.error } : {}) };
5033
+ },
5034
+ }
5035
+ : {}),
4703
5036
  emitTrace: (e) => framework.emitTrace(e as never),
4704
5037
  },
4705
5038
  );
@@ -4791,6 +5124,7 @@ export class AgentFramework {
4791
5124
 
4792
5125
  this.running = true;
4793
5126
  this.providerAdmissionClosed = false;
5127
+ this.earlyTraces = null;
4794
5128
  // A new lifecycle: a turn compiled before an earlier stop() never drives,
4795
5129
  // and withheld spills, closed for the generation a stop ended, are open
4796
5130
  // for this one.
@@ -5042,6 +5376,11 @@ export class AgentFramework {
5042
5376
  step(() => this.pushCoalescer?.suspend());
5043
5377
  step(() => this.flushCoalescingSnapshot());
5044
5378
  this.running = false;
5379
+ // Bounded work this stop cuts off is neither finished nor failed. Its
5380
+ // record stays live in the snapshot, so the next boot ends it as
5381
+ // `host-restart` and tells its result recipient — the same as a crash
5382
+ // (first live run, 2026-10-12, finding D: a reader fork ended silently).
5383
+ for (const run of this.ephemeralRuns.values()) run.endReason ??= 'host-stop';
5045
5384
  // Flushed-but-unsynced deferred writes: sync and ack now, while the
5046
5385
  // store is still open, rather than leaving them to a reboot replay.
5047
5386
  step(() => this.ackDeferredWrites());
@@ -5249,11 +5588,11 @@ export class AgentFramework {
5249
5588
  // since the name is never used again. These releases, and the pass after
5250
5589
  // them, come before the registry forgets its channels, since a notice
5251
5590
  // names its channel by the label the registry holds (channelForNotice).
5252
- for (const name of [...this.conversationAgentHomes.keys()]) {
5591
+ // The registry names the conversation forks and their home channels.
5592
+ for (const { name, home } of this.listAgents().filter((r) => r.kind === 'conversation-fork').map((r) => ({ name: r.name, home: this.homeOf(r.name) }))) {
5253
5593
  step(() => {
5254
- const home = this.conversationAgentHomes.get(name)!;
5255
- if (this.conversationRouter?.getBinding(home)?.agentName === name) this.conversationRouter.unbind(home);
5256
- this.disposeConversationAgent(name);
5594
+ if (home !== undefined && this.conversationRouter?.getBinding(home)?.agentName === name) this.conversationRouter.unbind(home);
5595
+ this.disposeConversationAgent(name, 'host-stop');
5257
5596
  });
5258
5597
  }
5259
5598
  // A window release the quiesce postponed (releasesAfterResume) never runs
@@ -5682,10 +6021,12 @@ export class AgentFramework {
5682
6021
  }
5683
6022
 
5684
6023
  /** The agents the heartbeat wakes: the residents, not conversation forks,
5685
- * ephemeral runs or the subconscious, which keep windows of their own. */
6024
+ * ephemeral runs or the subconscious, which keep windows of their own.
6025
+ * Read from the registry by role, as an untargeted push's recipients are
6026
+ * (the heartbeat's wakes were heartbeat-mcpl's pushes): the agents whose
6027
+ * spec takes untargeted inbound, in registration order. */
5686
6028
  private heartbeatTargets(): string[] {
5687
- return [...this.agents.keys()].filter((n) => !this.conversationAgentHomes.has(n)
5688
- && !this.ephemeralRuns.has(n) && n !== this.subconsciousAgentName);
6029
+ return this.registry.untargetedRecipients().filter((n) => this.agents.has(n));
5689
6030
  }
5690
6031
 
5691
6032
  /**
@@ -5783,7 +6124,7 @@ export class AgentFramework {
5783
6124
  private heartbeatRefusal(agentName: string, call: ToolCall): string | null {
5784
6125
  const tool = call.name.replace(/^(mcpl--)?heartbeat--/, '');
5785
6126
  if (!HEARTBEAT_CHANGING_TOOLS.includes(tool) || this.heartbeatTargets().includes(agentName)) return null;
5786
- const one = this.conversationAgentHomes.has(agentName) ? 'conversation'
6127
+ const one = this.registry.homeChannel(agentName) !== undefined ? 'conversation'
5787
6128
  : this.ephemeralRuns.has(agentName) ? 'run' : 'side agent';
5788
6129
  return `Nothing was changed: the heartbeat and its reminders are the agent's, and wake its residents, so this ${one} can't `
5789
6130
  + 'change them. heartbeat--heartbeat_status and heartbeat--reminder_list show them.';
@@ -6011,7 +6352,7 @@ export class AgentFramework {
6011
6352
  return true;
6012
6353
  }
6013
6354
  private holdProviderAcceleration(agent: Agent, error: Error, trigger?: InferenceRequest, auxiliary = false): boolean {
6014
- if (this.ephemeralRuns.has(agent.name) || this.conversationAgentHomes.has(agent.name)) return false;
6355
+ if (!this.registry.ownsProviderScheduling(agent.name)) return false;
6015
6356
  // A provider's stated wait binds later calls to the primary model, across
6016
6357
  // restarts. (An auxiliary call records its own model's wait at the gate.)
6017
6358
  const wait = auxiliary ? undefined : this.recordProviderWait(agent.name, agent.model, error);
@@ -6140,7 +6481,7 @@ export class AgentFramework {
6140
6481
  // From either copy of membrane (isMembraneError), a wait is stated only
6141
6482
  // as a number, as the default error policy reads it.
6142
6483
  if (!isMembraneError(error) || typeof error.retryAfterMs !== 'number') return undefined;
6143
- if (this.conversationAgentHomes.has(agent.name)) {
6484
+ if (this.registry.homeChannel(agent.name) !== undefined) {
6144
6485
  const wait = this.recordProviderWait(agent.name, agent.model, error);
6145
6486
  return error.retryable === true && wait !== undefined && this.holdForProviderWait(agent, wait, trigger ? [asTurnRetry(trigger)] : [], true)
6146
6487
  ? 'held'
@@ -6364,6 +6705,14 @@ export class AgentFramework {
6364
6705
  // failure or start reaching here after it holds nothing, so no retry
6365
6706
  // outlives the stop as a turn still alive. Nothing is started either.
6366
6707
  if (this.providerAdmissionClosed) return true;
6708
+ // A listener of the failure reported here (`report`) may have ended the
6709
+ // agent (stopAgent): its end dropped what of its turn was on its way,
6710
+ // and nothing is held for an agent that's gone, so no retry outlives it
6711
+ // as a turn still alive. (A caller that reports a failure before calling
6712
+ // here checks the agent first: nonOwningProviderWait holds only for an
6713
+ // agent whose registry record is still live, and the scheduler and the
6714
+ // last boundary pass this.agents' own Agent.)
6715
+ if (this.agents.get(agent.name) !== agent) return true;
6367
6716
  // A listener of the report may have released the wait
6368
6717
  // (releaseProviderWait) before anything was held: nothing binds now, so
6369
6718
  // the requests start, as the release of an installed hold starts them.
@@ -6901,9 +7250,21 @@ export class AgentFramework {
6901
7250
  /**
6902
7251
  * Add a trace event listener for observability.
6903
7252
  */
6904
- onTrace(listener: TraceEventListener): () => void {
7253
+ onTrace(listener: TraceEventListener, options?: { replay?: boolean }): () => void {
6905
7254
  const registration: TraceRegistration = { listener, active: true };
6906
7255
  this.traceListeners.push(registration);
7256
+ // A host attaches its sinks after create() returns, so the traces
7257
+ // create() emitted (registry restore, interrupted work, resumed epochs)
7258
+ // are replayed to a listener that asks; dropped at start().
7259
+ if (options?.replay) {
7260
+ const replayed: TraceEvent[] = [...(this.earlyTraces ?? [])];
7261
+ if (this.earlyTraces && this.earlyTracesDropped > 0) {
7262
+ replayed.push({ type: 'dendrite:early-traces-dropped', dropped: this.earlyTracesDropped, timestamp: Date.now() });
7263
+ }
7264
+ for (const event of replayed) {
7265
+ try { registration.listener(event); } catch (error) { console.error('Trace listener error (replay):', error); }
7266
+ }
7267
+ }
6907
7268
  // Return an unsubscribe so callers with a bounded lifetime (e.g. modules
6908
7269
  // that get torn down and recreated on session switch) don't leak
6909
7270
  // listeners. Existing callers that ignore the return value are unaffected.
@@ -7293,8 +7654,8 @@ export class AgentFramework {
7293
7654
  if (this.channelRegistry) {
7294
7655
  channelTools.push(AgentFramework.DRAFTS_TOOL);
7295
7656
  }
7296
- if (this.tuneOutCoordinator) {
7297
- channelTools.push(AgentFramework.TUNE_OUT_TOOL);
7657
+ if (this.tuneOutCoordinator && this.tuneOutOffered) {
7658
+ channelTools.push(AgentFramework.TUNE_OUT_TOOL, AgentFramework.FOCUS_TOOL);
7298
7659
  }
7299
7660
  const gateTools = this.eventGate
7300
7661
  ? [
@@ -7689,7 +8050,7 @@ export class AgentFramework {
7689
8050
  private tunedOutSendNote(agentName: string, channel: ChannelRef): string | undefined {
7690
8051
  try {
7691
8052
  const registry = this.channelRegistry;
7692
- if (!registry || this.conversationAgentHomes.has(agentName)) return undefined;
8053
+ if (!registry || this.registry.homeChannel(agentName) !== undefined) return undefined;
7693
8054
  if (registry.getDesiredState(channel.serverId, channel.channelId) !== 'tuned-out') return undefined;
7694
8055
  if (!this.closedChannelNoteOn(agentName)) return undefined;
7695
8056
  // How it ends is tune_out's own refusals' sentence, which names the
@@ -7722,7 +8083,7 @@ export class AgentFramework {
7722
8083
  * delivered, so that's logged and nothing is added. */
7723
8084
  private closedChannelNotes(agentName: string, channels: Iterable<ChannelRef>): string[] {
7724
8085
  try {
7725
- if (this.conversationAgentHomes.has(agentName)) return [];
8086
+ if (this.registry.homeChannel(agentName) !== undefined) return [];
7726
8087
  const notes = [...channels].flatMap((channel) => this.closedChannelNoteFor(agentName, channel) ?? []);
7727
8088
  // The setting is read only when there's a note to give.
7728
8089
  return notes.length > 0 && this.closedChannelNoteOn(agentName) ? notes : [];
@@ -7963,7 +8324,13 @@ export class AgentFramework {
7963
8324
  : t);
7964
8325
  return [...SUBCONSCIOUS_TOOLS, ...basics];
7965
8326
  }
7966
- return [...this.getAllTools(), ...(this.toolPresentations.has(agentName) ? presentationTools(this.toolPresentations.get(agentName)!.config.cataloguePath) : [])]
8327
+ const surfaceOwner = this.toolSurfaceOwners.get(agentName) ?? agentName;
8328
+ // Reader forks (subconscious.reader: 'forks') share the resident's
8329
+ // provider prefix, and the tool block is the front of it: the reader's
8330
+ // four tools are therefore in every agent's block and refused at
8331
+ // dispatch for everyone but a reader (see dispatchToolCall).
8332
+ const readerTools = this.readerForks ? SUBCONSCIOUS_TOOLS : [];
8333
+ return [...this.getAllTools(), ...readerTools, ...(this.toolPresentations.has(surfaceOwner) ? presentationTools(this.toolPresentations.get(surfaceOwner)!.config.cataloguePath) : [])]
7967
8334
  .map((tool) => {
7968
8335
  if (tool.name === 'think') {
7969
8336
  return this.buildThinkTool(
@@ -7991,7 +8358,9 @@ export class AgentFramework {
7991
8358
  }
7992
8359
 
7993
8360
  inspectToolPresentation(agentName: string, snapshot?: InferenceToolSnapshot): PresentationSnapshot | null {
7994
- const presentation = this.toolPresentations.get(agentName);
8361
+ // A derived agent shows its parent's presentation (see toolSurfaceOwners);
8362
+ // only the owner can edit it (editToolPresentation looks up by own name).
8363
+ const presentation = this.toolPresentations.get(this.toolSurfaceOwners.get(agentName) ?? agentName);
7995
8364
  if (!presentation) return null;
7996
8365
  const tools = this.availableToolsForPresentation(agentName, snapshot);
7997
8366
  const sources = new Map<string, string>();
@@ -9349,6 +9718,15 @@ export class AgentFramework {
9349
9718
  }
9350
9719
  this.deferredMessages.push(...restored);
9351
9720
  this.deferredWritesPersisted = true;
9721
+ // A tune-out release that was still deferred at the crash owes the
9722
+ // resident the wake its cancel requested (#232 gap 6).
9723
+ for (const m of restored) {
9724
+ if ((m.metadata as { kind?: unknown } | undefined)?.kind !== 'tune-out-cancel') continue;
9725
+ const target = m.forAgent ?? this.primaryAgentName;
9726
+ if (target) {
9727
+ this.pendingRequests.push({ agentName: target, reason: 'tune-out released (restored)', source: 'tune-out', timestamp: Date.now() });
9728
+ }
9729
+ }
9352
9730
  // Rewrite the durable queue without the already-landed entries so a
9353
9731
  // second interrupted boot does not re-scan them, without the malformed
9354
9732
  // and repeated ones, and with a seq for each entry that came without a
@@ -10249,7 +10627,18 @@ export class AgentFramework {
10249
10627
  * ContextManager, before the store. Until then the ContextManager stays
10250
10628
  * open.
10251
10629
  */
10252
- async createEphemeralAgent(config: AgentConfig): Promise<{
10630
+ async createEphemeralAgent(
10631
+ config: AgentConfig,
10632
+ /**
10633
+ * How the registry should describe this agent. Omitted: an independent
10634
+ * fresh worker with no spawner and no result route, which is what every
10635
+ * caller of this method got before the registry existed. Supplying a
10636
+ * spec (see the presets in `dendrite/`) declares who spawned it, where
10637
+ * its result goes and what it inherited, so lifecycle rules — orphan
10638
+ * notice, reparenting, held results — apply to it.
10639
+ */
10640
+ spec?: Omit<AgentSpec, 'name'>,
10641
+ ): Promise<{
10253
10642
  agent: Agent;
10254
10643
  contextManager: ContextManager;
10255
10644
  cleanup: () => void;
@@ -10266,24 +10655,79 @@ export class AgentFramework {
10266
10655
  }
10267
10656
  this.usedEphemeralAgentNames.set(config.name, 'run');
10268
10657
  const release: ManagerRelease = { released: false, calls: new Set() };
10658
+ let opened: string | undefined;
10269
10659
  try {
10270
- const namespace = `subagent/${config.name}`;
10660
+ // A namespace the spec names is where the context opens, as it is what
10661
+ // the record says and what inspection and resumption open later.
10662
+ let namespace = spec?.namespace ?? `subagent/${config.name}`;
10663
+ let contextManager: ContextManager;
10664
+ // The host's say, on the spec of what is about to exist.
10665
+ if (this.beforeDerive) {
10666
+ const effective: AgentSpec = spec
10667
+ ? { ...spec, name: config.name, model: config.model }
10668
+ : { ...workerSpec(config.name, { namespace }), model: config.model };
10669
+ const verdict = this.beforeDerive(effective);
10670
+ if (verdict && typeof verdict === 'object' && 'refuse' in verdict) {
10671
+ throw new Error(`${config.name} was not created: ${verdict.refuse}`);
10672
+ }
10673
+ }
10674
+ let inherit = spec?.inherit;
10271
10675
 
10272
- const contextManager = await ContextManager.open({
10273
- store: this.store,
10274
- namespace,
10275
- isolate: true,
10276
- strategy: config.strategy ?? new PassthroughStrategy(),
10277
- membrane: this.ephemeralAuxiliaryMembraneFor(config.name, release),
10278
- debugLogContext: !!process.env.DEBUG_CONTEXT,
10279
- });
10676
+ if (inherit?.mode === 'shared') {
10677
+ // Shared reads, separate writes: the child's context is the parent's
10678
+ // at a checkpoint, on a branch of its own. Nothing is copied.
10679
+ const derived = await this.deriveSharedContext(config, inherit, release);
10680
+ contextManager = derived.contextManager;
10681
+ inherit = derived.inherit;
10682
+ namespace = derived.namespace;
10683
+ } else {
10684
+ // A namespace belongs to the agent that opened it: another agent's
10685
+ // turns there would be read as its own by inspection and by its
10686
+ // resumption, which reopens "its own namespace". So one another
10687
+ // agent's context holds (live, a candidate, awaiting re-declaration
10688
+ // or ended) is refused; an agent's own reopens in a new incarnation.
10689
+ const holder = this.namespaceHolder(namespace, config.name);
10690
+ if (holder) {
10691
+ // The default is offered only where it isn't the namespace refused.
10692
+ const fallback = `subagent/${config.name}`;
10693
+ throw new Error(
10694
+ `Ephemeral agent "${config.name}" refused: namespace "${namespace}" holds the context of agent ` +
10695
+ `"${holder}"; name another${namespace === fallback ? '' : `, or none for ${fallback}`}`,
10696
+ );
10697
+ }
10698
+ this.namespaceOwners.set(namespace, config.name);
10699
+ opened = namespace;
10700
+ contextManager = await ContextManager.open({
10701
+ store: this.store,
10702
+ namespace,
10703
+ isolate: true,
10704
+ strategy: config.strategy ?? new PassthroughStrategy(),
10705
+ membrane: this.ephemeralAuxiliaryMembraneFor(config.name, release),
10706
+ debugLogContext: !!process.env.DEBUG_CONTEXT,
10707
+ });
10708
+ }
10709
+ // Either manager is the run's to release (main's manager release), the derived one included.
10280
10710
  this.trackManager(contextManager);
10281
10711
  release.manager = contextManager;
10282
10712
 
10283
10713
  const agent = new Agent(config, contextManager, this.membrane);
10284
10714
  this.adoptToolResultGuard(agent);
10285
10715
  agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
10286
- this.ephemeralCandidates.set(agent, contextManager);
10716
+ this.ephemeralCandidates.set(agent, {
10717
+ contextManager,
10718
+ config,
10719
+ spec: spec
10720
+ ? {
10721
+ ...spec,
10722
+ name: config.name,
10723
+ model: config.model,
10724
+ // A shared fork's context lives under its parent's namespace,
10725
+ // on the fork's own branch — not in a namespace of its own.
10726
+ namespace: inherit?.mode === 'shared' ? namespace : spec.namespace ?? namespace,
10727
+ ...(inherit ? { inherit } : {}),
10728
+ }
10729
+ : { ...workerSpec(config.name, { namespace }), model: config.model },
10730
+ });
10287
10731
  this.ephemeralAgents.add(agent);
10288
10732
  this.managerReleases.set(agent, release);
10289
10733
 
@@ -10296,10 +10740,1447 @@ export class AgentFramework {
10296
10740
  } catch (error) {
10297
10741
  if (release.manager) this.closeManager(release.manager);
10298
10742
  this.usedEphemeralAgentNames.delete(config.name);
10743
+ if (opened !== undefined) this.namespaceOwners.delete(opened);
10299
10744
  throw error;
10300
10745
  }
10301
10746
  }
10302
10747
 
10748
+ /**
10749
+ * Derive a new agent from a live one: the parent's context at a
10750
+ * checkpoint, a private continuation, and — unless overridden — the
10751
+ * parent's whole configuration (model, thinking, cache TTL, provider
10752
+ * params, prompt, tools). The parent keeps running.
10753
+ *
10754
+ * The returned agent is a bounded job, driven with
10755
+ * `runEphemeralToCompletion` exactly like one from `createEphemeralAgent`.
10756
+ *
10757
+ * **Inheritance.** `mode: 'shared'` (the default) gives the child the
10758
+ * parent's messages and fold state through shared storage: nothing is
10759
+ * copied, the child folds with the parent's memory tree, and neither can
10760
+ * alter what the other reads. It needs branch-bound store handles and is
10761
+ * refused, not silently downgraded, where they are missing. `mode: 'copy'`
10762
+ * is the historical mechanism — the parent's compiled context re-added as
10763
+ * the child's own messages — and works everywhere.
10764
+ *
10765
+ * **Rendering.** With `solve: 'reuse'` (the default) and nothing
10766
+ * overridden, the child's first request is the parent's request plus the
10767
+ * child's framing: the same system prompt and tool block, the parent's
10768
+ * turns presented as the child's own, and a cache marker on the end of
10769
+ * the parent's request so the prefix it wrote is read back. `solve:
10770
+ * 'fresh'` deliberately re-solves, e.g. at another budget; so does any
10771
+ * override that changes what is rendered. Those are operating choices —
10772
+ * the presentation changes and the provider cache misses.
10773
+ *
10774
+ * **Framing** is ordinary messages appended to the child before it runs:
10775
+ * the task, and whatever tells it which stream it is. The parent's turn
10776
+ * in flight is not in its store yet (see `getPendingAssistantRound`), so
10777
+ * a fork made from inside a tool call starts from the message before the
10778
+ * turn that made it.
10779
+ *
10780
+ * Context isolation is not process or filesystem isolation: parent and
10781
+ * child share tools, any shell and the workspace.
10782
+ */
10783
+ async deriveAgent(options: DeriveAgentOptions): Promise<{
10784
+ agent: Agent;
10785
+ contextManager: ContextManager;
10786
+ cleanup: () => void;
10787
+ }> {
10788
+ const parent = this.agents.get(options.from);
10789
+ const parentConfig = this.agentConfigs.get(options.from);
10790
+ if (!parent || !parentConfig) {
10791
+ throw new Error(`deriveAgent: "${options.from}" is not a registered agent`);
10792
+ }
10793
+ const mode = options.mode ?? 'shared';
10794
+ const shared = mode === 'shared';
10795
+ if (!shared && options.inheritRefusals === true) {
10796
+ throw new Error(`deriveAgent: "${options.name}" was not created: inheritRefusals is for mode 'shared', and a copy brings no refusal ledger`);
10797
+ }
10798
+ if (!shared && options.solve !== undefined) {
10799
+ throw new Error(`deriveAgent: "${options.name}" was not created: solve is for mode 'shared', and a copy is not solved`);
10800
+ }
10801
+
10802
+ const config: AgentConfig = {
10803
+ ...parentConfig,
10804
+ // The parent's LIVE settings, not just its recipe: a runtime budget or
10805
+ // think-policy override changes what it renders.
10806
+ ...(parent.contextBudgetTokens !== undefined ? { contextBudgetTokens: parent.contextBudgetTokens } : {}),
10807
+ sameRoundThinkTextPolicy: parent.getEffectiveSameRoundThinkTextPolicy(),
10808
+ ...options.config,
10809
+ name: options.name,
10810
+ strategy: options.strategy,
10811
+ // A shared inheritance has no copy step to rename the parent's turns,
10812
+ // so the mapping is declared: the parent's voice is the child's own,
10813
+ // presented under the name the parent's own requests use.
10814
+ ...(shared
10815
+ ? {
10816
+ selfParticipants: [...new Set([
10817
+ parent.name,
10818
+ ...(parentConfig.selfParticipants ?? []),
10819
+ ...(options.config?.selfParticipants ?? []),
10820
+ ])],
10821
+ presentAs: options.config?.presentAs ?? parent.presentAs,
10822
+ }
10823
+ : { selfParticipants: options.config?.selfParticipants, presentAs: options.config?.presentAs }),
10824
+ };
10825
+
10826
+ const spec = taskForkSpec(options.name, {
10827
+ parent: options.from,
10828
+ spawnedBy: options.spawnedBy ?? options.from,
10829
+ ...(options.resultTo ? { resultTo: options.resultTo } : {}),
10830
+ ...(options.deadlineMs !== undefined ? { deadlineMs: options.deadlineMs } : {}),
10831
+ ...(options.idleTimeoutMs !== undefined ? { idleTimeoutMs: options.idleTimeoutMs } : {}),
10832
+ ...(options.maxTurns !== undefined ? { maxTurns: options.maxTurns } : {}),
10833
+ ...(options.metadata ? { metadata: options.metadata } : {}),
10834
+ inherit: {
10835
+ mode,
10836
+ ...(options.solve ? { solve: options.solve } : {}),
10837
+ ...(options.inheritRefusals !== undefined ? { refusals: options.inheritRefusals } : {}),
10838
+ },
10839
+ });
10840
+ const { name: _name, ...specBody } = {
10841
+ ...spec,
10842
+ // The record states the mapping the agent's requests actually use.
10843
+ ...(config.selfParticipants?.length ? { selfParticipants: config.selfParticipants } : {}),
10844
+ ...(config.presentAs && config.presentAs !== config.name ? { presentAs: config.presentAs } : {}),
10845
+ ...(options.kind ? { kind: options.kind } : {}),
10846
+ ...(options.onParentEnd ? { onParentEnd: options.onParentEnd } : {}),
10847
+ };
10848
+
10849
+ const created = await this.createEphemeralAgent(config, specBody);
10850
+ try {
10851
+ if (this.agents.get(options.from) !== parent) {
10852
+ throw new Error(`deriveAgent: "${options.from}" ended while "${options.name}" was being derived from it`);
10853
+ }
10854
+ if (options.config?.allowedTools === undefined) {
10855
+ this.toolSurfaceOwners.set(options.name, this.toolSurfaceOwners.get(options.from) ?? options.from);
10856
+ }
10857
+
10858
+ if (!shared) {
10859
+ // The historical copy: the parent's compiled view, its turns renamed
10860
+ // so the child reads its inheritance as its own history.
10861
+ const { messages } = await parent.getContextManager().compile();
10862
+ for (const message of messages) {
10863
+ created.contextManager.addMessage(
10864
+ parent.isOwnParticipant(message.participant) ? options.name : message.participant,
10865
+ message.content,
10866
+ );
10867
+ }
10868
+ created.agent.markContextConsumed();
10869
+ }
10870
+ await this.framePendingRound(parent, created.contextManager, options, shared);
10871
+ for (const message of options.framing ?? []) {
10872
+ created.contextManager.addMessage(message.participant, message.content, message.metadata);
10873
+ }
10874
+ return created;
10875
+ } catch (error) {
10876
+ this.discardEphemeralCandidate(created.agent, created.contextManager);
10877
+ throw error;
10878
+ }
10879
+ }
10880
+
10881
+ private discardEphemeralCandidate(agent: Agent, contextManager: ContextManager): void {
10882
+ this.ephemeralCandidates.delete(agent);
10883
+ this.ephemeralAgents.delete(agent);
10884
+ if (this.toolSurfaceOwners.has(agent.name) && !this.agents.has(agent.name)) this.toolSurfaceOwners.delete(agent.name);
10885
+ this.releaseManager(agent);
10886
+ this.managerReleases.delete(agent);
10887
+ this.closeManager(contextManager);
10888
+ }
10889
+
10890
+ /**
10891
+ * The default wording for the call that derived a child: who it is, what
10892
+ * kind, and how long — the fields the consent event names, so the fork
10893
+ * never has to infer its own lifetime from context (Linn, 10-09).
10894
+ */
10895
+ private derivedNote(parentName: string, options: DeriveAgentOptions): string {
10896
+ const record = this.registry.inspect(options.name);
10897
+ const kind = record?.kind ?? options.kind ?? 'task-fork';
10898
+ const onParentEnd = record?.onParentEnd ?? options.onParentEnd ?? 'orphan';
10899
+ const route = record?.relationships.resultTo ?? options.resultTo;
10900
+ const returns = route
10901
+ ? route.as === 'message'
10902
+ ? `your report returns to ${route.to} as mail under your own name`
10903
+ : `your result returns to ${route.to} as this call's result`
10904
+ : null;
10905
+ const lifetime =
10906
+ onParentEnd === 'end'
10907
+ ? `You end when ${parentName} does${returns ? `; ${returns}` : ''}; nothing you do speaks as ${parentName}.`
10908
+ : `You finish your task and then end${returns ? `; ${returns}` : ''}; if ${parentName} ends first you keep running, as an orphan with the same return address.`;
10909
+ return (
10910
+ `[This call derived ${options.name}. You are ${options.name}, a ${kind}, continuing from here on your own branch. ` +
10911
+ `${lifetime} ${parentName} receives this call's result, not you.]`
10912
+ );
10913
+ }
10914
+
10915
+ /**
10916
+ * The parent's in-flight tool round, reproduced on the child's branch so
10917
+ * the child sees the call that made it (see `DeriveAgentOptions.pendingRound`).
10918
+ */
10919
+ private async framePendingRound(
10920
+ parent: Agent,
10921
+ child: ContextManager,
10922
+ options: DeriveAgentOptions,
10923
+ shared: boolean,
10924
+ ): Promise<void> {
10925
+ const round = options.pendingRound ?? {};
10926
+ if (round.include === false) return;
10927
+ const pending = this.pendingAssistantBlocks.get(parent.name);
10928
+ if (!pending) return;
10929
+ const calls = pending.filter((b): b is Extract<ContentBlock, { type: 'tool_use' }> => b.type === 'tool_use');
10930
+ if (calls.length === 0) return;
10931
+ const state = parent.state;
10932
+ const completed = state.status === 'waiting_for_tools' ? state.completed : [];
10933
+ const cap = this.resolveToolResultInlineCap(parent).cap;
10934
+ const { blocks: received } = await this.buildStoredToolResultContent(options.name, completed, cap);
10935
+ const byId = new Map(received.map((b) => [(b as { toolUseId: string }).toolUseId, b]));
10936
+ const open = calls.filter((c) => !byId.has(c.id));
10937
+ const madeBy = round.madeBy?.toolUseId ?? (open.length === 1 ? open[0]!.id : undefined);
10938
+ const results: ContentBlock[] = calls.map((call) => {
10939
+ const stored = byId.get(call.id);
10940
+ if (stored) return stored;
10941
+ const content =
10942
+ call.id === madeBy
10943
+ ? round.madeBy?.result ?? this.derivedNote(parent.name, options)
10944
+ : round.results?.[call.id] ??
10945
+ `[No result yet: this call was still running in ${parent.name} when ${options.name} was derived.]`;
10946
+ return { type: 'tool_result' as const, toolUseId: call.id, toolName: call.name, content, isError: false };
10947
+ });
10948
+ const metadata = { kind: 'dendrite-pending-round', from: parent.name } as unknown as MessageMetadata;
10949
+ child.addMessage(shared ? parent.name : options.name, structuredClone(pending), metadata);
10950
+ child.addMessage('user', results, metadata);
10951
+ }
10952
+
10953
+ /**
10954
+ * The assistant turn an agent has produced but not yet stored: its tool
10955
+ * calls and any text before them, held until every tool result is in so
10956
+ * the pair lands adjacently. A host deriving an agent from inside one of
10957
+ * those tool calls can reproduce the turn in the child's framing.
10958
+ */
10959
+ getPendingAssistantRound(agentName: string): ContentBlock[] | undefined {
10960
+ const blocks = this.pendingAssistantBlocks.get(agentName);
10961
+ return blocks ? structuredClone(blocks) : undefined;
10962
+ }
10963
+
10964
+ /**
10965
+ * Bring an ended bounded job back as a new incarnation of the same
10966
+ * identity, on the context it left: an isolated agent's own namespace, or
10967
+ * a derived agent's own branch. Nothing is re-derived and nothing is
10968
+ * re-seeded — the agent continues from its last stored message, which is
10969
+ * the end of its last complete round (a round whose tool results never
10970
+ * all arrived was never stored).
10971
+ *
10972
+ * Use it for a job that was stopped, failed, or was interrupted by a
10973
+ * restart. Configuration is not persisted with an agent, so the caller
10974
+ * supplies it again; the role mapping a derived agent used is restored
10975
+ * from its record unless the config sets one. Relationships are kept
10976
+ * where the other agent still exists; a job whose spawner or result
10977
+ * recipient is gone resumes without that edge and can be reparented.
10978
+ *
10979
+ * External effects are not replayed and not undone: a tool the previous
10980
+ * incarnation ran before it ended has had its effect, and a call it never
10981
+ * received a result for may be made again by the model. Drive the result
10982
+ * with `runEphemeralToCompletion`.
10983
+ */
10984
+ async resumeAgent(name: string, config: Omit<AgentConfig, 'name'>): Promise<{
10985
+ agent: Agent;
10986
+ contextManager: ContextManager;
10987
+ cleanup: () => void;
10988
+ }> {
10989
+ if (this.resumesPending.has(name)) {
10990
+ throw new Error(`resumeAgent: "${name}" is already being resumed`);
10991
+ }
10992
+ if (this.registry.has(name) || this.agents.has(name) || this.ephemeralRuns.has(name)) {
10993
+ throw new Error(`resumeAgent: "${name}" is still registered; only an ended agent resumes`);
10994
+ }
10995
+ const record = this.registry.inspect(name);
10996
+ if (!record?.ended) throw new Error(`resumeAgent: "${name}" is not a known ended agent`);
10997
+ if (record.lifetime.kind !== 'task') {
10998
+ throw new Error(
10999
+ `resumeAgent: "${name}" has a ${record.lifetime.kind} lifetime; only bounded jobs resume this way`,
11000
+ );
11001
+ }
11002
+ const fullConfig: AgentConfig = {
11003
+ ...config,
11004
+ name,
11005
+ ...(config.selfParticipants === undefined && record.selfParticipants
11006
+ ? { selfParticipants: record.selfParticipants }
11007
+ : {}),
11008
+ ...(config.presentAs === undefined && record.presentAs ? { presentAs: record.presentAs } : {}),
11009
+ };
11010
+ const spawnedBy = record.relationships.spawnedBy;
11011
+ const resultTo = record.relationships.resultTo;
11012
+ const keepSpawner = spawnedBy !== undefined && this.registry.has(spawnedBy);
11013
+ const spec: AgentSpec = {
11014
+ name,
11015
+ kind: record.kind,
11016
+ model: fullConfig.model,
11017
+ roles: record.roles,
11018
+ lifetime: record.lifetime,
11019
+ ...(record.activation ? { activation: record.activation } : {}),
11020
+ ...(keepSpawner ? { spawnedBy, onParentEnd: record.onParentEnd } : {}),
11021
+ ...(resultTo && this.registry.has(resultTo.to) ? { resultTo } : {}),
11022
+ ...(record.inherit ? { inherit: record.inherit } : {}),
11023
+ ...(fullConfig.selfParticipants?.length ? { selfParticipants: fullConfig.selfParticipants } : {}),
11024
+ ...(fullConfig.presentAs && fullConfig.presentAs !== name ? { presentAs: fullConfig.presentAs } : {}),
11025
+ ...(record.namespace ? { namespace: record.namespace } : {}),
11026
+ ...(record.metadata ? { metadata: record.metadata } : {}),
11027
+ };
11028
+ if (this.beforeDerive) {
11029
+ const verdict = this.beforeDerive(spec);
11030
+ if (verdict && typeof verdict === 'object' && 'refuse' in verdict) {
11031
+ throw new Error(`${name} was not resumed: ${verdict.refuse}`);
11032
+ }
11033
+ }
11034
+
11035
+ this.resumesPending.set(name, undefined);
11036
+ const release: ManagerRelease = { released: false, calls: new Set() };
11037
+ let contextManager: ContextManager;
11038
+ try {
11039
+ contextManager = await this.reopenAgentContext(record, config.strategy, this.ephemeralAuxiliaryMembraneFor(name, release));
11040
+ } catch (error) {
11041
+ this.resumesPending.delete(name);
11042
+ throw error;
11043
+ }
11044
+ this.trackManager(contextManager);
11045
+ release.manager = contextManager;
11046
+ let agent: Agent;
11047
+ try {
11048
+ if (this.registry.has(name) || this.agents.has(name) || this.ephemeralRuns.has(name)) {
11049
+ throw new Error(`resumeAgent: "${name}" was registered while it was being resumed`);
11050
+ }
11051
+ agent = new Agent(fullConfig, contextManager, this.membrane);
11052
+ this.adoptToolResultGuard(agent);
11053
+ agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
11054
+ } catch (error) {
11055
+ this.resumesPending.delete(name);
11056
+ this.closeManager(contextManager);
11057
+ throw error;
11058
+ }
11059
+
11060
+ this.usedEphemeralAgentNames.set(name, 'run'); // a resumed job is an ephemeral run: no global fallback (takesGlobalFallback)
11061
+ this.ephemeralAgents.add(agent); // and its usage counts in the shared ephemeral entry (#558)
11062
+ this.ephemeralCandidates.set(agent, { contextManager, config: fullConfig, resume: true, after: record.incarnation, spec });
11063
+ this.managerReleases.set(agent, release);
11064
+ this.resumesPending.set(name, agent);
11065
+ const cleanup = () => {
11066
+ if (this.resumesPending.get(name) === agent) this.resumesPending.delete(name);
11067
+ this.releasedByCleanup.add(agent);
11068
+ this.releaseManager(agent);
11069
+ };
11070
+ return { agent, contextManager, cleanup };
11071
+ }
11072
+
11073
+ /**
11074
+ * A context to read an agent's history from, without changing the live
11075
+ * system.
11076
+ *
11077
+ * - A live agent: a snapshot derived from its context — at the head, or
11078
+ * at `atSequence` for an earlier state. The snapshot is its own branch;
11079
+ * the agent keeps running and is not affected by anything done to it.
11080
+ * - An ended agent: the context it left, reopened where it lives.
11081
+ *
11082
+ * The returned manager uses a passthrough strategy: it shows what is
11083
+ * stored, raw. An ended agent's context is the real one — treat it as
11084
+ * read-only unless you mean to change what a later resume starts from.
11085
+ */
11086
+ async inspectAgentContext(name: string, options: { atSequence?: number } = {}): Promise<ContextManager> {
11087
+ const live = this.registry.has(name) ? this.agents.get(name) : undefined;
11088
+ if (live) {
11089
+ const source = live.getContextManager() as ContextManager & Partial<DerivingContextManager>;
11090
+ if (typeof source.derive !== 'function') {
11091
+ throw new Error(
11092
+ 'inspectAgentContext: a snapshot of a live agent needs a @animalabs/context-manager that provides ContextManager.derive',
11093
+ );
11094
+ }
11095
+ const taken = new Set(this.store.listBranches().map((branch) => branch.name));
11096
+ let n = 1;
11097
+ while (taken.has(`dendrite/inspect/${name}/${n}`)) n++;
11098
+ return source.derive({
11099
+ branch: `dendrite/inspect/${name}/${n}`,
11100
+ strategy: new PassthroughStrategy(),
11101
+ membrane: this.membrane,
11102
+ solve: 'fresh',
11103
+ ...(options.atSequence !== undefined ? { atSequence: options.atSequence } : {}),
11104
+ });
11105
+ }
11106
+ const record = this.registry.inspect(name);
11107
+ if (!record) throw new Error(`inspectAgentContext: "${name}" is not a known agent`);
11108
+ if (options.atSequence !== undefined) {
11109
+ throw new Error('inspectAgentContext: atSequence applies to a live agent; an ended agent is read as it was left');
11110
+ }
11111
+ return this.reopenAgentContext(record, new PassthroughStrategy());
11112
+ }
11113
+
11114
+ /** Open the context an ended agent left, wherever it lives. */
11115
+ private async reopenAgentContext(record: AgentRecord, strategy?: ContextStrategy, membrane: Membrane = this.membrane): Promise<ContextManager> {
11116
+ // The residents' view as createAgent gives it: held rows out, and another
11117
+ // agent's stored context injections out, since those are that agent's
11118
+ // context, not conversation (Weft's ninth-round probe: an ended reader
11119
+ // reopened for inspection showed a co-resident's injection it never saw live).
11120
+ const residentSees = (name: string) => (metadata: unknown): boolean =>
11121
+ RESIDENT_VIEW_FILTER({ metadata } as StoredMessage) && !isAnotherAgentsInjection(metadata, name);
11122
+ // A context derived from a reader of the shared slot sees, as it did live
11123
+ // (deriveSharedContext, #291), what its source saw and its own stored
11124
+ // injections; its source's view is composed the same way, up the shared
11125
+ // sources the registry names, to the reader the first was derived from.
11126
+ const derivedSees = (from: AgentRecord, walked = new Set<string>()): ((metadata: unknown) => boolean) => {
11127
+ const source = from.inherit?.mode === 'shared' ? from.inherit.from : undefined;
11128
+ if (source === undefined || walked.has(from.name)) return residentSees(from.name);
11129
+ walked.add(from.name);
11130
+ const sourceRecord = this.registry.inspect(source);
11131
+ const sourceSees = sourceRecord ? derivedSees(sourceRecord, walked) : residentSees(source);
11132
+ return (metadata) => sourceSees(metadata) || isOwnInjection(metadata, from.name);
11133
+ };
11134
+ // The predicate is the manager's view filter, and is recorded beside it,
11135
+ // so main's checks of whether an agent sees a row (agentSees) ask what its
11136
+ // view holds when the reopened context is live.
11137
+ const seen = (contextManager: ContextManager, sees: (metadata: unknown) => boolean): ContextManager => {
11138
+ this.storeSees.set(contextManager, sees);
11139
+ return contextManager;
11140
+ };
11141
+ if (record.inherit?.mode === 'shared') {
11142
+ const derivation = record.inherit.derivation as ContextDerivationRecord | undefined;
11143
+ const reopen = (ContextManager as unknown as { reopenDerived?: ReopenDerived }).reopenDerived;
11144
+ if (!derivation || typeof reopen !== 'function') {
11145
+ throw new Error(
11146
+ `Agent "${record.name}": its context is a derived branch, which this ` +
11147
+ '@animalabs/context-manager cannot reopen (ContextManager.reopenDerived)',
11148
+ );
11149
+ }
11150
+ // A context derived from a reader of the shared slot keeps the view it had.
11151
+ const sees = derivation.slots?.messageNamespace === null ? derivedSees(record) : undefined;
11152
+ const reopened = await reopen.call(ContextManager, {
11153
+ store: this.store,
11154
+ derivation,
11155
+ strategy: strategy ?? new PassthroughStrategy(),
11156
+ membrane,
11157
+ ...(sees ? { viewFilter: (message: StoredMessage) => sees(message.metadata) } : {}),
11158
+ debugLogContext: !!process.env.DEBUG_CONTEXT,
11159
+ });
11160
+ return sees ? seen(reopened, sees) : reopened;
11161
+ }
11162
+ if (!record.namespace) {
11163
+ throw new Error(`Agent "${record.name}": its record names no context to reopen`);
11164
+ }
11165
+ // A reader of the residents' shared slot (a resident dropped from the
11166
+ // configuration, say) is reopened as createAgent opened it: on the
11167
+ // shared slot, through the residents' view. Isolated, its namespace
11168
+ // holds none of what it read (nissa's #546 review, round 2).
11169
+ const sees = record.readsSharedSlot === true ? residentSees(record.name) : undefined;
11170
+ const opened = await ContextManager.open({
11171
+ store: this.store,
11172
+ namespace: record.namespace,
11173
+ ...(sees ? { viewFilter: (message: StoredMessage) => sees(message.metadata) } : { isolate: true }),
11174
+ strategy: strategy ?? new PassthroughStrategy(),
11175
+ membrane,
11176
+ debugLogContext: !!process.env.DEBUG_CONTEXT,
11177
+ });
11178
+ return sees ? seen(opened, sees) : opened;
11179
+ }
11180
+
11181
+ /** Open a derived agent's context on a branch of its own (see deriveAgent). */
11182
+ private async deriveSharedContext(
11183
+ config: AgentConfig,
11184
+ inherit: NonNullable<AgentSpec['inherit']>,
11185
+ release: ManagerRelease,
11186
+ ): Promise<{ contextManager: ContextManager; inherit: NonNullable<AgentSpec['inherit']>; namespace: string }> {
11187
+ const parent = this.agents.get(inherit.from);
11188
+ if (!parent) {
11189
+ throw new Error(`Agent "${config.name}": context source "${inherit.from}" is not a registered agent`);
11190
+ }
11191
+ if (this.surgeryHold) {
11192
+ throw new Error(
11193
+ `Agent "${config.name}" refused: the store is under live ${this.surgeryHold.verb} ` +
11194
+ `for ${this.surgeryHold.agentName} — retry when it completes`,
11195
+ );
11196
+ }
11197
+ const source = parent.getContextManager() as ContextManager & Partial<DerivingContextManager>;
11198
+ if (typeof source.derive !== 'function') {
11199
+ throw new Error(
11200
+ `Agent "${config.name}": shared context inheritance needs a @animalabs/context-manager ` +
11201
+ 'that provides ContextManager.derive; use inherit.mode "copy" with this version',
11202
+ );
11203
+ }
11204
+ // The branch is the child's durable state and outlives it. Names from an
11205
+ // earlier process may still own a branch of the same name.
11206
+ const taken = new Set(this.store.listBranches().map((branch) => branch.name));
11207
+ let ownBranch = `dendrite/${config.name}`;
11208
+ for (let n = 2; taken.has(ownBranch); n++) ownBranch = `dendrite/${config.name}~${n}`;
11209
+
11210
+ // The fork's view: what its source sees, and its own stored context
11211
+ // injections. context-manager's derive would give it the source's filter
11212
+ // alone, which is tied to the source's name (isAnotherAgentsInjection), so
11213
+ // the fork's own injections were hidden from its compile while its
11214
+ // parent's showed (#291; Weft's eleventh note, on the real compile path).
11215
+ // The composed predicate goes to derive, so the first compile already
11216
+ // reads through it, and is recorded beside the manager (storeSees), so
11217
+ // main's checks of whether an agent sees a row (agentSees: a tool
11218
+ // boundary's injection, an arrival, a reader of an unread message) ask
11219
+ // what its compile shows. A source with no view recorded has no filter
11220
+ // (an isolated run's): it sees every row, so its fork keeps none, and
11221
+ // sees its own.
11222
+ const sourceSees = this.storeSees.get(source);
11223
+ const sees = sourceSees
11224
+ ? (metadata: unknown): boolean => sourceSees(metadata) || isOwnInjection(metadata, config.name)
11225
+ : undefined;
11226
+ const contextManager = await source.derive({
11227
+ branch: ownBranch,
11228
+ strategy: config.strategy ?? new PassthroughStrategy(),
11229
+ // The run's auxiliary membrane, as the copy path's: its compression calls
11230
+ // are withdrawn once the run is released (Weft's ninth-round reading).
11231
+ membrane: this.ephemeralAuxiliaryMembraneFor(config.name, release),
11232
+ solve: inherit.solve ?? 'reuse',
11233
+ inherit: { refusals: inherit.refusals === true },
11234
+ ...(inherit.atSequence !== undefined ? { atSequence: inherit.atSequence } : {}),
11235
+ ...(sees ? { viewFilter: (message: StoredMessage) => sees(message.metadata) } : {}),
11236
+ debugLogContext: !!process.env.DEBUG_CONTEXT,
11237
+ });
11238
+ if (sees) this.storeSees.set(contextManager, sees);
11239
+ const derivation = (contextManager as ContextManager & Partial<DerivingContextManager>).getDerivation?.();
11240
+ return {
11241
+ contextManager,
11242
+ inherit: {
11243
+ ...inherit,
11244
+ solve: inherit.solve ?? 'reuse',
11245
+ refusals: inherit.refusals === true,
11246
+ ownBranch,
11247
+ ...(derivation
11248
+ ? { branch: derivation.parentBranch, atSequence: derivation.atSequence, derivation }
11249
+ : {}),
11250
+ },
11251
+ namespace: this.registry.get(inherit.from)?.namespace ?? `subagent/${config.name}`,
11252
+ };
11253
+ }
11254
+
11255
+ // ==========================================================================
11256
+ // Dendrite: agent registry, lifecycle and attributed communication
11257
+ // ==========================================================================
11258
+ //
11259
+ // One trust domain: there is no permission check on any of this. Any
11260
+ // caller may list, cancel, stop or reparent any agent. Context isolation
11261
+ // between agents is not process or filesystem isolation — agents sharing
11262
+ // tools, a shell or a workspace can affect the same external state.
11263
+
11264
+ /** Flat list of registered agents with their roles, lifetime and relationships. */
11265
+ listAgents(options: { includeEnded?: boolean } = {}): AgentRecord[] {
11266
+ return structuredClone(this.registry.list(options));
11267
+ }
11268
+
11269
+ /** One agent's record: the live one, else its most recent ended one. */
11270
+ getAgentRecord(name: string): AgentRecord | null {
11271
+ const record = this.registry.inspect(name);
11272
+ return record ? structuredClone(record) : null;
11273
+ }
11274
+
11275
+ /** The agent that owns default delivery, or null before any is declared. */
11276
+ getPrimaryAgentName(): string | null {
11277
+ return this.registry.primary();
11278
+ }
11279
+
11280
+ /**
11281
+ * Activations in flight, each with the agent that owns the work. Derived
11282
+ * from the live turn state, so it cannot disagree with what is running.
11283
+ */
11284
+ listActivations(): ActivationRecord[] {
11285
+ const activations: ActivationRecord[] = [];
11286
+ for (const [name, token] of this.activeTurnTokens) {
11287
+ const record = this.registry.get(name);
11288
+ if (!record) continue;
11289
+ const trigger = this.activeTurnTriggers.get(name);
11290
+ activations.push({
11291
+ id: `${name}#${record.incarnation}:${token}`,
11292
+ owner: { agent: name, incarnation: record.incarnation },
11293
+ ...(trigger?.timestamp !== undefined ? { requestedAt: trigger.timestamp } : {}),
11294
+ ...(trigger?.reason ? { reason: trigger.reason } : {}),
11295
+ ...(trigger?.source ? { source: trigger.source } : {}),
11296
+ ...(trigger?.channelId ? { channelId: trigger.channelId } : {}),
11297
+ });
11298
+ }
11299
+ return activations;
11300
+ }
11301
+
11302
+ /**
11303
+ * Policy epochs currently in force (tune-out holds), read from the
11304
+ * coordinator's own durable state — not mirrored. An epoch is neither an
11305
+ * agent nor a task: ending one leaves both running.
11306
+ */
11307
+ listPolicyEpochs(): PolicyEpoch[] {
11308
+ if (!this.channelRegistry) return [];
11309
+ const owner = this.registry.primary();
11310
+ const epochs: PolicyEpoch[] = [];
11311
+ for (const entry of this.channelRegistry.listChannelsRaw()) {
11312
+ const state = this.channelRegistry.getTuneOutState(entry.serverId, entry.descriptor.id);
11313
+ if (!state) continue;
11314
+ epochs.push({
11315
+ id: state.params.epochId,
11316
+ kind: 'tune-out',
11317
+ scope: { serverId: entry.serverId, channelId: entry.descriptor.id },
11318
+ ...(owner ? { owner } : {}),
11319
+ ...(this.subconsciousAgentName ? { reader: this.subconsciousAgentName } : {}),
11320
+ startedAtSequence: state.params.startedAtSequence,
11321
+ ...(state.params.expiresAtMs !== undefined ? { expiresAtMs: state.params.expiresAtMs } : {}),
11322
+ });
11323
+ }
11324
+ const scope = this.tuneOutCoordinator?.getAllExcept();
11325
+ if (scope) {
11326
+ epochs.push({
11327
+ id: scope.params.epochId,
11328
+ kind: 'tune-out',
11329
+ scope: { kind: 'all-except', except: scope.params.except },
11330
+ ...(owner ? { owner } : {}),
11331
+ ...(this.subconsciousAgentName ? { reader: this.subconsciousAgentName } : {}),
11332
+ startedAtSequence: scope.params.startedAtSequence,
11333
+ expiresAtMs: scope.params.expiresAtMs,
11334
+ });
11335
+ }
11336
+ return epochs;
11337
+ }
11338
+
11339
+ /**
11340
+ * Cancel an agent's activation in flight, leaving the agent registered.
11341
+ * For a bounded job, whose lifetime is that one run, this ends the job.
11342
+ * Returns false when nothing was running.
11343
+ */
11344
+ cancelActivation(name: string, options: { by?: string; reason?: string } = {}): boolean {
11345
+ if (!this.registry.has(name)) throw new Error(`cancelActivation: "${name}" is not a registered agent`);
11346
+ const run = this.ephemeralRuns.get(name);
11347
+ if (run) {
11348
+ run.endReason ??= 'stopped';
11349
+ run.endedBy ??= options.by;
11350
+ // The awaiting caller is told who stopped the job and why, whether or
11351
+ // not a stream was in flight: settled first, so the aborted stream's own
11352
+ // settle ('Stream cancelled') finds the run already settled. The caller's
11353
+ // teardown runs after this call, as for stopAgent.
11354
+ run.settle.reject(new AgentStoppedError(name, 'stopped', options.by, options.reason));
11355
+ }
11356
+ const cancelled = this.abortInference(name, options.reason ?? 'cancelled');
11357
+ if (cancelled || run) {
11358
+ this.emitTrace({
11359
+ type: 'dendrite:activation-cancelled',
11360
+ agentName: name,
11361
+ ...(options.by ? { by: options.by } : {}),
11362
+ ...(options.reason ? { reason: options.reason } : {}),
11363
+ });
11364
+ }
11365
+ return cancelled || run !== undefined;
11366
+ }
11367
+
11368
+ /**
11369
+ * End an agent: cancel its run, remove it from the framework, and settle
11370
+ * what it spawned — attention tenants end with it; task work is orphaned,
11371
+ * notified and offered for reparenting, and keeps running.
11372
+ *
11373
+ * The default-delivery owner cannot be stopped (the framework needs
11374
+ * exactly one); cancel its activation instead. A persistent agent stopped
11375
+ * here is declared by configuration and returns at the next boot.
11376
+ */
11377
+ stopAgent(name: string, options: { by?: string; reason?: string } = {}): EndOutcome {
11378
+ const record = this.registry.get(name);
11379
+ if (!record) throw new Error(`stopAgent: "${name}" is not a registered agent`);
11380
+ if (record.roles.defaultDelivery) {
11381
+ throw new Error(
11382
+ `stopAgent: "${name}" owns default delivery and cannot be stopped; use cancelActivation to end its current run`,
11383
+ );
11384
+ }
11385
+ const outcome = this.registry.end(name, 'stopped', options.by)!;
11386
+ this.teardownEndedAgent(outcome.ended, options.reason);
11387
+ this.settleEndedAgent(outcome);
11388
+ return structuredClone(outcome);
11389
+ }
11390
+
11391
+ /**
11392
+ * Give an agent a new parent. Clears its orphan state; a result route
11393
+ * whose recipient is gone follows the new parent, and the mail it already
11394
+ * produced for the lost recipient, results and messages, is delivered to
11395
+ * the new one.
11396
+ */
11397
+ reparentAgent(name: string, newParent: string, options: { by?: string } = {}): AgentRecord {
11398
+ // An orphan that finished before it was adopted: its record stays as it
11399
+ // ended, and the mail it held for a recipient that is gone goes to the
11400
+ // new parent.
11401
+ if (!this.registry.has(name) && this.registry.inspect(name)?.ended) {
11402
+ const ended = this.registry.readdressHeldMail(name, newParent);
11403
+ this.emitTrace({
11404
+ type: 'dendrite:agent-reparented',
11405
+ agentName: name,
11406
+ parent: newParent,
11407
+ ...(options.by ? { by: options.by } : {}),
11408
+ });
11409
+ for (const mail of ended.deliverable) this.attemptMailDelivery(mail);
11410
+ return structuredClone(ended.record);
11411
+ }
11412
+ const { record, deliverable } = this.registry.reparent(name, newParent);
11413
+ this.emitTrace({
11414
+ type: 'dendrite:agent-reparented',
11415
+ agentName: name,
11416
+ parent: newParent,
11417
+ ...(options.by ? { by: options.by } : {}),
11418
+ });
11419
+ this.lifecycleNotice(
11420
+ name,
11421
+ `[${NOTICE_TAG.agentLifecycle}: ${newParent} is now your parent` +
11422
+ (record.relationships.resultTo?.to === newParent ? ' and will receive your result' : '') +
11423
+ '.]',
11424
+ { event: 'reparented', parent: newParent },
11425
+ );
11426
+ for (const mail of deliverable) this.attemptMailDelivery(mail);
11427
+ return structuredClone(record);
11428
+ }
11429
+
11430
+ /**
11431
+ * An explicit message from one agent to another: the sender's verbatim
11432
+ * content, in the recipient's context under the SENDER's name, with the
11433
+ * sender's identity and incarnation and the causal chain in metadata.
11434
+ * Never an unmarked insertion into the recipient's own voice.
11435
+ */
11436
+ sendAgentMessage(
11437
+ from: string,
11438
+ to: string,
11439
+ content: ContentBlock[],
11440
+ options: { causedBy?: string[]; wake?: boolean } = {},
11441
+ ): { mailId: string; delivered: boolean } {
11442
+ if (!this.registry.inspect(from)) throw new Error(`sendAgentMessage: sender "${from}" is not a known agent`);
11443
+ if (!this.registry.inspect(to)) throw new Error(`sendAgentMessage: recipient "${to}" is not a known agent`);
11444
+ this.registry.noteMessagePath(from, to);
11445
+ const mail = this.registry.holdMail({
11446
+ kind: 'message',
11447
+ from: this.registry.ref(from),
11448
+ to,
11449
+ content,
11450
+ ...(options.causedBy?.length ? { causedBy: options.causedBy } : {}),
11451
+ });
11452
+ return { mailId: mail.id, delivered: this.attemptMailDelivery(mail, options.wake ?? true) };
11453
+ }
11454
+
11455
+ /**
11456
+ * Return a bounded job's result along the agent's declared result route.
11457
+ * The result is recorded durably before delivery is attempted, so it is
11458
+ * neither lost nor delivered twice across a restart; if the recipient is
11459
+ * gone it is held as attributed mail until the agent is reparented.
11460
+ *
11461
+ * May be called after the agent's run has ended — its record stays
11462
+ * inspectable. Refused for a `tool-result` route: there the awaiting
11463
+ * caller already holds the result and delivers it itself.
11464
+ */
11465
+ deliverAgentResult(
11466
+ from: string,
11467
+ content: ContentBlock[],
11468
+ options: { causedBy?: string[] } = {},
11469
+ ): { mailId: string; delivered: boolean; to: string } {
11470
+ const record = this.registry.inspect(from);
11471
+ if (!record) throw new Error(`deliverAgentResult: "${from}" is not a known agent`);
11472
+ const route = record.relationships.resultTo;
11473
+ if (!route) throw new Error(`deliverAgentResult: "${from}" has no result route`);
11474
+ if (route.as !== 'message') {
11475
+ throw new Error(
11476
+ `deliverAgentResult: "${from}" returns its result as a ${route.as}; the caller awaiting the run delivers it`,
11477
+ );
11478
+ }
11479
+ const mail = this.registry.holdMail({
11480
+ kind: 'result',
11481
+ from: { agent: from, incarnation: record.incarnation },
11482
+ to: route.to,
11483
+ content,
11484
+ ...(options.causedBy?.length ? { causedBy: options.causedBy } : {}),
11485
+ });
11486
+ // A finished job's output survives a hard exit from here on: the hold is
11487
+ // in the registry's recovery file as it is written (persistAgentRegistry),
11488
+ // and holdMail failed this call if it couldn't be. The release, once the
11489
+ // message has landed, waits for the store's sync (releaseLandedMail).
11490
+ return { mailId: mail.id, delivered: this.attemptMailDelivery(mail), to: route.to };
11491
+ }
11492
+
11493
+ /** Communications not yet handed to their recipient, and why. */
11494
+ listHeldMail(filter: { to?: string; from?: string } = {}): HeldMail[] {
11495
+ return structuredClone(this.registry.listMail(filter));
11496
+ }
11497
+
11498
+ /**
11499
+ * Try to hand one held item to its recipient. True when the message is in
11500
+ * (or queued for) the recipient's context; false when it stays held
11501
+ * because nobody can receive it. The held copy is released by
11502
+ * `addMessage` at the moment the message enters the store.
11503
+ */
11504
+ private attemptMailDelivery(mail: HeldMail, wake = true): boolean {
11505
+ const recipient = this.registry.has(mail.to) ? this.agents.get(mail.to) : undefined;
11506
+ if (!recipient) {
11507
+ const newlyHeld = mail.heldBecause !== 'recipient-gone';
11508
+ this.registry.markMailHeld(mail.id, 'recipient-gone');
11509
+ // A result its sender delivered after it had ended (one delivered while
11510
+ // it ran is told of when it ends, settleEndedAgent).
11511
+ const sender = this.registry.inspect(mail.from.agent);
11512
+ if (newlyHeld && mail.kind === 'result' && sender?.ended) this.tellResultHeld(sender, mail.id);
11513
+ this.emitTrace({
11514
+ type: 'dendrite:mail-held',
11515
+ mailId: mail.id,
11516
+ kind: mail.kind,
11517
+ from: mail.from.agent,
11518
+ to: mail.to,
11519
+ reason: 'recipient-gone',
11520
+ });
11521
+ return false;
11522
+ }
11523
+ const placement: { agent?: string; messageId?: MessageId; deferredId?: string } = {};
11524
+ this.addMessage(
11525
+ // The sender's own name: its words stay its own.
11526
+ recipient.isOwnParticipant(mail.from.agent) && mail.from.agent !== recipient.name
11527
+ ? `${mail.from.agent} (mail)`
11528
+ : mail.from.agent,
11529
+ mail.content as ContentBlock[],
11530
+ {
11531
+ kind: `agent-${mail.kind}`,
11532
+ dendrite: {
11533
+ mailId: mail.id,
11534
+ kind: mail.kind,
11535
+ from: mail.from,
11536
+ // When the sender produced it — distinct from when it is read. A
11537
+ // result held across a restart arrives later than it was made,
11538
+ // and the reader should be able to tell.
11539
+ producedAt: mail.createdAt,
11540
+ ...(mail.causedBy?.length ? { causedBy: mail.causedBy } : {}),
11541
+ },
11542
+ } as MessageMetadata,
11543
+ { forAgent: mail.to, placement },
11544
+ );
11545
+ if (!placement.messageId) {
11546
+ // Queued for the recipient's next boundary. `mail-delivered` fires
11547
+ // when the message actually enters its store (releaseLandedMail):
11548
+ // published is not delivered.
11549
+ this.emitTrace({
11550
+ type: 'dendrite:mail-deferred',
11551
+ mailId: mail.id,
11552
+ kind: mail.kind,
11553
+ from: mail.from.agent,
11554
+ to: mail.to,
11555
+ });
11556
+ }
11557
+ if (wake) {
11558
+ this.pendingRequests.push({
11559
+ agentName: mail.to,
11560
+ reason: `agent-${mail.kind}:${mail.from.agent}`,
11561
+ source: 'dendrite',
11562
+ timestamp: Date.now(),
11563
+ });
11564
+ }
11565
+ return true;
11566
+ }
11567
+
11568
+ /**
11569
+ * An ended orphan's result held for a recipient that is gone: its first
11570
+ * adoption candidate is told it exists and how it arrives, since nothing
11571
+ * else would surface it (reparentAgent delivers it).
11572
+ */
11573
+ private tellResultHeld(sender: AgentRecord, mailId: string): void {
11574
+ const candidate = sender.orphaned?.candidates[0];
11575
+ if (!candidate || !this.announceOrphans) return;
11576
+ this.lifecycleNotice(
11577
+ candidate,
11578
+ `[${NOTICE_TAG.agentLifecycle}: "${sender.name}" has ended after ${sender.orphaned!.formerParent.agent} did, ` +
11579
+ `so its result has nowhere to go. The result is held; reparenting "${sender.name}" to you delivers it.]`,
11580
+ // `heldMailId`, not `mailId`: a row carrying dendrite.mailId is read
11581
+ // as that mail having landed, which would release it.
11582
+ { event: 'result-held', agent: sender.name, heldMailId: mailId, formerParent: sender.orphaned!.formerParent },
11583
+ );
11584
+ }
11585
+
11586
+ /** A host-framed lifecycle fact in an agent's context. Never voiced as anyone. */
11587
+ private lifecycleNotice(agentName: string, text: string, detail: Record<string, unknown>): void {
11588
+ if (!this.agents.has(agentName)) return;
11589
+ this.addMessage(
11590
+ 'user',
11591
+ [{ type: 'text', text }],
11592
+ { system: true, kind: 'agent-lifecycle', dendrite: detail } as MessageMetadata,
11593
+ { forAgent: agentName },
11594
+ );
11595
+ }
11596
+
11597
+ /**
11598
+ * The creation ("consent") event: names what kind of agent this is, what
11599
+ * ends it and what happens to it when its spawner ends. A derived agent's
11600
+ * creation is also told to the agent it was derived from (#270, Linn:
11601
+ * "told when the body changes" as a property, not a courtesy): one
11602
+ * host-framed line in the source's context, built from the same facts as
11603
+ * the trace, with no wake; a host's derivationNotice can replace the line
11604
+ * or send none. Both derivation paths announce after the
11605
+ * fork's branch or copy exists, so a fork never inherits the notice about
11606
+ * itself; a source that has ended is told nothing (lifecycleNotice).
11607
+ */
11608
+ private announceAgentCreated(record: AgentRecord): void {
11609
+ this.emitTrace({
11610
+ type: 'dendrite:agent-created',
11611
+ agentName: record.name,
11612
+ kind: record.kind,
11613
+ ...(record.model ? { model: record.model } : {}),
11614
+ incarnation: record.incarnation,
11615
+ lifetime: record.lifetime,
11616
+ ...(record.relationships.spawnedBy
11617
+ ? { spawnedBy: record.relationships.spawnedBy, onParentEnd: record.onParentEnd }
11618
+ : {}),
11619
+ ...(record.relationships.resultTo ? { resultTo: record.relationships.resultTo } : {}),
11620
+ ...(record.inherit ? { inherit: record.inherit } : {}),
11621
+ });
11622
+ const from = record.inherit?.from;
11623
+ if (!from || from === record.name) return;
11624
+ const detail = {
11625
+ event: 'derived',
11626
+ agent: record.name,
11627
+ kind: record.kind,
11628
+ incarnation: record.incarnation,
11629
+ ...(record.model ? { model: record.model } : {}),
11630
+ inherit: record.inherit,
11631
+ ...(record.relationships.resultTo ? { resultTo: record.relationships.resultTo } : {}),
11632
+ lifetime: record.lifetime,
11633
+ };
11634
+ // A resumption is rare and always said. A first incarnation is tallied:
11635
+ // past the threshold, a reader loop that forks a hundred times a session
11636
+ // would fill its source's context with stamps (Linn, 2026-10-15), so
11637
+ // the source hears a count line every `threshold` forks instead, with
11638
+ // the latest described; every derivation still emits its trace.
11639
+ const threshold = this.collapseDerivationNoticesAfter;
11640
+ const tell = (defaultText: string, extra: Record<string, unknown> = {}) => {
11641
+ try {
11642
+ const text = this.derivationNoticeHook ? this.derivationNoticeHook(record, from, defaultText) : defaultText;
11643
+ if (text !== null) this.lifecycleNotice(from, text, { ...detail, ...extra });
11644
+ } catch (error) {
11645
+ console.error(`[dendrite] ${record.name} was created, but ${from} could not be told:`, error);
11646
+ }
11647
+ };
11648
+ if (record.incarnation > 1 || threshold <= 0) {
11649
+ tell(this.derivationNotice(record, from));
11650
+ return;
11651
+ }
11652
+ let byKind = this.derivationTallies.get(from);
11653
+ if (!byKind) this.derivationTallies.set(from, (byKind = new Map()));
11654
+ let tally = byKind.get(record.kind);
11655
+ if (!tally) byKind.set(record.kind, (tally = { count: 0, since: Date.now() }));
11656
+ tally.count++;
11657
+ if (tally.count <= threshold) {
11658
+ tell(this.derivationNotice(record, from));
11659
+ } else if ((tally.count - threshold) % threshold === 0) {
11660
+ const since = new Date(tally.since).toISOString().slice(11, 16) + ' UTC';
11661
+ tell(
11662
+ `[${NOTICE_TAG.agentLifecycle}: ${tally.count} ${record.kind}s have been derived from you since ${since} ` +
11663
+ `(${threshold} since the last note); the latest, ${record.name}, is ${this.derivationDescription(record, from)}.]`,
11664
+ { collapsed: { count: tally.count, sinceLastNote: threshold } },
11665
+ );
11666
+ }
11667
+ }
11668
+
11669
+ /** The derivation notice's text, addressed to the agent `from` the record was derived. */
11670
+ private derivationNotice(record: AgentRecord, from: string): string {
11671
+ const made = record.incarnation > 1
11672
+ ? `was resumed (incarnation ${record.incarnation}) as`
11673
+ : 'was derived from you as';
11674
+ return `[${NOTICE_TAG.agentLifecycle}: ${record.name} ${made} ${this.derivationDescription(record, from)}.]`;
11675
+ }
11676
+
11677
+ /** What a derived agent is, to the agent it was derived from: "a task-fork on your weights, …; …; …". */
11678
+ private derivationDescription(record: AgentRecord, from: string): string {
11679
+ const sourceModel = this.agentConfigs.get(from)?.model ?? this.registry.get(from)?.model;
11680
+ const weights = !record.model
11681
+ ? ''
11682
+ : record.model === sourceModel
11683
+ ? ` on your weights (${record.model})`
11684
+ : ` on ${record.model}`;
11685
+ const inherited = record.inherit?.mode === 'shared'
11686
+ ? 'reading your context up to this point'
11687
+ : 'with a copy of your context';
11688
+ const route = record.relationships.resultTo;
11689
+ const home = this.channelOfHome(record.homeChannel);
11690
+ const result = !route
11691
+ ? home
11692
+ ? `it speaks for itself in ${this.channelShown(home)}`
11693
+ : 'it returns nothing to you'
11694
+ : route.to === from
11695
+ ? `its result returns to you as ${route.as === 'message' ? 'a message' : 'a tool result'}`
11696
+ : `its result goes to ${route.to}`;
11697
+ const lifetime = record.lifetime;
11698
+ const minutes = (ms: number) => (ms < 60_000 ? `${Math.round(ms / 1_000)} s` : `${Math.round(ms / 60_000)} min`);
11699
+ const ends = lifetime.kind === 'task'
11700
+ ? 'it ends when its task does' +
11701
+ (lifetime.deadlineMs !== undefined || lifetime.idleTimeoutMs !== undefined
11702
+ ? ` (${[
11703
+ lifetime.deadlineMs !== undefined ? `deadline ${minutes(lifetime.deadlineMs)}` : '',
11704
+ lifetime.idleTimeoutMs !== undefined ? `idle ${minutes(lifetime.idleTimeoutMs)}` : '',
11705
+ ].filter(Boolean).join(', ')})`
11706
+ : '')
11707
+ : lifetime.kind === 'idle'
11708
+ ? `it ends after ${minutes(lifetime.idleTtlMs)} idle`
11709
+ : 'it persists';
11710
+ const parentEnd = record.relationships.spawnedBy === from
11711
+ ? record.onParentEnd === 'end' ? ', and ends with you' : ', and is orphaned if you end'
11712
+ : '';
11713
+ return `a ${record.kind}${weights}, ${inherited}; ${result}; ${ends}${parentEnd}`;
11714
+ }
11715
+
11716
+ /**
11717
+ * Remove an ended agent's live object from the framework. A bounded run
11718
+ * is ended through its awaiting caller (whose teardown owns the
11719
+ * name-keyed state); any other agent is torn down here.
11720
+ */
11721
+ private teardownEndedAgent(record: AgentRecord, detail?: string): void {
11722
+ const name = record.name;
11723
+ const reason = record.ended?.reason ?? 'stopped';
11724
+ const run = this.ephemeralRuns.get(name);
11725
+ if (run) {
11726
+ run.endReason = reason;
11727
+ run.endedBy = record.ended?.by;
11728
+ run.settle.reject(new AgentStoppedError(name, reason, record.ended?.by, detail));
11729
+ return;
11730
+ }
11731
+ const agent = this.agents.get(name);
11732
+ if (!agent) return;
11733
+ this.releaseAgentRuntime(agent, this.channelOfHome(record.homeChannel), undefined, record.lifetime.kind === 'persistent');
11734
+ }
11735
+
11736
+ /**
11737
+ * Everything the process holds for a live, non-ephemeral agent that has
11738
+ * ended, released once, on every path an agent ends by — stopAgent, the
11739
+ * idle reap of a conversation fork, an end that cascades from a parent's
11740
+ * (nissa's #546 finding 5: a stop that skipped the window release dropped a
11741
+ * write queued for the agent at the next flush, and a failure notice it
11742
+ * never read reached no one). The window release delivers what the agent
11743
+ * never read to the primary, now or after a resume, as the idle reap did.
11744
+ */
11745
+ private releaseAgentRuntime(agent: Agent, home?: ChannelRef, why = 'its agent ended', declared = false): void {
11746
+ const name = agent.name;
11747
+ if (home && this.conversationRouter?.getBinding(home)?.agentName === name) {
11748
+ this.conversationRouter.unbind(home);
11749
+ }
11750
+ this.closingConversationAgents.delete(name);
11751
+ // A continuation of its turn still on its way goes with it.
11752
+ this.dropContinuations(name, { kind: 'dropped', why });
11753
+ // A batch its guard still holds is settled as `aborted`: the stream's
11754
+ // own finally does neither once the agent has gone.
11755
+ if (agent.toolResultGuard.hasPending) {
11756
+ try {
11757
+ agent.toolResultGuard.abandon('aborted');
11758
+ } catch (error) {
11759
+ console.error(`[tool-result-guard] agent=${name} failed to record a batch the fork left:`, error);
11760
+ }
11761
+ }
11762
+ // Cancel before deregistration: a late stream event is discarded by
11763
+ // driveStream's Agent-identity check once the name no longer maps to it.
11764
+ if (this.activeStreams.has(name)) {
11765
+ try {
11766
+ agent.cancelStream();
11767
+ } catch (error) {
11768
+ console.error(`[dendrite] agent=${name} its stream's cancel failed as it ended; it is released all the same:`, error);
11769
+ }
11770
+ }
11771
+ this.activeStreams.delete(name);
11772
+ this.pendingAssistantBlocks.delete(name);
11773
+ this.eventGate?.onInferenceEnded(name);
11774
+ // Its sleep and self-wake end with it (the gate's sleep holds back every
11775
+ // agent's wakes), and neither wake goes to a name that's gone.
11776
+ this.eventGate?.agentGone(name);
11777
+ this.agents.delete(name);
11778
+ this.agentConfigs.delete(name);
11779
+ this.thinkingLossIncidents.forget(name);
11780
+ this.reasoningLoss.forget(name);
11781
+ // main's manager release: the context manager an ephemeral run or a fork
11782
+ // opened is released with the agent (releaseManager).
11783
+ this.releaseManager(agent);
11784
+ this.toolImageLedgers.delete(name);
11785
+ this.evictTurnCheckpoints(name);
11786
+ this.activeTurnTokens.delete(name);
11787
+ this.activeTurnTriggers.delete(name);
11788
+ this.logicalTurnToolCalls.delete(agent);
11789
+ this.pendingRequests = this.pendingRequests.filter((request) => request.agentName !== name);
11790
+ this.releaseWindowNowOrAfterResume(agent);
11791
+ this.turnRouting.delete(name);
11792
+ this.proseSpeechChains.delete(name);
11793
+ this.openLogical.delete(name);
11794
+ // What it was last told of where its speech goes goes with it, kept
11795
+ // only for an agent declared by configuration, which comes back under
11796
+ // its name at the next boot: so the stored baselines are those of
11797
+ // agents that are live or come back.
11798
+ if (!declared) this.forgetAnnouncedLocus(name);
11799
+ // Its scripts go with it, and leave the map once they've ended: no later
11800
+ // run of the name trims them.
11801
+ this.codeExecution?.scripts.agentGone(name);
11802
+ // A provider-wait hold (nonOwningProviderWait) goes with the agent: left,
11803
+ // its timer would requeue held wakes for an agent that is gone, and a wait
11804
+ // held until released would re-arm for the name indefinitely. The recorded
11805
+ // wait stays, and binds the name if its channel binds again.
11806
+ const cooldown = this.providerAccelerationCooldowns.get(name);
11807
+ if (cooldown) {
11808
+ clearTimeout(cooldown.timer);
11809
+ this.providerAccelerationCooldowns.delete(name);
11810
+ console.error(`[provider-wait] agent=${name} ended while held` +
11811
+ `${cooldown.waitModel !== undefined ? ` on ${cooldown.waitModel}` : ''}: ` +
11812
+ `${cooldown.heldRequests.length} held request(s) dropped with it (the recorded wait stays)`);
11813
+ }
11814
+ // main's per-name turn state goes too (#846's re-arm count and run of
11815
+ // stated waits, #840's exhausted turn), so a name bound again (a
11816
+ // channel's next conversation, a resume) starts afresh; a run of stated
11817
+ // waits ends here as at a turn's end, alerted if it ran long
11818
+ // (endStatedWaitRun).
11819
+ this.turnRearms.delete(name);
11820
+ this.exhaustingRearms.delete(name);
11821
+ this.exhaustedTurns.delete(name);
11822
+ this.endStatedWaitRun(name);
11823
+ if (name === this.subconsciousAgentName) {
11824
+ this.tuneOutCoordinator?.stopReader();
11825
+ this.subconsciousAgentName = null;
11826
+ }
11827
+ }
11828
+
11829
+ /**
11830
+ * Carry out what the registry decided when an agent ended: tear down the
11831
+ * attention tenants that end with it, and tell each orphan — and the
11832
+ * first agent that could adopt it — what happened.
11833
+ */
11834
+ private settleEndedAgent(outcome: EndOutcome | null): void {
11835
+ if (!outcome) return;
11836
+ for (const record of [...outcome.cascaded, outcome.ended]) {
11837
+ this.emitTrace({
11838
+ type: 'dendrite:agent-ended',
11839
+ agentName: record.name,
11840
+ kind: record.kind,
11841
+ incarnation: record.incarnation,
11842
+ reason: record.ended?.reason ?? 'stopped',
11843
+ ...(record.ended?.by ? { by: record.ended.by } : {}),
11844
+ ...(record.relationships.spawnedBy ? { spawnedBy: record.relationships.spawnedBy } : {}),
11845
+ });
11846
+ }
11847
+ for (const record of outcome.cascaded) this.teardownEndedAgent(record);
11848
+ // An orphan that ends with a result it delivered while it ran still held
11849
+ // for a recipient that is gone: its first candidate hears of it now.
11850
+ for (const record of [...outcome.cascaded, outcome.ended]) {
11851
+ if (!record.orphaned) continue;
11852
+ const held = this.registry.listMail({ from: record.name })
11853
+ .find((mail) => mail.kind === 'result' && mail.heldBecause === 'recipient-gone');
11854
+ if (held) this.tellResultHeld(record, held.id);
11855
+ }
11856
+
11857
+ const parent = outcome.ended;
11858
+ for (const { record, candidates } of outcome.orphaned) {
11859
+ this.announceOrphaned(
11860
+ record,
11861
+ candidates,
11862
+ record.orphaned?.formerParent.agent ?? parent.name,
11863
+ record.orphaned?.reason ?? parent.ended?.reason ?? 'stopped',
11864
+ );
11865
+ }
11866
+ }
11867
+
11868
+ /** An agent whose spawner has ended, and its first adoption candidate, are told; the trace says so. */
11869
+ private announceOrphaned(record: AgentRecord, candidates: string[], former: string, reason: string): void {
11870
+ this.emitTrace({
11871
+ type: 'dendrite:agent-orphaned',
11872
+ agentName: record.name,
11873
+ formerParent: former,
11874
+ reason,
11875
+ candidates,
11876
+ });
11877
+ const route = record.relationships.resultTo;
11878
+ const resultHeld = route?.as === 'message' && !this.registry.has(route.to);
11879
+ this.lifecycleNotice(
11880
+ record.name,
11881
+ `[${NOTICE_TAG.agentLifecycle}: ${former}, the agent that started you, has ended (${reason}). ` +
11882
+ 'Your own work is not cancelled.' +
11883
+ (resultHeld ? ' Your result will be held until another agent becomes its recipient.' : '') +
11884
+ ']',
11885
+ { event: 'orphaned', formerParent: record.orphaned?.formerParent, reason, candidates },
11886
+ );
11887
+ const candidate = candidates[0];
11888
+ if (candidate && this.announceOrphans) {
11889
+ this.lifecycleNotice(
11890
+ candidate,
11891
+ `[${NOTICE_TAG.agentLifecycle}: ${former} has ended (${reason}) while its ${record.kind} agent ` +
11892
+ `"${record.name}" was still running. "${record.name}" now has no parent` +
11893
+ (resultHeld ? ' and nowhere to return its result' : '') +
11894
+ '; it can be reparented to you.]',
11895
+ { event: 'adoption-offered', agent: record.name, formerParent: record.orphaned?.formerParent, reason },
11896
+ );
11897
+ }
11898
+ }
11899
+
11900
+ /** The registry's snapshot: the recovery file first (atomic, fsynced; it
11901
+ * wins at the next boot), then the branch-local state, kept for inspection
11902
+ * and for stores read by older code. Either write failing fails the call
11903
+ * that changed the registry (nissa's #546 finding 2): a change that isn't
11904
+ * durable isn't a success. */
11905
+ private persistAgentRegistry(snapshot: RegistrySnapshot): void {
11906
+ if (this.agentRegistryPath) this.writeRecoveryFile(this.agentRegistryPath, snapshot);
11907
+ this.store.setStateJson(AGENT_REGISTRY_STATE_ID, snapshot);
11908
+ }
11909
+
11910
+ /** An ended record: appended to the branch-local log, and the recovery
11911
+ * file rewritten with the newest records (as many as a restart reads). */
11912
+ private appendEndedAgentRecord(record: AgentRecord): void {
11913
+ this.rewriteEndedRecovery([...this.endedRecovery, record]);
11914
+ this.store.appendToStateJson(AGENT_REGISTRY_ENDED_ID, record);
11915
+ }
11916
+
11917
+ /**
11918
+ * A requested change was put back in the registry's memory: the recovery
11919
+ * files, each written before the branch-local store, are rewritten from it
11920
+ * (the snapshot's and the ended log's), so neither keeps the change the
11921
+ * caller was told failed. The branch-local snapshot needs no rewrite: it is
11922
+ * the last write a change makes, so a change that failed never reached it.
11923
+ * The branch-local ended log is append-only, so an end appended before the
11924
+ * failure stays there. It is read at boot only where no ended recovery
11925
+ * file exists (or the rewrite of that file failed too); the restore then
11926
+ * drops a bounded agent it lists as ended for the reason recorded, with no
11927
+ * restart notice to its recipient, while a persistent agent waits to be
11928
+ * re-declared as it would anyway. A rewrite that fails is reported, the
11929
+ * next is still made, and the caller's own error goes on.
11930
+ */
11931
+ private rewriteAgentRegistryAfterRollback(): void {
11932
+ const rewrites: Array<[string, string, () => void]> = [
11933
+ ['the ended log recovery file', 'the next end written', () => this.rewriteEndedRecovery(this.registry.endedHistory())],
11934
+ ...(this.agentRegistryPath
11935
+ ? [['the registry recovery file', 'the next registry write', () => this.writeRecoveryFile(this.agentRegistryPath!, this.registry.snapshot())] as [string, string, () => void]]
11936
+ : []),
11937
+ ];
11938
+ for (const [what, next, rewrite] of rewrites) {
11939
+ try {
11940
+ rewrite();
11941
+ } catch (error) {
11942
+ console.error(`[dendrite] after a registry change failed and was put back in memory, ${what} could not be rewritten to match; ${next} rewrites it:`, error);
11943
+ }
11944
+ }
11945
+ }
11946
+
11947
+ /**
11948
+ * An ended record past the registry's memory, from the branch-local ended
11949
+ * log. A scan of the whole log takes tens of milliseconds at a few thousand
11950
+ * ends, and a name nobody used (a tool call's typo) is a miss, so the log is
11951
+ * indexed by name once, at the first lookup, and each later lookup indexes
11952
+ * only what was appended since. A log that changed under the index (a
11953
+ * branch switch) is indexed again: one that shrank, one whose last indexed
11954
+ * entry is no longer the one indexed, or one whose entry no longer carries
11955
+ * the name indexed for it.
11956
+ */
11957
+ private findEndedAgentRecord(name: string): AgentRecord | undefined {
11958
+ return this.findInEndedLog('name', name);
11959
+ }
11960
+
11961
+ /**
11962
+ * The ended log's latest record whose own context a namespace holds. A
11963
+ * shared fork's record names its parent's namespace, where it owns only a
11964
+ * branch, so it is indexed by name alone.
11965
+ */
11966
+ private findInEndedLog(by: 'name' | 'namespace', value: string): AgentRecord | undefined {
11967
+ const byKey = by === 'name' ? this.endedLogIndex : this.endedLogNamespaceIndex;
11968
+ const reset = () => {
11969
+ this.endedLogIndex.clear();
11970
+ this.endedLogNamespaceIndex.clear();
11971
+ this.endedLogIndexed = 0;
11972
+ };
11973
+ const read = (index: number) => this.store.getStateItemJson(AGENT_REGISTRY_ENDED_ID, index) as AgentRecord | null;
11974
+ const mark = (record: AgentRecord | null) => (record ? `${record.name}#${record.incarnation}@${record.ended?.at}` : '');
11975
+ // A store that can't be read fails the lookup rather than answering
11976
+ // "never ended", which would let a namespace check pass.
11977
+ const length = this.store.getStateLen(AGENT_REGISTRY_ENDED_ID) ?? 0;
11978
+ for (let attempt = 0; attempt < 2; attempt++) {
11979
+ if (length < this.endedLogIndexed
11980
+ || (this.endedLogIndexed > 0 && mark(read(this.endedLogIndexed - 1)) !== this.endedLogTail)) {
11981
+ reset();
11982
+ }
11983
+ for (let index = this.endedLogIndexed; index < length; index++) {
11984
+ const record = read(index);
11985
+ if (!record?.ended || typeof record.name !== 'string') continue;
11986
+ this.endedLogIndex.set(record.name, index);
11987
+ if (typeof record.namespace === 'string' && record.inherit?.mode !== 'shared') {
11988
+ this.endedLogNamespaceIndex.set(record.namespace, index);
11989
+ }
11990
+ }
11991
+ this.endedLogIndexed = length;
11992
+ this.endedLogTail = length > 0 ? mark(read(length - 1)) : '';
11993
+ const at = byKey.get(value);
11994
+ if (at === undefined) return undefined;
11995
+ const record = read(at);
11996
+ if (record?.ended && record[by] === value) return record;
11997
+ reset();
11998
+ }
11999
+ return undefined;
12000
+ }
12001
+
12002
+ /** Another agent whose context a namespace holds: opened in this process, in the registry, or in the ended log. */
12003
+ private namespaceHolder(namespace: string, except: string): string | undefined {
12004
+ const opened = this.namespaceOwners.get(namespace);
12005
+ if (opened !== undefined && opened !== except) return opened;
12006
+ const held = this.registry.namespaceHolder(namespace, except);
12007
+ if (held) return held;
12008
+ const ended = this.findInEndedLog('namespace', namespace);
12009
+ return ended && ended.name !== except ? ended.name : undefined;
12010
+ }
12011
+
12012
+ /** The ended log's recovery file, rewritten as the newest records (as many as a restart reads). */
12013
+ private rewriteEndedRecovery(ended: AgentRecord[]): void {
12014
+ this.endedRecovery = ended.slice(-AGENT_REGISTRY_ENDED_RELOAD);
12015
+ if (this.agentRegistryEndedPath) this.writeRecoveryFile(this.agentRegistryEndedPath, { version: 1, ended: this.endedRecovery });
12016
+ }
12017
+
12018
+ /**
12019
+ * Load the previous process's registry. Returns the bounded work it
12020
+ * interrupted. A registry that can't be read, or that reads as anything
12021
+ * but a version-1 registry, refuses the start and names the state (nissa's
12022
+ * #546 finding 1, main's rule for the framework's own state): taken as
12023
+ * empty, the results it holds for delivery and the records of the work it
12024
+ * ran would be overwritten by this boot's first registration.
12025
+ */
12026
+ private restoreAgentRegistry(): AgentRecord[] {
12027
+ const refuse = (what: string, problem: string, cause?: unknown): Error => new Error(
12028
+ `the agent registry (${what}) ${problem}. The framework doesn't start without it: taken as empty, the results ` +
12029
+ 'it holds for delivery and the records of the work it ran would be overwritten at this boot. ' +
12030
+ (cause === undefined
12031
+ ? "It's unchanged, and the framework starts once it holds a version-1 registry, such as " +
12032
+ '{"version":1,"records":[],"mail":[],"incarnations":{}} for an empty one.'
12033
+ : "It's unchanged, and the framework starts once it can be read."),
12034
+ cause === undefined ? undefined : { cause },
12035
+ );
12036
+ let snapshot: unknown;
12037
+ let snapshotFrom = this.agentRegistryPath ?? AGENT_REGISTRY_STATE_ID;
12038
+ // Only a registry that was never written is absent: a recovery file that
12039
+ // exists is the registry, whatever it holds, so one that reads as `null`
12040
+ // is refused like any other that isn't a version-1 registry.
12041
+ let absent = false;
12042
+ try {
12043
+ snapshot = this.agentRegistryPath ? this.readRecoveryFile(this.agentRegistryPath) : undefined;
12044
+ if (snapshot === undefined) {
12045
+ snapshotFrom = AGENT_REGISTRY_STATE_ID;
12046
+ snapshot = this.store.getStateJson(AGENT_REGISTRY_STATE_ID) ?? null;
12047
+ absent = snapshot === null;
12048
+ }
12049
+ } catch (error) {
12050
+ throw refuse(snapshotFrom, "can't be read", error);
12051
+ }
12052
+ if (!absent && (snapshot === null || typeof snapshot !== 'object' || (snapshot as { version?: unknown }).version !== 1)) {
12053
+ const read = snapshot === null ? 'null' : `version ${JSON.stringify((snapshot as { version?: unknown }).version)}`;
12054
+ throw refuse(snapshotFrom, `reads as something other than a version-1 registry (${read})`);
12055
+ }
12056
+ if (!absent) {
12057
+ const { records, mail, incarnations } = snapshot as { records?: unknown; mail?: unknown; incarnations?: unknown };
12058
+ if (!Array.isArray(records) || !Array.isArray(mail)) {
12059
+ throw refuse(snapshotFrom, 'reads as a version-1 registry whose records or mail are not lists');
12060
+ }
12061
+ if (incarnations === null || typeof incarnations !== 'object' || Array.isArray(incarnations)) {
12062
+ throw refuse(snapshotFrom, 'reads as a version-1 registry with no map of incarnations, without which every agent it names would start again at incarnation 1');
12063
+ }
12064
+ }
12065
+
12066
+ let ended: unknown;
12067
+ let endedFrom = this.agentRegistryEndedPath ?? AGENT_REGISTRY_ENDED_ID;
12068
+ try {
12069
+ const file = this.agentRegistryEndedPath ? this.readRecoveryFile(this.agentRegistryEndedPath) : undefined;
12070
+ if (file !== undefined) {
12071
+ ended = (file as { ended?: unknown } | null)?.ended;
12072
+ } else {
12073
+ endedFrom = AGENT_REGISTRY_ENDED_ID;
12074
+ const tail = this.store.getStateTail(AGENT_REGISTRY_ENDED_ID, AGENT_REGISTRY_ENDED_RELOAD);
12075
+ ended = tail ? JSON.parse(tail.toString('utf8')) : [];
12076
+ }
12077
+ } catch (error) {
12078
+ throw refuse(endedFrom, "has an ended log that can't be read", error);
12079
+ }
12080
+ if (!Array.isArray(ended)) throw refuse(endedFrom, 'has an ended log that reads as something other than a list of records');
12081
+ this.endedRecovery = (ended as AgentRecord[]).slice(-AGENT_REGISTRY_ENDED_RELOAD);
12082
+ return this.registry.restore(snapshot as RegistrySnapshot | null, ended as AgentRecord[]).interrupted;
12083
+ }
12084
+
12085
+ /**
12086
+ * Boot reconciliation, once configuration has declared its agents.
12087
+ * Restart behaviour follows from lifetime: persistent agents were just
12088
+ * re-created; everything else that was live was interrupted, and the
12089
+ * recipient of an interrupted job's result is told so. Results held from
12090
+ * before the restart are delivered now, exactly once.
12091
+ */
12092
+ private reconcileAgentRegistry(interrupted: AgentRecord[]): void {
12093
+ for (const outcome of this.registry.reconcile().unconfigured) this.settleEndedAgent(outcome);
12094
+
12095
+ const byRecipient = new Map<string, AgentRecord[]>();
12096
+ for (const record of interrupted) {
12097
+ this.emitTrace({
12098
+ type: 'dendrite:agent-ended',
12099
+ agentName: record.name,
12100
+ kind: record.kind,
12101
+ incarnation: record.incarnation,
12102
+ reason: 'host-restart',
12103
+ ...(record.relationships.spawnedBy ? { spawnedBy: record.relationships.spawnedBy } : {}),
12104
+ });
12105
+ const route = record.relationships.resultTo;
12106
+ if (route?.as !== 'message' || !this.agents.has(route.to)) continue;
12107
+ // A result it had already handed over needs no apology.
12108
+ if (this.registry.listMail({ from: record.name }).some((mail) => mail.kind === 'result')) continue;
12109
+ byRecipient.set(route.to, [...(byRecipient.get(route.to) ?? []), record]);
12110
+ }
12111
+ for (const [recipient, all] of byRecipient) {
12112
+ // The interrupted records stay in the snapshot until this notice is
12113
+ // durable and their ends are written (settleInterrupted below), so a
12114
+ // crash after it landed, or an end whose write failed, comes here
12115
+ // again: a record an earlier interrupted notice to this recipient names
12116
+ // (its key lists each name, incarnation and creation time, since a
12117
+ // single-use name that aged out of memory starts again at incarnation
12118
+ // 1), in the recipient's store or the restored deferred queue, has been
12119
+ // told, however many of that notice's records have ended since.
12120
+ const toldKeys = new Set<string>();
12121
+ const noteTold = (metadata: unknown) => {
12122
+ const key = (metadata as { dendrite?: { noticeKey?: unknown } } | undefined)?.dendrite?.noticeKey;
12123
+ if (typeof key === 'string') for (const one of key.split(',')) toldKeys.add(one);
12124
+ };
12125
+ for (const message of this.agents.get(recipient)!.getContextManager()
12126
+ .queryMessages({ metadata: { 'dendrite.event': 'interrupted' } }).messages) noteTold(message.metadata);
12127
+ for (const message of this.deferredMessages) noteTold(message.metadata);
12128
+ const keyOf = (record: AgentRecord) => `${record.name}#${record.incarnation}@${record.createdAt}`;
12129
+ const records = all.filter((record) => !toldKeys.has(keyOf(record)));
12130
+ if (records.length === 0) continue;
12131
+ const noticeKey = records.map(keyOf).sort().join(',');
12132
+ const names = records.map((record) => `"${record.name}"`).join(', ');
12133
+ this.lifecycleNotice(
12134
+ recipient,
12135
+ `[${NOTICE_TAG.agentLifecycle}: the host restarted while ${names} ${records.length === 1 ? 'was' : 'were'} ` +
12136
+ `still working for you. ${records.length === 1 ? 'It was' : 'They were'} ended without a result.]`,
12137
+ { event: 'interrupted', agents: records.map((record) => record.name), reason: 'host-restart', noticeKey },
12138
+ );
12139
+ this.pendingRequests.push({
12140
+ agentName: recipient,
12141
+ reason: 'agent-interrupted',
12142
+ source: 'dendrite',
12143
+ timestamp: Date.now(),
12144
+ });
12145
+ }
12146
+ // The notices are durable once the store has synced (a quiesced host's
12147
+ // are in the deferred queue's recovery file already); only then are the
12148
+ // interrupted records' ends written and the snapshot let go of them.
12149
+ if (interrupted.length > 0) {
12150
+ try {
12151
+ this.store.sync();
12152
+ this.registry.settleInterrupted();
12153
+ } catch (error) {
12154
+ console.error('[dendrite] the restart notices could not be synced; the interrupted records stay in the registry, and the next boot tells their recipients again unless the notice is found:', error);
12155
+ }
12156
+ }
12157
+
12158
+ for (const mail of this.registry.listMail()) this.deliverHeldMail(mail);
12159
+ }
12160
+
12161
+ /**
12162
+ * Deliver held mail that hasn't landed. Its copy may have reached the
12163
+ * recipient's store in the same sync that the release missed, and then it
12164
+ * is released instead; and a write the deferred queue still holds for it
12165
+ * lands at the next flush and releases it then (releaseLandedMail). Either
12166
+ * way a second delivery would put it in the window twice under one mail id.
12167
+ */
12168
+ private deliverHeldMail(mail: HeldMail, wake = true): void {
12169
+ const recipient = this.agents.get(mail.to);
12170
+ if (recipient) {
12171
+ const already = recipient.getContextManager().queryMessages({ metadata: { 'dendrite.mailId': mail.id } });
12172
+ if (already.messages.length > 0) {
12173
+ this.registry.releaseMail(mail.id);
12174
+ return;
12175
+ }
12176
+ }
12177
+ const queued = this.deferredMessages.some(
12178
+ (m) => (m.metadata as { dendrite?: { mailId?: unknown } } | undefined)?.dendrite?.mailId === mail.id,
12179
+ );
12180
+ if (queued) return;
12181
+ this.attemptMailDelivery(mail, wake);
12182
+ }
12183
+
10303
12184
  private createDeferred<T>(): Deferred<T> {
10304
12185
  let resolve!: (value: T) => void;
10305
12186
  let reject!: (reason?: unknown) => void;
@@ -10463,7 +12344,8 @@ export class AgentFramework {
10463
12344
  // Only a fresh object returned by createEphemeralAgent may enter this path.
10464
12345
  // Never overwrite a resident/conversation owner or a concurrent run: their
10465
12346
  // cleanup is name-keyed and could cancel/deregister the legitimate owner.
10466
- if (this.ephemeralCandidates.get(agent) !== contextManager) {
12347
+ const candidate = this.ephemeralCandidates.get(agent);
12348
+ if (!candidate || candidate.contextManager !== contextManager) {
10467
12349
  throw new Error(`Ephemeral agent "${agent.name}" has no fresh generation ticket from this framework`);
10468
12350
  }
10469
12351
  // A live surgery holds the whole store: an agent admitted now would
@@ -10478,11 +12360,27 @@ export class AgentFramework {
10478
12360
  // Consume before any await/registration: the exact (Agent, ContextManager)
10479
12361
  // generation is one-shot even if startup later fails.
10480
12362
  this.ephemeralCandidates.delete(agent);
12363
+ if (candidate.resume && this.resumesPending.get(agent.name) === agent) this.resumesPending.delete(agent.name);
10481
12364
  if (this.agents.has(agent.name) || this.ephemeralRuns.has(agent.name)) {
10482
12365
  throw new Error(`Ephemeral agent "${agent.name}" is already registered or running`);
10483
12366
  }
12367
+ // Admit it to the registry first: a spec the registry refuses (spawner
12368
+ // or result recipient gone since creation) must not leave a live agent.
12369
+ const registered = this.registry.register(candidate.spec, { resume: candidate.resume === true, after: candidate.after });
12370
+ this.announceAgentCreated(registered);
10484
12371
  // Register temporarily so the event loop can drive it
10485
12372
  this.agents.set(agent.name, agent);
12373
+ // Kept while the run is live so the agent can itself be derived from.
12374
+ this.agentConfigs.set(agent.name, candidate.config);
12375
+ // A spawner that ended after creating it: it registered as that
12376
+ // spawner's orphan, and is told so, as is its first candidate.
12377
+ if (registered.orphaned) {
12378
+ this.announceOrphaned(registered, registered.orphaned.candidates, registered.orphaned.formerParent.agent, registered.orphaned.reason);
12379
+ }
12380
+ // Mail held for this name (a resumed incarnation's, sent while it was
12381
+ // ended) reaches it now rather than at the next boot. Its run is
12382
+ // starting, so it wakes nothing.
12383
+ for (const mail of this.registry.listMail({ to: agent.name })) this.deliverHeldMail(mail, false);
10486
12384
  const run: EphemeralRun = {
10487
12385
  settle: this.createDeferred<AgentSettleResult>(),
10488
12386
  inferenceStarted: false,
@@ -10491,15 +12389,39 @@ export class AgentFramework {
10491
12389
  };
10492
12390
  this.ephemeralRuns.set(agent.name, run);
10493
12391
 
12392
+ const lifetime = candidate.spec.lifetime;
10494
12393
  const STARTUP_TIMEOUT_MS = watchdogs?.startupTimeoutMs ?? 30_000;
10495
12394
  // After inference has started, give it 15 minutes of activity-bounded
10496
12395
  // life. The stream driver refreshes the deadline as it makes progress;
10497
- // sustained silence trips it.
10498
- const COMPLETION_IDLE_TIMEOUT_MS = watchdogs?.idleTimeoutMs ?? 15 * 60_000;
12396
+ // sustained silence trips it. An explicit watchdog wins; otherwise the
12397
+ // spec's task lifetime is the bound.
12398
+ const COMPLETION_IDLE_TIMEOUT_MS = watchdogs?.idleTimeoutMs
12399
+ ?? (lifetime.kind === 'task' ? lifetime.idleTimeoutMs : undefined)
12400
+ ?? 15 * 60_000;
10499
12401
  const IDLE_POLL_MS = watchdogs?.idlePollMs ?? 30_000;
10500
12402
 
10501
12403
  let startupWatchdog: ReturnType<typeof setTimeout> | null = null;
10502
12404
  let completionWatchdog: ReturnType<typeof setInterval> | null = null;
12405
+ let deadlineWatchdog: ReturnType<typeof setTimeout> | null = null;
12406
+
12407
+ // A task lifetime's wall-clock deadline: the identity must end, however
12408
+ // lively its stream is.
12409
+ // A timer holds at most MAX_TIMER_MS, so a longer deadline waits in
12410
+ // steps, each re-armed until the deadline is due.
12411
+ const deadline = new Promise<never>((_, reject) => {
12412
+ if (lifetime.kind !== 'task' || lifetime.deadlineMs === undefined) return;
12413
+ const deadlineMs = lifetime.deadlineMs;
12414
+ const dueAt = Date.now() + deadlineMs;
12415
+ const arm = (): void => {
12416
+ deadlineWatchdog = setTimeout(() => {
12417
+ if (Date.now() < dueAt) return arm();
12418
+ run.endReason = 'deadline';
12419
+ reject(new AgentStoppedError(agent.name, 'deadline', undefined, `${deadlineMs}ms elapsed`));
12420
+ }, Math.max(0, Math.min(dueAt - Date.now(), MAX_TIMER_MS)));
12421
+ deadlineWatchdog.unref?.();
12422
+ };
12423
+ arm();
12424
+ });
10503
12425
  // Whether the completion watchdog ended the run as stalled, for a
10504
12426
  // tool-result batch the run leaves behind (see the finally).
10505
12427
  let stalled = false;
@@ -10552,11 +12474,14 @@ export class AgentFramework {
10552
12474
  run.settle.promise,
10553
12475
  startupTimeout,
10554
12476
  completionIdleTimeout,
12477
+ deadline,
10555
12478
  ]);
12479
+ run.endReason ??= 'completed';
10556
12480
  return { speech: result.speech, toolCallsCount: result.toolCallsCount };
10557
12481
  } finally {
10558
12482
  if (startupWatchdog) clearTimeout(startupWatchdog);
10559
12483
  if (completionWatchdog) clearInterval(completionWatchdog);
12484
+ if (deadlineWatchdog) clearTimeout(deadlineWatchdog);
10560
12485
  // A batch still pending here has nothing left to settle it: once the
10561
12486
  // run is deregistered, driveStream's finally abandons nothing (it does
10562
12487
  // only while the agent is registered), and no later turn comes. The
@@ -10613,6 +12538,7 @@ export class AgentFramework {
10613
12538
  this.turnRouting.delete(agent.name);
10614
12539
  this.proseSpeechChains.delete(agent.name);
10615
12540
  this.openLogical.delete(agent.name);
12541
+ this.forgetAnnouncedLocus(agent.name);
10616
12542
  // The late stream finalizer intentionally refuses name-keyed cleanup once
10617
12543
  // deregistered, so disposal must release EventGate liveness itself.
10618
12544
  this.eventGate?.onInferenceEnded(agent.name);
@@ -10624,6 +12550,8 @@ export class AgentFramework {
10624
12550
  this.releaseWindowNowOrAfterResume(agent);
10625
12551
  }
10626
12552
  this.toolImageLedgers.delete(agent.name);
12553
+ if (this.agentConfigs.get(agent.name) === candidate.config) this.agentConfigs.delete(agent.name);
12554
+ this.toolSurfaceOwners.delete(agent.name);
10627
12555
  // Its scripts end with it: no turn of its is left for them to outlive.
10628
12556
  this.codeExecution?.scripts.agentGone(agent.name);
10629
12557
  // Spawn-and-dispose bookkeeping (main, d453165/fee96a7): without this,
@@ -10641,6 +12569,21 @@ export class AgentFramework {
10641
12569
  // A continuation of its turn still on its way goes too: the run, and
10642
12570
  // with it the turn, has ended above.
10643
12571
  this.dropContinuations(agent.name, null);
12572
+ if (run.endReason === 'host-stop') {
12573
+ // Cut off by stop(): left live for the next boot to end as interrupted.
12574
+ const record = this.registry.inspect(agent.name);
12575
+ this.emitTrace({
12576
+ type: 'dendrite:agent-interrupted',
12577
+ agentName: agent.name,
12578
+ kind: record?.kind ?? candidate.spec.kind,
12579
+ incarnation: record?.incarnation ?? 0,
12580
+ reason: 'host-stop',
12581
+ });
12582
+ } else {
12583
+ // The identity ends with its run. Whatever it spawned is settled by
12584
+ // the registry's rules (attention tenants end, task work is orphaned).
12585
+ this.settleEndedAgent(this.registry.end(agent.name, run.endReason ?? 'failed', run.endedBy, { record: true }));
12586
+ }
10644
12587
  }
10645
12588
  }
10646
12589
 
@@ -10674,12 +12617,22 @@ export class AgentFramework {
10674
12617
  'its messages will reach your subconscious where that\'s known. ' +
10675
12618
  'Calling tune_out again for a channel already tuned out keeps its ' +
10676
12619
  'settings, and, where this host can open the channel, tries the open ' +
10677
- 'again if it isn\'t open.',
12620
+ 'again if it isn\'t open. Without a subconscious it is a silent hold: ' +
12621
+ 'nothing summarizes or wakes you; the backlog is delivered when you ' +
12622
+ 'cancel. scope "all-except" holds EVERY channel (DMs and new channels ' +
12623
+ 'included) except channelId, for durationSeconds (required); your plain ' +
12624
+ 'speech lands in that channel meanwhile; the reader pauses rather than ' +
12625
+ 'cancels when its wake budget is spent. mode "check" reports what is ' +
12626
+ 'held without releasing it (`peek` shows one channel\'s newest held lines).',
10678
12627
  inputSchema: {
10679
12628
  type: 'object',
10680
12629
  properties: {
10681
- channelId: { type: 'string' },
10682
- mode: { type: 'string', enum: ['enter', 'cancel'], description: 'Default: enter.' },
12630
+ channelId: { type: 'string', description: 'The channel to tune out — or, with scope "all-except", the one channel to keep.' },
12631
+ serverId: { type: 'string', description: 'The channel\'s server, where the channel id exists on several servers; channelId also takes its server-qualified id ("serverId:channelId").' },
12632
+ mode: { type: 'string', enum: ['enter', 'cancel', 'check'], description: 'Default: enter.' },
12633
+ scope: { type: 'string', enum: ['channel', 'all-except'], description: 'Default: channel.' },
12634
+ peek: { type: 'string', description: 'mode "check": a held channel whose newest held lines to show (nothing is released).' },
12635
+ peekLimit: { type: 'number', description: 'mode "check": how many lines to peek (default 5, max 50).' },
10683
12636
  cadenceSeconds: {
10684
12637
  type: 'number',
10685
12638
  description: `Summary cadence in seconds (default ${TUNE_OUT_DEFAULTS.cadenceSeconds}; floors at 30).`,
@@ -10701,7 +12654,33 @@ export class AgentFramework {
10701
12654
  'delivered) after this many seconds; floors at 60. Unset = until cancelled.',
10702
12655
  },
10703
12656
  },
10704
- required: ['channelId'],
12657
+ required: [],
12658
+ },
12659
+ };
12660
+
12661
+ /** `focus`: the resident-facing alias for `tune_out` with scope "all-except" (#232 decision 2). */
12662
+ private static readonly FOCUS_TOOL: import('./types/index.js').ToolDefinition = {
12663
+ name: 'focus',
12664
+ description:
12665
+ 'Focus on one channel for a bounded time: every other channel — DMs ' +
12666
+ 'and channels that appear later included — is held and stops waking ' +
12667
+ 'you; your plain speech lands in the focused channel. The same hold as ' +
12668
+ 'tune_out with scope "all-except". mode "cancel" releases the held ' +
12669
+ 'backlog per channel; mode "check" reports what is held.',
12670
+ inputSchema: {
12671
+ type: 'object',
12672
+ properties: {
12673
+ channelId: { type: 'string', description: 'The one channel to keep.' },
12674
+ serverId: { type: 'string' },
12675
+ durationSeconds: { type: 'number', description: 'Required to enter; floors at 60.' },
12676
+ mode: { type: 'string', enum: ['enter', 'cancel', 'check'], description: 'Default: enter.' },
12677
+ cadenceSeconds: { type: 'number' },
12678
+ backlogCap: { type: 'number', description: 'Per held channel, at release.' },
12679
+ maxWakes: { type: 'number' },
12680
+ peek: { type: 'string' },
12681
+ peekLimit: { type: 'number' },
12682
+ },
12683
+ required: [],
10705
12684
  },
10706
12685
  };
10707
12686
 
@@ -12677,16 +14656,19 @@ export class AgentFramework {
12677
14656
 
12678
14657
  private disposeEmptiedForks(failures: ManagerReinitFailure[]): ManagerReinitFailure[] {
12679
14658
  const disposals: ManagerReinitFailure[] = [];
12680
- for (const [agentName, channelId] of [...this.conversationAgentHomes]) {
14659
+ // The forks are the registry's conversation-fork records; a fork's home is
14660
+ // the channel on its server (homeOf), which the router's binding is keyed by.
14661
+ const forks = this.listAgents().filter((r) => r.kind === 'conversation-fork').map((r) => ({ agentName: r.name, home: this.homeOf(r.name) }));
14662
+ for (const { agentName, home } of forks) {
12681
14663
  const agent = this.agents.get(agentName);
12682
14664
  if (!agent || agent.getContextManager().getMessageCount() > 0) continue;
12683
14665
  const heldWrites = this.deferredMessages.filter((msg) => (msg.forAgent ?? this.primaryAgentName) === agentName).length;
12684
14666
  let cleanupFailed: string | undefined;
12685
14667
  try {
12686
- if (this.conversationRouter?.getBinding(channelId)?.agentName === agentName) {
12687
- this.conversationRouter.unbind(channelId);
14668
+ if (home && this.conversationRouter && this.conversationRouter.getBinding(home)?.agentName === agentName) {
14669
+ this.conversationRouter.unbind(home);
12688
14670
  }
12689
- this.disposeConversationAgent(agentName);
14671
+ this.disposeConversationAgent(agentName, 'context-emptied');
12690
14672
  } catch (error) {
12691
14673
  cleanupFailed = failureDescription(error);
12692
14674
  }
@@ -13699,8 +15681,13 @@ export class AgentFramework {
13699
15681
  }
13700
15682
 
13701
15683
  private async createAgent(config: AgentConfig): Promise<Agent> {
15684
+ // The residents' view (RESIDENT_VIEW_FILTER: held rows out, read by
15685
+ // heldFromResidents) composed with this agent's own: another agent's
15686
+ // stored context injections out. Main's boundary and arrival checks read
15687
+ // the same predicate (storeSees, agentSees), so they and the compiled
15688
+ // view agree.
13702
15689
  const sees = (metadata: unknown): boolean =>
13703
- !(metadata as { tuneOut?: unknown } | undefined)?.tuneOut
15690
+ !heldFromResidents(metadata as Record<string, unknown> | undefined)
13704
15691
  && !isAnotherAgentsInjection(metadata, config.name);
13705
15692
  // Create context manager for this agent
13706
15693
  const contextManager = await ContextManager.open({
@@ -13721,7 +15708,8 @@ export class AgentFramework {
13721
15708
  // fetch_history, and audit.
13722
15709
  // Another agent's stored context injections (stored-injections.ts) are
13723
15710
  // that agent's context, not conversation: they stay out of this view,
13724
- // and so out of its folds, as the residents share this slot.
15711
+ // and so out of its folds, as the residents share this slot. The
15712
+ // resident filter keeps held (tuned-out) rows out as before (`sees`).
13725
15713
  viewFilter: (message) => sees(message.metadata),
13726
15714
  });
13727
15715
  this.trackManager(contextManager);
@@ -13731,6 +15719,10 @@ export class AgentFramework {
13731
15719
  this.adoptToolResultGuard(agent);
13732
15720
  agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
13733
15721
  this.sharedSlotAgents.add(agent);
15722
+ // The first resident declared owns default delivery.
15723
+ this.announceAgentCreated(
15724
+ this.registry.register({ ...residentSpec(config.name, { primary: !this.primaryAgentName }), model: config.model }),
15725
+ );
13734
15726
  let restoredSettings: AgentRuntimeSettingsOverrides | null;
13735
15727
  try {
13736
15728
  restoredSettings = this.readAgentRuntimeSettings(config.name);
@@ -13776,7 +15768,7 @@ export class AgentFramework {
13776
15768
  * passthrough, no memory pyramid: its durable output is what it delivers
13777
15769
  * into the resident's window, which accumulates there.
13778
15770
  */
13779
- private async createSubconsciousAgent(cfg: SubconsciousConfig): Promise<Agent> {
15771
+ private async createSubconsciousAgent(cfg: PersistentReaderConfig): Promise<Agent> {
13780
15772
  const primaryName = this.primaryAgentName;
13781
15773
  const primaryConfig = primaryName ? this.agentConfigs.get(primaryName) : undefined;
13782
15774
  if (!primaryName || !primaryConfig) {
@@ -13843,6 +15835,12 @@ export class AgentFramework {
13843
15835
  this.adoptToolResultGuard(agent);
13844
15836
  agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
13845
15837
  this.sharedSlotAgents.add(agent); // reads the shared slot through its merged view
15838
+ // An attention tenant of the resident it serves: reads the shared slot
15839
+ // through its merged view (held traffic included), never a broadcast
15840
+ // recipient, and outlives policy epochs.
15841
+ this.announceAgentCreated(
15842
+ this.registry.register({ ...subconsciousSpec(name, primaryName), model: agentConfig.model }),
15843
+ );
13846
15844
  this.agents.set(name, agent);
13847
15845
  this.agentConfigs.set(name, agentConfig);
13848
15846
  this.subconsciousAgentName = name;
@@ -13851,6 +15849,193 @@ export class AgentFramework {
13851
15849
  return agent;
13852
15850
  }
13853
15851
 
15852
+ /**
15853
+ * `subconscious.reader: 'forks'`: no persistent agent. The coordinator
15854
+ * asks for a fork per invocation (see invokeReaderFork); the config is
15855
+ * kept for the voice block, the speech switch and the strategy factory.
15856
+ */
15857
+ private configureReaderForks(cfg: ForkReaderConfig): void {
15858
+ const primaryName = this.primaryAgentName;
15859
+ const primaryConfig = primaryName ? this.agentConfigs.get(primaryName) : undefined;
15860
+ if (!primaryName || !primaryConfig) {
15861
+ throw new Error('subconscious requires a primary agent to attend to');
15862
+ }
15863
+ const strategy = primaryConfig.strategy;
15864
+ if (!cfg.strategyFactory && strategy && !(strategy instanceof PassthroughStrategy)) {
15865
+ throw new Error(
15866
+ `subconscious.reader "forks": the primary agent "${primaryName}" runs a ${strategy.name} strategy, ` +
15867
+ 'so a reader fork needs `subconscious.strategyFactory` returning a fresh instance of the same ' +
15868
+ 'class and configuration to reuse its fold state and rendering',
15869
+ );
15870
+ }
15871
+ // Whose weights read the resident's prefix is never defaulted. The
15872
+ // resident's own model is the copy; another is allowed when stated, so
15873
+ // the resident's consent can name it (architecture channel, 10-09).
15874
+ if (typeof cfg.model !== 'string' || cfg.model.length === 0) {
15875
+ throw new Error(
15876
+ `subconscious.reader "forks": a reader fork holds the whole prefix of "${primaryName}", so \`model\` ` +
15877
+ `must say whose weights run it — "${primaryConfig.model}" for a copy of the resident, or another model ` +
15878
+ 'by name. It is not defaulted',
15879
+ );
15880
+ }
15881
+ if (typeof cfg.voice !== 'string' || cfg.voice.length === 0) {
15882
+ throw new Error('subconscious.reader "forks": `voice` (the reader\'s framing block) is required');
15883
+ }
15884
+ this.readerForks = true;
15885
+ this.subconsciousConfig = cfg;
15886
+ }
15887
+
15888
+ /**
15889
+ * One reader invocation: derive a fork of the resident at its head with
15890
+ * the resident's refusals, hand it the held traffic since the last look
15891
+ * as ordinary framing, and run it. It reports through `deliver_summary`
15892
+ * (attributed mail), may cancel the hold, and ends when its turn ends.
15893
+ */
15894
+ private invokeReaderFork(invocation: ReaderInvocation): void {
15895
+ const primary = this.primaryAgentName;
15896
+ const cfg = this.subconsciousConfig;
15897
+ if (!primary || cfg?.reader !== 'forks' || !this.agents.has(primary)) return;
15898
+ const name = `reader/${primary}/${invocation.epochId.slice(0, 8)}/${++this.readerForkCounter}`;
15899
+
15900
+ void (async () => {
15901
+ try {
15902
+ // The queued messages are rows of the backlog a framing reads, after
15903
+ // the stored ones, so any framing that shows `backlog` shows them.
15904
+ const backlog = [...invocation.backlog, ...this.queuedReaderRows(primary, invocation.queued)];
15905
+ const content = (cfg.framing ?? defaultReaderFraming)({
15906
+ resident: primary,
15907
+ fork: name,
15908
+ voice: cfg.voice,
15909
+ channelId: invocation.channelId,
15910
+ epochId: invocation.epochId,
15911
+ trigger: invocation.trigger,
15912
+ notice: invocation.notice,
15913
+ dispositions: invocation.dispositions,
15914
+ backlog,
15915
+ queuedCount: invocation.queued.length,
15916
+ });
15917
+ const fork = await this.deriveAgent({
15918
+ name,
15919
+ from: primary,
15920
+ kind: 'subconscious-fork',
15921
+ ...(cfg.strategyFactory ? { strategy: cfg.strategyFactory() } : {}),
15922
+ // Whose weights, as the configuration states it; its report is a
15923
+ // tool call, so bare prose never routes anywhere.
15924
+ config: {
15925
+ model: cfg.model,
15926
+ proseRouting: 'disabled',
15927
+ },
15928
+ inheritRefusals: true,
15929
+ onParentEnd: 'end',
15930
+ resultTo: { to: primary, as: 'message' },
15931
+ idleTimeoutMs: cfg.forkIdleTimeoutMs ?? 10 * 60_000,
15932
+ framing: [{
15933
+ participant: 'user',
15934
+ content,
15935
+ metadata: {
15936
+ system: true,
15937
+ kind: 'tune-out-reader-framing',
15938
+ channelId: invocation.channelId,
15939
+ epochId: invocation.epochId,
15940
+ trigger: invocation.trigger,
15941
+ heldMessageIds: backlog.map((m) => m.id),
15942
+ } as MessageMetadata,
15943
+ }],
15944
+ metadata: { epochId: invocation.epochId, channelId: invocation.channelId, trigger: invocation.trigger },
15945
+ });
15946
+ this.emitTrace({
15947
+ type: 'tune-out:reader-fork',
15948
+ agentName: name,
15949
+ serverId: invocation.serverId,
15950
+ channelId: invocation.channelId,
15951
+ epochId: invocation.epochId,
15952
+ trigger: invocation.trigger,
15953
+ held: invocation.backlog.length + invocation.queued.length,
15954
+ queued: invocation.queued.length,
15955
+ });
15956
+ const outcome = await this.runEphemeralToCompletion(fork.agent, fork.contextManager);
15957
+ // The report is the fork's whole job. A reader that ends in prose
15958
+ // without `deliver_summary` has still reported — its final prose is
15959
+ // delivered as its report, under its own name, instead of vanishing
15960
+ // into proseRouting=disabled (first live run, 2026-10-12, finding A).
15961
+ const prose = outcome.speech.trim();
15962
+ if (prose && !this.tuneOutCoordinator?.readerReported(name)) {
15963
+ this.deliverFromReader(name, [{ type: 'text', text: prose }], {
15964
+ kind: 'subconscious-summary',
15965
+ channelId: invocation.channelId,
15966
+ epochId: invocation.epochId,
15967
+ report: 'prose',
15968
+ });
15969
+ this.emitTrace({
15970
+ type: 'tune-out:reader-prose-delivered',
15971
+ agentName: name,
15972
+ serverId: invocation.serverId,
15973
+ channelId: invocation.channelId,
15974
+ epochId: invocation.epochId,
15975
+ chars: prose.length,
15976
+ });
15977
+ }
15978
+ } catch (error) {
15979
+ console.error(`[tune-out] reader fork ${name} failed:`, error instanceof Error ? error.message : error);
15980
+ // A fork that failed before it reported never read what it was
15981
+ // handed: those messages go back to the channel's backlog, so the
15982
+ // next reader for it is handed them instead of no reader ever being.
15983
+ const reported = this.tuneOutCoordinator?.readerReported(name) ?? false;
15984
+ const returned = reported ? 0 : (this.tuneOutCoordinator?.readerFailed(invocation) ?? 0);
15985
+ this.emitTrace({
15986
+ type: 'tune-out:reader-fork-failed',
15987
+ agentName: name,
15988
+ serverId: invocation.serverId,
15989
+ channelId: invocation.channelId,
15990
+ epochId: invocation.epochId,
15991
+ trigger: invocation.trigger,
15992
+ error: error instanceof Error ? error.message : String(error),
15993
+ reported,
15994
+ returned,
15995
+ });
15996
+ } finally {
15997
+ this.tuneOutCoordinator?.forgetReader(name);
15998
+ }
15999
+ })();
16000
+ }
16001
+
16002
+ /**
16003
+ * Held messages still queued behind the resident's turn, as rows of a
16004
+ * reader fork's backlog: each named by its held handle, as module rows name
16005
+ * it until it lands (heldRows), at a provisional sequence past the
16006
+ * resident's last stored message, and timed when it was first held.
16007
+ */
16008
+ private queuedReaderRows(primary: string, queued: QueuedHeldMessage[]): StoredMessage[] {
16009
+ if (queued.length === 0) return [];
16010
+ const cm = this.agents.get(primary)!.getContextManager();
16011
+ const count = cm.getMessageCount();
16012
+ const last = count > 0 ? cm.getMessageWindow(count - 1, 1, { resolveBlobs: false }).messages[0]?.sequence ?? 0 : 0;
16013
+ return queued.map((m, i) => ({
16014
+ id: heldHandle(m.writeId),
16015
+ sequence: last + 1 + i,
16016
+ participant: m.participant,
16017
+ content: m.content,
16018
+ metadata: m.metadata as MessageMetadata | undefined,
16019
+ timestamp: new Date(m.heldAt ?? Date.now()),
16020
+ }));
16021
+ }
16022
+
16023
+ /** A reader fork's own words to the resident: attributed mail when it has a route, else under its name. */
16024
+ private deliverFromReader(reader: string, content: ContentBlock[], metadata: Record<string, unknown>): void {
16025
+ const route = this.registry.inspect(reader)?.relationships.resultTo;
16026
+ if (route?.as === 'message') {
16027
+ const mail = this.registry.holdMail({
16028
+ kind: 'result',
16029
+ from: this.registry.ref(reader),
16030
+ to: route.to,
16031
+ content,
16032
+ });
16033
+ this.attemptMailDelivery(mail);
16034
+ return;
16035
+ }
16036
+ this.addMessage(reader, content, metadata as MessageMetadata);
16037
+ }
16038
+
13854
16039
  private async runLoop(): Promise<void> {
13855
16040
  // A loop ends with its lifecycle. A stop() whose budget ran out while
13856
16041
  // this loop was in a call (a module's onProcess) returns before the call
@@ -14164,7 +16349,7 @@ export class AgentFramework {
14164
16349
  // stand between a tool's call and its results, so this clock
14165
16350
  // reads the time it's written (clock-rows.ts).
14166
16351
  const clock = this.storeDueClock(agent, 'tool-boundary');
14167
- const deferred = this.drainDeferredFor(agent.name, (msg) => !isInjectableMidTurn(msg, agent.name));
16352
+ const deferred = this.drainDeferredFor(agent.name, (msg) => !isInjectableMidTurn(msg, agent));
14168
16353
  // Stored in queue order, cut at the first that can't be stored: it
14169
16354
  // and what follows wait for the turn's end, and only what was
14170
16355
  // stored is injected (the round's results go on either way). One
@@ -14177,10 +16362,17 @@ export class AgentFramework {
14177
16362
  const stored = this.writeDrainedInOrder(deferred, `tool boundary for ${agent.name}`, (msg) =>
14178
16363
  agent.getContextManager().addMessage(msg.participant, msg.content, withDeferredWriteId(msg.metadata, msg.id)));
14179
16364
  for (const [msg, { id, content }] of stored) {
16365
+ this.releaseLandedMail(msg.metadata, agent.name);
14180
16366
  this.noteSpokenTo(agent.name, msg.metadata);
14181
16367
  // Every other resident reads what this boundary stores; for its
14182
16368
  // own agent, the boundary weighs what it injects below.
14183
16369
  this.noteForOtherResidents(agent, msg.metadata, id);
16370
+ // A message the agent's own view leaves out is stored and not
16371
+ // injected: a held message (the reader has it; admission's
16372
+ // predicate, heldFromResidents, is in every resident's view
16373
+ // filter) or another agent's stored context injection. Injecting
16374
+ // it here would put in the live turn what the next compile
16375
+ // hides (#232 gap 5; main's #853 reads the view itself).
14184
16376
  if (!this.agentSees(agent, msg.metadata)) continue;
14185
16377
  heard.push({ msg, id, content });
14186
16378
  }
@@ -14306,6 +16498,34 @@ export class AgentFramework {
14306
16498
  : 0;
14307
16499
  const overPhysical = projectedRealTokens > (agent.physicalWindowTokens ?? Infinity);
14308
16500
 
16501
+ // Under `speech.locus: 'delivery'` incoming traffic never moves the
16502
+ // pin — so when someone addresses the agent mid-turn from another
16503
+ // channel, the agent must be told who and from where, or a neutral
16504
+ // message reads as part of the conversation it landed in and gets
16505
+ // answered there (first live run, 2026-10-12, finding C). One
16506
+ // notice per channel per turn; it rides the same injection batch.
16507
+ // The turn's own mode: a silent turn routes no prose, whatever the
16508
+ // agent's mode, so its boundary neither notices nor re-pins. The
16509
+ // trigger is this stream's: its start holds the agent's turn token
16510
+ // while the agent waits on this round's tools.
16511
+ const routesLocus = (agent.proseRouting === 'locus' || agent.proseRouting === 'hybrid')
16512
+ && !this.activeTurnTriggers.get(agent.name)?.suppressProse;
16513
+ if (this.speechLocus === 'delivery' && routesLocus && !shouldEndTurn && !overBudget && currentState.stream) {
16514
+ const routing = this.routingOf(agent.name);
16515
+ for (const inj of [...midTurnInjections]) {
16516
+ const m = inj.metadata as Record<string, unknown> | undefined;
16517
+ if (!isConversationalInjection(inj.metadata)) continue;
16518
+ if (typeof m?.channelId !== 'string') continue;
16519
+ if (!isAddressedMessage(m.tags as string[] | undefined)) continue;
16520
+ // Its channel as a re-pin reads it (repinChannel): on the
16521
+ // message's own server.
16522
+ const channel = this.channelNamed(m.channelId, typeof m.serverId === 'string' ? m.serverId : undefined);
16523
+ if (!channel) continue;
16524
+ const notice = this.addressedElsewhereNotice(agent, routing, channel, authorName(m));
16525
+ if (notice) midTurnInjections.push(notice);
16526
+ }
16527
+ }
16528
+
14309
16529
  // Addressed re-pin (2026-07-31 Mythos misroutes, antra-ratified):
14310
16530
  // when someone ADDRESSES the agent mid-turn from another channel
14311
16531
  // (mention / reply / DM — the same chat:addressed signal the
@@ -14336,9 +16556,7 @@ export class AgentFramework {
14336
16556
  // agent's mode, so its boundary neither re-pins nor notes. The
14337
16557
  // trigger is this stream's: its start holds the agent's turn token
14338
16558
  // while the agent waits on this round's tools.
14339
- const routesLocus = (agent.proseRouting === 'locus' || agent.proseRouting === 'hybrid')
14340
- && !this.activeTurnTriggers.get(agent.name)?.suppressProse;
14341
- if (routesLocus && !shouldEndTurn && !overBudget && currentState.stream) {
16559
+ if (this.speechLocus === 'latest-inbound' && routesLocus && !shouldEndTurn && !overBudget && currentState.stream) {
14342
16560
  // Two signals qualify an injection to move the pin (n=6 + n=7):
14343
16561
  // - chat:addressed — someone explicitly spoke TO the agent
14344
16562
  // (mention / reply / DM);
@@ -14738,12 +16956,12 @@ export class AgentFramework {
14738
16956
  if (!decision.trigger) return;
14739
16957
  }
14740
16958
 
14741
- // Broadcast requests exclude conversation forks — they are driven by
14742
- // their own channel's messages, not by framework-wide events.
16959
+ // A broadcast request reaches the agents whose role says they take
16960
+ // untargeted inbound; channel-bound and bounded-job agents are driven
16961
+ // by their own channel or task, not by framework-wide events.
14743
16962
  const targetAgents =
14744
16963
  response.requestInference === true
14745
- ? Array.from(this.agents.keys()).filter(
14746
- (n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName)
16964
+ ? this.registry.untargetedRecipients()
14747
16965
  : response.requestInference;
14748
16966
 
14749
16967
  for (const agentName of targetAgents) {
@@ -14794,6 +17012,27 @@ export class AgentFramework {
14794
17012
  * Convert an incoming MCPL channel message to a context message.
14795
17013
  * This replaces the old MCPLModule.onProcess() message conversion.
14796
17014
  */
17015
+ /**
17016
+ * A delivered inbound message becomes the latest delivered inbound (the
17017
+ * channel context's reply anchor; the legacy speech fallback) through the
17018
+ * registry's noteDelivered — at once for a plain channels/incoming, whose
17019
+ * acceptance the registry answers synchronously; parked for a coalesced one
17020
+ * until its acceptance is acknowledged (see parkedAnchors).
17021
+ */
17022
+ private noteDeliveredOrPark(event: {
17023
+ serverId: string; channelId: string; messageId: string; threadId?: string; tags?: string[];
17024
+ eventId?: string; coalescingSubject?: string; firstView?: boolean;
17025
+ }): void {
17026
+ // On the server the message came from (noteDelivered).
17027
+ const anchor = { channel: { serverId: event.serverId, channelId: event.channelId }, messageId: event.messageId, threadId: event.threadId, tags: event.tags, firstView: event.firstView === true };
17028
+ if (event.coalescingSubject && typeof event.eventId === 'string') {
17029
+ this.parkedAnchors.set(`${event.serverId}:${event.eventId}`, anchor);
17030
+ if (this.parkedAnchors.size > 1000) this.parkedAnchors.delete(this.parkedAnchors.keys().next().value!);
17031
+ return;
17032
+ }
17033
+ this.channelRegistry?.noteDelivered(anchor.channel, anchor.messageId, anchor.threadId, anchor.tags, anchor.firstView);
17034
+ }
17035
+
14797
17036
  private async handleMcplChannelIncoming(event: {
14798
17037
  type: 'mcpl:channel-incoming';
14799
17038
  serverId: string;
@@ -14877,6 +17116,53 @@ export class AgentFramework {
14877
17116
  // each storage site below, over exactly the blocks stored there.
14878
17117
  metadata.sourceBodyDigest = sourceBodyDigest(event.content);
14879
17118
 
17119
+ // Stored as the model reads it: `author: body`. The wire formatter
17120
+ // writes the participant, and channel traffic is stored under the
17121
+ // anonymous `user`, so a body stored bare reaches the model as
17122
+ // `user: <text>` with the author nowhere (Weft's rig, 2026-10-13). A
17123
+ // server that renders the name into the text itself is left as is.
17124
+ const content = presentAuthor({ participant: 'user', content: event.content, metadata }).content;
17125
+
17126
+ // Admission (Dendrite §D, #232 root cause A): decided once, here, before
17127
+ // anything downstream acts — the hold, the wake gate, who may be woken,
17128
+ // whether this counts as the latest delivered inbound. Every path below
17129
+ // consumes `decision`; none re-asks the policy.
17130
+ const addressed = isAddressedMessage(event.tags);
17131
+ const decision = this.admitInbound({
17132
+ lane: 'channel',
17133
+ serverId: event.serverId,
17134
+ channelId: event.channelId,
17135
+ messageId: event.messageId,
17136
+ authorId: event.author?.id,
17137
+ tags: event.tags,
17138
+ addressed,
17139
+ vetoed: event.triggerInference === false,
17140
+ targetAgents: event.targetAgents,
17141
+ gateContent: event.content
17142
+ .filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text')
17143
+ .map((b) => b.text)
17144
+ .join('\n'),
17145
+ gateMetadata: {
17146
+ ...event.metadata,
17147
+ eventType: 'mcpl:channel-incoming',
17148
+ serverId: event.serverId,
17149
+ channelId: event.channelId,
17150
+ messageId: event.messageId,
17151
+ threadId: event.threadId,
17152
+ author: event.author,
17153
+ ...(event.tags ? { tags: event.tags } : {}),
17154
+ },
17155
+ });
17156
+ metadata.triggered = decision.wake;
17157
+ if (decision.held) {
17158
+ // The admission record rides the message; the hold's own bookkeeping
17159
+ // (cadence tally, suppressed-mention ack, reader wake) follows.
17160
+ metadata.tuneOut = { epochId: decision.held.epochId };
17161
+ this.tuneOutCoordinator?.recordHeld(
17162
+ event.serverId, event.channelId, event.messageId, event.tags, event.author?.id, decision.gatePass,
17163
+ );
17164
+ }
17165
+
14880
17166
  // Per-channel conversation routing: messages go to the channel's fork
14881
17167
  // agent (spawned from the template on first qualifying message), never
14882
17168
  // to the primary conversation.
@@ -14886,14 +17172,19 @@ export class AgentFramework {
14886
17172
  if (event.deliverTo) {
14887
17173
  const target = this.agents.get(event.deliverTo);
14888
17174
  if (!target) return undefined;
14889
- if (this.conversationRouter && this.conversationAgentHomes.has(target.name)) {
17175
+ if (this.conversationRouter && this.registry.homeChannel(target.name) !== undefined) {
14890
17176
  metadata.triggered = event.triggerInference ?? false;
14891
17177
  // A fork's channel messages are stored past its deferral; a clock goes
14892
17178
  // ahead of one only while the fork has no turn alive (storeDueClock).
14893
17179
  if (!this.turnAlive(target) && takesClockBefore('user', event.content, metadata, target.name)) {
14894
17180
  this.storeDueClock(target, 'event');
14895
17181
  }
14896
- const placement = this.writeToFork(target, event.content, AgentFramework.withStoredDigest(metadata, event.content));
17182
+ // The stored copy carries the author as the fork reads them (presentAuthor), and its digest is of that copy.
17183
+ const placement = this.writeToFork(target, content, AgentFramework.withStoredDigest(metadata, content));
17184
+ // Delivered to the fork, so it is the latest delivered inbound too
17185
+ // (the channel context's `incoming`; the fallback locus under the
17186
+ // legacy rule), as a resident's delivery is below.
17187
+ if (decision.locus) this.noteDeliveredOrPark(event);
14897
17188
  // A correction may target an older engagement. Refresh only the
14898
17189
  // binding whose agent actually received the message.
14899
17190
  const channel: ChannelRef = { serverId: event.serverId, channelId: event.channelId };
@@ -14901,12 +17192,12 @@ export class AgentFramework {
14901
17192
  this.conversationRouter.touch(channel);
14902
17193
  }
14903
17194
  this.emitTrace({ type: 'message:added', messageId: placement.messageId ?? '', source: 'mcpl:channel-incoming' });
14904
- if (event.triggerInference) {
17195
+ if (decision.wake) {
14905
17196
  this.pendingRequests.push({
14906
17197
  agentName: target.name, reason: 'mcpl:channel-incoming', source: event.serverId, timestamp: Date.now(),
14907
17198
  channelId: event.channelId, channelServerId: event.serverId,
14908
17199
  counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
14909
- addressed: isAddressedMessage(event.tags, event.metadata),
17200
+ addressed,
14910
17201
  ...systemNoticeMark(event.tags),
14911
17202
  ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
14912
17203
  });
@@ -14914,14 +17205,14 @@ export class AgentFramework {
14914
17205
  return placement;
14915
17206
  }
14916
17207
  if (this.conversationRouter) return undefined; // the trunk never holds channel traffic
14917
- event = { ...event, targetAgents: [target.name] };
17208
+ decision.recipients = [target.name];
14918
17209
  } else if (this.conversationRouter) {
14919
- return this.routeConversationIncoming(event, metadata);
17210
+ return this.routeConversationIncoming({ ...event, content }, metadata, decision);
14920
17211
  }
14921
17212
 
14922
17213
  // Addressed-while-closed invitation — parity with the push-event path
14923
17214
  // (channels.publish surfaces like portal-mcpl deliver mentions here).
14924
- const incomingContent = [...event.content];
17215
+ const incomingContent = [...content];
14925
17216
  {
14926
17217
  const invitation = this.buildClosedChannelInvitation({
14927
17218
  serverId: event.serverId,
@@ -14938,7 +17229,11 @@ export class AgentFramework {
14938
17229
  missedCharacters: typeof event.metadata?.missedCharacters === 'number'
14939
17230
  ? event.metadata.missedCharacters
14940
17231
  : undefined,
14941
- targetAgents: event.targetAgents,
17232
+ // A correction's one agent (deliverTo) is the agent whose window the
17233
+ // invitation goes into, so its mode is the one read (main's #852):
17234
+ // admission recorded it as the recipients, where main's code
17235
+ // rewrites the event's targetAgents.
17236
+ targetAgents: event.deliverTo ? decision.recipients : event.targetAgents,
14942
17237
  });
14943
17238
  if (invitation) {
14944
17239
  incomingContent.push(invitation.block);
@@ -14946,38 +17241,17 @@ export class AgentFramework {
14946
17241
  }
14947
17242
  }
14948
17243
 
14949
- // Tune-out divert (issue #77): stamped, stored, no resident wake. The
14950
- // coordinator has already handled wake bookkeeping (coalesced
14951
- // subconscious invocation, durable count, suppression ack) before we
14952
- // stamp; the subconscious reads the message through its merged view.
14953
- const divert = this.tuneOutCoordinator?.onIncoming(
14954
- event.serverId,
14955
- event.channelId,
14956
- event.messageId,
14957
- event.tags,
14958
- event.author?.id,
14959
- // The resident's gate verdict rides the event; it preconditions
14960
- // subconscious wakes (gate composes, it is not replaced).
14961
- event.triggerInference !== false,
14962
- ) ?? null;
14963
- if (divert) {
14964
- metadata.tuneOut = { epochId: divert.epochId };
14965
- }
14966
-
17244
+ // Captured where it arrived: a held message is stored with its admission
17245
+ // record and never enters a resident's view; a delivered one is also
17246
+ // the latest delivered inbound (the channel context's `incoming`, and
17247
+ // the fallback locus under the legacy rule) — a held one never is.
14967
17248
  const placement: CoalescingPlacement = { agent: '' };
14968
17249
  const id = this.addMessage('user', incomingContent, AgentFramework.withStoredDigest(metadata, incomingContent), { placement, bypassDeferralFor: event.assemblingFor });
14969
17250
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
17251
+ if (decision.locus) this.noteDeliveredOrPark(event);
14970
17252
 
14971
- if (event.triggerInference && !divert) {
14972
- const addressed = isAddressedMessage(event.tags, event.metadata);
14973
- // Honor explicit targeting, mirroring the push-event path: an event
14974
- // that names its agents wakes exactly those; an untargeted event
14975
- // keeps the historical broadcast. (targetAgents was declared on
14976
- // McplChannelIncomingEvent from the start but never honored here —
14977
- // tune-out's wake routing is the first setter.)
14978
- const targetAgents = event.targetAgents
14979
- ?? [...this.agents.keys()].filter((n) => n !== this.subconsciousAgentName);
14980
- for (const agentName of targetAgents) {
17253
+ if (decision.wake) {
17254
+ for (const agentName of decision.recipients) {
14981
17255
  if (!this.agents.has(agentName)) continue;
14982
17256
  this.pendingRequests.push({
14983
17257
  agentName,
@@ -15021,6 +17295,7 @@ export class AgentFramework {
15021
17295
  coalescingSubject?: string;
15022
17296
  },
15023
17297
  messageMetadata: Record<string, unknown>,
17298
+ admission: AdmissionDecision,
15024
17299
  ): Promise<CoalescingPlacement | undefined> {
15025
17300
  const router = this.conversationRouter!;
15026
17301
  const channel: ChannelRef = { serverId: event.serverId, channelId: event.channelId };
@@ -15083,9 +17358,9 @@ export class AgentFramework {
15083
17358
  }
15084
17359
  }
15085
17360
 
15086
- // Respect an explicit server-config-level veto (shouldTriggerInference)
15087
- // on top of the router's own trigger policy.
15088
- const trigger = decision.trigger && event.triggerInference !== false;
17361
+ // The admission decision (server veto, wake gate) composes with the
17362
+ // router's own trigger policy.
17363
+ const trigger = decision.trigger && admission.wake;
15089
17364
  messageMetadata.triggered = trigger;
15090
17365
 
15091
17366
  // As for a correction above: a clock ahead only while no turn is alive.
@@ -15093,6 +17368,10 @@ export class AgentFramework {
15093
17368
  this.storeDueClock(agent, 'event');
15094
17369
  }
15095
17370
  const placement = this.writeToFork(agent, event.content, AgentFramework.withStoredDigest(messageMetadata, event.content));
17371
+ // Delivered to the channel's fork: the latest delivered inbound, as for a
17372
+ // resident (the channel context's `incoming`; the fallback locus under
17373
+ // the legacy rule). A held or dropped message never is.
17374
+ if (admission.locus) this.noteDeliveredOrPark(event as Parameters<AgentFramework['noteDeliveredOrPark']>[0]);
15096
17375
  router.touch(channel);
15097
17376
  this.emitTrace({ type: 'message:added', messageId: placement.messageId ?? '', source: 'mcpl:channel-incoming' });
15098
17377
 
@@ -15106,7 +17385,7 @@ export class AgentFramework {
15106
17385
  // triggering channel too so the trunk/active path stays consistent.
15107
17386
  channelId: event.channelId, channelServerId: event.serverId,
15108
17387
  counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
15109
- addressed: isAddressedMessage(event.tags, event.metadata),
17388
+ addressed: isAddressedMessage(event.tags),
15110
17389
  ...systemNoticeMark(event.tags),
15111
17390
  ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
15112
17391
  });
@@ -15250,10 +17529,17 @@ export class AgentFramework {
15250
17529
  const agent = new Agent(config, contextManager, this.membrane);
15251
17530
  this.adoptToolResultGuard(agent);
15252
17531
  agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
17532
+ this.announceAgentCreated(this.registry.register({
17533
+ ...conversationForkSpec(name, {
17534
+ template: router.templateAgent,
17535
+ homeChannel: home,
17536
+ idleTtlMs: router.idleTtlMs,
17537
+ }),
17538
+ model: config.model,
17539
+ }));
15253
17540
  this.managerReleases.set(agent, release);
15254
17541
  this.agents.set(name, agent);
15255
17542
  this.agentConfigs.set(name, config);
15256
- this.conversationAgentHomes.set(name, home);
15257
17543
  return agent;
15258
17544
  } catch (error) {
15259
17545
  if (release.manager) this.closeManager(release.manager);
@@ -15399,61 +17685,27 @@ export class AgentFramework {
15399
17685
  * stop() has ended binds nothing (spawnConversationAgent). The fork's
15400
17686
  * context stays in Chronicle for investigation.
15401
17687
  */
15402
- private disposeConversationAgent(agentName: string): void {
15403
- this.closingConversationAgents.delete(agentName);
15404
- // A continuation of its turn still on its way goes with it.
15405
- this.dropContinuations(agentName, { kind: 'dropped', why: 'its agent was disposed' });
15406
- const home = this.conversationAgentHomes.get(agentName);
17688
+ private disposeConversationAgent(agentName: string, reason: AgentEndReason = 'idle-ttl'): void {
17689
+ const home = this.homeOf(agentName);
15407
17690
  const agent = this.agents.get(agentName);
15408
- // A fork stop() ends can still be running: its stream, its tool round.
15409
- // Its name is never used again, so its end takes the stream's
15410
- // activeStreams and pendingAssistantBlocks entries, as a run's end does
15411
- // (runEphemeralToCompletion), and settles a batch its guard still holds,
15412
- // withheld as `aborted`: the stream's own finally does neither once the
15413
- // fork has gone. A fork the idle sweep ends has no stream entries left,
15414
- // and a batch still pending there is one whose successor never ran,
15415
- // which a run's end settles too.
15416
- if (agent?.toolResultGuard.hasPending) {
15417
- try {
15418
- agent.toolResultGuard.abandon('aborted');
15419
- } catch (error) {
15420
- console.error(`[tool-result-guard] agent=${agentName} failed to record a batch the fork left:`, error);
15421
- }
15422
- }
15423
- this.activeStreams.delete(agentName);
15424
- this.pendingAssistantBlocks.delete(agentName);
15425
- this.agents.delete(agentName);
15426
- this.thinkingLossIncidents.forget(agentName);
15427
- this.reasoningLoss.forget(agentName);
15428
- // Its pending self-wake ends with it, rather than wake a name that's gone.
15429
- this.eventGate?.agentGone(agentName);
17691
+ // The one release every ended agent gets (releaseAgentRuntime), then the
17692
+ // registry's end, whose outcome settles what the fork spawned.
15430
17693
  if (agent) {
15431
- this.releaseManager(agent);
15432
- this.releaseWindowNowOrAfterResume(agent);
15433
- }
15434
- this.turnRouting.delete(agentName);
15435
- this.proseSpeechChains.delete(agentName);
15436
- this.openLogical.delete(agentName);
15437
- this.toolImageLedgers.delete(agentName);
15438
- this.agentConfigs.delete(agentName);
15439
- this.conversationAgentHomes.delete(agentName);
15440
- this.evictTurnCheckpoints(agentName);
15441
- if (agent) this.logicalTurnToolCalls.delete(agent);
15442
- // Its scripts go with it, and leave the map once they've ended: no later
15443
- // run of a fork's name trims them.
15444
- this.codeExecution?.scripts.agentGone(agentName);
15445
- // A provider-wait hold (nonOwningProviderWait) goes with the agent: left,
15446
- // its timer would requeue held wakes for an agent that is gone, and a wait
15447
- // held until released would re-arm for the name indefinitely. The recorded
15448
- // wait stays, and binds the name if its channel binds again.
15449
- const cooldown = this.providerAccelerationCooldowns.get(agentName);
15450
- if (cooldown) {
15451
- clearTimeout(cooldown.timer);
15452
- this.providerAccelerationCooldowns.delete(agentName);
15453
- console.error(`[provider-wait] agent=${agentName} disposed while held` +
15454
- `${cooldown.waitModel !== undefined ? ` on ${cooldown.waitModel}` : ''}: ` +
15455
- `${cooldown.heldRequests.length} held request(s) dropped with it (the recorded wait stays)`);
15456
- }
17694
+ this.releaseAgentRuntime(agent, home, 'its agent was disposed');
17695
+ } else {
17696
+ // Nothing live under the name: only what the process keeps by name.
17697
+ this.closingConversationAgents.delete(agentName);
17698
+ this.thinkingLossIncidents.forget(agentName);
17699
+ this.reasoningLoss.forget(agentName);
17700
+ // A continuation of its turn still on its way goes with it.
17701
+ this.dropContinuations(agentName, { kind: 'dropped', why: 'its agent was disposed' });
17702
+ this.turnRouting.delete(agentName);
17703
+ this.proseSpeechChains.delete(agentName);
17704
+ this.openLogical.delete(agentName);
17705
+ this.forgetAnnouncedLocus(agentName);
17706
+ this.codeExecution?.scripts.agentGone(agentName);
17707
+ }
17708
+ this.settleEndedAgent(this.registry.end(agentName, reason, 'framework', { record: true }));
15457
17709
  this.emitTrace({
15458
17710
  type: 'mcpl:conversation-disposed',
15459
17711
  agentName,
@@ -15475,7 +17727,7 @@ export class AgentFramework {
15475
17727
  agentName: string,
15476
17728
  injections: ContextInjection[],
15477
17729
  ): ContextInjection[] {
15478
- const home = this.conversationAgentHomes.get(agentName);
17730
+ const home = this.homeOf(agentName);
15479
17731
  const registry = this.channelRegistry;
15480
17732
  if (!home || !registry || injections.length === 0) {
15481
17733
  return injections;
@@ -15519,7 +17771,7 @@ export class AgentFramework {
15519
17771
  // Agent vanished (external reset) — nothing to close, just make sure
15520
17772
  // its bookkeeping doesn't linger.
15521
17773
  this.agentConfigs.delete(binding.agentName);
15522
- this.conversationAgentHomes.delete(binding.agentName);
17774
+ this.settleEndedAgent(this.registry.end(binding.agentName, 'idle-ttl', 'framework', { record: true }));
15523
17775
  continue;
15524
17776
  }
15525
17777
  this.writeToFork(
@@ -15552,7 +17804,7 @@ export class AgentFramework {
15552
17804
  !this.activeStreams.has(agentName) &&
15553
17805
  !this.pendingRequests.some((r) => r.agentName === agentName)
15554
17806
  ) {
15555
- this.disposeConversationAgent(agentName);
17807
+ this.disposeConversationAgent(agentName, 'idle-ttl');
15556
17808
  }
15557
17809
  }
15558
17810
  }
@@ -16067,7 +18319,11 @@ export class AgentFramework {
16067
18319
  if (restored.recent) this.coalescingRecentReceipts = restored.recent;
16068
18320
  this.pushCoalescer = restored.coalescer;
16069
18321
  // Batches restored from a snapshot still owe the agent a wake (§14 v41).
16070
- for (const occ of restored.coalescer.pendingBatchOccurrences()) void this.wakeForCoalescedBatch(occ);
18322
+ for (const occ of restored.coalescer.pendingBatchOccurrences()) {
18323
+ void this.wakeForCoalescedBatch(occ).catch((error: unknown) => {
18324
+ console.error(`[coalescing] ${occ.serverId}: the wake a restored batch (${occ.eventId}) owes its agent failed:`, error);
18325
+ });
18326
+ }
16071
18327
  }
16072
18328
 
16073
18329
  /**
@@ -16175,6 +18431,13 @@ export class AgentFramework {
16175
18431
  // counts what was stored before a restart as shown: every agent's
16176
18432
  // shown watermark starts at the head at boot.
16177
18433
  const firstView = this.pushCoalescer.receiptFirstView(serverId, this.coalescingBinding(serverId), message.eventId);
18434
+ // Acknowledged: the anchor the delivery parked is committed now (and a
18435
+ // retry answered from the receipt commits its first attempt's).
18436
+ const parked = this.parkedAnchors.get(`${serverId}:${message.eventId}`);
18437
+ if (parked) {
18438
+ this.parkedAnchors.delete(`${serverId}:${message.eventId}`);
18439
+ this.channelRegistry?.noteDelivered(parked.channel, parked.messageId, parked.threadId, parked.tags, parked.firstView || firstView === true);
18440
+ }
16178
18441
  return { messageId: message.messageId, accepted: true, coalesce: result.coalesce, ...(firstView ? { firstView: true as const } : {}) };
16179
18442
  });
16180
18443
  }
@@ -16303,7 +18566,7 @@ export class AgentFramework {
16303
18566
  return [];
16304
18567
  }
16305
18568
  }
16306
- return [...this.agents.keys()].filter((n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName);
18569
+ return this.registry.untargetedRecipients();
16307
18570
  }
16308
18571
 
16309
18572
  /** Deliver through the lane's ordinary path; report where it landed. */
@@ -16391,11 +18654,58 @@ export class AgentFramework {
16391
18654
  private async queueCoalescedWakes(occ: CoalescedOccurrence<CoalescedDelivery>, plain = false, readsDeferredWrite?: string, allAmbient = false): Promise<void> {
16392
18655
  const subject = coalescingSubjectKey(occ.serverId, occ.binding, occ.scope, occ.key);
16393
18656
  if (occ.event.lane !== 'push' || !occ.event.event.triggerInference) return;
16394
- const origin = occ.event.event.origin;
18657
+ const push = occ.event.event;
18658
+ const origin = push.origin;
16395
18659
  // The wake carries the batch's channel so the turn freezes its reply
16396
18660
  // locus there (the materialized delivery at assembly wakes nobody).
16397
18661
  const channelId = occ.scope.kind === 'channel' ? occ.scope.id : this.derivePushEventChannel(origin)?.channelId;
16398
18662
  const authorId = occ.identity?.author?.id ?? (typeof origin?.authorId === 'string' ? origin.authorId : undefined);
18663
+ // A deferred batch is admitted when it asks for its wake: the hold and
18664
+ // the gate decide now; its materialized delivery at assembly is vetoed
18665
+ // (`triggerInference: false`) and so is not shown to the gate again.
18666
+ const decision = this.admitInbound({
18667
+ lane: 'push',
18668
+ serverId: occ.serverId,
18669
+ channelId,
18670
+ messageId: occ.eventId,
18671
+ authorId,
18672
+ tags: occ.tags,
18673
+ addressed: isAddressedMessage(occ.tags),
18674
+ vetoed: false,
18675
+ targetAgents: push.targetAgents,
18676
+ gateContent: (push.content ?? [])
18677
+ .filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text')
18678
+ .map((b) => b.text)
18679
+ .join('\n'),
18680
+ gateMetadata: {
18681
+ ...(origin ?? {}),
18682
+ serverId: occ.serverId,
18683
+ featureSet: push.featureSet,
18684
+ eventId: occ.eventId,
18685
+ eventType: 'mcpl:push-event',
18686
+ ...(occ.tags ? { tags: occ.tags } : {}),
18687
+ },
18688
+ });
18689
+ if (decision.held && !plain && this.pushCoalescer) {
18690
+ // A held batch wakes nobody, so no turn's assembly would store it while
18691
+ // the hold lasts, and readers of held traffic and the release would
18692
+ // never see it. It's stored now, stamped with its hold as a plain push
18693
+ // under the hold is, and consumed, so the turn after the hold doesn't
18694
+ // take it again. Outside the coalescer's lock: this runs inside it.
18695
+ // A hold that ended before the batch was stored left it stored unheld:
18696
+ // it then takes the wake an unheld push gets, every recipient's.
18697
+ const coalescer = this.pushCoalescer;
18698
+ void this.coalescedAudience(occ)
18699
+ .then((audience) => (audience[0] !== undefined ? coalescer.materializeNow(audience[0], subject) : false))
18700
+ .then(async (stored) => {
18701
+ if (stored && !(channelId !== undefined && this.tuneOutCoordinator?.holdFor(occ.serverId, channelId))) {
18702
+ await this.queueCoalescedWakes(occ, true);
18703
+ }
18704
+ })
18705
+ .catch((err) => console.error(`[coalescing] a held batch for ${logValue(subject)} could not be stored:`, err));
18706
+ return;
18707
+ }
18708
+ if (!decision.wake) return;
16399
18709
  for (const agentName of await this.coalescedAudience(occ)) {
16400
18710
  this.pendingRequests.push({
16401
18711
  agentName,
@@ -16404,7 +18714,7 @@ export class AgentFramework {
16404
18714
  timestamp: Date.now(),
16405
18715
  channelId, ...(channelId !== undefined ? { channelServerId: occ.serverId } : {}),
16406
18716
  counterparty: authorId ? `${occ.serverId}:user:${authorId}` : undefined,
16407
- addressed: isAddressedMessage(occ.tags, origin),
18717
+ addressed: isAddressedMessage(occ.tags),
16408
18718
  // No systemNotice mark here: a render folds the batch's every
16409
18719
  // occurrence, and only its latest's tags are at hand, so marking it
16410
18720
  // would hide a folded message's channel whenever a notice came last.
@@ -16493,9 +18803,13 @@ export class AgentFramework {
16493
18803
  // not the agent that happened to be asked). A reader is one whose view
16494
18804
  // filter keeps the message: a tune-out-diverted message never enters a
16495
18805
  // resident's compiled view, and only the subconscious reads it through
16496
- // its merged view — no subconscious, nobody reads it.
16497
- const readers = (this.sharedSlotAgents.has(agent)
16498
- ? [...this.sharedSlotAgents].filter((a) => this.agents.get(a.name) === a)
18806
+ // its merged view — no subconscious, nobody reads it. The slot's readers
18807
+ // are the registry's (residents and the subconscious), each asked its own
18808
+ // view (agentSees, main's #853).
18809
+ const readers = (this.registry.readsSharedSlot(agent.name)
18810
+ ? this.registry.sharedSlotReaders()
18811
+ .map((name) => this.agents.get(name))
18812
+ .filter((reader): reader is Agent => reader !== undefined)
16499
18813
  : [agent]).filter((a) => this.agentSees(a, message.metadata));
16500
18814
  const branch = cm.currentBranch().name;
16501
18815
  const ts = message.timestamp instanceof Date ? message.timestamp.getTime() : Number(message.timestamp);
@@ -16646,6 +18960,41 @@ export class AgentFramework {
16646
18960
  // content merely by setting `origin.silent`.
16647
18961
  const silentHeartbeat = isSilentHeartbeatMarker({ ...event, content });
16648
18962
 
18963
+ // Admission (Dendrite §D): the same decision as the channel lane. A DM
18964
+ // arrives here, so this is where tuning out a DM takes effect (#232 gap
18965
+ // 1). An event with no channel provenance cannot be held.
18966
+ const decision = this.admitInbound({
18967
+ lane: 'push',
18968
+ serverId: event.serverId,
18969
+ channelId: triggerChannel?.channelId,
18970
+ messageId: event.eventId,
18971
+ authorId: typeof event.origin?.authorId === 'string' ? event.origin.authorId : undefined,
18972
+ tags: event.tags,
18973
+ addressed: isAddressedMessage(event.tags),
18974
+ vetoed: event.triggerInference === false,
18975
+ targetAgents: event.targetAgents,
18976
+ gateContent: content
18977
+ .filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text')
18978
+ .map((b) => b.text)
18979
+ .join('\n'),
18980
+ gateMetadata: {
18981
+ ...(event.origin ?? {}),
18982
+ serverId: event.serverId,
18983
+ featureSet: event.featureSet,
18984
+ eventId: event.eventId,
18985
+ eventType: 'mcpl:push-event',
18986
+ ...(event.tags ? { tags: event.tags } : {}),
18987
+ },
18988
+ });
18989
+ metadata.triggered = decision.wake;
18990
+ if (decision.held && triggerChannel) {
18991
+ metadata.tuneOut = { epochId: decision.held.epochId };
18992
+ this.tuneOutCoordinator?.recordHeld(
18993
+ event.serverId, triggerChannel.channelId, event.eventId, event.tags,
18994
+ typeof event.origin?.authorId === 'string' ? event.origin.authorId : undefined, decision.gatePass,
18995
+ );
18996
+ }
18997
+
16649
18998
  const placement: CoalescingPlacement = { agent: '' };
16650
18999
  if (!silentHeartbeat) {
16651
19000
  const id = this.addMessage('user', content, AgentFramework.withStoredDigest(metadata, content), { placement, bypassDeferralFor: event.assemblingFor });
@@ -16654,12 +19003,8 @@ export class AgentFramework {
16654
19003
  console.error(`[heartbeat] ${event.serverId}: accepted silent scheduled wake ${logValue(event.eventId ?? '')}`);
16655
19004
  }
16656
19005
 
16657
- if (event.triggerInference) {
16658
- // Default broadcast excludes conversation forks (channel-driven).
16659
- const targetAgents = event.targetAgents
16660
- ?? [...this.agents.keys()].filter(
16661
- (n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName);
16662
- for (const agentName of targetAgents) {
19006
+ if (decision.wake) {
19007
+ for (const agentName of decision.recipients) {
16663
19008
  this.pendingRequests.push({
16664
19009
  agentName,
16665
19010
  reason: 'mcpl:push-event',
@@ -16669,7 +19014,7 @@ export class AgentFramework {
16669
19014
  // DMs and addressed-while-closed messages arrive as push events;
16670
19015
  // they must outrank ambient chatter in a batched wake's locus
16671
19016
  // selection just like their channels/incoming counterparts.
16672
- addressed: isAddressedMessage(event.tags, event.origin),
19017
+ addressed: isAddressedMessage(event.tags),
16673
19018
  ...ambientMark(event.tags),
16674
19019
  ...systemNoticeMark(event.tags),
16675
19020
  ...(silentHeartbeat ? {
@@ -16696,10 +19041,10 @@ export class AgentFramework {
16696
19041
  * a fork is that fork's conversation, so no other agent takes it.
16697
19042
  */
16698
19043
  private mayTakeGateRoute(agentName: string, route: ChannelRef): boolean {
16699
- const home = this.conversationAgentHomes.get(agentName);
19044
+ const home = this.homeOf(agentName);
16700
19045
  if (home !== undefined) return sameChannel(home, route);
16701
- for (const boundChannel of this.conversationAgentHomes.values()) {
16702
- if (sameChannel(boundChannel, route)) return false;
19046
+ for (const record of this.registry.list()) {
19047
+ if (sameChannel(this.channelOfHome(record.homeChannel), route)) return false;
16703
19048
  }
16704
19049
  return true;
16705
19050
  }
@@ -16718,6 +19063,35 @@ export class AgentFramework {
16718
19063
  return named.length === 1 ? named[0] : undefined;
16719
19064
  }
16720
19065
 
19066
+ /**
19067
+ * A live agent's home channel, from its registry record (channelOfHome):
19068
+ * defined for a conversation fork, and only for one. Undefined for an agent
19069
+ * with none, and for one that has ended.
19070
+ */
19071
+ private homeOf(agentName: string): ChannelRef | undefined {
19072
+ return this.channelOfHome(this.registry.homeChannel(agentName));
19073
+ }
19074
+
19075
+ /**
19076
+ * The channel a registry record's `homeChannel` names: the channel on its
19077
+ * server. A bare channel id, as a record written before homes carried
19078
+ * their server keeps, is read as a wake that names its channel without its
19079
+ * server is (channelNamed): the one registered channel it names, read when
19080
+ * it's used, since the servers registered at that moment are what it can
19081
+ * mean. An id that names no one registered channel, as one no server
19082
+ * registers now or two servers do, is the home on no server (an empty
19083
+ * serverId, which channelNamed reads as none given), so the agent keeps a
19084
+ * home. A path that reads the pair finds no server registering it, and
19085
+ * refuses it; one that reads the name reads what the name means then: none
19086
+ * or two channels, which it refuses, or, once one server alone registers
19087
+ * the id, that server's channel.
19088
+ */
19089
+ private channelOfHome(home: AgentRecord['homeChannel']): ChannelRef | undefined {
19090
+ if (home === undefined) return undefined;
19091
+ if (typeof home !== 'string') return home;
19092
+ return this.channelNamed(home) ?? { serverId: '', channelId: home };
19093
+ }
19094
+
16721
19095
  /**
16722
19096
  * Explicit mode's typing channel for one start of a turn, which has no
16723
19097
  * locus, read once as the start opens (driveStream then shows what the
@@ -17297,6 +19671,42 @@ export class AgentFramework {
17297
19671
  }
17298
19672
  }
17299
19673
 
19674
+ /**
19675
+ * `channel_publish` with no channel goes where the turn's plain speech
19676
+ * would: the frozen turn locus (the waking delivery's channel, or one
19677
+ * opened this turn), then the registry's own resolution. Under
19678
+ * `speech.locus: 'delivery'` the registry alone has no default, and the
19679
+ * tool failed on the very turn its own delivery woke (first live run,
19680
+ * 2026-10-12). Both dispatch paths — the stream driver's and
19681
+ * executeToolCall's — pass through here. Only a call made inside the turn
19682
+ * (callingTurnOf) takes the turn's locus: one made outside it, as a script
19683
+ * left running after its turn makes or a host's executeToolCall naming the
19684
+ * agent, takes none of the turn's channels. A conversation fork's home
19685
+ * isn't the turn's but the fork's own, so such a call from a fork naming
19686
+ * no channel still goes there (homeOf), as the fork's dispatched calls do
19687
+ * (dispatchChannelToolCall); anyone else's takes the registry's own
19688
+ * default.
19689
+ */
19690
+ private publishInputWithTurnLocus(input: unknown, agentName: string, callingTurn: TurnRouting | undefined): unknown {
19691
+ const given = (input as { channelId?: unknown } | null | undefined)?.channelId;
19692
+ if (typeof given === 'string') return input;
19693
+ const locus = callingTurn
19694
+ ? callingTurn.locus ?? this.channelRegistry?.resolveLocus(agentName) ?? null
19695
+ : this.homeOf(agentName) ?? null;
19696
+ if (!locus) return input;
19697
+ // The pair: a serverId beside a channelId names that server's channel.
19698
+ // A serverId given with no channelId names the locus's id on that
19699
+ // server, as the registry reads one given beside its own default: the
19700
+ // default's id on the server given (handleToolPublish). So a fork's call
19701
+ // naming another server and no channel reaches that server's channel
19702
+ // with its home's id, where one is registered: its dispatched calls are
19703
+ // refused that (dispatchChannelToolCall), and executeToolCall applies
19704
+ // none of that guard, on main as here.
19705
+ const record = (input as Record<string, unknown> | null | undefined) ?? {};
19706
+ const serverId = typeof record.serverId === 'string' && record.serverId ? record.serverId : locus.serverId;
19707
+ return { ...record, channelId: locus.channelId, serverId };
19708
+ }
19709
+
17300
19710
  /**
17301
19711
  * Stamps for the rows the agent's reply stores in its current turn: its
17302
19712
  * assistant responses, its tool_result rows (through `storeResults`), and
@@ -18077,7 +20487,7 @@ export class AgentFramework {
18077
20487
  }
18078
20488
  const registry = this.channelRegistry;
18079
20489
  if (!registry) return refuse('No channels are configured, so there is nowhere to resend to.');
18080
- const home = this.conversationAgentHomes.get(agentName);
20490
+ const home = this.homeOf(agentName);
18081
20491
  let spec = typeof input.destination === 'string' ? input.destination.trim() : '';
18082
20492
  if (present(input.destination) && typeof input.destination !== 'string') {
18083
20493
  return refuse('destination must be "#channel", "@person" or a channel id.');
@@ -18513,17 +20923,56 @@ export class AgentFramework {
18513
20923
  }
18514
20924
  }
18515
20925
 
20926
+ private rememberAnnouncedLocus(agentName: string, locus: ChannelRef | null): void {
20927
+ this.lastAnnouncedLocus.set(agentName, locus);
20928
+ this.writeAnnouncedLocus(agentName, locus ? { serverId: locus.serverId, channelId: locus.channelId } : null);
20929
+ }
20930
+
20931
+ private forgetAnnouncedLocus(agentName: string): void {
20932
+ this.lastAnnouncedLocus.delete(agentName);
20933
+ this.writeAnnouncedLocus(agentName, undefined);
20934
+ }
20935
+
20936
+ /** The stored baseline's entry for the agent set (rememberAnnouncedLocus),
20937
+ * or removed (undefined) when the agent ends without coming back. */
20938
+ private writeAnnouncedLocus(agentName: string, locus: ChannelRef | null | undefined): void {
20939
+ try {
20940
+ const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
20941
+ const state = (data && typeof data === 'object' ? data : {}) as Record<string, unknown>;
20942
+ const loci = { ...((state.announcedLoci as Record<string, ChannelRef | null> | undefined) ?? {}) };
20943
+ if (locus === undefined) {
20944
+ if (!Object.hasOwn(loci, agentName)) return;
20945
+ delete loci[agentName];
20946
+ } else {
20947
+ loci[agentName] = locus;
20948
+ }
20949
+ state.announcedLoci = loci;
20950
+ this.store.setStateJson(FRAMEWORK_STATE_ID, state);
20951
+ } catch (err) {
20952
+ console.error(`[routing] ${agentName}: the announced locus wasn't ${locus === undefined ? 'removed from' : 'written to'} the store:`, err);
20953
+ }
20954
+ }
20955
+
18516
20956
  /**
18517
20957
  * Announce-on-change locus notice (turn-frozen routing). Appends a durable
18518
20958
  * `[routing]` window message when the turn's effective outbound locus
18519
20959
  * differs from the last one announced — the agent must never have to guess
18520
20960
  * where its plain prose lands. Durable append (never retracted) keeps the
18521
- * KV prefix stable; change-only keeps it out of steady-state turns. The
18522
- * first resolution after boot announces only a non-null locus ("prose has
18523
- * no destination" is the unremarkable boot default). Never triggers
20961
+ * KV prefix stable; change-only keeps it out of steady-state turns. An
20962
+ * agent never told of a locus, here or before a restart, hears only of a
20963
+ * non-null one ("prose has no destination" is the unremarkable start).
20964
+ * Never triggers
18524
20965
  * inference; never locus-eligible itself (system + no channel metadata).
18525
20966
  */
18526
20967
  private announceLocusIfChanged(agentName: string, locus: ChannelRef | null): void {
20968
+ if (!this.lastAnnouncedLocus.has(agentName)) {
20969
+ try {
20970
+ const stored = (this.store.getStateJson(FRAMEWORK_STATE_ID) as { announcedLoci?: Record<string, ChannelRef | null> } | null)?.announcedLoci;
20971
+ if (stored && Object.hasOwn(stored, agentName)) this.lastAnnouncedLocus.set(agentName, stored[agentName] ?? null);
20972
+ } catch (err) {
20973
+ console.error(`[routing] ${agentName}: the announced locus couldn't be read back, so this turn compares with none:`, err);
20974
+ }
20975
+ }
18527
20976
  const hasBaseline = this.lastAnnouncedLocus.has(agentName);
18528
20977
  const prev = this.lastAnnouncedLocus.get(agentName) ?? null;
18529
20978
  if (hasBaseline ? sameChannel(prev, locus) : locus === null) {
@@ -18557,7 +21006,7 @@ export class AgentFramework {
18557
21006
  );
18558
21007
  // The baseline is what the agent was told: it moves once the notice is
18559
21008
  // stored, so a notice that fails is announced again at the next turn.
18560
- this.lastAnnouncedLocus.set(agentName, locus);
21009
+ this.rememberAnnouncedLocus(agentName, locus);
18561
21010
  this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' });
18562
21011
  console.error(
18563
21012
  `[routing] ${agentName}: locus ${prev ? qualifiedChannelName(prev) : '(none)'} -> ${locus ? qualifiedChannelName(locus) : '(none)'} (announced in window)`,
@@ -18575,7 +21024,11 @@ export class AgentFramework {
18575
21024
  * as the agent's first view) that addressed the agent, or that follows up
18576
21025
  * in a channel the turn sent into (TurnRouting.engaged). Undefined for any
18577
21026
  * other message. A tool boundary weighs the messages it injects by it,
18578
- * and noteArrival the ones no boundary of the turn weighs.
21027
+ * and noteArrival the ones no boundary of the turn weighs. The rule is
21028
+ * pure; what is done with its answer is `speech.locus`'s: under
21029
+ * `'latest-inbound'` the locus moves, under `'delivery'` (the default) the
21030
+ * agent is told it was addressed from elsewhere and the locus stays
21031
+ * (addressedElsewhereNotice).
18579
21032
  */
18580
21033
  private repinChannel(metadata: MessageMetadata | undefined, routing: TurnRouting): ChannelRef | undefined {
18581
21034
  if (!isConversationalInjection(metadata)) return undefined;
@@ -18586,7 +21039,7 @@ export class AgentFramework {
18586
21039
  // registers that id (channelNamed).
18587
21040
  const channel = this.channelNamed(m.channelId, typeof m.serverId === 'string' ? m.serverId : undefined);
18588
21041
  if (!channel) return undefined;
18589
- if (isAddressedMessage(m.tags as string[] | undefined, m)) return channel;
21042
+ if (isAddressedMessage(m.tags as string[] | undefined)) return channel;
18590
21043
  return routing.engaged.has(destinationKey(channel)) ? channel : undefined;
18591
21044
  }
18592
21045
 
@@ -18834,8 +21287,10 @@ export class AgentFramework {
18834
21287
  * Weigh a message that no tool boundary of the agent's open logical turn
18835
21288
  * will weigh: one that would re-pin the locus becomes one of the turn's
18836
21289
  * arrivals (TurnRouting.arrivals). A message the agent's own view leaves
18837
- * out, such as one a tune-out diverted (metadata.tuneOut), never enters
18838
- * its compiled view, so the agent won't see it, and it moves nothing.
21290
+ * out (agentSees), such as one held from the residents (heldFromResidents,
21291
+ * the admission stage's one predicate, which every resident's view filter
21292
+ * reads) or another agent's stored context injection, never enters its
21293
+ * compiled view, so the agent won't see it, and it moves nothing.
18839
21294
  */
18840
21295
  private noteArrival(agentName: string, metadata: MessageMetadata | undefined, messageId: MessageId): void {
18841
21296
  const open = this.openLogical.get(agentName);
@@ -18844,17 +21299,19 @@ export class AgentFramework {
18844
21299
  if (agent && !this.agentSees(agent, metadata)) return;
18845
21300
  if (isConversationalInjection(metadata)) (open.heard ??= new Map()).set(messageId, ++lastHeardAt);
18846
21301
  const channel = this.repinChannel(metadata, open);
18847
- if (channel) open.arrivals.push({ messageId, channel });
21302
+ if (!channel) return;
21303
+ const tags = (metadata as Record<string, unknown> | undefined)?.tags as string[] | undefined;
21304
+ open.arrivals.push({ messageId, channel, addressed: isAddressedMessage(tags) });
18848
21305
  }
18849
21306
 
18850
21307
  /**
18851
21308
  * Whether `agent`'s store is the residents' one message slot, which every
18852
- * ordinary resident reads (sharedSlotAgents). The subconscious reads that
21309
+ * ordinary resident reads (`readsSharedSlot` in the registry). The subconscious reads that
18853
21310
  * slot through a merged view, but its own store is isolated, as a fork's
18854
21311
  * or an ephemeral agent's is.
18855
21312
  */
18856
21313
  private isOrdinaryResident(agent: Agent): boolean {
18857
- return this.sharedSlotAgents.has(agent) && agent.name !== this.subconsciousAgentName;
21314
+ return this.registry.readsSharedSlot(agent.name) && agent.name !== this.subconsciousAgentName;
18858
21315
  }
18859
21316
 
18860
21317
  /**
@@ -18865,8 +21322,9 @@ export class AgentFramework {
18865
21322
  */
18866
21323
  private noteForOtherResidents(agent: Agent, metadata: MessageMetadata | undefined, messageId: MessageId): void {
18867
21324
  if (!this.isOrdinaryResident(agent)) return;
18868
- for (const reader of this.sharedSlotAgents) {
18869
- if (reader !== agent && this.agents.get(reader.name) === reader && this.isOrdinaryResident(reader)) {
21325
+ for (const name of this.registry.sharedSlotReaders()) {
21326
+ const reader = this.agents.get(name);
21327
+ if (reader && reader !== agent && this.isOrdinaryResident(reader)) {
18870
21328
  this.noteSpokenTo(reader.name, metadata);
18871
21329
  this.noteArrival(reader.name, metadata, messageId);
18872
21330
  }
@@ -18948,7 +21406,7 @@ export class AgentFramework {
18948
21406
  const metadata = { system: true, kind: 'routing-notice' } as MessageMetadata;
18949
21407
  try {
18950
21408
  const id = agent.getContextManager().addMessage('user', content, metadata);
18951
- this.lastAnnouncedLocus.set(agent.name, channel);
21409
+ this.rememberAnnouncedLocus(agent.name, channel);
18952
21410
  this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' });
18953
21411
  console.error(`[routing] ${agent.name}: ${cause} re-pin ${prevPin ? qualifiedChannelName(prevPin) : '(none)'} -> ${qualifiedChannelName(channel)}`);
18954
21412
  return { participant: 'user', content, metadata, id };
@@ -18958,6 +21416,78 @@ export class AgentFramework {
18958
21416
  }
18959
21417
  }
18960
21418
 
21419
+ /**
21420
+ * Under `speech.locus: 'delivery'`: tell the agent that someone addressed
21421
+ * it from another channel while its turn was busy, since incoming traffic
21422
+ * never moves the locus and a neutral message carries nothing that names
21423
+ * its channel (first live run, 2026-10-12, finding C). Once per channel
21424
+ * per turn (TurnRouting.addressedElsewhere); nothing for the turn's own
21425
+ * locus. Stored durably; returned as stored for a live stream to inject
21426
+ * with its boundary's other messages. Undefined when nothing was stored.
21427
+ */
21428
+ private addressedElsewhereNotice(
21429
+ agent: Agent,
21430
+ routing: TurnRouting,
21431
+ channel: ChannelRef,
21432
+ who: string,
21433
+ when = 'while you were busy',
21434
+ ): { participant: string; content: ContentBlock[]; metadata: MessageMetadata } | undefined {
21435
+ const pin = routing.locus ?? null;
21436
+ const key = destinationKey(channel);
21437
+ if (sameChannel(channel, pin) || routing.addressedElsewhere.has(key)) return undefined;
21438
+ const where = pin ? `still lands in ${this.channelShown(pin)}` : 'stays in your archive (no channel)';
21439
+ const content: ContentBlock[] = [{
21440
+ type: 'text',
21441
+ text:
21442
+ // The author's name is a value someone supplied (main's #852: one
21443
+ // line, its notice openings marked); the channel is shown as
21444
+ // channelShown gives it.
21445
+ `[${NOTICE_TAG.routing}] ${suppliedValue(who)} addressed you from ${this.channelShown(channel)} ${when}. ` +
21446
+ `Your plain speech ${where} this turn; reaching them takes an explicit send.`,
21447
+ }];
21448
+ const metadata = { system: true, kind: 'routing-notice' } as MessageMetadata;
21449
+ const before = this.storedCountOf(agent);
21450
+ try {
21451
+ const id = agent.getContextManager().addMessage('user', content, metadata);
21452
+ routing.addressedElsewhere.add(key);
21453
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' });
21454
+ return { participant: 'user', content, metadata };
21455
+ } catch (err) {
21456
+ // A write can throw after its row landed, as storeInjections' can: a
21457
+ // notice that landed is the turn's notice for the channel, as one
21458
+ // stored without a throw is.
21459
+ const text = (content[0] as { text: string }).text;
21460
+ const landed = before === undefined ? undefined : this.storedSince(agent, before, (m) =>
21461
+ (m.metadata as { kind?: unknown } | undefined)?.kind === 'routing-notice'
21462
+ && m.content.some((b) => b.type === 'text' && (b as { text: string }).text === text),
21463
+ 'routing', 'the addressed-elsewhere notice');
21464
+ if (landed && landed.length > 0) {
21465
+ routing.addressedElsewhere.add(key);
21466
+ console.error(`addressed-elsewhere notice: ${agent.name}: the notice landed though its write threw:`, err);
21467
+ return { participant: 'user', content, metadata };
21468
+ }
21469
+ console.error(`addressed-elsewhere notice: failed to record routing notice${landed ? '' : ' (whether it landed is unknown)'}:`, err);
21470
+ return undefined;
21471
+ }
21472
+ }
21473
+
21474
+ /**
21475
+ * The arrivals a start of the turn consumes under `speech.locus:
21476
+ * 'delivery'`: each one that addressed the agent, whose message is still
21477
+ * in the store, gets the addressed-elsewhere notice, oldest first. A
21478
+ * follow-up in an engaged channel addressed nobody and is not announced.
21479
+ */
21480
+ private noticeAddressedArrivals(agent: Agent, routing: TurnRouting, arrivals: Arrival[], when: string): void {
21481
+ if (arrivals.length === 0) return;
21482
+ const cm = agent.getContextManager();
21483
+ for (const { messageId, channel, addressed } of arrivals) {
21484
+ if (!addressed) continue;
21485
+ const message = cm.getMessage(messageId);
21486
+ if (!message) continue;
21487
+ this.addressedElsewhereNotice(agent, routing, channel, authorName(message.metadata as Record<string, unknown> | undefined), when);
21488
+ }
21489
+ }
21490
+
18961
21491
  /**
18962
21492
  * Durable window notice for a channel the agent did not ask to open: a
18963
21493
  * subscription-policy admission, announced as the decision before its
@@ -19147,7 +21677,7 @@ export class AgentFramework {
19147
21677
  */
19148
21678
  private canCloseChannels(who: string | undefined, channels: readonly ChannelRef[]): boolean {
19149
21679
  if (who === undefined) return false;
19150
- const home = this.conversationAgentHomes.get(who);
21680
+ const home = this.homeOf(who);
19151
21681
  return channels.every((channel) => !home || sameChannel(home, channel)) && this.canAgentUseTool(who, 'channel_close');
19152
21682
  }
19153
21683
 
@@ -19170,7 +21700,7 @@ export class AgentFramework {
19170
21700
  */
19171
21701
  private holdsChannelTraffic(who: string | undefined, channels: readonly ChannelRef[]): boolean {
19172
21702
  if (who === undefined) return false;
19173
- const home = this.conversationAgentHomes.get(who);
21703
+ const home = this.homeOf(who);
19174
21704
  if (home !== undefined) {
19175
21705
  return channels.every((channel) => sameChannel(channel, home) && this.conversationRouter?.getBinding(channel)?.agentName === who);
19176
21706
  }
@@ -19187,7 +21717,12 @@ export class AgentFramework {
19187
21717
  * otherwise the delivery agent and the readers of the residents' shared slot do. An ephemeral run holds none.
19188
21718
  */
19189
21719
  private holdsChannelTrafficForTools(who: string): boolean {
19190
- if (this.conversationAgentHomes.has(who)) return true;
21720
+ if (this.registry.homeChannel(who) !== undefined) return true;
21721
+ // An agent that shares its source's context (a reader fork) shares its
21722
+ // source's words too: its tool block is the source's prefix, and a
21723
+ // sentence that differed would send the whole window uncached.
21724
+ const inherit = this.registry.get(who)?.inherit;
21725
+ if (inherit?.mode === 'shared' && inherit.from !== who) return this.holdsChannelTrafficForTools(inherit.from);
19191
21726
  if (this.conversationRouter) return false;
19192
21727
  return this.residentHoldsTraffic(who);
19193
21728
  }
@@ -19210,7 +21745,7 @@ export class AgentFramework {
19210
21745
  * notice passed on later, at resume or at boot, names it by its name.
19211
21746
  */
19212
21747
  private agentForPrimary(agentName: string): { first: string; again: string } {
19213
- const home = this.conversationAgentHomes.get(agentName);
21748
+ const home = this.homeOf(agentName);
19214
21749
  if (home === undefined) return { first: agentName, again: agentName };
19215
21750
  const label = this.channelRegistry?.getDescriptor(home)?.label;
19216
21751
  const bound = this.labelledChannel({ ...home, ...(label ? { label } : {}) });
@@ -19232,7 +21767,7 @@ export class AgentFramework {
19232
21767
  /** Whether `agentName` is a conversation fork made from the primary: the
19233
21768
  * router's template is the primary (connectome-host's is). */
19234
21769
  private forkOfPrimary(agentName: string): boolean {
19235
- return this.conversationAgentHomes.has(agentName) && this.primaryAgentName !== null &&
21770
+ return this.registry.homeChannel(agentName) !== undefined && this.primaryAgentName !== null &&
19236
21771
  this.conversationRouter?.templateAgent === this.primaryAgentName;
19237
21772
  }
19238
21773
 
@@ -19304,7 +21839,7 @@ export class AgentFramework {
19304
21839
  return [];
19305
21840
  }
19306
21841
  const who = ended ? primary : agentName || this.deliveryAgent()?.name;
19307
- const canOpen = who !== undefined && !this.conversationAgentHomes.has(who) && this.canAgentUseTool(who, 'channel_open');
21842
+ const canOpen = who !== undefined && this.registry.homeChannel(who) === undefined && this.canAgentUseTool(who, 'channel_open');
19308
21843
  const canClose = this.canCloseChannels(who, [{ serverId, channelId: channel.channelId }]);
19309
21844
  const selected = selectedForOpening === 'unrecorded'
19310
21845
  ? " It stays selected for opening, because the host couldn't record undoing that: a later delivery or reconnect tries again"
@@ -19346,7 +21881,7 @@ export class AgentFramework {
19346
21881
  };
19347
21882
  const notices: ChannelNotice[] = [];
19348
21883
  if (tellPrimary) {
19349
- const primaryCanOpen = !this.conversationAgentHomes.has(primary!) && this.canAgentUseTool(primary!, 'channel_open');
21884
+ const primaryCanOpen = this.registry.homeChannel(primary!) === undefined && this.canAgentUseTool(primary!, 'channel_open');
19350
21885
  const reader = this.agentForPrimary(who!);
19351
21886
  notices.push({
19352
21887
  text: `[${NOTICE_TAG.channels}] Not open: ${shown}. A reply from ${reader.first} went out there, but opening the channel failed, and ${reader.again} can't close it.` +
@@ -19429,7 +21964,7 @@ export class AgentFramework {
19429
21964
  if (trigger?.suppressProse) return;
19430
21965
  // A subagent's run ends with its result; a conversation fork answers
19431
21966
  // its home channel's message.
19432
- if (this.ephemeralRuns.has(name) || this.conversationAgentHomes.has(name)) return;
21967
+ if (this.ephemeralRuns.has(name) || this.registry.homeChannel(name) !== undefined) return;
19433
21968
  if (this.providerAdmissionClosed || this.agents.get(name) !== agent) {
19434
21969
  console.error(`[continue] ${name}: not continued — ${this.providerAdmissionClosed ? 'the framework is stopping' : 'the agent is no longer registered'}`);
19435
21970
  return;
@@ -20388,8 +22923,7 @@ export class AgentFramework {
20388
22923
  attempt = 0,
20389
22924
  providerGateAlreadyHeld = false,
20390
22925
  ): Promise<void> {
20391
- const ownsProviderGate =
20392
- !this.ephemeralRuns.has(agent.name) && !this.conversationAgentHomes.has(agent.name);
22926
+ const ownsProviderGate = this.registry.ownsProviderScheduling(agent.name);
20393
22927
  // A stopping framework starts no turn, whoever owns provider admission
20394
22928
  // (conversation agents and ephemeral runs do not take the gate).
20395
22929
  if (this.providerAdmissionClosed) {
@@ -21248,11 +23782,28 @@ export class AgentFramework {
21248
23782
  // A turn in a mode with a locus reads its arrivals' messages, a turn
21249
23783
  // with no channel (noChannel) included: an addressed message, or a
21250
23784
  // follow-up where the turn sent, gives it its channel, as a re-pin does.
21251
- const arrival = turnProseRouting === 'locus' || turnProseRouting === 'hybrid'
23785
+ // Under `speech.locus: 'delivery'` an arrival moves nothing; one that
23786
+ // addressed the agent from another channel is announced instead, as a
23787
+ // boundary would have announced it (addressedElsewhereNotice).
23788
+ const routesLocus = turnProseRouting === 'locus' || turnProseRouting === 'hybrid';
23789
+ const arrivals = routing.arrivals;
23790
+ const arrival = routesLocus && this.speechLocus === 'latest-inbound'
21252
23791
  ? this.remainingArrival(agent, routing)
21253
23792
  : undefined;
21254
23793
  routing.arrivals = [];
21255
23794
  if (arrival) this.repinTurnLocus(agent, routing, arrival, 'arrival');
23795
+ else if (routesLocus && this.speechLocus === 'delivery') {
23796
+ // Which start this is, since from inside they look alike and the
23797
+ // reason says whether the earlier prose landed (Linn, 2026-10-14).
23798
+ const when = attempt > 0
23799
+ ? 'while your turn was retried'
23800
+ : trigger?.reason === 'context_budget_restart'
23801
+ ? 'at your budget restart'
23802
+ : isTurnContinuation(trigger?.reason ?? '')
23803
+ ? 'before this re-inference on your tool results'
23804
+ : 'while your turn waited on a provider hold';
23805
+ this.noticeAddressedArrivals(agent, routing, arrivals, when);
23806
+ }
21256
23807
  }
21257
23808
  // A silent start, or a disabled agent's, has no locus to show, whatever a
21258
23809
  // channel_open in a silent turn left on its object.
@@ -21474,7 +24025,7 @@ export class AgentFramework {
21474
24025
  // can record a wait while this turn compiles, after the last boundary
21475
24026
  // checked. Check its waits again here, where nothing has been sent, and
21476
24027
  // hold the turn as the boundary would rather than send it into the wait.
21477
- if (this.conversationAgentHomes.has(agent.name)) {
24028
+ if (this.registry.homeChannel(agent.name) !== undefined) {
21478
24029
  const wait = this.providerWaits?.active(agent.name, agent.model);
21479
24030
  // The turn has begun (inference:started), so the hold keeps its retry.
21480
24031
  if (wait && this.holdForProviderWait(agent, wait, trigger ? [asTurnRetry(withTurnCarry(trigger, carry))] : [], false, () => {
@@ -21495,6 +24046,7 @@ export class AgentFramework {
21495
24046
  })) {
21496
24047
  console.error(`[provider-wait] agent=${agent.name} model=${agent.model} binds after the compile ` +
21497
24048
  `(recorded while the turn compiled); ${this.providerAdmissionClosed ? 'the framework is stopping, so nothing is held'
24049
+ : this.agents.get(agent.name) !== agent ? 'the agent ended as the failure was reported, so nothing is held'
21498
24050
  : this.providerAccelerationCooldowns.get(agent.name)?.heldRequests.some(isTurnRetry) ? 'turn held'
21499
24051
  : 'the wait was released as the failure was reported, so the retry is queued'}, nothing sent`);
21500
24052
  this.endFailureAtStop(agent.name, turnToken, turnStartedAt, routing);
@@ -23206,7 +25758,7 @@ export class AgentFramework {
23206
25758
  .filter((c): c is { id?: unknown; name: string; input?: unknown; unparseableInput?: string } =>
23207
25759
  typeof c.name === 'string' && !cutIds.has(c.id as string)
23208
25760
  && markedInputRefusal({ ...c, name: c.name }) === undefined
23209
- && this.canAgentUseTool(agent.name, c.name));
25761
+ && this.mayCallTool(agent.name, c.name));
23210
25762
  const toolNames = toolCalls.map((c) => c.name);
23211
25763
  // A refused skip_reply isn't a silencing call; a plain one is.
23212
25764
  const fallbackCause = liveProseRouting
@@ -24101,7 +26653,7 @@ export class AgentFramework {
24101
26653
  // turn isn't over (pendingContinuationAgents).
24102
26654
  if (frameReachedTerminal && ownsPhysicalStream && this.closingConversationAgents.has(agent.name)
24103
26655
  && !this.pendingContinuationAgents().has(agent.name)) {
24104
- this.disposeConversationAgent(agent.name);
26656
+ this.disposeConversationAgent(agent.name, 'idle-ttl');
24105
26657
  }
24106
26658
  }
24107
26659
  }
@@ -24122,8 +26674,10 @@ export class AgentFramework {
24122
26674
  * (ctx.callTool → executeToolCallFrom with {kind:'module'}), which is exempt
24123
26675
  * from agent allowlists. Registered agents' allowedTools is enforced at
24124
26676
  * dispatch, including namespaced utility names. Genuinely unidentified public callers (including
24125
- * unknown legacy names) remain unrestricted. Framework-created identities
24126
- * are refused while unregistered, even if their configured list allows it.
26677
+ * unknown legacy names) remain unrestricted. A name the framework made in
26678
+ * this process is refused while it isn't registered, even if its configured
26679
+ * list allows the tool; after a restart, such a name is a host's caller name
26680
+ * like any other.
24127
26681
  */
24128
26682
  async executeToolCall(call: ToolCall): Promise<ToolResult> {
24129
26683
  return this.executeToolCallFrom(call, {
@@ -24400,7 +26954,11 @@ export class AgentFramework {
24400
26954
  // synthesized verbs (think, skip_reply) are deliberately not routed
24401
26955
  // here.
24402
26956
  if (call.name.startsWith('channel_') && this.channelRegistry) {
24403
- return this.channelRegistry.handleChannelToolCall(call.name, call.input, origin);
26957
+ const turn = call.name === 'channel_publish' && call.callerAgentName ? this.callingTurnOf(call.callerAgentName, call) : undefined;
26958
+ const input = call.name === 'channel_publish' && call.callerAgentName
26959
+ ? this.publishInputWithTurnLocus(call.input, call.callerAgentName, turn)
26960
+ : call.input;
26961
+ return this.channelRegistry.handleChannelToolCall(call.name, input, origin.kind === 'agent' && turn ? { ...origin, inTurn: true } : origin);
24404
26962
  }
24405
26963
 
24406
26964
  // drafts: the calling resident's own held drafts. A puppeted call has
@@ -24423,6 +26981,20 @@ export class AgentFramework {
24423
26981
  // Module tools. The caller uses the result in process, so it gets the
24424
26982
  // module's own result, as the module returned it, less a refusal it
24425
26983
  // claims, which is the dispatcher's to say.
26984
+ // The tune-out tool, in process: the same body the dispatcher runs, its
26985
+ // answer awaited here (it can wait for the tune-out's transport).
26986
+ if ((call.name === 'tune_out' || call.name === 'focus') && this.tuneOutCoordinator) {
26987
+ const agentName = call.callerAgentName ?? this.primaryAgentName ?? '__ephemeral__';
26988
+ const input = (call.input ?? {}) as Record<string, unknown>;
26989
+ // The calling turn's own channels, as the channel tools read them here (origin.own).
26990
+ const own = origin.kind === 'agent' ? origin.own ?? [] : [];
26991
+ const turn = this.callingTurnOf(agentName, call);
26992
+ return call.name === 'focus' ? this.handleFocusTool(agentName, input, own, turn) : this.handleTuneOutTool(agentName, input, own, turn);
26993
+ }
26994
+ if ((SUBCONSCIOUS_TOOL_NAMES as readonly string[]).includes(call.name)) {
26995
+ const routed = this.readerToolCall(origin.kind === 'agent' ? origin.agentName : call.callerAgentName ?? '__ephemeral__', call);
26996
+ return 'refused' in routed ? failed({ success: false, error: `${routed.refused}, so the call didn't run.`, isError: true }) : routed.result;
26997
+ }
24426
26998
  return this.moduleRegistry.handleToolCall(call);
24427
26999
  }
24428
27000
 
@@ -24463,6 +27035,38 @@ export class AgentFramework {
24463
27035
  return { ok: true, call: inner, serverId: target.serverId, prefix, ...(target.instance !== undefined ? { instance: target.instance } : {}) };
24464
27036
  }
24465
27037
 
27038
+ /**
27039
+ * A reader reads and reports: a reader fork (registry kind
27040
+ * 'subconscious-fork', in every incarnation) may call only its own tools,
27041
+ * and speak_in_channel where allowChannelSpeech allows it. Every other
27042
+ * tool the resident has stays in its block (the shared prefix) and is
27043
+ * refused here, so with speech off a fork can post nowhere (scout's
27044
+ * condition, 2026-10-19). It's decided at each call from what the reader
27045
+ * may call, not from what it is shown, so a tool added or moved out of
27046
+ * the block later is refused too.
27047
+ */
27048
+ private readerRefuses(agentName: string, toolName: string): boolean {
27049
+ if (this.registry.get(agentName)?.kind !== 'subconscious-fork') return false;
27050
+ return !this.readerMayCall(agentName, toolName);
27051
+ }
27052
+
27053
+ /**
27054
+ * What a reader fork may call: exactly its own tools and the speech its
27055
+ * configuration allows, never widened or narrowed by its resident's
27056
+ * allowedTools. The resident's list bounds the resident's own surface;
27057
+ * the reader's tools are the framework's, for reading on its behalf. A
27058
+ * denyToolsAtDispatch entry the fork takes from its resident that names
27059
+ * one of them still refuses it (mayCallTool checks the deny first): a
27060
+ * resident never holds a reader tool, so such an entry is about readers.
27061
+ * The fork still shows its resident's block (the shared prefix): what it
27062
+ * may call is decided here, at each call, apart from what it is shown.
27063
+ */
27064
+ private readerMayCall(agentName: string, toolName: string): boolean {
27065
+ if (this.registry.get(agentName)?.kind !== 'subconscious-fork') return false;
27066
+ if (toolName === 'speak_in_channel') return this.subconsciousConfig?.allowChannelSpeech === true;
27067
+ return (SUBCONSCIOUS_TOOL_NAMES as readonly string[]).includes(toolName);
27068
+ }
27069
+
24466
27070
  /** A toolsets--act call a host toolset resolved (HostPresentation.resolveAct):
24467
27071
  * its refusal answered, or the tool's own call dispatched in its place,
24468
27072
  * with the act's id, so its result is the act's. */
@@ -26128,23 +28732,41 @@ export class AgentFramework {
26128
28732
  return best;
26129
28733
  }
26130
28734
 
26131
- /** Registered policy; framework-created identities have no dispatch authority
26132
- * while unregistered. Genuine legacy caller names retain their surface. */
28735
+ /** Registered policy. Within this process, a name the framework made has no
28736
+ * dispatch authority while it isn't registered; after a restart, a host call names
28737
+ * its caller as any host call does. Genuine legacy caller names retain
28738
+ * their surface. */
26133
28739
  private canAgentUseTool(agentName: string, toolName: string): boolean {
26134
28740
  const agent = this.agents.get(agentName);
26135
28741
  return agent ? agent.canUseTool(toolName) : !this.usedEphemeralAgentNames.has(agentName);
26136
28742
  }
26137
28743
 
28744
+ /** Whether a call of `toolName` as `agentName` is refused at dispatch (AgentConfig.denyToolsAtDispatch, or a reader's own rule). */
28745
+ private deniedAtDispatch(agentName: string, toolName: string): boolean {
28746
+ return this.agents.get(agentName)?.deniesAtDispatch(toolName) === true || this.readerRefuses(agentName, toolName);
28747
+ }
28748
+
28749
+ /** Whether `agentName` may call `toolName`: the one rule dispatch applies (agentToolPermissionFailure), for whatever else judges a call. */
28750
+ private mayCallTool(agentName: string, toolName: string): boolean {
28751
+ return !this.deniedAtDispatch(agentName, toolName)
28752
+ && (this.canAgentUseTool(agentName, toolName) || this.readerMayCall(agentName, toolName));
28753
+ }
28754
+
26138
28755
  private agentToolPermissionFailure(agentName: string, call: ToolCall, reportRefusal = true): ToolResult | undefined {
26139
- if (this.canAgentUseTool(agentName, call.name)) return undefined;
26140
- const why = !this.agents.has(agentName) && this.usedEphemeralAgentNames.has(agentName)
26141
- ? `Caller '${agentName}' is not a running agent; framework-created agents have tool authority only while registered`
26142
- : `Tool '${call.name}' is not permitted by this agent's allowedTools`;
28756
+ // A tool refused at dispatch is refused on every path that runs the
28757
+ // agent's call (a toolset's act, executeToolCall), not only the stream's.
28758
+ if (this.mayCallTool(agentName, call.name)) return undefined;
28759
+ const denied = this.deniedAtDispatch(agentName, call.name);
28760
+ const why = denied
28761
+ ? `${call.name} is not available to ${agentName}`
28762
+ : !this.agents.has(agentName) && this.usedEphemeralAgentNames.has(agentName)
28763
+ ? `Caller '${agentName}' is not a running agent; framework-created agents have tool authority only while registered`
28764
+ : `Tool '${call.name}' is not permitted by this agent's allowedTools`;
26143
28765
  // Off the dispatcher, nothing opened the call to refuse it in RFC-007's
26144
28766
  // lifecycle, so it's a failure, not a refusal (ToolResult.refused), whose
26145
28767
  // text says the call didn't run.
26146
28768
  if (!reportRefusal) return { success: false, error: `${why}, so the call didn't run.`, isError: true };
26147
- const error = `${why}.`;
28769
+ const error = denied ? why : `${why}.`;
26148
28770
  const refusal = this.refusedResult(agentName, call.id, error);
26149
28771
  this.emitTrace({
26150
28772
  type: 'tool:failed', module: 'framework', tool: call.name, callId: call.id, error,
@@ -26215,7 +28837,7 @@ export class AgentFramework {
26215
28837
  * (`refusedHere`) counts as one the list refuses.
26216
28838
  */
26217
28839
  private roundCallOf(agentName: string, call: { name: string; input?: unknown; unparseableInput?: string }, refusedHere = false): RoundCall {
26218
- const permitted = !refusedHere && markedInputRefusal(call) === undefined && this.canAgentUseTool(agentName, call.name);
28840
+ const permitted = !refusedHere && markedInputRefusal(call) === undefined && this.mayCallTool(agentName, call.name);
26219
28841
  const refused = bareToolName(call.name) === 'skip_reply' && (!permitted || this.refusesSelfWake(call));
26220
28842
  return { name: call.name, cause: permitted || refused ? silenceCauseOf([call]) : null, refused, permitted };
26221
28843
  }
@@ -26239,6 +28861,23 @@ export class AgentFramework {
26239
28861
  });
26240
28862
  return;
26241
28863
  }
28864
+ // Dispatch-time deny (AgentConfig.denyToolsAtDispatch): the tool stays
28865
+ // advertised so the agent's request prefix is unchanged; the call is
28866
+ // refused here, before anything runs.
28867
+ if (this.deniedAtDispatch(agentName, call.name)) {
28868
+ const error = `${call.name} is not available to ${agentName}`;
28869
+ const result = this.refusedResult(agentName, call.id, error);
28870
+ this.emitTrace({ type: 'tool:failed', module: 'dendrite', tool: call.name, callId: call.id, error });
28871
+ this.pushEvent({
28872
+ type: 'tool-result',
28873
+ callId: call.id,
28874
+ toolName: call.name,
28875
+ agentName,
28876
+ moduleName: 'dendrite',
28877
+ result,
28878
+ });
28879
+ return;
28880
+ }
26242
28881
  const refusal = this.agentToolPermissionFailure(agentName, call) ?? this.markedInputFailure(agentName, call);
26243
28882
  if (refusal) {
26244
28883
  this.pushEvent({
@@ -26324,20 +28963,22 @@ export class AgentFramework {
26324
28963
  }
26325
28964
 
26326
28965
  // Route the tune-out tool (residents) and the subconscious's surface
26327
- if (enrichedCall.name === 'tune_out' && this.tuneOutCoordinator) {
28966
+ if ((enrichedCall.name === 'tune_out' || enrichedCall.name === 'focus') && this.tuneOutCoordinator) {
26328
28967
  this.dispatchTuneOutToolCall(agentName, enrichedCall);
26329
28968
  return;
26330
28969
  }
26331
- if (
26332
- this.tuneOutCoordinator &&
26333
- agentName === this.subconsciousAgentName &&
26334
- (SUBCONSCIOUS_TOOL_NAMES as readonly string[]).includes(enrichedCall.name)
26335
- ) {
26336
- void this.tuneOutCoordinator
26337
- .handleSubconsciousTool(enrichedCall.name, enrichedCall.input as Record<string, unknown>, this.callOwnChannels(agentName, enrichedCall))
26338
- // A rejection is answered with a failed result, as a framework tool's
26339
- // dispatch failure is (dispatchAgentSettingsToolCall), never left unhandled.
26340
- .catch((err): ToolResult => ({ success: false, error: thrownText(err), isError: true }))
28970
+ if ((SUBCONSCIOUS_TOOL_NAMES as readonly string[]).includes(enrichedCall.name)) {
28971
+ const routed = this.readerToolCall(agentName, enrichedCall);
28972
+ if ('refused' in routed) {
28973
+ const error = routed.refused;
28974
+ const result = this.refusedResult(agentName, call.id, error);
28975
+ this.emitTrace({ type: 'tool:failed', module: 'tune-out', tool: call.name, callId: call.id, error });
28976
+ this.pushEvent({
28977
+ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'tune-out', result,
28978
+ });
28979
+ return;
28980
+ }
28981
+ void routed.result
26341
28982
  .then((result) => {
26342
28983
  // Through pushEvent, like every other tool's result, so one that
26343
28984
  // arrives after stop() is dropped rather than rejected unhandled.
@@ -26717,6 +29358,7 @@ export class AgentFramework {
26717
29358
  opts?.fromQueue ? withDeferredWriteId(metadata, opts.fromQueue.id) : metadata,
26718
29359
  );
26719
29360
  if (opts?.placement) { opts.placement.agent = agent.name; opts.placement.messageId = stored; }
29361
+ this.releaseLandedMail(metadata, agent.name);
26720
29362
  this.noteStored(agent, metadata, stored, opts?.bypassDeferralFor);
26721
29363
  return stored;
26722
29364
  }
@@ -26875,6 +29517,37 @@ export class AgentFramework {
26875
29517
  this.noteForOtherResidents(agent, metadata, stored);
26876
29518
  }
26877
29519
 
29520
+ /**
29521
+ * Held agent mail is released only when its message has actually entered
29522
+ * the recipient's store — never on a deferral. Called at every site that
29523
+ * writes a message into a context: the direct path above and the two
29524
+ * deferred-write flushes (turn start, tool boundary). Both writes reach
29525
+ * disk in the same sync, so a crash keeps either both or neither.
29526
+ */
29527
+ private releaseLandedMail(metadata: MessageMetadata | undefined, recipient: string): void {
29528
+ const stamp = (metadata as { dendrite?: { mailId?: unknown; kind?: unknown; from?: { agent?: unknown } } } | undefined)?.dendrite;
29529
+ if (typeof stamp?.mailId !== 'string') return;
29530
+ // The message is in the recipient's store; the release is written only
29531
+ // once the store has synced it, so a hard exit between the two leaves
29532
+ // the mail held and the next boot's check of the recipient's store
29533
+ // settles it — never a release on disk for a message that isn't (nissa's
29534
+ // #546 review, round 2, K). A sync that fails leaves it held the same way.
29535
+ try {
29536
+ this.store.sync();
29537
+ } catch (error) {
29538
+ console.error(`[dendrite] mail ${stamp.mailId} landed for ${recipient} but the store did not sync, so it stays held until the next boot settles it:`, error);
29539
+ return;
29540
+ }
29541
+ this.registry.releaseMail(stamp.mailId);
29542
+ this.emitTrace({
29543
+ type: 'dendrite:mail-delivered',
29544
+ mailId: stamp.mailId,
29545
+ kind: String(stamp.kind ?? 'message'),
29546
+ from: String(stamp.from?.agent ?? ''),
29547
+ to: recipient,
29548
+ });
29549
+ }
29550
+
26878
29551
  /**
26879
29552
  * Splice out the deferred messages addressed to `agentName` (explicitly
26880
29553
  * via forAgent, or implicitly when it is the primary), leaving other
@@ -27324,6 +29997,8 @@ export class AgentFramework {
27324
29997
  const stored = this.writeDrainedInOrder(this.drainDeferredFor(agent.name), `${occasion} flush for ${agent.name}`, (msg) =>
27325
29998
  agent.getContextManager().addMessage(msg.participant, msg.content, withDeferredWriteId(msg.metadata, msg.id)));
27326
29999
  for (const [msg, { id }] of stored) {
30000
+ // Held mail whose message just landed is released (Dendrite).
30001
+ this.releaseLandedMail(msg.metadata, agent.name);
27327
30002
  this.emitTrace({ type: 'message:added', messageId: id, source: `deferred-flush:${occasion}` });
27328
30003
  this.noteSpokenTo(agent.name, msg.metadata);
27329
30004
  this.noteArrival(agent.name, msg.metadata, id);
@@ -27911,7 +30586,7 @@ export class AgentFramework {
27911
30586
  // target never read it (passesToPrimary); addMessage takes it off any
27912
30587
  // other write.
27913
30588
  const forRun = opts?.forAgent !== undefined
27914
- && (this.ephemeralRuns.has(opts.forAgent) || this.conversationAgentHomes.has(opts.forAgent));
30589
+ && (this.ephemeralRuns.has(opts.forAgent) || this.registry.homeChannel(opts.forAgent) !== undefined);
27915
30590
  const placement: { deferredId?: string } = {};
27916
30591
  const id = this.addMessage(participant, content, forRun ? { ...metadata, [PRIMARY_IF_UNREAD_KEY]: true } as MessageMetadata : metadata, {
27917
30592
  placement, ...(opts?.forAgent !== undefined ? { forAgent: opts.forAgent } : {}), ...(forRun ? { markedForRun: true as const } : {}),
@@ -27923,7 +30598,7 @@ export class AgentFramework {
27923
30598
  * The writes the framework holds for the store `reader` reads, in queue
27924
30599
  * order: those addressed to it and, when it's an ordinary resident, those
27925
30600
  * addressed to any ordinary resident, since residents share one message
27926
- * slot (sharedSlotAgents). A write whose target is gone isn't among them:
30601
+ * slot (`readsSharedSlot` in the registry). A write whose target is gone isn't among them:
27927
30602
  * no module reads that agent's store, and the write waits for its window's
27928
30603
  * release (drainDeferredFor) or, at its flush, is dropped (addMessage), a
27929
30604
  * failed reply, or a channel notice its reader could act on, passing to the
@@ -28315,6 +30990,10 @@ export class AgentFramework {
28315
30990
  if (typeof bounded.error === 'string') bounded.error = boundFailureText(bounded.error);
28316
30991
  if (typeof bounded.stack === 'string') bounded.stack = boundFailureText(bounded.stack);
28317
30992
 
30993
+ if (this.earlyTraces) {
30994
+ if (this.earlyTraces.length < 1000) this.earlyTraces.push(traceEvent);
30995
+ else this.earlyTracesDropped++;
30996
+ }
28318
30997
  // An event goes to the subscriptions present when its delivery starts,
28319
30998
  // each one only if it is still active when its turn comes, as the DOM's
28320
30999
  // EventTarget delivers: a subscription made during delivery waits for the
@@ -29023,10 +31702,11 @@ export class AgentFramework {
29023
31702
  this.setMcplServerConfig(config);
29024
31703
  }
29025
31704
 
29026
- // Find shouldTriggerInference callback:
29027
- // Per-server callback takes precedence; fall back to EventGate; fall back to no filter.
29028
- const triggerFilter = serverConfigs.find(c => c.shouldTriggerInference)?.shouldTriggerInference
29029
- ?? (this.eventGate ? this.eventGate.asShouldTriggerCallback() : undefined);
31705
+ // The server-level veto on waking (a per-server shouldTriggerInference).
31706
+ // The wake gate is deliberately NOT wired into the inbound lanes any
31707
+ // more: it runs in the admission stage, after the hold decision, so a
31708
+ // held message never forms a gate batch (#232 gap 3, root cause A).
31709
+ const triggerFilter = serverConfigs.find(c => c.shouldTriggerInference)?.shouldTriggerInference;
29030
31710
 
29031
31711
  // Push events handler (Step 6)
29032
31712
  this.installPushCoalescer(coalescing);
@@ -29081,14 +31761,28 @@ export class AgentFramework {
29081
31761
  }
29082
31762
  },
29083
31763
  shouldTriggerInference: triggerFilter,
31764
+ latestInboundLocus: this.speechLocus === 'latest-inbound',
31765
+ // A tool close of the channel an all-except hold excepts ends that
31766
+ // hold through the cancel flow, since there is nothing left to speak
31767
+ // into. A tool close of a tuned-out channel is refused
31768
+ // before this hook is called (ChannelRegistry.tunedOutRefusal).
31769
+ onToolClose: (serverId, channelId) => {
31770
+ const coordinator = this.tuneOutCoordinator;
31771
+ if (!coordinator) return undefined;
31772
+ const scope = coordinator.getAllExcept();
31773
+ if (!scope || scope.params.except.serverId !== serverId || scope.params.except.channelId !== channelId) return undefined;
31774
+ coordinator.cancelAllExcept('channel-close', 'the excepted channel was closed');
31775
+ return 'The focus on it ended all the same, and what the other channels held was delivered.';
31776
+ },
31777
+ focusChannelResolver: () => this.tuneOutCoordinator?.focusChannel() ?? null,
29084
31778
  // Route a conversation fork's plain-text speech to its HOME channel, not
29085
31779
  // the process-global most-recent-inbound locus (item 3). The trunk agent
29086
31780
  // has no home entry, so this returns undefined and routeSpeech falls back
29087
- // to defaultPublishChannel. `conversationAgentHomes` is the permanent
31781
+ // to defaultPublishChannel. The registry's `homeChannel` is the permanent
29088
31782
  // spawn-time binding; `channelForAgent` is the router's live binding as a
29089
31783
  // belt-and-suspenders fallback.
29090
31784
  homeChannelResolver: (agentName) =>
29091
- this.conversationAgentHomes.get(agentName)
31785
+ this.homeOf(agentName)
29092
31786
  ?? this.conversationRouter?.channelForAgent(agentName),
29093
31787
  // A single TRUNK agent's channel for its CURRENT turn (item-3 redux):
29094
31788
  // its locus once the turn has one, a re-pin's or a channel_open's
@@ -29989,6 +32683,9 @@ export class AgentFramework {
29989
32683
  */
29990
32684
  private makeHostPresentation(storePath: string | undefined): HostPresentation {
29991
32685
  return new HostPresentation({
32686
+ // A derived agent showing its parent's tool list (toolSurfaceOwners) is
32687
+ // shown its parent's presentation too, so the two blocks stay one.
32688
+ ownerOf: (agentName) => this.toolSurfaceOwners.get(agentName) ?? agentName,
29992
32689
  // As the agent is shown them: its description overrides applied.
29993
32690
  available: (agentName) => (this.agents.has(agentName) ? this.compressionToolsForAgent(agentName) : this.getAllTools()),
29994
32691
  placedInBuiltin: (name) => this.placedInBuiltin(name),
@@ -33229,7 +35926,7 @@ export class AgentFramework {
33229
35926
  // ones; channel_open is rejected outright — opening channels mutates
33230
35927
  // framework-global state (the open-channel set every agent's channel
33231
35928
  // context is built from), which is not a fork's call to make.
33232
- const home = this.conversationAgentHomes.get(agentName);
35929
+ const home = this.homeOf(agentName);
33233
35930
  if (home) {
33234
35931
  const reject = (error: string): void => {
33235
35932
  // RFC-007: the guard refuses before the tool runs — no lifecycle events.
@@ -33333,8 +36030,11 @@ export class AgentFramework {
33333
36030
  const readThrough = publisher ? this.publishReadThrough(publisher, call.id) : null;
33334
36031
  const published: { outcome?: PublishOutcome } = {};
33335
36032
 
33336
- this.channelRegistry!.handleChannelToolCall(call.name, call.input, {
33337
- kind: 'agent', agentName, own: callingTurn ? this.ownChannels(callingTurn) : [], ...(earlierWake ? { earlierWake } : {}),
36033
+ const channelInput = call.name === 'channel_publish'
36034
+ ? this.publishInputWithTurnLocus(call.input, agentName, callingTurn)
36035
+ : call.input;
36036
+ this.channelRegistry!.handleChannelToolCall(call.name, channelInput, {
36037
+ kind: 'agent', agentName, own: callingTurn ? this.ownChannels(callingTurn) : [], ...(callingTurn ? { inTurn: true } : {}), ...(earlierWake ? { earlierWake } : {}),
33338
36038
  }, published)
33339
36039
  .then((result) => {
33340
36040
  const destination = published.outcome?.status === 'delivered' ? published.outcome.destination : undefined;
@@ -33382,7 +36082,7 @@ export class AgentFramework {
33382
36082
  && this.openLogical.get(agentName) === callingTurn) {
33383
36083
  callingTurn.locus = opened;
33384
36084
  noteTurnChannel(callingTurn, opened);
33385
- this.lastAnnouncedLocus.set(agentName, opened);
36085
+ this.rememberAnnouncedLocus(agentName, opened);
33386
36086
  const inboundOnlyServer = this.channelRegistry?.inboundOnlyServer(opened);
33387
36087
  // The routing word comes first, ahead of any backscroll, so a
33388
36088
  // result cut to its head for being long keeps it.
@@ -33647,6 +36347,26 @@ export class AgentFramework {
33647
36347
  }
33648
36348
  }
33649
36349
 
36350
+ /**
36351
+ * A reader's own tool (SUBCONSCIOUS_TOOL_NAMES), for both entries that run
36352
+ * an agent's call: the dispatcher's, for the model's call, and
36353
+ * executeToolCallFrom's, for a host's or the puppet's. The tools are
36354
+ * advertised to residents so reader forks share their prefix, and only a
36355
+ * registered reader may use them. A rejection is answered with a failed
36356
+ * result, never left unhandled.
36357
+ */
36358
+ private readerToolCall(agentName: string, call: ToolCall): { refused: string } | { result: Promise<ToolResult> } {
36359
+ const isReader = agentName === this.subconsciousAgentName || this.registry.get(agentName)?.kind === 'subconscious-fork';
36360
+ if (!this.tuneOutCoordinator || !isReader) return { refused: `${call.name} is available to the resident's reader, not to ${agentName}` };
36361
+ const epoch = this.registry.get(agentName)?.metadata?.epochId;
36362
+ const fork = agentName === this.subconsciousAgentName ? undefined : { epochId: typeof epoch === 'string' ? epoch : undefined };
36363
+ return {
36364
+ result: this.tuneOutCoordinator
36365
+ .handleSubconsciousTool(call.name, call.input as Record<string, unknown>, this.callOwnChannels(agentName, call), agentName, fork)
36366
+ .catch((err): ToolResult => ({ success: false, error: thrownText(err), isError: true })),
36367
+ };
36368
+ }
36369
+
33650
36370
  /**
33651
36371
  * Handle the synthesized `sleep` / `wake` tools. `sleep` arms the gate's
33652
36372
  * suppression window, optionally announces in the sticky channel, and ends
@@ -33654,127 +36374,277 @@ export class AgentFramework {
33654
36374
  */
33655
36375
  /** tune_out tool (residents): enter or cancel a channel tune-out. */
33656
36376
  private dispatchTuneOutToolCall(agentName: string, call: ToolCall): void {
33657
- const coordinator = this.tuneOutCoordinator!;
33658
- const input = (call.input ?? {}) as {
33659
- channelId?: string; mode?: string;
33660
- cadenceSeconds?: number; backlogCap?: number; maxWakes?: number;
33661
- durationSeconds?: number;
33662
- };
33663
- const name = String(input.channelId ?? '');
36377
+ const input = (call.input ?? {}) as Record<string, unknown>;
33664
36378
  // Inside the calling turn, a name one of the turn's own channels answers
33665
36379
  // to by its id means that channel, as for the channel tools
33666
- // (ChannelRegistry.ownChannelFor). Otherwise the one channel the name
33667
- // means, which may be server-qualified; a name two servers' channels
33668
- // answer to is refused with them as candidates, never taken to be the
33669
- // first registered.
33670
- const mine = this.channelRegistry?.ownChannelFor(name, this.callOwnChannels(agentName, call));
33671
- const named = mine ? ('channel' in mine ? [mine.channel] : []) : this.channelRegistry?.channelsNamed(name) ?? [];
33672
- const shared = mine && 'error' in mine ? mine.error
33673
- : named.length > 1 ? this.channelRegistry?.sharedNameRefusalFor(name) : undefined;
33674
- const channel = named.length === 1 ? named[0]! : undefined;
33675
- const channelId = channel?.channelId ?? name;
33676
- // The name the agent knows the channel by: its id, or where another
33677
- // server shares the id, its server-qualified name. An answer that waits
33678
- // for the transport reads it again then, since a registration meanwhile
33679
- // can change which name picks the channel.
33680
- const shownNow = (): string => (channel ? this.channelRegistry?.nameShown(channel) ?? channelId : name);
33681
- const shown = shownNow();
33682
- let result: { success: boolean; data?: unknown; error?: string; isError?: boolean };
36380
+ // (ChannelRegistry.ownChannelFor): read here, while the call is the
36381
+ // turn's, before the answer can wait.
36382
+ const own = this.callOwnChannels(agentName, call);
36383
+ const turn = this.callingTurnOf(agentName, call);
36384
+ // The answer can wait for the tune-out's transport (openForTuneOut), so
36385
+ // the result goes through pushEvent when it is known, as every other
36386
+ // tool's does; a rejection is answered too, never left unhandled.
36387
+ void (call.name === 'focus' ? this.handleFocusTool(agentName, input, own, turn) : this.handleTuneOutTool(agentName, input, own, turn))
36388
+ .catch((err: unknown) => ({ success: false, error: thrownText(err), isError: true }))
36389
+ .then((result) => {
36390
+ this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'tune-out', result });
36391
+ });
36392
+ }
36393
+
36394
+ /** `focus` is `tune_out` with scope "all-except". */
36395
+ private handleFocusTool(agentName: string, input: Record<string, unknown>, own: readonly ChannelRef[] = [], turn?: TurnRouting): Promise<{ success: boolean; data?: unknown; error?: string; isError?: boolean }> {
36396
+ return this.handleTuneOutTool(agentName, { ...input, scope: 'all-except' }, own, turn);
36397
+ }
36398
+
36399
+ /**
36400
+ * A tune-out call's refusal for a channel it can't resolve: main's words
36401
+ * for a name no registered channel, and none of the calling turn's own,
36402
+ * answers to, and the registry's own otherwise, as for an id that is
36403
+ * ambiguous across servers (#232 gap 8).
36404
+ */
36405
+ private unknownChannelWords(channelId: string, resolution: string, own: readonly ChannelRef[]): string {
36406
+ const known = (this.channelRegistry?.channelsNamed(channelId).length ?? 0) > 0
36407
+ || own.some((c) => c.channelId === channelId);
36408
+ return known ? resolution : `unknown channel: ${channelId}`;
36409
+ }
36410
+
36411
+ /**
36412
+ * The tune_out tool's body. Channel resolution is server-qualified
36413
+ * (ChannelRegistry.resolveChannel): with a `serverId`, that server's
36414
+ * channel with the id; inside the calling turn, a name one of the turn's
36415
+ * own channels answers to by its id means that channel; otherwise the one
36416
+ * channel the name means, which may be server-qualified, and a name two
36417
+ * servers' channels answer to is refused with them as candidates (#232
36418
+ * gap 8). `turn` is the turn that made the call (callingTurnOf), the only
36419
+ * one whose speech an all-except entry moves.
36420
+ */
36421
+ private async handleTuneOutTool(
36422
+ agentName: string,
36423
+ input: Record<string, unknown>,
36424
+ own: readonly ChannelRef[] = [],
36425
+ turn?: TurnRouting,
36426
+ ): Promise<{ success: boolean; data?: unknown; error?: string; isError?: boolean }> {
36427
+ const coordinator = this.tuneOutCoordinator;
36428
+ const channelRegistry = this.channelRegistry;
36429
+ if (!coordinator || !channelRegistry) {
36430
+ return { success: false, error: 'tune-out is not available (no MCPL channels)', isError: true };
36431
+ }
33683
36432
  // An open that throws is answered too, never left unhandled.
33684
36433
  const openThrew = (err: unknown): string =>
33685
36434
  'The tune-out is recorded, but its messages may not reach the subconscious, since opening its channel ' +
33686
36435
  `failed: ${thrownText(err)}`;
33687
- if (shared) {
33688
- result = { success: false, error: shared, isError: true };
33689
- } else if (!channel) {
33690
- result = { success: false, error: `unknown channel: ${name}`, isError: true };
33691
- } else if (input.mode === 'cancel') {
33692
- const r = coordinator.cancel(channel.serverId, channelId, 'agent-tool', 'agent request');
33693
- result = r.ok
33694
- ? { success: true, data: { cancelled: shown } }
36436
+ const serverId = typeof input.serverId === 'string' && input.serverId ? input.serverId : undefined;
36437
+ const allExcept = input.scope === 'all-except';
36438
+
36439
+ if (input.mode === 'check') {
36440
+ // A peek's name means what the other modes' names mean: the one
36441
+ // channel it picks, by its id or the server-qualified name the agent
36442
+ // is shown where another server shares the id, or inside the turn one
36443
+ // of the turn's own channels (resolveChannel), and its lines are that
36444
+ // channel's, on its server.
36445
+ let peek: ChannelRef | undefined;
36446
+ if (typeof input.peek === 'string' && input.peek) {
36447
+ const resolved = channelRegistry.resolveChannel(input.peek, serverId, own);
36448
+ if ('error' in resolved) return { success: false, error: this.unknownChannelWords(input.peek, resolved.error, own), isError: true };
36449
+ peek = { serverId: resolved.serverId, channelId: resolved.channelId };
36450
+ }
36451
+ // Read as the other options are (readOptions): a numeric string is its
36452
+ // number, and a value that gives none is refused, named.
36453
+ const peekLimit = input.peekLimit == null ? undefined : Number(input.peekLimit);
36454
+ if (peekLimit !== undefined && !Number.isFinite(peekLimit)) {
36455
+ const got = typeof input.peekLimit === 'string' ? `the string ${JSON.stringify(input.peekLimit)}` : `the value ${JSON.stringify(input.peekLimit)}`;
36456
+ return { success: false, error: `Nothing was checked: peekLimit must be a finite number of lines, and it was ${got}. Leave it out for 5.`, isError: true };
36457
+ }
36458
+ return { success: true, data: coordinator.check({ ...(peek ? { peek } : {}), ...(peekLimit !== undefined ? { peekLimit } : {}) }) };
36459
+ }
36460
+ if (input.mode === 'cancel' && (allExcept || (!input.channelId && coordinator.getAllExcept()))) {
36461
+ const r = coordinator.cancelAllExcept('agent-tool', 'agent request');
36462
+ return r.ok ? { success: true, data: { cancelled: 'all-except' } } : { success: false, error: r.error, isError: true };
36463
+ }
36464
+
36465
+ const channelId = String(input.channelId ?? '');
36466
+ const resolved = channelRegistry.resolveChannel(channelId, serverId, own);
36467
+ if ('error' in resolved) return { success: false, error: this.unknownChannelWords(channelId, resolved.error, own), isError: true };
36468
+ // The name the agent knows the channel by: its id, or where another
36469
+ // server shares the id, its server-qualified name. An answer that waits
36470
+ // for the transport reads it again then, since a registration meanwhile
36471
+ // can change which name picks the channel.
36472
+ const shownNow = (): string => channelRegistry.nameShown(resolved);
36473
+ if (input.mode === 'cancel') {
36474
+ const r = coordinator.cancel(resolved.serverId, resolved.channelId, 'agent-tool', 'agent request');
36475
+ return r.ok
36476
+ ? { success: true, data: { cancelled: shownNow() } }
33695
36477
  : { success: false, error: r.error, isError: true };
33696
- } else {
33697
- const r = coordinator.enter(channel.serverId, channelId, {
36478
+ }
36479
+ if (allExcept) {
36480
+ const r = coordinator.enterAllExcept(resolved, {
33698
36481
  cadenceSeconds: input.cadenceSeconds,
33699
36482
  backlogCap: input.backlogCap,
33700
36483
  maxWakes: input.maxWakes,
33701
36484
  durationSeconds: input.durationSeconds,
33702
36485
  }, 'agent-tool');
33703
- if (r.ok) {
33704
- const data = {
36486
+ if (!r.ok) return { success: false, error: r.error, isError: true };
36487
+ const speaker = this.agents.get(agentName);
36488
+ const home = this.homeOf(agentName);
36489
+ const open = turn && this.openLogical.get(agentName) === turn ? turn : undefined;
36490
+ if (open && open.locus == null && !open.noChannel && !open.heartbeat && !home
36491
+ && (speaker?.proseRouting === 'locus' || speaker?.proseRouting === 'hybrid')) {
36492
+ open.locus = resolved;
36493
+ noteTurnChannel(open, resolved);
36494
+ this.rememberAnnouncedLocus(agentName, resolved);
36495
+ }
36496
+ const speech = home
36497
+ ? `Your plain speech stays in ${this.channelShown(home)}, your conversation's channel.`
36498
+ : open?.locus && !sameChannel(open.locus, resolved)
36499
+ ? `This turn's plain speech still lands in ${this.channelShown(open.locus)}; from your next turn it lands in the excepted channel while this holds, except on a turn that has no channel, such as a heartbeat's.`
36500
+ : open && (open.noChannel || open.heartbeat)
36501
+ ? "This turn has no channel, so its plain speech goes to none; from your next turn it lands in the excepted channel while this holds, except on a turn that has no channel, such as a heartbeat's."
36502
+ : "Your plain speech lands in the excepted channel while this holds, except on a turn that has no channel, such as a heartbeat's.";
36503
+ return {
36504
+ success: true,
36505
+ data: {
36506
+ scope: 'all-except',
36507
+ except: shownNow(),
36508
+ announcedIn: coordinator.announces() ? shownNow() : null,
36509
+ expiresAtMs: r.params.expiresAtMs,
33705
36510
  cadenceSeconds: r.params.cadenceSeconds,
33706
36511
  backlogCap: r.params.backlogCap,
33707
36512
  maxWakes: r.params.maxWakes,
33708
- };
33709
- // A tuned-out channel's traffic must keep arriving for the
33710
- // subconscious, so the answer waits for its transport (openForTuneOut).
33711
- // The tune-out is recorded either way, so it's a success, and where
33712
- // the transport isn't open, openError says why its messages don't or
33713
- // may not reach the subconscious, and when they will where that's
33714
- // known, or that the tune-out isn't in effect any more. A rejection is
33715
- // answered too, never left unhandled, and the result goes through
33716
- // pushEvent, as every other tool's does.
33717
- void this.channelRegistry!.openForTuneOut(channel.serverId, channelId)
33718
- .catch(openThrew)
33719
- .then((openError) => {
33720
- this.pushEvent({
33721
- type: 'tool-result',
33722
- callId: call.id,
33723
- toolName: call.name,
33724
- agentName,
33725
- moduleName: 'tune-out',
33726
- result: { success: true, data: { tunedOut: shownNow(), ...data, ...(openError ? { openError } : {}) } },
33727
- });
33728
- });
33729
- return;
33730
- }
36513
+ note: coordinator.hasReader()
36514
+ ? `${speech} Reader wakes pause, not cancel, when the budget is spent.`
36515
+ : `No subconscious is configured: this is a silent hold. ${speech} The backlog is delivered per channel when you cancel or the duration ends.`,
36516
+ },
36517
+ };
36518
+ }
36519
+ const r = coordinator.enter(resolved.serverId, resolved.channelId, {
36520
+ cadenceSeconds: input.cadenceSeconds,
36521
+ backlogCap: input.backlogCap,
36522
+ maxWakes: input.maxWakes,
36523
+ durationSeconds: input.durationSeconds,
36524
+ }, 'agent-tool');
36525
+ if (!r.ok) {
33731
36526
  // tune_out repeated on a channel already tuned out is where an agent
33732
36527
  // retries a transport the first open left closed, so where it isn't
33733
36528
  // open, or work is queued for it, the repeat goes through
33734
36529
  // openForTuneOut, which joins the open in flight or waits and, where
33735
36530
  // this host can open the channel, opens it again; the refusal says
33736
36531
  // what its transport is now.
33737
- const tuneOut = this.channelRegistry!.getTuneOutState(channel.serverId, channelId);
33738
- if (r.alreadyTunedOut && tuneOut && !this.channelRegistry!.tuneOutTransportOpen(channel.serverId, channelId)) {
33739
- void this.channelRegistry!.openForTuneOut(channel.serverId, channelId)
33740
- .catch(openThrew)
33741
- .then((openError) => {
33742
- // The tune-out is read again: one that ended or was replaced while
33743
- // the open was out isn't described as standing, whatever the open
33744
- // answered for it.
33745
- const now = this.channelRegistry!.getTuneOutState(channel.serverId, channelId);
33746
- const error = now?.params.epochId === tuneOut.params.epochId
33747
- ? alreadyTunedOutText(this.channelRegistry!.quotedShown(channel), now.params,
33748
- openError ?? 'Its transport is open now, so its messages reach the subconscious.')
33749
- : TUNE_OUT_REPLACED;
33750
- this.pushEvent({
33751
- type: 'tool-result',
33752
- callId: call.id,
33753
- toolName: call.name,
33754
- agentName,
33755
- moduleName: 'tune-out',
33756
- result: { success: false, error, isError: true },
33757
- });
33758
- });
33759
- return;
33760
- }
33761
- result = { success: false, error: r.error, isError: true };
33762
- }
33763
- this.pushEvent({
33764
- type: 'tool-result',
33765
- callId: call.id,
33766
- toolName: call.name,
33767
- agentName,
33768
- moduleName: 'tune-out',
33769
- result,
36532
+ const tuneOut = channelRegistry.getTuneOutState(resolved.serverId, resolved.channelId);
36533
+ if (r.alreadyTunedOut && tuneOut && !channelRegistry.tuneOutTransportOpen(resolved.serverId, resolved.channelId)) {
36534
+ const openError = await channelRegistry.openForTuneOut(resolved.serverId, resolved.channelId).catch(openThrew);
36535
+ // The tune-out is read again: one that ended or was replaced while
36536
+ // the open was out isn't described as standing, whatever the open
36537
+ // answered for it.
36538
+ const now = channelRegistry.getTuneOutState(resolved.serverId, resolved.channelId);
36539
+ const error = now?.params.epochId === tuneOut.params.epochId
36540
+ // A name in a sentence is quoted as tune_out takes it back (main's #852).
36541
+ ? alreadyTunedOutText(channelRegistry.quotedShown(resolved), now.params,
36542
+ openError ?? 'Its transport is open now, so its messages reach the subconscious.')
36543
+ : TUNE_OUT_REPLACED;
36544
+ return { success: false, error, isError: true };
36545
+ }
36546
+ return { success: false, error: r.error, isError: true };
36547
+ }
36548
+ // A tuned-out channel's traffic must keep arriving for its reader, so the
36549
+ // answer waits for its transport (openForTuneOut). The tune-out is
36550
+ // recorded either way, so it's a success, and where the transport isn't
36551
+ // open, openError says why its messages don't or may not reach the
36552
+ // reader, and when they will where that's known, or that the tune-out
36553
+ // isn't in effect any more.
36554
+ const openError = await channelRegistry.openForTuneOut(resolved.serverId, resolved.channelId).catch(openThrew);
36555
+ return {
36556
+ success: true,
36557
+ data: {
36558
+ tunedOut: shownNow(),
36559
+ cadenceSeconds: r.params.cadenceSeconds,
36560
+ backlogCap: r.params.backlogCap,
36561
+ maxWakes: r.params.maxWakes,
36562
+ ...(openError ? { openError } : {}),
36563
+ ...(coordinator.hasReader()
36564
+ ? {}
36565
+ : { note: 'No subconscious is configured: this is a silent hold. Nothing summarizes or wakes you; the backlog is delivered when you cancel.' }),
36566
+ },
36567
+ };
36568
+ }
36569
+
36570
+ /**
36571
+ * The admission stage for one inbound event: the pure decision over the
36572
+ * framework's policy inputs (recipients, the hold in force, the wake
36573
+ * gate), traced. Callers apply it; nothing downstream re-asks.
36574
+ */
36575
+ private admitInbound(env: InboundEnvelope): AdmissionDecision {
36576
+ const gateInfo = (e: InboundEnvelope) => ({
36577
+ content: e.gateContent,
36578
+ eventType: String(e.gateMetadata.eventType ?? 'unknown'),
36579
+ serverId: String(e.gateMetadata.serverId ?? e.serverId),
36580
+ channelId: typeof e.gateMetadata.channelId === 'string' ? e.gateMetadata.channelId : '',
36581
+ metadata: e.gateMetadata,
36582
+ tags: Array.isArray(e.gateMetadata.tags) ? (e.gateMetadata.tags as string[]) : undefined,
36583
+ });
36584
+ const decision = admit(env, {
36585
+ untargetedRecipients: () => this.registry.untargetedRecipients(),
36586
+ holdFor: (serverId, channelId) => {
36587
+ const hold = this.tuneOutCoordinator?.holdFor(serverId, channelId) ?? null;
36588
+ return hold ? { kind: 'tune-out', epochId: hold.epochId, serverId, channelId } : null;
36589
+ },
36590
+ ...(this.eventGate
36591
+ ? {
36592
+ gate: {
36593
+ evaluate: (e: InboundEnvelope) => this.eventGate!.evaluate(gateInfo(e)).trigger,
36594
+ // "Would the gate have woken them": a debounce policy says yes,
36595
+ // later — for the hold's reader that is a pass, not a mute.
36596
+ probe: (e: InboundEnvelope) => {
36597
+ const d = this.eventGate!.probe(gateInfo(e));
36598
+ return d.trigger || (typeof d.behavior === 'object' && d.behavior !== null && 'debounce' in d.behavior);
36599
+ },
36600
+ },
36601
+ }
36602
+ : {}),
33770
36603
  });
36604
+ this.emitTrace({
36605
+ type: 'admission:decided',
36606
+ lane: env.lane,
36607
+ serverId: env.serverId,
36608
+ channelId: env.channelId,
36609
+ messageId: env.messageId,
36610
+ recipients: decision.recipients,
36611
+ held: decision.held ? decision.held.epochId : null,
36612
+ wake: decision.wake,
36613
+ gatePass: decision.gatePass,
36614
+ });
36615
+ return decision;
36616
+ }
36617
+
36618
+ /**
36619
+ * Make delivered messages durable now, including those the delivery left
36620
+ * in the deferred queue: a tune-out release flips durable epoch state
36621
+ * first, so its dump must not be lost to a crash in between (#232 gap 6).
36622
+ */
36623
+ private commitDeliveries(): void {
36624
+ if (this.deferredMessages.length > 0) {
36625
+ this.deferredWritesPersisted = true;
36626
+ this.persistDeferredWrites();
36627
+ }
36628
+ try {
36629
+ this.store.sync();
36630
+ } catch (err) {
36631
+ console.error('[tune-out] could not sync the store after a release:', err);
36632
+ }
36633
+ }
36634
+
36635
+ /** The history-reading tool the primary resident actually has, if any (#232 gap 11). */
36636
+ private residentHistoryTool(): string | null {
36637
+ const primary = this.primaryAgentName ? this.agents.get(this.primaryAgentName) : undefined;
36638
+ if (!primary) return null;
36639
+ const names = this.getAllTools().map((t) => t.name).filter((n) => primary.canUseTool(n));
36640
+ return names.find((n) => /(^|--)(fetch_history|channel_read|read_history|history)$/.test(n)) ?? null;
33771
36641
  }
33772
36642
 
33773
36643
  private dispatchSleepToolCall(agentName: string, call: ToolCall): void {
33774
36644
  // The gate's sleep quiets the whole agent, every conversation of it, so a
33775
36645
  // conversation fork or an ephemeral run neither sets one nor ends the
33776
36646
  // agent's own. Refused before the tool runs, as a fork's channel_open is.
33777
- const fork = this.conversationAgentHomes.has(agentName);
36647
+ const fork = this.registry.homeChannel(agentName) !== undefined;
33778
36648
  if (fork || this.ephemeralRuns.has(agentName)) {
33779
36649
  const one = fork ? 'conversation' : 'run';
33780
36650
  // Only where the fork can follow it: skip_reply needs the channel