@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
@@ -1,4 +1,5 @@
1
1
  import { ToolPresentation, presentationTools, isPresentationTool, renderCatalogue } 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 } from './mcpl/references.js';
@@ -9,8 +10,9 @@ import { MembraneError } from '@animalabs/membrane';
9
10
  import { classifyFailure, retryDecision, MEMBRANE_RETRY_SETTINGS } from '@animalabs/membrane/retry';
10
11
  import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy, OverBudgetError, UncoveredDropError, stripUnpairedToolBlocks, continuesBody, MalformedContentError, malformedContentPlaces, CompressionWithdrawn } from '@animalabs/context-manager';
11
12
  import { SILENT_HEARTBEAT_SEPARATOR } from './silent-heartbeat.js';
12
- import { SUBCONSCIOUS_TOOLS, SUBCONSCIOUS_TOOL_NAMES } from './tune-out/tools.js';
13
+ import { SUBCONSCIOUS_TOOLS, SUBCONSCIOUS_TOOL_NAMES, defaultReaderFraming, } from './tune-out/tools.js';
13
14
  import { TuneOutCoordinator, TUNE_OUT_DEFAULTS, alreadyTunedOutText } from './tune-out/coordinator.js';
15
+ import { admit } from './admission/admit.js';
14
16
  import { bodyGroupRun, bodyRangeIn, locateMessageIndex, locateMessageIndices, removeStoredMessage, removeStoredMessages, } from './stored-removal.js';
15
17
  import { asModuleRow, heldHandle, heldWriteId, matchesMessageQuery } from './held-writes.js';
16
18
  import { ProcessQueueImpl } from './queue.js';
@@ -41,6 +43,7 @@ import { InferenceRouter } from './mcpl/inference-router.js';
41
43
  import { ChannelRegistry, TUNE_OUT_REPLACED, connectorText, desiredStateInLog, destinationKey, journalToolDescription, NON_HOLDER_CHANNEL_DESCRIPTIONS, NO_CHANNEL_EXAMPLE, NO_PERSON_EXAMPLE, qualifiedChannelName, sameChannel } from './mcpl/channel-registry.js';
42
44
  import { KEEP_IDLE_MS, KEEP_NEWEST, ProseDraftStore, draftState, uncertainAttempt } from './prose-drafts.js';
43
45
  import { ConversationRouter, CONVERSATION_ROUTING_DEPRECATION_NOTICE } from './mcpl/conversation-router.js';
46
+ import { AgentRegistry, conversationForkSpec, residentSpec, subconsciousSpec, taskForkSpec, workerSpec, } from './dendrite/index.js';
44
47
  import { INBOUND_SOURCE_KEY, sourceBodyDigest } from './mcpl/inbound-source.js';
45
48
  import { markNoticeOpenings, nameToTypeBack, quotedValue, showsAsItself, suppliedValue } from './supplied-text.js';
46
49
  import { NOTICE_TAG } from './notice-tags.js';
@@ -65,7 +68,7 @@ import { splitThinkingDrops, ThinkingLossIncidents } from './thinking-drops.js';
65
68
  import { asNextRequestCarries, roundRows } from './sent-rows.js';
66
69
  import { ReasoningLossTeller } from './reasoning-loss.js';
67
70
  import { contentAsStored, nameAsStored } from './text-as-stored.js';
68
- import { STORED_INJECTION_KEY, STORED_INJECTION_KIND, asStoredRow, hasRenderedContent, isAnotherAgentsInjection, isStoredKind, storedInjectionMarkOf, unitHash } from './stored-injections.js';
71
+ import { STORED_INJECTION_KEY, STORED_INJECTION_KIND, asStoredRow, hasRenderedContent, isAnotherAgentsInjection, isOwnInjection, isStoredKind, storedInjectionMarkOf, unitHash } from './stored-injections.js';
69
72
  /** An MCPL tool call refused because its server isn't in the registry: one
70
73
  * closed for good, one removed, or one whose connect failed without
71
74
  * reconnect, which this host doesn't bring back on its own. A server that's
@@ -244,10 +247,11 @@ const PRIMARY_IF_UNREAD_KEY = 'primaryIfUnread';
244
247
  * a deferred write, `external` a module's own row for an outside message,
245
248
  * `openaiResponsesItems` membrane's mark of an imported Responses
246
249
  * history, `contextInjection` an agent's stored context injection
247
- * (stored-injections.ts), which every other agent's view leaves out, and
250
+ * (stored-injections.ts), which every other agent's view leaves out,
248
251
  * `primaryIfUnread` a module's write for an ephemeral run or a
249
252
  * conversation fork, which goes to the primary if its target never read it
250
- * (passesToPrimary). A
253
+ * (passesToPrimary), and `dendrite` the stamp of delivered agent mail, whose
254
+ * `mailId` releases that held mail once the row lands (releaseLandedMail). A
251
255
  * server's metadata, a channel message's `metadata` or a
252
256
  * push event's `origin`, is stored without them, and the host writes its own
253
257
  * after it: MCPL SPEC §16.6 makes metadata the producer's untrusted claims,
@@ -258,7 +262,7 @@ const PRIMARY_IF_UNREAD_KEY = 'primaryIfUnread';
258
262
  */
259
263
  const HOST_METADATA_KEYS = [
260
264
  'system', 'kind', 'draftOwner', 'draftIds', 'tuneOut', 'coalescingSubject', 'firstView',
261
- 'deferredWriteId', 'external', 'openaiResponsesItems', STORED_INJECTION_KEY, PRIMARY_IF_UNREAD_KEY,
265
+ 'deferredWriteId', 'external', 'openaiResponsesItems', STORED_INJECTION_KEY, PRIMARY_IF_UNREAD_KEY, 'dendrite',
262
266
  ];
263
267
  /**
264
268
  * True when an injected message is real conversational input — something the
@@ -510,13 +514,15 @@ function mcplToolNoWordsText(serverId, callName) {
510
514
  'Whether it did anything is unknown: check before calling it again.';
511
515
  }
512
516
  /**
513
- * True when an incoming channel message explicitly addressed the agent.
514
- * Primary signal is the MCPL RFC-001 `chat:addressed` tag (mention / reply /
515
- * DM, as classified by the server); the metadata booleans are the fallback
516
- * for servers that predate tags. Addressed messages outrank ambient chatter
517
+ * True when an incoming channel message explicitly addressed the agent: the
518
+ * MCPL RFC-001 `chat:addressed` tag (mention / reply / DM, as classified by
519
+ * the server). Tag-only — there is no metadata fallback, so a server that
520
+ * does not tag never addresses. Addressed messages outrank ambient chatter
517
521
  * when picking a turn's frozen speech locus.
518
522
  */
519
- const isAddressedMessage = (tags, _metadata) => Array.isArray(tags) && tags.includes('chat:addressed');
523
+ const isAddressedMessage = (tags) => Array.isArray(tags) && tags.includes('chat:addressed');
524
+ /** The author a channel message names, for a notice about it. */
525
+ const authorName = (metadata) => metadata?.author?.name ?? 'Someone';
520
526
  /** A wake's `ambient` mark: only a message its server tagged `chat:ambient`
521
527
  * (RFC-001) is known to ask nothing of the agent. */
522
528
  const ambientMark = (tags) => Array.isArray(tags) && tags.includes('chat:ambient') ? { ambient: true } : {};
@@ -669,7 +675,7 @@ import { HostPresentation, BUILTIN_TOOLSET } from './host-presentation.js';
669
675
  import { isEmptyChange } from './mcpl/tool-presence.js';
670
676
  import { ToolsetsModule, resolveAct, withLookHint } from './mcpl/toolsets-module.js';
671
677
  import { ModernMcpConnection } from './mcpl/modern-connection.js';
672
- import { WITHHELD_TEXT, asText, credentialRedactor, redactUrl, redactServerText, resolveServerBinding, serverConfigProblems, serverCredentialPatterns, serverTextRedactor } from './mcpl/protocol-family.js';
678
+ import { MAX_TIMER_MS, WITHHELD_TEXT, asText, credentialRedactor, redactUrl, redactServerText, resolveServerBinding, serverConfigProblems, serverCredentialPatterns, serverTextRedactor } from './mcpl/protocol-family.js';
673
679
  import { MALFORMED_CONTENT_NOTICE, normalizeStandardToolResult } from './mcpl/tool-result-normalize.js';
674
680
  import { boundedForLog, describeCause } from './mcpl/errors.js';
675
681
  import { assertServerConfigBooleans, buildWebSocketUrl, isWebSocketTransport } from './mcpl/transport.js';
@@ -679,6 +685,12 @@ import { firstFreeBranchName } from './branch-names.js';
679
685
  import { DEFAULT_DISCORD_AWARENESS_EMOJI, DiscordAwarenessOutbox, DiscordAwarenessRefusal, defaultDiscordAwarenessOutboxPath, extractDiscordAwarenessRefs, selectDiscordAwarenessRefs, checkedDiscordAwarenessMarks, boundDiscordAwarenessText, discordChannelOf, isPermanentDiscordReactionFailure, unsyncedWriteText, } from './recovery/discord-awareness-outbox.js';
680
686
  import { OperatorActionError, OperatorLog, capIds, defaultOperatorLogPath, } from './operator-log.js';
681
687
  const FRAMEWORK_STATE_ID = 'framework/state';
688
+ /** Dendrite agent registry: live records, incarnation counters, held mail. */
689
+ const AGENT_REGISTRY_STATE_ID = 'framework/dendrite';
690
+ /** Dendrite agent registry: append-only history of ended agent records. */
691
+ const AGENT_REGISTRY_ENDED_ID = 'framework/dendrite/ended';
692
+ /** How many ended agent records are reloaded for inspection at boot. */
693
+ const AGENT_REGISTRY_ENDED_RELOAD = 256;
682
694
  /** Snapshot slot mirroring context writes deferred while quiesced (#122). */
683
695
  const DEFERRED_WRITES_ID = 'framework/deferred-writes';
684
696
  /** RFC-006 coalescing bookkeeping: receipts (retry window) + subject history. */
@@ -750,11 +762,12 @@ function describeDeferredWrite(msg) {
750
762
  /** Whether a deferred message can join a live stream at a tool boundary:
751
763
  * no tool blocks (the membrane's tool cycle) and not named as the agent
752
764
  * itself (it would render as an assistant turn, a prefill). */
753
- function isInjectableMidTurn(msg, agentName) {
754
- // Exact, as membrane's role test is: only a participant equal to the
755
- // request's assistantParticipant (Agent's name) renders as the assistant.
756
- // It's the name as sent, which the boundary takes as the store keeps it.
757
- return nameAsStored(msg.participant) !== agentName
765
+ function isInjectableMidTurn(msg, agent) {
766
+ // Exact, as membrane's role test is: a participant the request assigns
767
+ // the assistant role (the agent's name, and for a derived agent the
768
+ // voice it inherited) renders as the assistant. It's the name as sent,
769
+ // which the boundary takes as the store keeps it.
770
+ return !agent.isOwnParticipant(nameAsStored(msg.participant))
758
771
  && !msg.content.some((b) => b.type === 'tool_use' || b.type === 'tool_result');
759
772
  }
760
773
  /**
@@ -933,6 +946,7 @@ function freshTurnRouting() {
933
946
  engaged: new Set(),
934
947
  wasIn: [],
935
948
  continuation: false, deliveries: [], suppressed: 0, drafts: [], keptPrivate: 0, arrivals: [], heard: new Map(),
949
+ addressedElsewhere: new Set(),
936
950
  };
937
951
  }
938
952
  const sameReach = (a, b) => {
@@ -1618,6 +1632,33 @@ function extractGateFields(event) {
1618
1632
  : undefined;
1619
1633
  return { content, metadata, mount, paths };
1620
1634
  }
1635
+ /**
1636
+ * The admission record on a stored message: it was held from the residents
1637
+ * by a policy epoch (`metadata.tuneOut = { epochId }`). ONE predicate for
1638
+ * "held from the resident", read by the compiled-view filter and by the
1639
+ * mid-turn injection alike (#232 H1), so the window and the wire agree.
1640
+ */
1641
+ export const heldFromResidents = (metadata) => !!metadata?.tuneOut;
1642
+ /**
1643
+ * What a reader of the residents' shared message slot does not see: held
1644
+ * messages. Used by every resident, and by a context derived from one when
1645
+ * it is reopened.
1646
+ */
1647
+ const RESIDENT_VIEW_FILTER = (message) => !heldFromResidents(message.metadata);
1648
+ /** Rejection delivered to the caller awaiting a bounded run that was ended from outside. */
1649
+ export class AgentStoppedError extends Error {
1650
+ agentName;
1651
+ reason;
1652
+ by;
1653
+ constructor(agentName, reason, by, detail) {
1654
+ super(`Agent "${agentName}" was ended (${reason}${by ? ` by ${by}` : ''})` +
1655
+ (detail ? `: ${detail}` : ''));
1656
+ this.agentName = agentName;
1657
+ this.reason = reason;
1658
+ this.by = by;
1659
+ this.name = 'AgentStoppedError';
1660
+ }
1661
+ }
1621
1662
  export const REINIT_MOVES = ['undo', 'redo', 'rollback', 'suppression', 'branch switch'];
1622
1663
  const RECOVERY = 're-initializes it and stores its queued writes, at resume on a quiesced host; a restart can lose them.';
1623
1664
  export function refusedWorkAlert(move, cause) {
@@ -2248,6 +2289,13 @@ export class AgentFramework {
2248
2289
  quiesceReason;
2249
2290
  quiescedAt;
2250
2291
  traceListeners = [];
2292
+ /** Traces emitted before start() — registry restore, interrupted work,
2293
+ * resumed epochs — replayed to listeners that attach afterwards, since a
2294
+ * host attaches its sinks after create() returns. Dropped at start().
2295
+ * It keeps the first 1,000, the start of the stream, and counts the rest
2296
+ * (earlyTracesDropped), which a replay ends by naming. */
2297
+ earlyTraces = [];
2298
+ earlyTracesDropped = 0;
2251
2299
  syncIntervalMs;
2252
2300
  syncTimer = null;
2253
2301
  maintenanceIntervalMs;
@@ -2308,8 +2356,9 @@ export class AgentFramework {
2308
2356
  midTurnInputSignals = new Set();
2309
2357
  /** Last outbound locus announced to each agent as a durable `[routing]`
2310
2358
  * window message. Announce-on-change only: steady state emits nothing
2311
- * (KV-safe, no per-turn chatter). In-memory — after a process restart the
2312
- * first turn's locus is announced once to re-establish the baseline. */
2359
+ * (KV-safe, no per-turn chatter). Kept in the store as well
2360
+ * (rememberAnnouncedLocus), so a restart compares with what the agent was
2361
+ * last told rather than with nothing. */
2313
2362
  lastAnnouncedLocus = new Map();
2314
2363
  // ---- Held prose drafts (src/prose-drafts.ts) ---------------------------
2315
2364
  /** Every resident's held drafts: plain speech that was suppressed, bounced
@@ -2395,6 +2444,13 @@ export class AgentFramework {
2395
2444
  usedEphemeralAgentNames = new Map();
2396
2445
  /** One-shot generation tickets minted by createEphemeralAgent. */
2397
2446
  ephemeralCandidates = new WeakMap();
2447
+ /**
2448
+ * Derived agent → the agent whose tool presentation it shows. A derived
2449
+ * agent that inherits its parent's tools must advertise the identical
2450
+ * tool block (it is the front of the shared request prefix), including
2451
+ * the parent's resident-edited visibility and descriptions.
2452
+ */
2453
+ toolSurfaceOwners = new Map();
2398
2454
  /** Every agent createEphemeralAgent made, for as long as the object lives:
2399
2455
  * its usage counts in the usage record's one shared ephemeral entry. Kept
2400
2456
  * by the object, not the run's record (ephemeralRuns), which its cleanup
@@ -2465,8 +2521,25 @@ export class AgentFramework {
2465
2521
  subconsciousStrategy = null;
2466
2522
  /** Its config (speak_in_channel gate, voice block provenance). */
2467
2523
  subconsciousConfig = null;
2468
- /** Tune-out coordinator (issue #77); non-null iff subconscious + channels. */
2524
+ /** Tune-out coordinator (issue #77); non-null iff a reader is configured + channels. */
2469
2525
  tuneOutCoordinator = null;
2526
+ /** Residents are offered `tune_out` (a reader is configured, or `tuneOut.enabled`). */
2527
+ tuneOutOffered = false;
2528
+ /**
2529
+ * The reply anchor a coalesced delivery would set, parked until the
2530
+ * acceptance is acknowledged to the server (handleCoalescedIncoming): an
2531
+ * acceptance whose commit fails after its delivery moves no anchor, and the
2532
+ * retry the receipt answers, which delivers nothing again, commits the one
2533
+ * its first attempt parked. Keyed by server and event id; bounded.
2534
+ */
2535
+ parkedAnchors = new Map();
2536
+ /** `tuneOut.announce`: the excepted channel hears the all-except scope open and close. */
2537
+ tuneOutAnnounce = true;
2538
+ /** `speech.locus`: where prose goes when nothing names a destination (see FrameworkConfig). */
2539
+ speechLocus = 'delivery';
2540
+ /** `subconscious.reader: 'forks'`: the reader is a succession of forks (Dendrite). */
2541
+ readerForks = false;
2542
+ readerForkCounter = 0;
2470
2543
  /** Agents an abandon could not cancel (token held, no stream) — surfaced
2471
2544
  * in HostModeStatus until the next quiesce/resume. */
2472
2545
  lastUnabandonable = [];
@@ -2484,6 +2557,24 @@ export class AgentFramework {
2484
2557
  /** True once the cap has been met in the current run, so its line is
2485
2558
  * logged once a run, whatever began the run. */
2486
2559
  deferredWritesCapLogged = false;
2560
+ /** The agent registry and its ended log, as recovery files beside the store
2561
+ * (nissa's #546 finding 3): Chronicle state follows the store's branch, and
2562
+ * an undo or an operator's surgery must not erase a result held for
2563
+ * delivery or the record of work that ran. The file wins where it exists;
2564
+ * the branch-local state is read only for stores that predate it. */
2565
+ agentRegistryPath;
2566
+ agentRegistryEndedPath;
2567
+ /** The ended log as the recovery file keeps it: the newest records, bounded. */
2568
+ endedRecovery = [];
2569
+ /** The branch-local ended log by name (its newest entry for each), and how far it is indexed (findEndedAgentRecord). */
2570
+ endedLogIndex = new Map();
2571
+ /** The ended log's entries by the namespace their context holds (the latest for each). */
2572
+ endedLogNamespaceIndex = new Map();
2573
+ /** Namespaces this process opened for an ephemeral agent's context, by owner: candidates not yet registered included. */
2574
+ namespaceOwners = new Map();
2575
+ endedLogIndexed = 0;
2576
+ /** The last indexed entry's mark, so a log that changed under the index is seen. */
2577
+ endedLogTail = '';
2487
2578
  /** Branch-independent recovery files (see FrameworkConfig.hostModePath /
2488
2579
  * deferredWritesPath). Undefined → branch-local slot fallback. */
2489
2580
  hostModePath;
@@ -2634,12 +2725,32 @@ export class AgentFramework {
2634
2725
  coalescingSnapshotDirty = null;
2635
2726
  coalescingRecentReceipts = [];
2636
2727
  /**
2637
- * Agents that read the residents' shared un-namespaced message slot: every
2638
- * ordinary resident (their context managers are not isolated) and the
2639
- * subconscious (isolated, but merging that slot read-only). A message in
2640
- * that slot is unread only if NONE of them has compiled past it. Forks and
2641
- * ephemeral agents have isolated slots and are their own only readers.
2642
- */
2728
+ * Dendrite agent registry — every agent this framework runs, with its
2729
+ * roles, lifetime and relationships. `agents` stays the map of live Agent
2730
+ * OBJECTS (the generation identity streams are checked against); the
2731
+ * registry is what says what each of them is for. Selection sites read
2732
+ * roles from here instead of excluding kinds by hand.
2733
+ */
2734
+ registry = new AgentRegistry({
2735
+ persist: (snapshot) => this.persistAgentRegistry(snapshot),
2736
+ appendEnded: (record) => this.appendEndedAgentRecord(record),
2737
+ onRollback: () => this.rewriteAgentRegistryAfterRollback(),
2738
+ findEnded: (name) => this.findEndedAgentRecord(name),
2739
+ });
2740
+ /** Tell the first adoption candidate when task work is orphaned (config.dendrite.announceOrphans). */
2741
+ announceOrphans = true;
2742
+ /**
2743
+ * After this many derivations of one kind from one agent, that agent hears
2744
+ * a count line every so many instead of a line per fork
2745
+ * (config.dendrite.collapseDerivationNoticesAfter; 0 = never collapse).
2746
+ */
2747
+ collapseDerivationNoticesAfter = 10;
2748
+ /** The host's say over each derivation, ephemeral spawn and resume (config.dendrite.beforeDerive). */
2749
+ beforeDerive;
2750
+ /** What a source is told of a derivation (config.dendrite.derivationNotice); absent, the default line. */
2751
+ derivationNoticeHook;
2752
+ /** Per source agent, per kind: how many derivations, since when, and at which count the last note went. */
2753
+ derivationTallies = new Map();
2643
2754
  sharedSlotAgents = new Set();
2644
2755
  /** The batched-wake lines already written (placeWakeLines), so every agent a
2645
2756
  * wake was for, and every later start of it, writes each at most once. */
@@ -2648,6 +2759,7 @@ export class AgentFramework {
2648
2759
  * in the round counted by their share of the bound: such a round is
2649
2760
  * stored by its share whether or not it fits (buildStoredToolResultContent). */
2650
2761
  toolRoundsByShare = new WeakSet();
2762
+ /** Group commit: one fsync per event-loop turn, shared by every waiter. */
2651
2763
  /** The windows of forks and runs deregistered while the host was quiesced,
2652
2764
  * by agent name (releaseWindowNowOrAfterResume); resume() releases them
2653
2765
  * (runReleasesAfterResume), or stop() passes on what they left, unread or
@@ -2656,6 +2768,7 @@ export class AgentFramework {
2656
2768
  releasesAfterResume = new Map();
2657
2769
  managerReleases = new WeakMap();
2658
2770
  releasedByCleanup = new WeakSet();
2771
+ resumesPending = new Map();
2659
2772
  // context-manager 0.23.1: a strategy that keeps its context, as
2660
2773
  // AutobiographicalStrategy does (hostContext, autobiographical.ts:2897),
2661
2774
  // keeps its manager reachable while its work is pending, since the
@@ -2676,19 +2789,17 @@ export class AgentFramework {
2676
2789
  conversationRouter = null;
2677
2790
  /** Agent configs by name — fork agents are built from the template's config. */
2678
2791
  agentConfigs = new Map();
2679
- /**
2680
- * Fork agent → its home channel. Permanent (unlike router bindings, which
2681
- * expire): publish/injection scoping must survive unbinding so the closure
2682
- * turn still lands in the right channel.
2683
- */
2684
- conversationAgentHomes = new Map();
2792
+ // A fork agent's home channel is its registry record's `homeChannel`
2793
+ // (homeOf). Permanent (unlike router bindings, which expire):
2794
+ // publish/injection scoping must survive unbinding so the closure turn
2795
+ // still lands in the right channel.
2685
2796
  /** Forks being spawned, by channel id, each until its spawn settles (see
2686
2797
  * routeConversation). The promise never rejects. */
2687
2798
  conversationSpawns = new Map();
2688
2799
  /** Last idle-TTL sweep timestamp. */
2689
2800
  lastConversationSweep = 0;
2690
2801
  /** Forks whose TTL closure turn has been queued — disposed (removed from
2691
- * agents/agentConfigs/conversationAgentHomes) when their stream ends. */
2802
+ * agents/agentConfigs and ended in the registry) when their stream ends. */
2692
2803
  closingConversationAgents = new Set();
2693
2804
  // Client-side programmatic tool calling (`code_execution`). Null unless
2694
2805
  // config.codeExecution.enabled: its settings, and every agent's scripts,
@@ -2991,7 +3102,7 @@ export class AgentFramework {
2991
3102
  // Register framework states
2992
3103
  ensureStateRegistered(store, { id: FRAMEWORK_STATE_ID, strategy: 'snapshot' });
2993
3104
  ensureStateRegistered(store, { id: DEFERRED_WRITES_ID, strategy: 'snapshot' });
2994
- for (const id of [COALESCING_STATE_ID, COALESCING_RECENT_ID]) {
3105
+ for (const id of [COALESCING_STATE_ID, COALESCING_RECENT_ID, AGENT_REGISTRY_STATE_ID]) {
2995
3106
  ensureStateRegistered(store, { id, strategy: 'snapshot' });
2996
3107
  }
2997
3108
  ensureStateRegistered(store, {
@@ -3019,6 +3130,12 @@ export class AgentFramework {
3019
3130
  throw new Error(`the conversation router's generation counters (${CONVERSATION_ROUTER_STATE_ID}) could not be read: ${thrownText(error)}`, { cause: error });
3020
3131
  }
3021
3132
  }
3133
+ ensureStateRegistered(store, {
3134
+ id: AGENT_REGISTRY_ENDED_ID,
3135
+ strategy: 'append_log',
3136
+ deltaSnapshotEvery: 100,
3137
+ fullSnapshotEvery: 20,
3138
+ });
3022
3139
  // Process logging config (default: disabled)
3023
3140
  const processLoggingPersist = config.processLogging?.persist === true;
3024
3141
  // Register process log state only if persistence is enabled
@@ -3081,8 +3198,36 @@ export class AgentFramework {
3081
3198
  // subconscious disabled now may have run and left its own entry.
3082
3199
  framework.restoreUsageState([
3083
3200
  ...config.agents.map((agent) => String(agent.name)),
3084
- ...(config.subconscious ? [String(config.subconscious.name ?? 'Subconscious')] : []),
3201
+ // A forks reader has no agent of its own; its forks are bounded jobs.
3202
+ ...(config.subconscious && config.subconscious.reader !== 'forks' ? [String(config.subconscious.name ?? 'Subconscious')] : []),
3085
3203
  ]);
3204
+ framework.announceOrphans = config.dendrite?.announceOrphans ?? true;
3205
+ framework.collapseDerivationNoticesAfter = Math.max(0, Math.floor(config.dendrite?.collapseDerivationNoticesAfter ?? 10));
3206
+ framework.beforeDerive = config.dendrite?.beforeDerive;
3207
+ framework.derivationNoticeHook = config.dendrite?.derivationNotice;
3208
+ framework.speechLocus = config.speech?.locus ?? 'delivery';
3209
+ framework.tuneOutOffered = !!config.subconscious?.enabled || config.tuneOut?.enabled === true;
3210
+ framework.tuneOutAnnounce = config.tuneOut?.announce !== false;
3211
+ // What the previous process left in the agent registry. Read before any
3212
+ // agent is declared so a restored persistent agent becomes a new
3213
+ // incarnation of the same identity rather than a stranger. The registry
3214
+ // lives in recovery files beside the store (see agentRegistryPath).
3215
+ framework.agentRegistryPath = config.agentRegistryPath
3216
+ ?? (config.storePath ? join(config.storePath, 'recovery', 'dendrite-registry.json') : undefined);
3217
+ framework.agentRegistryEndedPath = config.agentRegistryEndedPath
3218
+ ?? (config.storePath ? join(config.storePath, 'recovery', 'dendrite-ended.json') : undefined);
3219
+ if (!framework.agentRegistryPath || !framework.agentRegistryEndedPath) {
3220
+ const missing = [
3221
+ ...(framework.agentRegistryPath ? [] : ['agentRegistryPath']),
3222
+ ...(framework.agentRegistryEndedPath ? [] : ['agentRegistryEndedPath']),
3223
+ ];
3224
+ console.error(`[dendrite] no storePath / ${missing.join(' / ')}: the agent registry's ` +
3225
+ (framework.agentRegistryPath
3226
+ ? 'ended log persists only in the branch-local store, where a branch switch or historical rollback can erase it'
3227
+ : 'records and held results persist only in the branch-local store, where a branch switch or historical rollback can erase them') +
3228
+ ', and a crash can lose what wasn\'t synced; configure the recovery paths');
3229
+ }
3230
+ const interruptedAgents = framework.restoreAgentRegistry();
3086
3231
  // Create agents
3087
3232
  for (const agentConfig of config.agents) {
3088
3233
  await framework.createAgent(agentConfig);
@@ -3092,8 +3237,13 @@ export class AgentFramework {
3092
3237
  // The subconscious resident (issue #77) registers after the residents so
3093
3238
  // it can never become primary; it is excluded from every broadcast
3094
3239
  // fan-out and woken only by the tune-out coordinator's explicit pushes.
3095
- if (config.subconscious?.enabled === true) {
3096
- await framework.createSubconsciousAgent(config.subconscious);
3240
+ if (config.subconscious?.enabled) {
3241
+ if (config.subconscious.reader === 'forks') {
3242
+ framework.configureReaderForks(config.subconscious);
3243
+ }
3244
+ else {
3245
+ await framework.createSubconsciousAgent(config.subconscious);
3246
+ }
3097
3247
  }
3098
3248
  // Finish any branch-local suppression interrupted after Chronicle switched
3099
3249
  // branches. This runs before modules, MCPL connections, or inbound traffic.
@@ -3218,7 +3368,8 @@ export class AgentFramework {
3218
3368
  })(),
3219
3369
  });
3220
3370
  },
3221
- getAgentNames: () => [...framework.agents.keys()].filter((n) => n !== framework.subconsciousAgentName),
3371
+ getAgentNames: () => framework.registry.gateRecipients(),
3372
+ isHeld: (serverId, channelId) => !!(serverId && channelId && framework.tuneOutCoordinator?.holdFor(serverId, channelId)),
3222
3373
  // Push events reach the gate with the adapter's raw channel id; map
3223
3374
  // them to the registered composite id the same way ingestion does
3224
3375
  // (handleMcplPushEvent registers that channel on arrival, before any
@@ -3372,6 +3523,14 @@ export class AgentFramework {
3372
3523
  // and each MCPL server's tab, per agent, kept beside the store.
3373
3524
  framework.hostPresentation = framework.makeHostPresentation(config.storePath);
3374
3525
  // Initialize MCPL subsystems if configured
3526
+ // Configuration has now declared its agents, and host mode and the
3527
+ // deferred-write queue are restored: settle what the previous process
3528
+ // left unfinished (interrupted bounded work, persistent agents no longer
3529
+ // configured, results still held for delivery). After the restore, so a
3530
+ // quiesced host holds the restart notices and redelivered mail as it
3531
+ // holds any write, and mail an entry of the restored queue carries is
3532
+ // left to that entry's landing (nissa's #546 finding 4).
3533
+ framework.reconcileAgentRegistry(interruptedAgents);
3375
3534
  if (config.mcplServers && config.mcplServers.length > 0) {
3376
3535
  // Check every server's config before any is brought up: its boolean
3377
3536
  // settings, and its tool prefix, which mustn't collide with a module
@@ -3422,21 +3581,34 @@ export class AgentFramework {
3422
3581
  // MCPL or none.
3423
3582
  await framework.ensureToolsetsModule();
3424
3583
  }
3425
- // Tune-out coordinator (issue #77): needs both the subconscious resident
3426
- // and the MCPL channel subsystem; created after both exist.
3427
- if (framework.subconsciousAgentName && framework.channelRegistry) {
3584
+ // Tune-out coordinator (issue #77, #232 §3.3): the hold machinery exists
3585
+ // wherever tune_out is offered — with a reader (persistent subconscious
3586
+ // or reader forks) it summarizes and wakes; with `tuneOut.enabled` alone
3587
+ // it is a silent hold, and a persisted epoch is resumed and released
3588
+ // (#232 gap 10). Where neither is configured nothing could ever cancel a
3589
+ // tune-out on record, so each one ends at the start instead
3590
+ // (endTuneOutsWithoutCoordinator), as main has it.
3591
+ if (framework.channelRegistry && framework.tuneOutOffered) {
3428
3592
  framework.tuneOutCoordinator = new TuneOutCoordinator(framework.channelRegistry, framework.mcplServerRegistry, {
3429
3593
  addMessage: (participant, content, metadata, forAgent) => framework.addMessage(participant, content, metadata, forAgent ? { forAgent } : undefined),
3430
3594
  requestInference: (agentName, reason, source) => {
3431
3595
  framework.pendingRequests.push({ agentName, reason, source, timestamp: Date.now() });
3432
3596
  },
3433
3597
  subconsciousName: () => framework.subconsciousAgentName,
3598
+ ...(framework.readerForks
3599
+ ? {
3600
+ invokeReader: (invocation) => framework.invokeReaderFork(invocation),
3601
+ deliverFromReader: (reader, content, metadata) => framework.deliverFromReader(reader, content, metadata),
3602
+ }
3603
+ : {}),
3434
3604
  primaryName: () => framework.primaryAgentName,
3435
3605
  getStoredMessages: () => {
3436
3606
  const primary = framework.primaryAgentName
3437
3607
  ? framework.agents.get(framework.primaryAgentName) : undefined;
3438
3608
  return primary ? primary.getContextManager().getAllMessages() : [];
3439
3609
  },
3610
+ commitDeliveries: () => framework.commitDeliveries(),
3611
+ historyTool: () => framework.residentHistoryTool(),
3440
3612
  getDeferredMessages: () => {
3441
3613
  const forPrimary = (m) => (m.forAgent ?? framework.primaryAgentName) === framework.primaryAgentName;
3442
3614
  // Settled first, so a write that landed is in the stored rows
@@ -3444,7 +3616,7 @@ export class AgentFramework {
3444
3616
  framework.settleUnknownLandings(forPrimary);
3445
3617
  return framework.deferredMessages.filter(forPrimary)
3446
3618
  .sort(bySeq)
3447
- .map((m) => ({ participant: m.participant, content: m.content, metadata: m.metadata }));
3619
+ .map((m) => ({ participant: m.participant, content: m.content, metadata: m.metadata, writeId: m.id, heldAt: m.heldAt }));
3448
3620
  },
3449
3621
  // Every reader of the shared slot compiles the dump with its own
3450
3622
  // strategy, so the dump fits the smallest of their bounds.
@@ -3459,9 +3631,20 @@ export class AgentFramework {
3459
3631
  },
3460
3632
  currentSequence: () => framework.store.currentSequence(),
3461
3633
  setSubconsciousAnchor: (sequence) => framework.subconsciousStrategy?.setAnchor(sequence),
3462
- isForkBound: (channel) => [...framework.conversationAgentHomes.values()].some((home) => sameChannel(home, channel)),
3634
+ isForkBound: (channel) => framework.registry.list().some((record) => sameChannel(framework.homeOf(record.name), channel)),
3463
3635
  isPrivilegedAuthor: (authorId) => framework.eventGate?.isPrivilegedUser(authorId) ?? false,
3464
3636
  allowChannelSpeech: () => framework.subconsciousConfig?.allowChannelSpeech === true,
3637
+ holdWithoutReader: () => framework.tuneOutOffered,
3638
+ ...(framework.tuneOutAnnounce
3639
+ ? {
3640
+ // The excepted channel on its server: a bare id two servers
3641
+ // register is refused (publishForAgent).
3642
+ announce: async (serverId, channelId, text) => {
3643
+ const r = await framework.channelRegistry.publishForAgent({ serverId, channelId }, text, framework.primaryAgentName ?? 'resident');
3644
+ return { success: r.success, ...(r.error ? { error: r.error } : {}) };
3645
+ },
3646
+ }
3647
+ : {}),
3465
3648
  emitTrace: (e) => framework.emitTrace(e),
3466
3649
  });
3467
3650
  framework.tuneOutCoordinator.resumeActiveEpochs();
@@ -3549,6 +3732,7 @@ export class AgentFramework {
3549
3732
  }
3550
3733
  this.running = true;
3551
3734
  this.providerAdmissionClosed = false;
3735
+ this.earlyTraces = null;
3552
3736
  // A new lifecycle: a turn compiled before an earlier stop() never drives,
3553
3737
  // and withheld spills, closed for the generation a stop ended, are open
3554
3738
  // for this one.
@@ -3814,6 +3998,12 @@ export class AgentFramework {
3814
3998
  step(() => this.pushCoalescer?.suspend());
3815
3999
  step(() => this.flushCoalescingSnapshot());
3816
4000
  this.running = false;
4001
+ // Bounded work this stop cuts off is neither finished nor failed. Its
4002
+ // record stays live in the snapshot, so the next boot ends it as
4003
+ // `host-restart` and tells its result recipient — the same as a crash
4004
+ // (first live run, 2026-10-12, finding D: a reader fork ended silently).
4005
+ for (const run of this.ephemeralRuns.values())
4006
+ run.endReason ??= 'host-stop';
3817
4007
  // Flushed-but-unsynced deferred writes: sync and ack now, while the
3818
4008
  // store is still open, rather than leaving them to a reboot replay.
3819
4009
  step(() => this.ackDeferredWrites());
@@ -4009,12 +4199,12 @@ export class AgentFramework {
4009
4199
  // since the name is never used again. These releases, and the pass after
4010
4200
  // them, come before the registry forgets its channels, since a notice
4011
4201
  // names its channel by the label the registry holds (channelForNotice).
4012
- for (const name of [...this.conversationAgentHomes.keys()]) {
4202
+ // The registry names the conversation forks and their home channels.
4203
+ for (const { name, home } of this.listAgents().filter((r) => r.kind === 'conversation-fork').map((r) => ({ name: r.name, home: this.homeOf(r.name) }))) {
4013
4204
  step(() => {
4014
- const home = this.conversationAgentHomes.get(name);
4015
- if (this.conversationRouter?.getBinding(home)?.agentName === name)
4205
+ if (home !== undefined && this.conversationRouter?.getBinding(home)?.agentName === name)
4016
4206
  this.conversationRouter.unbind(home);
4017
- this.disposeConversationAgent(name);
4207
+ this.disposeConversationAgent(name, 'host-stop');
4018
4208
  });
4019
4209
  }
4020
4210
  // A window release the quiesce postponed (releasesAfterResume) never runs
@@ -4452,10 +4642,12 @@ export class AgentFramework {
4452
4642
  }
4453
4643
  }
4454
4644
  /** The agents the heartbeat wakes: the residents, not conversation forks,
4455
- * ephemeral runs or the subconscious, which keep windows of their own. */
4645
+ * ephemeral runs or the subconscious, which keep windows of their own.
4646
+ * Read from the registry by role, as an untargeted push's recipients are
4647
+ * (the heartbeat's wakes were heartbeat-mcpl's pushes): the agents whose
4648
+ * spec takes untargeted inbound, in registration order. */
4456
4649
  heartbeatTargets() {
4457
- return [...this.agents.keys()].filter((n) => !this.conversationAgentHomes.has(n)
4458
- && !this.ephemeralRuns.has(n) && n !== this.subconsciousAgentName);
4650
+ return this.registry.untargetedRecipients().filter((n) => this.agents.has(n));
4459
4651
  }
4460
4652
  /**
4461
4653
  * The event a heartbeat wake is to the gate: a push from the server id
@@ -4552,7 +4744,7 @@ export class AgentFramework {
4552
4744
  const tool = call.name.replace(/^(mcpl--)?heartbeat--/, '');
4553
4745
  if (!HEARTBEAT_CHANGING_TOOLS.includes(tool) || this.heartbeatTargets().includes(agentName))
4554
4746
  return null;
4555
- const one = this.conversationAgentHomes.has(agentName) ? 'conversation'
4747
+ const one = this.registry.homeChannel(agentName) !== undefined ? 'conversation'
4556
4748
  : this.ephemeralRuns.has(agentName) ? 'run' : 'side agent';
4557
4749
  return `Nothing was changed: the heartbeat and its reminders are the agent's, and wake its residents, so this ${one} can't `
4558
4750
  + 'change them. heartbeat--heartbeat_status and heartbeat--reminder_list show them.';
@@ -4801,7 +4993,7 @@ export class AgentFramework {
4801
4993
  return true;
4802
4994
  }
4803
4995
  holdProviderAcceleration(agent, error, trigger, auxiliary = false) {
4804
- if (this.ephemeralRuns.has(agent.name) || this.conversationAgentHomes.has(agent.name))
4996
+ if (!this.registry.ownsProviderScheduling(agent.name))
4805
4997
  return false;
4806
4998
  // A provider's stated wait binds later calls to the primary model, across
4807
4999
  // restarts. (An auxiliary call records its own model's wait at the gate.)
@@ -4941,7 +5133,7 @@ export class AgentFramework {
4941
5133
  // as a number, as the default error policy reads it.
4942
5134
  if (!isMembraneError(error) || typeof error.retryAfterMs !== 'number')
4943
5135
  return undefined;
4944
- if (this.conversationAgentHomes.has(agent.name)) {
5136
+ if (this.registry.homeChannel(agent.name) !== undefined) {
4945
5137
  const wait = this.recordProviderWait(agent.name, agent.model, error);
4946
5138
  return error.retryable === true && wait !== undefined && this.holdForProviderWait(agent, wait, trigger ? [asTurnRetry(trigger)] : [], true)
4947
5139
  ? 'held'
@@ -5175,6 +5367,15 @@ export class AgentFramework {
5175
5367
  // outlives the stop as a turn still alive. Nothing is started either.
5176
5368
  if (this.providerAdmissionClosed)
5177
5369
  return true;
5370
+ // A listener of the failure reported here (`report`) may have ended the
5371
+ // agent (stopAgent): its end dropped what of its turn was on its way,
5372
+ // and nothing is held for an agent that's gone, so no retry outlives it
5373
+ // as a turn still alive. (A caller that reports a failure before calling
5374
+ // here checks the agent first: nonOwningProviderWait holds only for an
5375
+ // agent whose registry record is still live, and the scheduler and the
5376
+ // last boundary pass this.agents' own Agent.)
5377
+ if (this.agents.get(agent.name) !== agent)
5378
+ return true;
5178
5379
  // A listener of the report may have released the wait
5179
5380
  // (releaseProviderWait) before anything was held: nothing binds now, so
5180
5381
  // the requests start, as the release of an installed hold starts them.
@@ -5699,9 +5900,26 @@ export class AgentFramework {
5699
5900
  /**
5700
5901
  * Add a trace event listener for observability.
5701
5902
  */
5702
- onTrace(listener) {
5903
+ onTrace(listener, options) {
5703
5904
  const registration = { listener, active: true };
5704
5905
  this.traceListeners.push(registration);
5906
+ // A host attaches its sinks after create() returns, so the traces
5907
+ // create() emitted (registry restore, interrupted work, resumed epochs)
5908
+ // are replayed to a listener that asks; dropped at start().
5909
+ if (options?.replay) {
5910
+ const replayed = [...(this.earlyTraces ?? [])];
5911
+ if (this.earlyTraces && this.earlyTracesDropped > 0) {
5912
+ replayed.push({ type: 'dendrite:early-traces-dropped', dropped: this.earlyTracesDropped, timestamp: Date.now() });
5913
+ }
5914
+ for (const event of replayed) {
5915
+ try {
5916
+ registration.listener(event);
5917
+ }
5918
+ catch (error) {
5919
+ console.error('Trace listener error (replay):', error);
5920
+ }
5921
+ }
5922
+ }
5705
5923
  // Return an unsubscribe so callers with a bounded lifetime (e.g. modules
5706
5924
  // that get torn down and recreated on session switch) don't leak
5707
5925
  // listeners. Existing callers that ignore the return value are unaffected.
@@ -6048,8 +6266,8 @@ export class AgentFramework {
6048
6266
  if (this.channelRegistry) {
6049
6267
  channelTools.push(AgentFramework.DRAFTS_TOOL);
6050
6268
  }
6051
- if (this.tuneOutCoordinator) {
6052
- channelTools.push(AgentFramework.TUNE_OUT_TOOL);
6269
+ if (this.tuneOutCoordinator && this.tuneOutOffered) {
6270
+ channelTools.push(AgentFramework.TUNE_OUT_TOOL, AgentFramework.FOCUS_TOOL);
6053
6271
  }
6054
6272
  const gateTools = this.eventGate
6055
6273
  ? [
@@ -6438,7 +6656,7 @@ export class AgentFramework {
6438
6656
  tunedOutSendNote(agentName, channel) {
6439
6657
  try {
6440
6658
  const registry = this.channelRegistry;
6441
- if (!registry || this.conversationAgentHomes.has(agentName))
6659
+ if (!registry || this.registry.homeChannel(agentName) !== undefined)
6442
6660
  return undefined;
6443
6661
  if (registry.getDesiredState(channel.serverId, channel.channelId) !== 'tuned-out')
6444
6662
  return undefined;
@@ -6473,7 +6691,7 @@ export class AgentFramework {
6473
6691
  * delivered, so that's logged and nothing is added. */
6474
6692
  closedChannelNotes(agentName, channels) {
6475
6693
  try {
6476
- if (this.conversationAgentHomes.has(agentName))
6694
+ if (this.registry.homeChannel(agentName) !== undefined)
6477
6695
  return [];
6478
6696
  const notes = [...channels].flatMap((channel) => this.closedChannelNoteFor(agentName, channel) ?? []);
6479
6697
  // The setting is read only when there's a note to give.
@@ -6694,7 +6912,13 @@ export class AgentFramework {
6694
6912
  : t);
6695
6913
  return [...SUBCONSCIOUS_TOOLS, ...basics];
6696
6914
  }
6697
- return [...this.getAllTools(), ...(this.toolPresentations.has(agentName) ? presentationTools(this.toolPresentations.get(agentName).config.cataloguePath) : [])]
6915
+ const surfaceOwner = this.toolSurfaceOwners.get(agentName) ?? agentName;
6916
+ // Reader forks (subconscious.reader: 'forks') share the resident's
6917
+ // provider prefix, and the tool block is the front of it: the reader's
6918
+ // four tools are therefore in every agent's block and refused at
6919
+ // dispatch for everyone but a reader (see dispatchToolCall).
6920
+ const readerTools = this.readerForks ? SUBCONSCIOUS_TOOLS : [];
6921
+ return [...this.getAllTools(), ...readerTools, ...(this.toolPresentations.has(surfaceOwner) ? presentationTools(this.toolPresentations.get(surfaceOwner).config.cataloguePath) : [])]
6698
6922
  .map((tool) => {
6699
6923
  if (tool.name === 'think') {
6700
6924
  return this.buildThinkTool(snapshot?.sameRoundThinkTextPolicy
@@ -6720,7 +6944,9 @@ export class AgentFramework {
6720
6944
  return tools;
6721
6945
  }
6722
6946
  inspectToolPresentation(agentName, snapshot) {
6723
- const presentation = this.toolPresentations.get(agentName);
6947
+ // A derived agent shows its parent's presentation (see toolSurfaceOwners);
6948
+ // only the owner can edit it (editToolPresentation looks up by own name).
6949
+ const presentation = this.toolPresentations.get(this.toolSurfaceOwners.get(agentName) ?? agentName);
6724
6950
  if (!presentation)
6725
6951
  return null;
6726
6952
  const tools = this.availableToolsForPresentation(agentName, snapshot);
@@ -8017,6 +8243,16 @@ export class AgentFramework {
8017
8243
  }
8018
8244
  this.deferredMessages.push(...restored);
8019
8245
  this.deferredWritesPersisted = true;
8246
+ // A tune-out release that was still deferred at the crash owes the
8247
+ // resident the wake its cancel requested (#232 gap 6).
8248
+ for (const m of restored) {
8249
+ if (m.metadata?.kind !== 'tune-out-cancel')
8250
+ continue;
8251
+ const target = m.forAgent ?? this.primaryAgentName;
8252
+ if (target) {
8253
+ this.pendingRequests.push({ agentName: target, reason: 'tune-out released (restored)', source: 'tune-out', timestamp: Date.now() });
8254
+ }
8255
+ }
8020
8256
  // Rewrite the durable queue without the already-landed entries so a
8021
8257
  // second interrupted boot does not re-scan them, without the malformed
8022
8258
  // and repeated ones, and with a seq for each entry that came without a
@@ -8847,7 +9083,16 @@ export class AgentFramework {
8847
9083
  * ContextManager, before the store. Until then the ContextManager stays
8848
9084
  * open.
8849
9085
  */
8850
- async createEphemeralAgent(config) {
9086
+ async createEphemeralAgent(config,
9087
+ /**
9088
+ * How the registry should describe this agent. Omitted: an independent
9089
+ * fresh worker with no spawner and no result route, which is what every
9090
+ * caller of this method got before the registry existed. Supplying a
9091
+ * spec (see the presets in `dendrite/`) declares who spawned it, where
9092
+ * its result goes and what it inherited, so lifecycle rules — orphan
9093
+ * notice, reparenting, held results — apply to it.
9094
+ */
9095
+ spec) {
8851
9096
  // Checked before anything happens: the constructor checks too, but only
8852
9097
  // after the name is reserved and ContextManager.open has registered the
8853
9098
  // agent's message and context states in the store.
@@ -8860,22 +9105,76 @@ export class AgentFramework {
8860
9105
  }
8861
9106
  this.usedEphemeralAgentNames.set(config.name, 'run');
8862
9107
  const release = { released: false, calls: new Set() };
9108
+ let opened;
8863
9109
  try {
8864
- const namespace = `subagent/${config.name}`;
8865
- const contextManager = await ContextManager.open({
8866
- store: this.store,
8867
- namespace,
8868
- isolate: true,
8869
- strategy: config.strategy ?? new PassthroughStrategy(),
8870
- membrane: this.ephemeralAuxiliaryMembraneFor(config.name, release),
8871
- debugLogContext: !!process.env.DEBUG_CONTEXT,
8872
- });
9110
+ // A namespace the spec names is where the context opens, as it is what
9111
+ // the record says and what inspection and resumption open later.
9112
+ let namespace = spec?.namespace ?? `subagent/${config.name}`;
9113
+ let contextManager;
9114
+ // The host's say, on the spec of what is about to exist.
9115
+ if (this.beforeDerive) {
9116
+ const effective = spec
9117
+ ? { ...spec, name: config.name, model: config.model }
9118
+ : { ...workerSpec(config.name, { namespace }), model: config.model };
9119
+ const verdict = this.beforeDerive(effective);
9120
+ if (verdict && typeof verdict === 'object' && 'refuse' in verdict) {
9121
+ throw new Error(`${config.name} was not created: ${verdict.refuse}`);
9122
+ }
9123
+ }
9124
+ let inherit = spec?.inherit;
9125
+ if (inherit?.mode === 'shared') {
9126
+ // Shared reads, separate writes: the child's context is the parent's
9127
+ // at a checkpoint, on a branch of its own. Nothing is copied.
9128
+ const derived = await this.deriveSharedContext(config, inherit, release);
9129
+ contextManager = derived.contextManager;
9130
+ inherit = derived.inherit;
9131
+ namespace = derived.namespace;
9132
+ }
9133
+ else {
9134
+ // A namespace belongs to the agent that opened it: another agent's
9135
+ // turns there would be read as its own by inspection and by its
9136
+ // resumption, which reopens "its own namespace". So one another
9137
+ // agent's context holds (live, a candidate, awaiting re-declaration
9138
+ // or ended) is refused; an agent's own reopens in a new incarnation.
9139
+ const holder = this.namespaceHolder(namespace, config.name);
9140
+ if (holder) {
9141
+ // The default is offered only where it isn't the namespace refused.
9142
+ const fallback = `subagent/${config.name}`;
9143
+ throw new Error(`Ephemeral agent "${config.name}" refused: namespace "${namespace}" holds the context of agent ` +
9144
+ `"${holder}"; name another${namespace === fallback ? '' : `, or none for ${fallback}`}`);
9145
+ }
9146
+ this.namespaceOwners.set(namespace, config.name);
9147
+ opened = namespace;
9148
+ contextManager = await ContextManager.open({
9149
+ store: this.store,
9150
+ namespace,
9151
+ isolate: true,
9152
+ strategy: config.strategy ?? new PassthroughStrategy(),
9153
+ membrane: this.ephemeralAuxiliaryMembraneFor(config.name, release),
9154
+ debugLogContext: !!process.env.DEBUG_CONTEXT,
9155
+ });
9156
+ }
9157
+ // Either manager is the run's to release (main's manager release), the derived one included.
8873
9158
  this.trackManager(contextManager);
8874
9159
  release.manager = contextManager;
8875
9160
  const agent = new Agent(config, contextManager, this.membrane);
8876
9161
  this.adoptToolResultGuard(agent);
8877
9162
  agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
8878
- this.ephemeralCandidates.set(agent, contextManager);
9163
+ this.ephemeralCandidates.set(agent, {
9164
+ contextManager,
9165
+ config,
9166
+ spec: spec
9167
+ ? {
9168
+ ...spec,
9169
+ name: config.name,
9170
+ model: config.model,
9171
+ // A shared fork's context lives under its parent's namespace,
9172
+ // on the fork's own branch — not in a namespace of its own.
9173
+ namespace: inherit?.mode === 'shared' ? namespace : spec.namespace ?? namespace,
9174
+ ...(inherit ? { inherit } : {}),
9175
+ }
9176
+ : { ...workerSpec(config.name, { namespace }), model: config.model },
9177
+ });
8879
9178
  this.ephemeralAgents.add(agent);
8880
9179
  this.managerReleases.set(agent, release);
8881
9180
  const cleanup = () => {
@@ -8888,8 +9187,1371 @@ export class AgentFramework {
8888
9187
  if (release.manager)
8889
9188
  this.closeManager(release.manager);
8890
9189
  this.usedEphemeralAgentNames.delete(config.name);
9190
+ if (opened !== undefined)
9191
+ this.namespaceOwners.delete(opened);
9192
+ throw error;
9193
+ }
9194
+ }
9195
+ /**
9196
+ * Derive a new agent from a live one: the parent's context at a
9197
+ * checkpoint, a private continuation, and — unless overridden — the
9198
+ * parent's whole configuration (model, thinking, cache TTL, provider
9199
+ * params, prompt, tools). The parent keeps running.
9200
+ *
9201
+ * The returned agent is a bounded job, driven with
9202
+ * `runEphemeralToCompletion` exactly like one from `createEphemeralAgent`.
9203
+ *
9204
+ * **Inheritance.** `mode: 'shared'` (the default) gives the child the
9205
+ * parent's messages and fold state through shared storage: nothing is
9206
+ * copied, the child folds with the parent's memory tree, and neither can
9207
+ * alter what the other reads. It needs branch-bound store handles and is
9208
+ * refused, not silently downgraded, where they are missing. `mode: 'copy'`
9209
+ * is the historical mechanism — the parent's compiled context re-added as
9210
+ * the child's own messages — and works everywhere.
9211
+ *
9212
+ * **Rendering.** With `solve: 'reuse'` (the default) and nothing
9213
+ * overridden, the child's first request is the parent's request plus the
9214
+ * child's framing: the same system prompt and tool block, the parent's
9215
+ * turns presented as the child's own, and a cache marker on the end of
9216
+ * the parent's request so the prefix it wrote is read back. `solve:
9217
+ * 'fresh'` deliberately re-solves, e.g. at another budget; so does any
9218
+ * override that changes what is rendered. Those are operating choices —
9219
+ * the presentation changes and the provider cache misses.
9220
+ *
9221
+ * **Framing** is ordinary messages appended to the child before it runs:
9222
+ * the task, and whatever tells it which stream it is. The parent's turn
9223
+ * in flight is not in its store yet (see `getPendingAssistantRound`), so
9224
+ * a fork made from inside a tool call starts from the message before the
9225
+ * turn that made it.
9226
+ *
9227
+ * Context isolation is not process or filesystem isolation: parent and
9228
+ * child share tools, any shell and the workspace.
9229
+ */
9230
+ async deriveAgent(options) {
9231
+ const parent = this.agents.get(options.from);
9232
+ const parentConfig = this.agentConfigs.get(options.from);
9233
+ if (!parent || !parentConfig) {
9234
+ throw new Error(`deriveAgent: "${options.from}" is not a registered agent`);
9235
+ }
9236
+ const mode = options.mode ?? 'shared';
9237
+ const shared = mode === 'shared';
9238
+ if (!shared && options.inheritRefusals === true) {
9239
+ throw new Error(`deriveAgent: "${options.name}" was not created: inheritRefusals is for mode 'shared', and a copy brings no refusal ledger`);
9240
+ }
9241
+ if (!shared && options.solve !== undefined) {
9242
+ throw new Error(`deriveAgent: "${options.name}" was not created: solve is for mode 'shared', and a copy is not solved`);
9243
+ }
9244
+ const config = {
9245
+ ...parentConfig,
9246
+ // The parent's LIVE settings, not just its recipe: a runtime budget or
9247
+ // think-policy override changes what it renders.
9248
+ ...(parent.contextBudgetTokens !== undefined ? { contextBudgetTokens: parent.contextBudgetTokens } : {}),
9249
+ sameRoundThinkTextPolicy: parent.getEffectiveSameRoundThinkTextPolicy(),
9250
+ ...options.config,
9251
+ name: options.name,
9252
+ strategy: options.strategy,
9253
+ // A shared inheritance has no copy step to rename the parent's turns,
9254
+ // so the mapping is declared: the parent's voice is the child's own,
9255
+ // presented under the name the parent's own requests use.
9256
+ ...(shared
9257
+ ? {
9258
+ selfParticipants: [...new Set([
9259
+ parent.name,
9260
+ ...(parentConfig.selfParticipants ?? []),
9261
+ ...(options.config?.selfParticipants ?? []),
9262
+ ])],
9263
+ presentAs: options.config?.presentAs ?? parent.presentAs,
9264
+ }
9265
+ : { selfParticipants: options.config?.selfParticipants, presentAs: options.config?.presentAs }),
9266
+ };
9267
+ const spec = taskForkSpec(options.name, {
9268
+ parent: options.from,
9269
+ spawnedBy: options.spawnedBy ?? options.from,
9270
+ ...(options.resultTo ? { resultTo: options.resultTo } : {}),
9271
+ ...(options.deadlineMs !== undefined ? { deadlineMs: options.deadlineMs } : {}),
9272
+ ...(options.idleTimeoutMs !== undefined ? { idleTimeoutMs: options.idleTimeoutMs } : {}),
9273
+ ...(options.maxTurns !== undefined ? { maxTurns: options.maxTurns } : {}),
9274
+ ...(options.metadata ? { metadata: options.metadata } : {}),
9275
+ inherit: {
9276
+ mode,
9277
+ ...(options.solve ? { solve: options.solve } : {}),
9278
+ ...(options.inheritRefusals !== undefined ? { refusals: options.inheritRefusals } : {}),
9279
+ },
9280
+ });
9281
+ const { name: _name, ...specBody } = {
9282
+ ...spec,
9283
+ // The record states the mapping the agent's requests actually use.
9284
+ ...(config.selfParticipants?.length ? { selfParticipants: config.selfParticipants } : {}),
9285
+ ...(config.presentAs && config.presentAs !== config.name ? { presentAs: config.presentAs } : {}),
9286
+ ...(options.kind ? { kind: options.kind } : {}),
9287
+ ...(options.onParentEnd ? { onParentEnd: options.onParentEnd } : {}),
9288
+ };
9289
+ const created = await this.createEphemeralAgent(config, specBody);
9290
+ try {
9291
+ if (this.agents.get(options.from) !== parent) {
9292
+ throw new Error(`deriveAgent: "${options.from}" ended while "${options.name}" was being derived from it`);
9293
+ }
9294
+ if (options.config?.allowedTools === undefined) {
9295
+ this.toolSurfaceOwners.set(options.name, this.toolSurfaceOwners.get(options.from) ?? options.from);
9296
+ }
9297
+ if (!shared) {
9298
+ // The historical copy: the parent's compiled view, its turns renamed
9299
+ // so the child reads its inheritance as its own history.
9300
+ const { messages } = await parent.getContextManager().compile();
9301
+ for (const message of messages) {
9302
+ created.contextManager.addMessage(parent.isOwnParticipant(message.participant) ? options.name : message.participant, message.content);
9303
+ }
9304
+ created.agent.markContextConsumed();
9305
+ }
9306
+ await this.framePendingRound(parent, created.contextManager, options, shared);
9307
+ for (const message of options.framing ?? []) {
9308
+ created.contextManager.addMessage(message.participant, message.content, message.metadata);
9309
+ }
9310
+ return created;
9311
+ }
9312
+ catch (error) {
9313
+ this.discardEphemeralCandidate(created.agent, created.contextManager);
9314
+ throw error;
9315
+ }
9316
+ }
9317
+ discardEphemeralCandidate(agent, contextManager) {
9318
+ this.ephemeralCandidates.delete(agent);
9319
+ this.ephemeralAgents.delete(agent);
9320
+ if (this.toolSurfaceOwners.has(agent.name) && !this.agents.has(agent.name))
9321
+ this.toolSurfaceOwners.delete(agent.name);
9322
+ this.releaseManager(agent);
9323
+ this.managerReleases.delete(agent);
9324
+ this.closeManager(contextManager);
9325
+ }
9326
+ /**
9327
+ * The default wording for the call that derived a child: who it is, what
9328
+ * kind, and how long — the fields the consent event names, so the fork
9329
+ * never has to infer its own lifetime from context (Linn, 10-09).
9330
+ */
9331
+ derivedNote(parentName, options) {
9332
+ const record = this.registry.inspect(options.name);
9333
+ const kind = record?.kind ?? options.kind ?? 'task-fork';
9334
+ const onParentEnd = record?.onParentEnd ?? options.onParentEnd ?? 'orphan';
9335
+ const route = record?.relationships.resultTo ?? options.resultTo;
9336
+ const returns = route
9337
+ ? route.as === 'message'
9338
+ ? `your report returns to ${route.to} as mail under your own name`
9339
+ : `your result returns to ${route.to} as this call's result`
9340
+ : null;
9341
+ const lifetime = onParentEnd === 'end'
9342
+ ? `You end when ${parentName} does${returns ? `; ${returns}` : ''}; nothing you do speaks as ${parentName}.`
9343
+ : `You finish your task and then end${returns ? `; ${returns}` : ''}; if ${parentName} ends first you keep running, as an orphan with the same return address.`;
9344
+ return (`[This call derived ${options.name}. You are ${options.name}, a ${kind}, continuing from here on your own branch. ` +
9345
+ `${lifetime} ${parentName} receives this call's result, not you.]`);
9346
+ }
9347
+ /**
9348
+ * The parent's in-flight tool round, reproduced on the child's branch so
9349
+ * the child sees the call that made it (see `DeriveAgentOptions.pendingRound`).
9350
+ */
9351
+ async framePendingRound(parent, child, options, shared) {
9352
+ const round = options.pendingRound ?? {};
9353
+ if (round.include === false)
9354
+ return;
9355
+ const pending = this.pendingAssistantBlocks.get(parent.name);
9356
+ if (!pending)
9357
+ return;
9358
+ const calls = pending.filter((b) => b.type === 'tool_use');
9359
+ if (calls.length === 0)
9360
+ return;
9361
+ const state = parent.state;
9362
+ const completed = state.status === 'waiting_for_tools' ? state.completed : [];
9363
+ const cap = this.resolveToolResultInlineCap(parent).cap;
9364
+ const { blocks: received } = await this.buildStoredToolResultContent(options.name, completed, cap);
9365
+ const byId = new Map(received.map((b) => [b.toolUseId, b]));
9366
+ const open = calls.filter((c) => !byId.has(c.id));
9367
+ const madeBy = round.madeBy?.toolUseId ?? (open.length === 1 ? open[0].id : undefined);
9368
+ const results = calls.map((call) => {
9369
+ const stored = byId.get(call.id);
9370
+ if (stored)
9371
+ return stored;
9372
+ const content = call.id === madeBy
9373
+ ? round.madeBy?.result ?? this.derivedNote(parent.name, options)
9374
+ : round.results?.[call.id] ??
9375
+ `[No result yet: this call was still running in ${parent.name} when ${options.name} was derived.]`;
9376
+ return { type: 'tool_result', toolUseId: call.id, toolName: call.name, content, isError: false };
9377
+ });
9378
+ const metadata = { kind: 'dendrite-pending-round', from: parent.name };
9379
+ child.addMessage(shared ? parent.name : options.name, structuredClone(pending), metadata);
9380
+ child.addMessage('user', results, metadata);
9381
+ }
9382
+ /**
9383
+ * The assistant turn an agent has produced but not yet stored: its tool
9384
+ * calls and any text before them, held until every tool result is in so
9385
+ * the pair lands adjacently. A host deriving an agent from inside one of
9386
+ * those tool calls can reproduce the turn in the child's framing.
9387
+ */
9388
+ getPendingAssistantRound(agentName) {
9389
+ const blocks = this.pendingAssistantBlocks.get(agentName);
9390
+ return blocks ? structuredClone(blocks) : undefined;
9391
+ }
9392
+ /**
9393
+ * Bring an ended bounded job back as a new incarnation of the same
9394
+ * identity, on the context it left: an isolated agent's own namespace, or
9395
+ * a derived agent's own branch. Nothing is re-derived and nothing is
9396
+ * re-seeded — the agent continues from its last stored message, which is
9397
+ * the end of its last complete round (a round whose tool results never
9398
+ * all arrived was never stored).
9399
+ *
9400
+ * Use it for a job that was stopped, failed, or was interrupted by a
9401
+ * restart. Configuration is not persisted with an agent, so the caller
9402
+ * supplies it again; the role mapping a derived agent used is restored
9403
+ * from its record unless the config sets one. Relationships are kept
9404
+ * where the other agent still exists; a job whose spawner or result
9405
+ * recipient is gone resumes without that edge and can be reparented.
9406
+ *
9407
+ * External effects are not replayed and not undone: a tool the previous
9408
+ * incarnation ran before it ended has had its effect, and a call it never
9409
+ * received a result for may be made again by the model. Drive the result
9410
+ * with `runEphemeralToCompletion`.
9411
+ */
9412
+ async resumeAgent(name, config) {
9413
+ if (this.resumesPending.has(name)) {
9414
+ throw new Error(`resumeAgent: "${name}" is already being resumed`);
9415
+ }
9416
+ if (this.registry.has(name) || this.agents.has(name) || this.ephemeralRuns.has(name)) {
9417
+ throw new Error(`resumeAgent: "${name}" is still registered; only an ended agent resumes`);
9418
+ }
9419
+ const record = this.registry.inspect(name);
9420
+ if (!record?.ended)
9421
+ throw new Error(`resumeAgent: "${name}" is not a known ended agent`);
9422
+ if (record.lifetime.kind !== 'task') {
9423
+ throw new Error(`resumeAgent: "${name}" has a ${record.lifetime.kind} lifetime; only bounded jobs resume this way`);
9424
+ }
9425
+ const fullConfig = {
9426
+ ...config,
9427
+ name,
9428
+ ...(config.selfParticipants === undefined && record.selfParticipants
9429
+ ? { selfParticipants: record.selfParticipants }
9430
+ : {}),
9431
+ ...(config.presentAs === undefined && record.presentAs ? { presentAs: record.presentAs } : {}),
9432
+ };
9433
+ const spawnedBy = record.relationships.spawnedBy;
9434
+ const resultTo = record.relationships.resultTo;
9435
+ const keepSpawner = spawnedBy !== undefined && this.registry.has(spawnedBy);
9436
+ const spec = {
9437
+ name,
9438
+ kind: record.kind,
9439
+ model: fullConfig.model,
9440
+ roles: record.roles,
9441
+ lifetime: record.lifetime,
9442
+ ...(record.activation ? { activation: record.activation } : {}),
9443
+ ...(keepSpawner ? { spawnedBy, onParentEnd: record.onParentEnd } : {}),
9444
+ ...(resultTo && this.registry.has(resultTo.to) ? { resultTo } : {}),
9445
+ ...(record.inherit ? { inherit: record.inherit } : {}),
9446
+ ...(fullConfig.selfParticipants?.length ? { selfParticipants: fullConfig.selfParticipants } : {}),
9447
+ ...(fullConfig.presentAs && fullConfig.presentAs !== name ? { presentAs: fullConfig.presentAs } : {}),
9448
+ ...(record.namespace ? { namespace: record.namespace } : {}),
9449
+ ...(record.metadata ? { metadata: record.metadata } : {}),
9450
+ };
9451
+ if (this.beforeDerive) {
9452
+ const verdict = this.beforeDerive(spec);
9453
+ if (verdict && typeof verdict === 'object' && 'refuse' in verdict) {
9454
+ throw new Error(`${name} was not resumed: ${verdict.refuse}`);
9455
+ }
9456
+ }
9457
+ this.resumesPending.set(name, undefined);
9458
+ const release = { released: false, calls: new Set() };
9459
+ let contextManager;
9460
+ try {
9461
+ contextManager = await this.reopenAgentContext(record, config.strategy, this.ephemeralAuxiliaryMembraneFor(name, release));
9462
+ }
9463
+ catch (error) {
9464
+ this.resumesPending.delete(name);
8891
9465
  throw error;
8892
9466
  }
9467
+ this.trackManager(contextManager);
9468
+ release.manager = contextManager;
9469
+ let agent;
9470
+ try {
9471
+ if (this.registry.has(name) || this.agents.has(name) || this.ephemeralRuns.has(name)) {
9472
+ throw new Error(`resumeAgent: "${name}" was registered while it was being resumed`);
9473
+ }
9474
+ agent = new Agent(fullConfig, contextManager, this.membrane);
9475
+ this.adoptToolResultGuard(agent);
9476
+ agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
9477
+ }
9478
+ catch (error) {
9479
+ this.resumesPending.delete(name);
9480
+ this.closeManager(contextManager);
9481
+ throw error;
9482
+ }
9483
+ this.usedEphemeralAgentNames.set(name, 'run'); // a resumed job is an ephemeral run: no global fallback (takesGlobalFallback)
9484
+ this.ephemeralAgents.add(agent); // and its usage counts in the shared ephemeral entry (#558)
9485
+ this.ephemeralCandidates.set(agent, { contextManager, config: fullConfig, resume: true, after: record.incarnation, spec });
9486
+ this.managerReleases.set(agent, release);
9487
+ this.resumesPending.set(name, agent);
9488
+ const cleanup = () => {
9489
+ if (this.resumesPending.get(name) === agent)
9490
+ this.resumesPending.delete(name);
9491
+ this.releasedByCleanup.add(agent);
9492
+ this.releaseManager(agent);
9493
+ };
9494
+ return { agent, contextManager, cleanup };
9495
+ }
9496
+ /**
9497
+ * A context to read an agent's history from, without changing the live
9498
+ * system.
9499
+ *
9500
+ * - A live agent: a snapshot derived from its context — at the head, or
9501
+ * at `atSequence` for an earlier state. The snapshot is its own branch;
9502
+ * the agent keeps running and is not affected by anything done to it.
9503
+ * - An ended agent: the context it left, reopened where it lives.
9504
+ *
9505
+ * The returned manager uses a passthrough strategy: it shows what is
9506
+ * stored, raw. An ended agent's context is the real one — treat it as
9507
+ * read-only unless you mean to change what a later resume starts from.
9508
+ */
9509
+ async inspectAgentContext(name, options = {}) {
9510
+ const live = this.registry.has(name) ? this.agents.get(name) : undefined;
9511
+ if (live) {
9512
+ const source = live.getContextManager();
9513
+ if (typeof source.derive !== 'function') {
9514
+ throw new Error('inspectAgentContext: a snapshot of a live agent needs a @animalabs/context-manager that provides ContextManager.derive');
9515
+ }
9516
+ const taken = new Set(this.store.listBranches().map((branch) => branch.name));
9517
+ let n = 1;
9518
+ while (taken.has(`dendrite/inspect/${name}/${n}`))
9519
+ n++;
9520
+ return source.derive({
9521
+ branch: `dendrite/inspect/${name}/${n}`,
9522
+ strategy: new PassthroughStrategy(),
9523
+ membrane: this.membrane,
9524
+ solve: 'fresh',
9525
+ ...(options.atSequence !== undefined ? { atSequence: options.atSequence } : {}),
9526
+ });
9527
+ }
9528
+ const record = this.registry.inspect(name);
9529
+ if (!record)
9530
+ throw new Error(`inspectAgentContext: "${name}" is not a known agent`);
9531
+ if (options.atSequence !== undefined) {
9532
+ throw new Error('inspectAgentContext: atSequence applies to a live agent; an ended agent is read as it was left');
9533
+ }
9534
+ return this.reopenAgentContext(record, new PassthroughStrategy());
9535
+ }
9536
+ /** Open the context an ended agent left, wherever it lives. */
9537
+ async reopenAgentContext(record, strategy, membrane = this.membrane) {
9538
+ // The residents' view as createAgent gives it: held rows out, and another
9539
+ // agent's stored context injections out, since those are that agent's
9540
+ // context, not conversation (Weft's ninth-round probe: an ended reader
9541
+ // reopened for inspection showed a co-resident's injection it never saw live).
9542
+ const residentSees = (name) => (metadata) => RESIDENT_VIEW_FILTER({ metadata }) && !isAnotherAgentsInjection(metadata, name);
9543
+ // A context derived from a reader of the shared slot sees, as it did live
9544
+ // (deriveSharedContext, #291), what its source saw and its own stored
9545
+ // injections; its source's view is composed the same way, up the shared
9546
+ // sources the registry names, to the reader the first was derived from.
9547
+ const derivedSees = (from, walked = new Set()) => {
9548
+ const source = from.inherit?.mode === 'shared' ? from.inherit.from : undefined;
9549
+ if (source === undefined || walked.has(from.name))
9550
+ return residentSees(from.name);
9551
+ walked.add(from.name);
9552
+ const sourceRecord = this.registry.inspect(source);
9553
+ const sourceSees = sourceRecord ? derivedSees(sourceRecord, walked) : residentSees(source);
9554
+ return (metadata) => sourceSees(metadata) || isOwnInjection(metadata, from.name);
9555
+ };
9556
+ // The predicate is the manager's view filter, and is recorded beside it,
9557
+ // so main's checks of whether an agent sees a row (agentSees) ask what its
9558
+ // view holds when the reopened context is live.
9559
+ const seen = (contextManager, sees) => {
9560
+ this.storeSees.set(contextManager, sees);
9561
+ return contextManager;
9562
+ };
9563
+ if (record.inherit?.mode === 'shared') {
9564
+ const derivation = record.inherit.derivation;
9565
+ const reopen = ContextManager.reopenDerived;
9566
+ if (!derivation || typeof reopen !== 'function') {
9567
+ throw new Error(`Agent "${record.name}": its context is a derived branch, which this ` +
9568
+ '@animalabs/context-manager cannot reopen (ContextManager.reopenDerived)');
9569
+ }
9570
+ // A context derived from a reader of the shared slot keeps the view it had.
9571
+ const sees = derivation.slots?.messageNamespace === null ? derivedSees(record) : undefined;
9572
+ const reopened = await reopen.call(ContextManager, {
9573
+ store: this.store,
9574
+ derivation,
9575
+ strategy: strategy ?? new PassthroughStrategy(),
9576
+ membrane,
9577
+ ...(sees ? { viewFilter: (message) => sees(message.metadata) } : {}),
9578
+ debugLogContext: !!process.env.DEBUG_CONTEXT,
9579
+ });
9580
+ return sees ? seen(reopened, sees) : reopened;
9581
+ }
9582
+ if (!record.namespace) {
9583
+ throw new Error(`Agent "${record.name}": its record names no context to reopen`);
9584
+ }
9585
+ // A reader of the residents' shared slot (a resident dropped from the
9586
+ // configuration, say) is reopened as createAgent opened it: on the
9587
+ // shared slot, through the residents' view. Isolated, its namespace
9588
+ // holds none of what it read (nissa's #546 review, round 2).
9589
+ const sees = record.readsSharedSlot === true ? residentSees(record.name) : undefined;
9590
+ const opened = await ContextManager.open({
9591
+ store: this.store,
9592
+ namespace: record.namespace,
9593
+ ...(sees ? { viewFilter: (message) => sees(message.metadata) } : { isolate: true }),
9594
+ strategy: strategy ?? new PassthroughStrategy(),
9595
+ membrane,
9596
+ debugLogContext: !!process.env.DEBUG_CONTEXT,
9597
+ });
9598
+ return sees ? seen(opened, sees) : opened;
9599
+ }
9600
+ /** Open a derived agent's context on a branch of its own (see deriveAgent). */
9601
+ async deriveSharedContext(config, inherit, release) {
9602
+ const parent = this.agents.get(inherit.from);
9603
+ if (!parent) {
9604
+ throw new Error(`Agent "${config.name}": context source "${inherit.from}" is not a registered agent`);
9605
+ }
9606
+ if (this.surgeryHold) {
9607
+ throw new Error(`Agent "${config.name}" refused: the store is under live ${this.surgeryHold.verb} ` +
9608
+ `for ${this.surgeryHold.agentName} — retry when it completes`);
9609
+ }
9610
+ const source = parent.getContextManager();
9611
+ if (typeof source.derive !== 'function') {
9612
+ throw new Error(`Agent "${config.name}": shared context inheritance needs a @animalabs/context-manager ` +
9613
+ 'that provides ContextManager.derive; use inherit.mode "copy" with this version');
9614
+ }
9615
+ // The branch is the child's durable state and outlives it. Names from an
9616
+ // earlier process may still own a branch of the same name.
9617
+ const taken = new Set(this.store.listBranches().map((branch) => branch.name));
9618
+ let ownBranch = `dendrite/${config.name}`;
9619
+ for (let n = 2; taken.has(ownBranch); n++)
9620
+ ownBranch = `dendrite/${config.name}~${n}`;
9621
+ // The fork's view: what its source sees, and its own stored context
9622
+ // injections. context-manager's derive would give it the source's filter
9623
+ // alone, which is tied to the source's name (isAnotherAgentsInjection), so
9624
+ // the fork's own injections were hidden from its compile while its
9625
+ // parent's showed (#291; Weft's eleventh note, on the real compile path).
9626
+ // The composed predicate goes to derive, so the first compile already
9627
+ // reads through it, and is recorded beside the manager (storeSees), so
9628
+ // main's checks of whether an agent sees a row (agentSees: a tool
9629
+ // boundary's injection, an arrival, a reader of an unread message) ask
9630
+ // what its compile shows. A source with no view recorded has no filter
9631
+ // (an isolated run's): it sees every row, so its fork keeps none, and
9632
+ // sees its own.
9633
+ const sourceSees = this.storeSees.get(source);
9634
+ const sees = sourceSees
9635
+ ? (metadata) => sourceSees(metadata) || isOwnInjection(metadata, config.name)
9636
+ : undefined;
9637
+ const contextManager = await source.derive({
9638
+ branch: ownBranch,
9639
+ strategy: config.strategy ?? new PassthroughStrategy(),
9640
+ // The run's auxiliary membrane, as the copy path's: its compression calls
9641
+ // are withdrawn once the run is released (Weft's ninth-round reading).
9642
+ membrane: this.ephemeralAuxiliaryMembraneFor(config.name, release),
9643
+ solve: inherit.solve ?? 'reuse',
9644
+ inherit: { refusals: inherit.refusals === true },
9645
+ ...(inherit.atSequence !== undefined ? { atSequence: inherit.atSequence } : {}),
9646
+ ...(sees ? { viewFilter: (message) => sees(message.metadata) } : {}),
9647
+ debugLogContext: !!process.env.DEBUG_CONTEXT,
9648
+ });
9649
+ if (sees)
9650
+ this.storeSees.set(contextManager, sees);
9651
+ const derivation = contextManager.getDerivation?.();
9652
+ return {
9653
+ contextManager,
9654
+ inherit: {
9655
+ ...inherit,
9656
+ solve: inherit.solve ?? 'reuse',
9657
+ refusals: inherit.refusals === true,
9658
+ ownBranch,
9659
+ ...(derivation
9660
+ ? { branch: derivation.parentBranch, atSequence: derivation.atSequence, derivation }
9661
+ : {}),
9662
+ },
9663
+ namespace: this.registry.get(inherit.from)?.namespace ?? `subagent/${config.name}`,
9664
+ };
9665
+ }
9666
+ // ==========================================================================
9667
+ // Dendrite: agent registry, lifecycle and attributed communication
9668
+ // ==========================================================================
9669
+ //
9670
+ // One trust domain: there is no permission check on any of this. Any
9671
+ // caller may list, cancel, stop or reparent any agent. Context isolation
9672
+ // between agents is not process or filesystem isolation — agents sharing
9673
+ // tools, a shell or a workspace can affect the same external state.
9674
+ /** Flat list of registered agents with their roles, lifetime and relationships. */
9675
+ listAgents(options = {}) {
9676
+ return structuredClone(this.registry.list(options));
9677
+ }
9678
+ /** One agent's record: the live one, else its most recent ended one. */
9679
+ getAgentRecord(name) {
9680
+ const record = this.registry.inspect(name);
9681
+ return record ? structuredClone(record) : null;
9682
+ }
9683
+ /** The agent that owns default delivery, or null before any is declared. */
9684
+ getPrimaryAgentName() {
9685
+ return this.registry.primary();
9686
+ }
9687
+ /**
9688
+ * Activations in flight, each with the agent that owns the work. Derived
9689
+ * from the live turn state, so it cannot disagree with what is running.
9690
+ */
9691
+ listActivations() {
9692
+ const activations = [];
9693
+ for (const [name, token] of this.activeTurnTokens) {
9694
+ const record = this.registry.get(name);
9695
+ if (!record)
9696
+ continue;
9697
+ const trigger = this.activeTurnTriggers.get(name);
9698
+ activations.push({
9699
+ id: `${name}#${record.incarnation}:${token}`,
9700
+ owner: { agent: name, incarnation: record.incarnation },
9701
+ ...(trigger?.timestamp !== undefined ? { requestedAt: trigger.timestamp } : {}),
9702
+ ...(trigger?.reason ? { reason: trigger.reason } : {}),
9703
+ ...(trigger?.source ? { source: trigger.source } : {}),
9704
+ ...(trigger?.channelId ? { channelId: trigger.channelId } : {}),
9705
+ });
9706
+ }
9707
+ return activations;
9708
+ }
9709
+ /**
9710
+ * Policy epochs currently in force (tune-out holds), read from the
9711
+ * coordinator's own durable state — not mirrored. An epoch is neither an
9712
+ * agent nor a task: ending one leaves both running.
9713
+ */
9714
+ listPolicyEpochs() {
9715
+ if (!this.channelRegistry)
9716
+ return [];
9717
+ const owner = this.registry.primary();
9718
+ const epochs = [];
9719
+ for (const entry of this.channelRegistry.listChannelsRaw()) {
9720
+ const state = this.channelRegistry.getTuneOutState(entry.serverId, entry.descriptor.id);
9721
+ if (!state)
9722
+ continue;
9723
+ epochs.push({
9724
+ id: state.params.epochId,
9725
+ kind: 'tune-out',
9726
+ scope: { serverId: entry.serverId, channelId: entry.descriptor.id },
9727
+ ...(owner ? { owner } : {}),
9728
+ ...(this.subconsciousAgentName ? { reader: this.subconsciousAgentName } : {}),
9729
+ startedAtSequence: state.params.startedAtSequence,
9730
+ ...(state.params.expiresAtMs !== undefined ? { expiresAtMs: state.params.expiresAtMs } : {}),
9731
+ });
9732
+ }
9733
+ const scope = this.tuneOutCoordinator?.getAllExcept();
9734
+ if (scope) {
9735
+ epochs.push({
9736
+ id: scope.params.epochId,
9737
+ kind: 'tune-out',
9738
+ scope: { kind: 'all-except', except: scope.params.except },
9739
+ ...(owner ? { owner } : {}),
9740
+ ...(this.subconsciousAgentName ? { reader: this.subconsciousAgentName } : {}),
9741
+ startedAtSequence: scope.params.startedAtSequence,
9742
+ expiresAtMs: scope.params.expiresAtMs,
9743
+ });
9744
+ }
9745
+ return epochs;
9746
+ }
9747
+ /**
9748
+ * Cancel an agent's activation in flight, leaving the agent registered.
9749
+ * For a bounded job, whose lifetime is that one run, this ends the job.
9750
+ * Returns false when nothing was running.
9751
+ */
9752
+ cancelActivation(name, options = {}) {
9753
+ if (!this.registry.has(name))
9754
+ throw new Error(`cancelActivation: "${name}" is not a registered agent`);
9755
+ const run = this.ephemeralRuns.get(name);
9756
+ if (run) {
9757
+ run.endReason ??= 'stopped';
9758
+ run.endedBy ??= options.by;
9759
+ // The awaiting caller is told who stopped the job and why, whether or
9760
+ // not a stream was in flight: settled first, so the aborted stream's own
9761
+ // settle ('Stream cancelled') finds the run already settled. The caller's
9762
+ // teardown runs after this call, as for stopAgent.
9763
+ run.settle.reject(new AgentStoppedError(name, 'stopped', options.by, options.reason));
9764
+ }
9765
+ const cancelled = this.abortInference(name, options.reason ?? 'cancelled');
9766
+ if (cancelled || run) {
9767
+ this.emitTrace({
9768
+ type: 'dendrite:activation-cancelled',
9769
+ agentName: name,
9770
+ ...(options.by ? { by: options.by } : {}),
9771
+ ...(options.reason ? { reason: options.reason } : {}),
9772
+ });
9773
+ }
9774
+ return cancelled || run !== undefined;
9775
+ }
9776
+ /**
9777
+ * End an agent: cancel its run, remove it from the framework, and settle
9778
+ * what it spawned — attention tenants end with it; task work is orphaned,
9779
+ * notified and offered for reparenting, and keeps running.
9780
+ *
9781
+ * The default-delivery owner cannot be stopped (the framework needs
9782
+ * exactly one); cancel its activation instead. A persistent agent stopped
9783
+ * here is declared by configuration and returns at the next boot.
9784
+ */
9785
+ stopAgent(name, options = {}) {
9786
+ const record = this.registry.get(name);
9787
+ if (!record)
9788
+ throw new Error(`stopAgent: "${name}" is not a registered agent`);
9789
+ if (record.roles.defaultDelivery) {
9790
+ throw new Error(`stopAgent: "${name}" owns default delivery and cannot be stopped; use cancelActivation to end its current run`);
9791
+ }
9792
+ const outcome = this.registry.end(name, 'stopped', options.by);
9793
+ this.teardownEndedAgent(outcome.ended, options.reason);
9794
+ this.settleEndedAgent(outcome);
9795
+ return structuredClone(outcome);
9796
+ }
9797
+ /**
9798
+ * Give an agent a new parent. Clears its orphan state; a result route
9799
+ * whose recipient is gone follows the new parent, and the mail it already
9800
+ * produced for the lost recipient, results and messages, is delivered to
9801
+ * the new one.
9802
+ */
9803
+ reparentAgent(name, newParent, options = {}) {
9804
+ // An orphan that finished before it was adopted: its record stays as it
9805
+ // ended, and the mail it held for a recipient that is gone goes to the
9806
+ // new parent.
9807
+ if (!this.registry.has(name) && this.registry.inspect(name)?.ended) {
9808
+ const ended = this.registry.readdressHeldMail(name, newParent);
9809
+ this.emitTrace({
9810
+ type: 'dendrite:agent-reparented',
9811
+ agentName: name,
9812
+ parent: newParent,
9813
+ ...(options.by ? { by: options.by } : {}),
9814
+ });
9815
+ for (const mail of ended.deliverable)
9816
+ this.attemptMailDelivery(mail);
9817
+ return structuredClone(ended.record);
9818
+ }
9819
+ const { record, deliverable } = this.registry.reparent(name, newParent);
9820
+ this.emitTrace({
9821
+ type: 'dendrite:agent-reparented',
9822
+ agentName: name,
9823
+ parent: newParent,
9824
+ ...(options.by ? { by: options.by } : {}),
9825
+ });
9826
+ this.lifecycleNotice(name, `[${NOTICE_TAG.agentLifecycle}: ${newParent} is now your parent` +
9827
+ (record.relationships.resultTo?.to === newParent ? ' and will receive your result' : '') +
9828
+ '.]', { event: 'reparented', parent: newParent });
9829
+ for (const mail of deliverable)
9830
+ this.attemptMailDelivery(mail);
9831
+ return structuredClone(record);
9832
+ }
9833
+ /**
9834
+ * An explicit message from one agent to another: the sender's verbatim
9835
+ * content, in the recipient's context under the SENDER's name, with the
9836
+ * sender's identity and incarnation and the causal chain in metadata.
9837
+ * Never an unmarked insertion into the recipient's own voice.
9838
+ */
9839
+ sendAgentMessage(from, to, content, options = {}) {
9840
+ if (!this.registry.inspect(from))
9841
+ throw new Error(`sendAgentMessage: sender "${from}" is not a known agent`);
9842
+ if (!this.registry.inspect(to))
9843
+ throw new Error(`sendAgentMessage: recipient "${to}" is not a known agent`);
9844
+ this.registry.noteMessagePath(from, to);
9845
+ const mail = this.registry.holdMail({
9846
+ kind: 'message',
9847
+ from: this.registry.ref(from),
9848
+ to,
9849
+ content,
9850
+ ...(options.causedBy?.length ? { causedBy: options.causedBy } : {}),
9851
+ });
9852
+ return { mailId: mail.id, delivered: this.attemptMailDelivery(mail, options.wake ?? true) };
9853
+ }
9854
+ /**
9855
+ * Return a bounded job's result along the agent's declared result route.
9856
+ * The result is recorded durably before delivery is attempted, so it is
9857
+ * neither lost nor delivered twice across a restart; if the recipient is
9858
+ * gone it is held as attributed mail until the agent is reparented.
9859
+ *
9860
+ * May be called after the agent's run has ended — its record stays
9861
+ * inspectable. Refused for a `tool-result` route: there the awaiting
9862
+ * caller already holds the result and delivers it itself.
9863
+ */
9864
+ deliverAgentResult(from, content, options = {}) {
9865
+ const record = this.registry.inspect(from);
9866
+ if (!record)
9867
+ throw new Error(`deliverAgentResult: "${from}" is not a known agent`);
9868
+ const route = record.relationships.resultTo;
9869
+ if (!route)
9870
+ throw new Error(`deliverAgentResult: "${from}" has no result route`);
9871
+ if (route.as !== 'message') {
9872
+ throw new Error(`deliverAgentResult: "${from}" returns its result as a ${route.as}; the caller awaiting the run delivers it`);
9873
+ }
9874
+ const mail = this.registry.holdMail({
9875
+ kind: 'result',
9876
+ from: { agent: from, incarnation: record.incarnation },
9877
+ to: route.to,
9878
+ content,
9879
+ ...(options.causedBy?.length ? { causedBy: options.causedBy } : {}),
9880
+ });
9881
+ // A finished job's output survives a hard exit from here on: the hold is
9882
+ // in the registry's recovery file as it is written (persistAgentRegistry),
9883
+ // and holdMail failed this call if it couldn't be. The release, once the
9884
+ // message has landed, waits for the store's sync (releaseLandedMail).
9885
+ return { mailId: mail.id, delivered: this.attemptMailDelivery(mail), to: route.to };
9886
+ }
9887
+ /** Communications not yet handed to their recipient, and why. */
9888
+ listHeldMail(filter = {}) {
9889
+ return structuredClone(this.registry.listMail(filter));
9890
+ }
9891
+ /**
9892
+ * Try to hand one held item to its recipient. True when the message is in
9893
+ * (or queued for) the recipient's context; false when it stays held
9894
+ * because nobody can receive it. The held copy is released by
9895
+ * `addMessage` at the moment the message enters the store.
9896
+ */
9897
+ attemptMailDelivery(mail, wake = true) {
9898
+ const recipient = this.registry.has(mail.to) ? this.agents.get(mail.to) : undefined;
9899
+ if (!recipient) {
9900
+ const newlyHeld = mail.heldBecause !== 'recipient-gone';
9901
+ this.registry.markMailHeld(mail.id, 'recipient-gone');
9902
+ // A result its sender delivered after it had ended (one delivered while
9903
+ // it ran is told of when it ends, settleEndedAgent).
9904
+ const sender = this.registry.inspect(mail.from.agent);
9905
+ if (newlyHeld && mail.kind === 'result' && sender?.ended)
9906
+ this.tellResultHeld(sender, mail.id);
9907
+ this.emitTrace({
9908
+ type: 'dendrite:mail-held',
9909
+ mailId: mail.id,
9910
+ kind: mail.kind,
9911
+ from: mail.from.agent,
9912
+ to: mail.to,
9913
+ reason: 'recipient-gone',
9914
+ });
9915
+ return false;
9916
+ }
9917
+ const placement = {};
9918
+ this.addMessage(
9919
+ // The sender's own name: its words stay its own.
9920
+ recipient.isOwnParticipant(mail.from.agent) && mail.from.agent !== recipient.name
9921
+ ? `${mail.from.agent} (mail)`
9922
+ : mail.from.agent, mail.content, {
9923
+ kind: `agent-${mail.kind}`,
9924
+ dendrite: {
9925
+ mailId: mail.id,
9926
+ kind: mail.kind,
9927
+ from: mail.from,
9928
+ // When the sender produced it — distinct from when it is read. A
9929
+ // result held across a restart arrives later than it was made,
9930
+ // and the reader should be able to tell.
9931
+ producedAt: mail.createdAt,
9932
+ ...(mail.causedBy?.length ? { causedBy: mail.causedBy } : {}),
9933
+ },
9934
+ }, { forAgent: mail.to, placement });
9935
+ if (!placement.messageId) {
9936
+ // Queued for the recipient's next boundary. `mail-delivered` fires
9937
+ // when the message actually enters its store (releaseLandedMail):
9938
+ // published is not delivered.
9939
+ this.emitTrace({
9940
+ type: 'dendrite:mail-deferred',
9941
+ mailId: mail.id,
9942
+ kind: mail.kind,
9943
+ from: mail.from.agent,
9944
+ to: mail.to,
9945
+ });
9946
+ }
9947
+ if (wake) {
9948
+ this.pendingRequests.push({
9949
+ agentName: mail.to,
9950
+ reason: `agent-${mail.kind}:${mail.from.agent}`,
9951
+ source: 'dendrite',
9952
+ timestamp: Date.now(),
9953
+ });
9954
+ }
9955
+ return true;
9956
+ }
9957
+ /**
9958
+ * An ended orphan's result held for a recipient that is gone: its first
9959
+ * adoption candidate is told it exists and how it arrives, since nothing
9960
+ * else would surface it (reparentAgent delivers it).
9961
+ */
9962
+ tellResultHeld(sender, mailId) {
9963
+ const candidate = sender.orphaned?.candidates[0];
9964
+ if (!candidate || !this.announceOrphans)
9965
+ return;
9966
+ this.lifecycleNotice(candidate, `[${NOTICE_TAG.agentLifecycle}: "${sender.name}" has ended after ${sender.orphaned.formerParent.agent} did, ` +
9967
+ `so its result has nowhere to go. The result is held; reparenting "${sender.name}" to you delivers it.]`,
9968
+ // `heldMailId`, not `mailId`: a row carrying dendrite.mailId is read
9969
+ // as that mail having landed, which would release it.
9970
+ { event: 'result-held', agent: sender.name, heldMailId: mailId, formerParent: sender.orphaned.formerParent });
9971
+ }
9972
+ /** A host-framed lifecycle fact in an agent's context. Never voiced as anyone. */
9973
+ lifecycleNotice(agentName, text, detail) {
9974
+ if (!this.agents.has(agentName))
9975
+ return;
9976
+ this.addMessage('user', [{ type: 'text', text }], { system: true, kind: 'agent-lifecycle', dendrite: detail }, { forAgent: agentName });
9977
+ }
9978
+ /**
9979
+ * The creation ("consent") event: names what kind of agent this is, what
9980
+ * ends it and what happens to it when its spawner ends. A derived agent's
9981
+ * creation is also told to the agent it was derived from (#270, Linn:
9982
+ * "told when the body changes" as a property, not a courtesy): one
9983
+ * host-framed line in the source's context, built from the same facts as
9984
+ * the trace, with no wake; a host's derivationNotice can replace the line
9985
+ * or send none. Both derivation paths announce after the
9986
+ * fork's branch or copy exists, so a fork never inherits the notice about
9987
+ * itself; a source that has ended is told nothing (lifecycleNotice).
9988
+ */
9989
+ announceAgentCreated(record) {
9990
+ this.emitTrace({
9991
+ type: 'dendrite:agent-created',
9992
+ agentName: record.name,
9993
+ kind: record.kind,
9994
+ ...(record.model ? { model: record.model } : {}),
9995
+ incarnation: record.incarnation,
9996
+ lifetime: record.lifetime,
9997
+ ...(record.relationships.spawnedBy
9998
+ ? { spawnedBy: record.relationships.spawnedBy, onParentEnd: record.onParentEnd }
9999
+ : {}),
10000
+ ...(record.relationships.resultTo ? { resultTo: record.relationships.resultTo } : {}),
10001
+ ...(record.inherit ? { inherit: record.inherit } : {}),
10002
+ });
10003
+ const from = record.inherit?.from;
10004
+ if (!from || from === record.name)
10005
+ return;
10006
+ const detail = {
10007
+ event: 'derived',
10008
+ agent: record.name,
10009
+ kind: record.kind,
10010
+ incarnation: record.incarnation,
10011
+ ...(record.model ? { model: record.model } : {}),
10012
+ inherit: record.inherit,
10013
+ ...(record.relationships.resultTo ? { resultTo: record.relationships.resultTo } : {}),
10014
+ lifetime: record.lifetime,
10015
+ };
10016
+ // A resumption is rare and always said. A first incarnation is tallied:
10017
+ // past the threshold, a reader loop that forks a hundred times a session
10018
+ // would fill its source's context with stamps (Linn, 2026-10-15), so
10019
+ // the source hears a count line every `threshold` forks instead, with
10020
+ // the latest described; every derivation still emits its trace.
10021
+ const threshold = this.collapseDerivationNoticesAfter;
10022
+ const tell = (defaultText, extra = {}) => {
10023
+ try {
10024
+ const text = this.derivationNoticeHook ? this.derivationNoticeHook(record, from, defaultText) : defaultText;
10025
+ if (text !== null)
10026
+ this.lifecycleNotice(from, text, { ...detail, ...extra });
10027
+ }
10028
+ catch (error) {
10029
+ console.error(`[dendrite] ${record.name} was created, but ${from} could not be told:`, error);
10030
+ }
10031
+ };
10032
+ if (record.incarnation > 1 || threshold <= 0) {
10033
+ tell(this.derivationNotice(record, from));
10034
+ return;
10035
+ }
10036
+ let byKind = this.derivationTallies.get(from);
10037
+ if (!byKind)
10038
+ this.derivationTallies.set(from, (byKind = new Map()));
10039
+ let tally = byKind.get(record.kind);
10040
+ if (!tally)
10041
+ byKind.set(record.kind, (tally = { count: 0, since: Date.now() }));
10042
+ tally.count++;
10043
+ if (tally.count <= threshold) {
10044
+ tell(this.derivationNotice(record, from));
10045
+ }
10046
+ else if ((tally.count - threshold) % threshold === 0) {
10047
+ const since = new Date(tally.since).toISOString().slice(11, 16) + ' UTC';
10048
+ tell(`[${NOTICE_TAG.agentLifecycle}: ${tally.count} ${record.kind}s have been derived from you since ${since} ` +
10049
+ `(${threshold} since the last note); the latest, ${record.name}, is ${this.derivationDescription(record, from)}.]`, { collapsed: { count: tally.count, sinceLastNote: threshold } });
10050
+ }
10051
+ }
10052
+ /** The derivation notice's text, addressed to the agent `from` the record was derived. */
10053
+ derivationNotice(record, from) {
10054
+ const made = record.incarnation > 1
10055
+ ? `was resumed (incarnation ${record.incarnation}) as`
10056
+ : 'was derived from you as';
10057
+ return `[${NOTICE_TAG.agentLifecycle}: ${record.name} ${made} ${this.derivationDescription(record, from)}.]`;
10058
+ }
10059
+ /** What a derived agent is, to the agent it was derived from: "a task-fork on your weights, …; …; …". */
10060
+ derivationDescription(record, from) {
10061
+ const sourceModel = this.agentConfigs.get(from)?.model ?? this.registry.get(from)?.model;
10062
+ const weights = !record.model
10063
+ ? ''
10064
+ : record.model === sourceModel
10065
+ ? ` on your weights (${record.model})`
10066
+ : ` on ${record.model}`;
10067
+ const inherited = record.inherit?.mode === 'shared'
10068
+ ? 'reading your context up to this point'
10069
+ : 'with a copy of your context';
10070
+ const route = record.relationships.resultTo;
10071
+ const home = this.channelOfHome(record.homeChannel);
10072
+ const result = !route
10073
+ ? home
10074
+ ? `it speaks for itself in ${this.channelShown(home)}`
10075
+ : 'it returns nothing to you'
10076
+ : route.to === from
10077
+ ? `its result returns to you as ${route.as === 'message' ? 'a message' : 'a tool result'}`
10078
+ : `its result goes to ${route.to}`;
10079
+ const lifetime = record.lifetime;
10080
+ const minutes = (ms) => (ms < 60_000 ? `${Math.round(ms / 1_000)} s` : `${Math.round(ms / 60_000)} min`);
10081
+ const ends = lifetime.kind === 'task'
10082
+ ? 'it ends when its task does' +
10083
+ (lifetime.deadlineMs !== undefined || lifetime.idleTimeoutMs !== undefined
10084
+ ? ` (${[
10085
+ lifetime.deadlineMs !== undefined ? `deadline ${minutes(lifetime.deadlineMs)}` : '',
10086
+ lifetime.idleTimeoutMs !== undefined ? `idle ${minutes(lifetime.idleTimeoutMs)}` : '',
10087
+ ].filter(Boolean).join(', ')})`
10088
+ : '')
10089
+ : lifetime.kind === 'idle'
10090
+ ? `it ends after ${minutes(lifetime.idleTtlMs)} idle`
10091
+ : 'it persists';
10092
+ const parentEnd = record.relationships.spawnedBy === from
10093
+ ? record.onParentEnd === 'end' ? ', and ends with you' : ', and is orphaned if you end'
10094
+ : '';
10095
+ return `a ${record.kind}${weights}, ${inherited}; ${result}; ${ends}${parentEnd}`;
10096
+ }
10097
+ /**
10098
+ * Remove an ended agent's live object from the framework. A bounded run
10099
+ * is ended through its awaiting caller (whose teardown owns the
10100
+ * name-keyed state); any other agent is torn down here.
10101
+ */
10102
+ teardownEndedAgent(record, detail) {
10103
+ const name = record.name;
10104
+ const reason = record.ended?.reason ?? 'stopped';
10105
+ const run = this.ephemeralRuns.get(name);
10106
+ if (run) {
10107
+ run.endReason = reason;
10108
+ run.endedBy = record.ended?.by;
10109
+ run.settle.reject(new AgentStoppedError(name, reason, record.ended?.by, detail));
10110
+ return;
10111
+ }
10112
+ const agent = this.agents.get(name);
10113
+ if (!agent)
10114
+ return;
10115
+ this.releaseAgentRuntime(agent, this.channelOfHome(record.homeChannel), undefined, record.lifetime.kind === 'persistent');
10116
+ }
10117
+ /**
10118
+ * Everything the process holds for a live, non-ephemeral agent that has
10119
+ * ended, released once, on every path an agent ends by — stopAgent, the
10120
+ * idle reap of a conversation fork, an end that cascades from a parent's
10121
+ * (nissa's #546 finding 5: a stop that skipped the window release dropped a
10122
+ * write queued for the agent at the next flush, and a failure notice it
10123
+ * never read reached no one). The window release delivers what the agent
10124
+ * never read to the primary, now or after a resume, as the idle reap did.
10125
+ */
10126
+ releaseAgentRuntime(agent, home, why = 'its agent ended', declared = false) {
10127
+ const name = agent.name;
10128
+ if (home && this.conversationRouter?.getBinding(home)?.agentName === name) {
10129
+ this.conversationRouter.unbind(home);
10130
+ }
10131
+ this.closingConversationAgents.delete(name);
10132
+ // A continuation of its turn still on its way goes with it.
10133
+ this.dropContinuations(name, { kind: 'dropped', why });
10134
+ // A batch its guard still holds is settled as `aborted`: the stream's
10135
+ // own finally does neither once the agent has gone.
10136
+ if (agent.toolResultGuard.hasPending) {
10137
+ try {
10138
+ agent.toolResultGuard.abandon('aborted');
10139
+ }
10140
+ catch (error) {
10141
+ console.error(`[tool-result-guard] agent=${name} failed to record a batch the fork left:`, error);
10142
+ }
10143
+ }
10144
+ // Cancel before deregistration: a late stream event is discarded by
10145
+ // driveStream's Agent-identity check once the name no longer maps to it.
10146
+ if (this.activeStreams.has(name)) {
10147
+ try {
10148
+ agent.cancelStream();
10149
+ }
10150
+ catch (error) {
10151
+ console.error(`[dendrite] agent=${name} its stream's cancel failed as it ended; it is released all the same:`, error);
10152
+ }
10153
+ }
10154
+ this.activeStreams.delete(name);
10155
+ this.pendingAssistantBlocks.delete(name);
10156
+ this.eventGate?.onInferenceEnded(name);
10157
+ // Its sleep and self-wake end with it (the gate's sleep holds back every
10158
+ // agent's wakes), and neither wake goes to a name that's gone.
10159
+ this.eventGate?.agentGone(name);
10160
+ this.agents.delete(name);
10161
+ this.agentConfigs.delete(name);
10162
+ this.thinkingLossIncidents.forget(name);
10163
+ this.reasoningLoss.forget(name);
10164
+ // main's manager release: the context manager an ephemeral run or a fork
10165
+ // opened is released with the agent (releaseManager).
10166
+ this.releaseManager(agent);
10167
+ this.toolImageLedgers.delete(name);
10168
+ this.evictTurnCheckpoints(name);
10169
+ this.activeTurnTokens.delete(name);
10170
+ this.activeTurnTriggers.delete(name);
10171
+ this.logicalTurnToolCalls.delete(agent);
10172
+ this.pendingRequests = this.pendingRequests.filter((request) => request.agentName !== name);
10173
+ this.releaseWindowNowOrAfterResume(agent);
10174
+ this.turnRouting.delete(name);
10175
+ this.proseSpeechChains.delete(name);
10176
+ this.openLogical.delete(name);
10177
+ // What it was last told of where its speech goes goes with it, kept
10178
+ // only for an agent declared by configuration, which comes back under
10179
+ // its name at the next boot: so the stored baselines are those of
10180
+ // agents that are live or come back.
10181
+ if (!declared)
10182
+ this.forgetAnnouncedLocus(name);
10183
+ // Its scripts go with it, and leave the map once they've ended: no later
10184
+ // run of the name trims them.
10185
+ this.codeExecution?.scripts.agentGone(name);
10186
+ // A provider-wait hold (nonOwningProviderWait) goes with the agent: left,
10187
+ // its timer would requeue held wakes for an agent that is gone, and a wait
10188
+ // held until released would re-arm for the name indefinitely. The recorded
10189
+ // wait stays, and binds the name if its channel binds again.
10190
+ const cooldown = this.providerAccelerationCooldowns.get(name);
10191
+ if (cooldown) {
10192
+ clearTimeout(cooldown.timer);
10193
+ this.providerAccelerationCooldowns.delete(name);
10194
+ console.error(`[provider-wait] agent=${name} ended while held` +
10195
+ `${cooldown.waitModel !== undefined ? ` on ${cooldown.waitModel}` : ''}: ` +
10196
+ `${cooldown.heldRequests.length} held request(s) dropped with it (the recorded wait stays)`);
10197
+ }
10198
+ // main's per-name turn state goes too (#846's re-arm count and run of
10199
+ // stated waits, #840's exhausted turn), so a name bound again (a
10200
+ // channel's next conversation, a resume) starts afresh; a run of stated
10201
+ // waits ends here as at a turn's end, alerted if it ran long
10202
+ // (endStatedWaitRun).
10203
+ this.turnRearms.delete(name);
10204
+ this.exhaustingRearms.delete(name);
10205
+ this.exhaustedTurns.delete(name);
10206
+ this.endStatedWaitRun(name);
10207
+ if (name === this.subconsciousAgentName) {
10208
+ this.tuneOutCoordinator?.stopReader();
10209
+ this.subconsciousAgentName = null;
10210
+ }
10211
+ }
10212
+ /**
10213
+ * Carry out what the registry decided when an agent ended: tear down the
10214
+ * attention tenants that end with it, and tell each orphan — and the
10215
+ * first agent that could adopt it — what happened.
10216
+ */
10217
+ settleEndedAgent(outcome) {
10218
+ if (!outcome)
10219
+ return;
10220
+ for (const record of [...outcome.cascaded, outcome.ended]) {
10221
+ this.emitTrace({
10222
+ type: 'dendrite:agent-ended',
10223
+ agentName: record.name,
10224
+ kind: record.kind,
10225
+ incarnation: record.incarnation,
10226
+ reason: record.ended?.reason ?? 'stopped',
10227
+ ...(record.ended?.by ? { by: record.ended.by } : {}),
10228
+ ...(record.relationships.spawnedBy ? { spawnedBy: record.relationships.spawnedBy } : {}),
10229
+ });
10230
+ }
10231
+ for (const record of outcome.cascaded)
10232
+ this.teardownEndedAgent(record);
10233
+ // An orphan that ends with a result it delivered while it ran still held
10234
+ // for a recipient that is gone: its first candidate hears of it now.
10235
+ for (const record of [...outcome.cascaded, outcome.ended]) {
10236
+ if (!record.orphaned)
10237
+ continue;
10238
+ const held = this.registry.listMail({ from: record.name })
10239
+ .find((mail) => mail.kind === 'result' && mail.heldBecause === 'recipient-gone');
10240
+ if (held)
10241
+ this.tellResultHeld(record, held.id);
10242
+ }
10243
+ const parent = outcome.ended;
10244
+ for (const { record, candidates } of outcome.orphaned) {
10245
+ this.announceOrphaned(record, candidates, record.orphaned?.formerParent.agent ?? parent.name, record.orphaned?.reason ?? parent.ended?.reason ?? 'stopped');
10246
+ }
10247
+ }
10248
+ /** An agent whose spawner has ended, and its first adoption candidate, are told; the trace says so. */
10249
+ announceOrphaned(record, candidates, former, reason) {
10250
+ this.emitTrace({
10251
+ type: 'dendrite:agent-orphaned',
10252
+ agentName: record.name,
10253
+ formerParent: former,
10254
+ reason,
10255
+ candidates,
10256
+ });
10257
+ const route = record.relationships.resultTo;
10258
+ const resultHeld = route?.as === 'message' && !this.registry.has(route.to);
10259
+ this.lifecycleNotice(record.name, `[${NOTICE_TAG.agentLifecycle}: ${former}, the agent that started you, has ended (${reason}). ` +
10260
+ 'Your own work is not cancelled.' +
10261
+ (resultHeld ? ' Your result will be held until another agent becomes its recipient.' : '') +
10262
+ ']', { event: 'orphaned', formerParent: record.orphaned?.formerParent, reason, candidates });
10263
+ const candidate = candidates[0];
10264
+ if (candidate && this.announceOrphans) {
10265
+ this.lifecycleNotice(candidate, `[${NOTICE_TAG.agentLifecycle}: ${former} has ended (${reason}) while its ${record.kind} agent ` +
10266
+ `"${record.name}" was still running. "${record.name}" now has no parent` +
10267
+ (resultHeld ? ' and nowhere to return its result' : '') +
10268
+ '; it can be reparented to you.]', { event: 'adoption-offered', agent: record.name, formerParent: record.orphaned?.formerParent, reason });
10269
+ }
10270
+ }
10271
+ /** The registry's snapshot: the recovery file first (atomic, fsynced; it
10272
+ * wins at the next boot), then the branch-local state, kept for inspection
10273
+ * and for stores read by older code. Either write failing fails the call
10274
+ * that changed the registry (nissa's #546 finding 2): a change that isn't
10275
+ * durable isn't a success. */
10276
+ persistAgentRegistry(snapshot) {
10277
+ if (this.agentRegistryPath)
10278
+ this.writeRecoveryFile(this.agentRegistryPath, snapshot);
10279
+ this.store.setStateJson(AGENT_REGISTRY_STATE_ID, snapshot);
10280
+ }
10281
+ /** An ended record: appended to the branch-local log, and the recovery
10282
+ * file rewritten with the newest records (as many as a restart reads). */
10283
+ appendEndedAgentRecord(record) {
10284
+ this.rewriteEndedRecovery([...this.endedRecovery, record]);
10285
+ this.store.appendToStateJson(AGENT_REGISTRY_ENDED_ID, record);
10286
+ }
10287
+ /**
10288
+ * A requested change was put back in the registry's memory: the recovery
10289
+ * files, each written before the branch-local store, are rewritten from it
10290
+ * (the snapshot's and the ended log's), so neither keeps the change the
10291
+ * caller was told failed. The branch-local snapshot needs no rewrite: it is
10292
+ * the last write a change makes, so a change that failed never reached it.
10293
+ * The branch-local ended log is append-only, so an end appended before the
10294
+ * failure stays there. It is read at boot only where no ended recovery
10295
+ * file exists (or the rewrite of that file failed too); the restore then
10296
+ * drops a bounded agent it lists as ended for the reason recorded, with no
10297
+ * restart notice to its recipient, while a persistent agent waits to be
10298
+ * re-declared as it would anyway. A rewrite that fails is reported, the
10299
+ * next is still made, and the caller's own error goes on.
10300
+ */
10301
+ rewriteAgentRegistryAfterRollback() {
10302
+ const rewrites = [
10303
+ ['the ended log recovery file', 'the next end written', () => this.rewriteEndedRecovery(this.registry.endedHistory())],
10304
+ ...(this.agentRegistryPath
10305
+ ? [['the registry recovery file', 'the next registry write', () => this.writeRecoveryFile(this.agentRegistryPath, this.registry.snapshot())]]
10306
+ : []),
10307
+ ];
10308
+ for (const [what, next, rewrite] of rewrites) {
10309
+ try {
10310
+ rewrite();
10311
+ }
10312
+ catch (error) {
10313
+ 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);
10314
+ }
10315
+ }
10316
+ }
10317
+ /**
10318
+ * An ended record past the registry's memory, from the branch-local ended
10319
+ * log. A scan of the whole log takes tens of milliseconds at a few thousand
10320
+ * ends, and a name nobody used (a tool call's typo) is a miss, so the log is
10321
+ * indexed by name once, at the first lookup, and each later lookup indexes
10322
+ * only what was appended since. A log that changed under the index (a
10323
+ * branch switch) is indexed again: one that shrank, one whose last indexed
10324
+ * entry is no longer the one indexed, or one whose entry no longer carries
10325
+ * the name indexed for it.
10326
+ */
10327
+ findEndedAgentRecord(name) {
10328
+ return this.findInEndedLog('name', name);
10329
+ }
10330
+ /**
10331
+ * The ended log's latest record whose own context a namespace holds. A
10332
+ * shared fork's record names its parent's namespace, where it owns only a
10333
+ * branch, so it is indexed by name alone.
10334
+ */
10335
+ findInEndedLog(by, value) {
10336
+ const byKey = by === 'name' ? this.endedLogIndex : this.endedLogNamespaceIndex;
10337
+ const reset = () => {
10338
+ this.endedLogIndex.clear();
10339
+ this.endedLogNamespaceIndex.clear();
10340
+ this.endedLogIndexed = 0;
10341
+ };
10342
+ const read = (index) => this.store.getStateItemJson(AGENT_REGISTRY_ENDED_ID, index);
10343
+ const mark = (record) => (record ? `${record.name}#${record.incarnation}@${record.ended?.at}` : '');
10344
+ // A store that can't be read fails the lookup rather than answering
10345
+ // "never ended", which would let a namespace check pass.
10346
+ const length = this.store.getStateLen(AGENT_REGISTRY_ENDED_ID) ?? 0;
10347
+ for (let attempt = 0; attempt < 2; attempt++) {
10348
+ if (length < this.endedLogIndexed
10349
+ || (this.endedLogIndexed > 0 && mark(read(this.endedLogIndexed - 1)) !== this.endedLogTail)) {
10350
+ reset();
10351
+ }
10352
+ for (let index = this.endedLogIndexed; index < length; index++) {
10353
+ const record = read(index);
10354
+ if (!record?.ended || typeof record.name !== 'string')
10355
+ continue;
10356
+ this.endedLogIndex.set(record.name, index);
10357
+ if (typeof record.namespace === 'string' && record.inherit?.mode !== 'shared') {
10358
+ this.endedLogNamespaceIndex.set(record.namespace, index);
10359
+ }
10360
+ }
10361
+ this.endedLogIndexed = length;
10362
+ this.endedLogTail = length > 0 ? mark(read(length - 1)) : '';
10363
+ const at = byKey.get(value);
10364
+ if (at === undefined)
10365
+ return undefined;
10366
+ const record = read(at);
10367
+ if (record?.ended && record[by] === value)
10368
+ return record;
10369
+ reset();
10370
+ }
10371
+ return undefined;
10372
+ }
10373
+ /** Another agent whose context a namespace holds: opened in this process, in the registry, or in the ended log. */
10374
+ namespaceHolder(namespace, except) {
10375
+ const opened = this.namespaceOwners.get(namespace);
10376
+ if (opened !== undefined && opened !== except)
10377
+ return opened;
10378
+ const held = this.registry.namespaceHolder(namespace, except);
10379
+ if (held)
10380
+ return held;
10381
+ const ended = this.findInEndedLog('namespace', namespace);
10382
+ return ended && ended.name !== except ? ended.name : undefined;
10383
+ }
10384
+ /** The ended log's recovery file, rewritten as the newest records (as many as a restart reads). */
10385
+ rewriteEndedRecovery(ended) {
10386
+ this.endedRecovery = ended.slice(-AGENT_REGISTRY_ENDED_RELOAD);
10387
+ if (this.agentRegistryEndedPath)
10388
+ this.writeRecoveryFile(this.agentRegistryEndedPath, { version: 1, ended: this.endedRecovery });
10389
+ }
10390
+ /**
10391
+ * Load the previous process's registry. Returns the bounded work it
10392
+ * interrupted. A registry that can't be read, or that reads as anything
10393
+ * but a version-1 registry, refuses the start and names the state (nissa's
10394
+ * #546 finding 1, main's rule for the framework's own state): taken as
10395
+ * empty, the results it holds for delivery and the records of the work it
10396
+ * ran would be overwritten by this boot's first registration.
10397
+ */
10398
+ restoreAgentRegistry() {
10399
+ const refuse = (what, problem, cause) => new Error(`the agent registry (${what}) ${problem}. The framework doesn't start without it: taken as empty, the results ` +
10400
+ 'it holds for delivery and the records of the work it ran would be overwritten at this boot. ' +
10401
+ (cause === undefined
10402
+ ? "It's unchanged, and the framework starts once it holds a version-1 registry, such as " +
10403
+ '{"version":1,"records":[],"mail":[],"incarnations":{}} for an empty one.'
10404
+ : "It's unchanged, and the framework starts once it can be read."), cause === undefined ? undefined : { cause });
10405
+ let snapshot;
10406
+ let snapshotFrom = this.agentRegistryPath ?? AGENT_REGISTRY_STATE_ID;
10407
+ // Only a registry that was never written is absent: a recovery file that
10408
+ // exists is the registry, whatever it holds, so one that reads as `null`
10409
+ // is refused like any other that isn't a version-1 registry.
10410
+ let absent = false;
10411
+ try {
10412
+ snapshot = this.agentRegistryPath ? this.readRecoveryFile(this.agentRegistryPath) : undefined;
10413
+ if (snapshot === undefined) {
10414
+ snapshotFrom = AGENT_REGISTRY_STATE_ID;
10415
+ snapshot = this.store.getStateJson(AGENT_REGISTRY_STATE_ID) ?? null;
10416
+ absent = snapshot === null;
10417
+ }
10418
+ }
10419
+ catch (error) {
10420
+ throw refuse(snapshotFrom, "can't be read", error);
10421
+ }
10422
+ if (!absent && (snapshot === null || typeof snapshot !== 'object' || snapshot.version !== 1)) {
10423
+ const read = snapshot === null ? 'null' : `version ${JSON.stringify(snapshot.version)}`;
10424
+ throw refuse(snapshotFrom, `reads as something other than a version-1 registry (${read})`);
10425
+ }
10426
+ if (!absent) {
10427
+ const { records, mail, incarnations } = snapshot;
10428
+ if (!Array.isArray(records) || !Array.isArray(mail)) {
10429
+ throw refuse(snapshotFrom, 'reads as a version-1 registry whose records or mail are not lists');
10430
+ }
10431
+ if (incarnations === null || typeof incarnations !== 'object' || Array.isArray(incarnations)) {
10432
+ 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');
10433
+ }
10434
+ }
10435
+ let ended;
10436
+ let endedFrom = this.agentRegistryEndedPath ?? AGENT_REGISTRY_ENDED_ID;
10437
+ try {
10438
+ const file = this.agentRegistryEndedPath ? this.readRecoveryFile(this.agentRegistryEndedPath) : undefined;
10439
+ if (file !== undefined) {
10440
+ ended = file?.ended;
10441
+ }
10442
+ else {
10443
+ endedFrom = AGENT_REGISTRY_ENDED_ID;
10444
+ const tail = this.store.getStateTail(AGENT_REGISTRY_ENDED_ID, AGENT_REGISTRY_ENDED_RELOAD);
10445
+ ended = tail ? JSON.parse(tail.toString('utf8')) : [];
10446
+ }
10447
+ }
10448
+ catch (error) {
10449
+ throw refuse(endedFrom, "has an ended log that can't be read", error);
10450
+ }
10451
+ if (!Array.isArray(ended))
10452
+ throw refuse(endedFrom, 'has an ended log that reads as something other than a list of records');
10453
+ this.endedRecovery = ended.slice(-AGENT_REGISTRY_ENDED_RELOAD);
10454
+ return this.registry.restore(snapshot, ended).interrupted;
10455
+ }
10456
+ /**
10457
+ * Boot reconciliation, once configuration has declared its agents.
10458
+ * Restart behaviour follows from lifetime: persistent agents were just
10459
+ * re-created; everything else that was live was interrupted, and the
10460
+ * recipient of an interrupted job's result is told so. Results held from
10461
+ * before the restart are delivered now, exactly once.
10462
+ */
10463
+ reconcileAgentRegistry(interrupted) {
10464
+ for (const outcome of this.registry.reconcile().unconfigured)
10465
+ this.settleEndedAgent(outcome);
10466
+ const byRecipient = new Map();
10467
+ for (const record of interrupted) {
10468
+ this.emitTrace({
10469
+ type: 'dendrite:agent-ended',
10470
+ agentName: record.name,
10471
+ kind: record.kind,
10472
+ incarnation: record.incarnation,
10473
+ reason: 'host-restart',
10474
+ ...(record.relationships.spawnedBy ? { spawnedBy: record.relationships.spawnedBy } : {}),
10475
+ });
10476
+ const route = record.relationships.resultTo;
10477
+ if (route?.as !== 'message' || !this.agents.has(route.to))
10478
+ continue;
10479
+ // A result it had already handed over needs no apology.
10480
+ if (this.registry.listMail({ from: record.name }).some((mail) => mail.kind === 'result'))
10481
+ continue;
10482
+ byRecipient.set(route.to, [...(byRecipient.get(route.to) ?? []), record]);
10483
+ }
10484
+ for (const [recipient, all] of byRecipient) {
10485
+ // The interrupted records stay in the snapshot until this notice is
10486
+ // durable and their ends are written (settleInterrupted below), so a
10487
+ // crash after it landed, or an end whose write failed, comes here
10488
+ // again: a record an earlier interrupted notice to this recipient names
10489
+ // (its key lists each name, incarnation and creation time, since a
10490
+ // single-use name that aged out of memory starts again at incarnation
10491
+ // 1), in the recipient's store or the restored deferred queue, has been
10492
+ // told, however many of that notice's records have ended since.
10493
+ const toldKeys = new Set();
10494
+ const noteTold = (metadata) => {
10495
+ const key = metadata?.dendrite?.noticeKey;
10496
+ if (typeof key === 'string')
10497
+ for (const one of key.split(','))
10498
+ toldKeys.add(one);
10499
+ };
10500
+ for (const message of this.agents.get(recipient).getContextManager()
10501
+ .queryMessages({ metadata: { 'dendrite.event': 'interrupted' } }).messages)
10502
+ noteTold(message.metadata);
10503
+ for (const message of this.deferredMessages)
10504
+ noteTold(message.metadata);
10505
+ const keyOf = (record) => `${record.name}#${record.incarnation}@${record.createdAt}`;
10506
+ const records = all.filter((record) => !toldKeys.has(keyOf(record)));
10507
+ if (records.length === 0)
10508
+ continue;
10509
+ const noticeKey = records.map(keyOf).sort().join(',');
10510
+ const names = records.map((record) => `"${record.name}"`).join(', ');
10511
+ this.lifecycleNotice(recipient, `[${NOTICE_TAG.agentLifecycle}: the host restarted while ${names} ${records.length === 1 ? 'was' : 'were'} ` +
10512
+ `still working for you. ${records.length === 1 ? 'It was' : 'They were'} ended without a result.]`, { event: 'interrupted', agents: records.map((record) => record.name), reason: 'host-restart', noticeKey });
10513
+ this.pendingRequests.push({
10514
+ agentName: recipient,
10515
+ reason: 'agent-interrupted',
10516
+ source: 'dendrite',
10517
+ timestamp: Date.now(),
10518
+ });
10519
+ }
10520
+ // The notices are durable once the store has synced (a quiesced host's
10521
+ // are in the deferred queue's recovery file already); only then are the
10522
+ // interrupted records' ends written and the snapshot let go of them.
10523
+ if (interrupted.length > 0) {
10524
+ try {
10525
+ this.store.sync();
10526
+ this.registry.settleInterrupted();
10527
+ }
10528
+ catch (error) {
10529
+ 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);
10530
+ }
10531
+ }
10532
+ for (const mail of this.registry.listMail())
10533
+ this.deliverHeldMail(mail);
10534
+ }
10535
+ /**
10536
+ * Deliver held mail that hasn't landed. Its copy may have reached the
10537
+ * recipient's store in the same sync that the release missed, and then it
10538
+ * is released instead; and a write the deferred queue still holds for it
10539
+ * lands at the next flush and releases it then (releaseLandedMail). Either
10540
+ * way a second delivery would put it in the window twice under one mail id.
10541
+ */
10542
+ deliverHeldMail(mail, wake = true) {
10543
+ const recipient = this.agents.get(mail.to);
10544
+ if (recipient) {
10545
+ const already = recipient.getContextManager().queryMessages({ metadata: { 'dendrite.mailId': mail.id } });
10546
+ if (already.messages.length > 0) {
10547
+ this.registry.releaseMail(mail.id);
10548
+ return;
10549
+ }
10550
+ }
10551
+ const queued = this.deferredMessages.some((m) => m.metadata?.dendrite?.mailId === mail.id);
10552
+ if (queued)
10553
+ return;
10554
+ this.attemptMailDelivery(mail, wake);
8893
10555
  }
8894
10556
  createDeferred() {
8895
10557
  let resolve;
@@ -9037,7 +10699,8 @@ export class AgentFramework {
9037
10699
  // Only a fresh object returned by createEphemeralAgent may enter this path.
9038
10700
  // Never overwrite a resident/conversation owner or a concurrent run: their
9039
10701
  // cleanup is name-keyed and could cancel/deregister the legitimate owner.
9040
- if (this.ephemeralCandidates.get(agent) !== contextManager) {
10702
+ const candidate = this.ephemeralCandidates.get(agent);
10703
+ if (!candidate || candidate.contextManager !== contextManager) {
9041
10704
  throw new Error(`Ephemeral agent "${agent.name}" has no fresh generation ticket from this framework`);
9042
10705
  }
9043
10706
  // A live surgery holds the whole store: an agent admitted now would
@@ -9050,11 +10713,29 @@ export class AgentFramework {
9050
10713
  // Consume before any await/registration: the exact (Agent, ContextManager)
9051
10714
  // generation is one-shot even if startup later fails.
9052
10715
  this.ephemeralCandidates.delete(agent);
10716
+ if (candidate.resume && this.resumesPending.get(agent.name) === agent)
10717
+ this.resumesPending.delete(agent.name);
9053
10718
  if (this.agents.has(agent.name) || this.ephemeralRuns.has(agent.name)) {
9054
10719
  throw new Error(`Ephemeral agent "${agent.name}" is already registered or running`);
9055
10720
  }
10721
+ // Admit it to the registry first: a spec the registry refuses (spawner
10722
+ // or result recipient gone since creation) must not leave a live agent.
10723
+ const registered = this.registry.register(candidate.spec, { resume: candidate.resume === true, after: candidate.after });
10724
+ this.announceAgentCreated(registered);
9056
10725
  // Register temporarily so the event loop can drive it
9057
10726
  this.agents.set(agent.name, agent);
10727
+ // Kept while the run is live so the agent can itself be derived from.
10728
+ this.agentConfigs.set(agent.name, candidate.config);
10729
+ // A spawner that ended after creating it: it registered as that
10730
+ // spawner's orphan, and is told so, as is its first candidate.
10731
+ if (registered.orphaned) {
10732
+ this.announceOrphaned(registered, registered.orphaned.candidates, registered.orphaned.formerParent.agent, registered.orphaned.reason);
10733
+ }
10734
+ // Mail held for this name (a resumed incarnation's, sent while it was
10735
+ // ended) reaches it now rather than at the next boot. Its run is
10736
+ // starting, so it wakes nothing.
10737
+ for (const mail of this.registry.listMail({ to: agent.name }))
10738
+ this.deliverHeldMail(mail, false);
9058
10739
  const run = {
9059
10740
  settle: this.createDeferred(),
9060
10741
  inferenceStarted: false,
@@ -9062,14 +10743,39 @@ export class AgentFramework {
9062
10743
  toolCallsCount: 0,
9063
10744
  };
9064
10745
  this.ephemeralRuns.set(agent.name, run);
10746
+ const lifetime = candidate.spec.lifetime;
9065
10747
  const STARTUP_TIMEOUT_MS = watchdogs?.startupTimeoutMs ?? 30_000;
9066
10748
  // After inference has started, give it 15 minutes of activity-bounded
9067
10749
  // life. The stream driver refreshes the deadline as it makes progress;
9068
- // sustained silence trips it.
9069
- const COMPLETION_IDLE_TIMEOUT_MS = watchdogs?.idleTimeoutMs ?? 15 * 60_000;
10750
+ // sustained silence trips it. An explicit watchdog wins; otherwise the
10751
+ // spec's task lifetime is the bound.
10752
+ const COMPLETION_IDLE_TIMEOUT_MS = watchdogs?.idleTimeoutMs
10753
+ ?? (lifetime.kind === 'task' ? lifetime.idleTimeoutMs : undefined)
10754
+ ?? 15 * 60_000;
9070
10755
  const IDLE_POLL_MS = watchdogs?.idlePollMs ?? 30_000;
9071
10756
  let startupWatchdog = null;
9072
10757
  let completionWatchdog = null;
10758
+ let deadlineWatchdog = null;
10759
+ // A task lifetime's wall-clock deadline: the identity must end, however
10760
+ // lively its stream is.
10761
+ // A timer holds at most MAX_TIMER_MS, so a longer deadline waits in
10762
+ // steps, each re-armed until the deadline is due.
10763
+ const deadline = new Promise((_, reject) => {
10764
+ if (lifetime.kind !== 'task' || lifetime.deadlineMs === undefined)
10765
+ return;
10766
+ const deadlineMs = lifetime.deadlineMs;
10767
+ const dueAt = Date.now() + deadlineMs;
10768
+ const arm = () => {
10769
+ deadlineWatchdog = setTimeout(() => {
10770
+ if (Date.now() < dueAt)
10771
+ return arm();
10772
+ run.endReason = 'deadline';
10773
+ reject(new AgentStoppedError(agent.name, 'deadline', undefined, `${deadlineMs}ms elapsed`));
10774
+ }, Math.max(0, Math.min(dueAt - Date.now(), MAX_TIMER_MS)));
10775
+ deadlineWatchdog.unref?.();
10776
+ };
10777
+ arm();
10778
+ });
9073
10779
  // Whether the completion watchdog ended the run as stalled, for a
9074
10780
  // tool-result batch the run leaves behind (see the finally).
9075
10781
  let stalled = false;
@@ -9115,7 +10821,9 @@ export class AgentFramework {
9115
10821
  run.settle.promise,
9116
10822
  startupTimeout,
9117
10823
  completionIdleTimeout,
10824
+ deadline,
9118
10825
  ]);
10826
+ run.endReason ??= 'completed';
9119
10827
  return { speech: result.speech, toolCallsCount: result.toolCallsCount };
9120
10828
  }
9121
10829
  finally {
@@ -9123,6 +10831,8 @@ export class AgentFramework {
9123
10831
  clearTimeout(startupWatchdog);
9124
10832
  if (completionWatchdog)
9125
10833
  clearInterval(completionWatchdog);
10834
+ if (deadlineWatchdog)
10835
+ clearTimeout(deadlineWatchdog);
9126
10836
  // A batch still pending here has nothing left to settle it: once the
9127
10837
  // run is deregistered, driveStream's finally abandons nothing (it does
9128
10838
  // only while the agent is registered), and no later turn comes. The
@@ -9182,6 +10892,7 @@ export class AgentFramework {
9182
10892
  this.turnRouting.delete(agent.name);
9183
10893
  this.proseSpeechChains.delete(agent.name);
9184
10894
  this.openLogical.delete(agent.name);
10895
+ this.forgetAnnouncedLocus(agent.name);
9185
10896
  // The late stream finalizer intentionally refuses name-keyed cleanup once
9186
10897
  // deregistered, so disposal must release EventGate liveness itself.
9187
10898
  this.eventGate?.onInferenceEnded(agent.name);
@@ -9193,6 +10904,9 @@ export class AgentFramework {
9193
10904
  this.releaseWindowNowOrAfterResume(agent);
9194
10905
  }
9195
10906
  this.toolImageLedgers.delete(agent.name);
10907
+ if (this.agentConfigs.get(agent.name) === candidate.config)
10908
+ this.agentConfigs.delete(agent.name);
10909
+ this.toolSurfaceOwners.delete(agent.name);
9196
10910
  // Its scripts end with it: no turn of its is left for them to outlive.
9197
10911
  this.codeExecution?.scripts.agentGone(agent.name);
9198
10912
  // Spawn-and-dispose bookkeeping (main, d453165/fee96a7): without this,
@@ -9210,6 +10924,22 @@ export class AgentFramework {
9210
10924
  // A continuation of its turn still on its way goes too: the run, and
9211
10925
  // with it the turn, has ended above.
9212
10926
  this.dropContinuations(agent.name, null);
10927
+ if (run.endReason === 'host-stop') {
10928
+ // Cut off by stop(): left live for the next boot to end as interrupted.
10929
+ const record = this.registry.inspect(agent.name);
10930
+ this.emitTrace({
10931
+ type: 'dendrite:agent-interrupted',
10932
+ agentName: agent.name,
10933
+ kind: record?.kind ?? candidate.spec.kind,
10934
+ incarnation: record?.incarnation ?? 0,
10935
+ reason: 'host-stop',
10936
+ });
10937
+ }
10938
+ else {
10939
+ // The identity ends with its run. Whatever it spawned is settled by
10940
+ // the registry's rules (attention tenants end, task work is orphaned).
10941
+ this.settleEndedAgent(this.registry.end(agent.name, run.endReason ?? 'failed', run.endedBy, { record: true }));
10942
+ }
9213
10943
  }
9214
10944
  }
9215
10945
  /**
@@ -9240,12 +10970,22 @@ export class AgentFramework {
9240
10970
  'its messages will reach your subconscious where that\'s known. ' +
9241
10971
  'Calling tune_out again for a channel already tuned out keeps its ' +
9242
10972
  'settings, and, where this host can open the channel, tries the open ' +
9243
- 'again if it isn\'t open.',
10973
+ 'again if it isn\'t open. Without a subconscious it is a silent hold: ' +
10974
+ 'nothing summarizes or wakes you; the backlog is delivered when you ' +
10975
+ 'cancel. scope "all-except" holds EVERY channel (DMs and new channels ' +
10976
+ 'included) except channelId, for durationSeconds (required); your plain ' +
10977
+ 'speech lands in that channel meanwhile; the reader pauses rather than ' +
10978
+ 'cancels when its wake budget is spent. mode "check" reports what is ' +
10979
+ 'held without releasing it (`peek` shows one channel\'s newest held lines).',
9244
10980
  inputSchema: {
9245
10981
  type: 'object',
9246
10982
  properties: {
9247
- channelId: { type: 'string' },
9248
- mode: { type: 'string', enum: ['enter', 'cancel'], description: 'Default: enter.' },
10983
+ channelId: { type: 'string', description: 'The channel to tune out — or, with scope "all-except", the one channel to keep.' },
10984
+ 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").' },
10985
+ mode: { type: 'string', enum: ['enter', 'cancel', 'check'], description: 'Default: enter.' },
10986
+ scope: { type: 'string', enum: ['channel', 'all-except'], description: 'Default: channel.' },
10987
+ peek: { type: 'string', description: 'mode "check": a held channel whose newest held lines to show (nothing is released).' },
10988
+ peekLimit: { type: 'number', description: 'mode "check": how many lines to peek (default 5, max 50).' },
9249
10989
  cadenceSeconds: {
9250
10990
  type: 'number',
9251
10991
  description: `Summary cadence in seconds (default ${TUNE_OUT_DEFAULTS.cadenceSeconds}; floors at 30).`,
@@ -9265,7 +11005,31 @@ export class AgentFramework {
9265
11005
  'delivered) after this many seconds; floors at 60. Unset = until cancelled.',
9266
11006
  },
9267
11007
  },
9268
- required: ['channelId'],
11008
+ required: [],
11009
+ },
11010
+ };
11011
+ /** `focus`: the resident-facing alias for `tune_out` with scope "all-except" (#232 decision 2). */
11012
+ static FOCUS_TOOL = {
11013
+ name: 'focus',
11014
+ description: 'Focus on one channel for a bounded time: every other channel — DMs ' +
11015
+ 'and channels that appear later included — is held and stops waking ' +
11016
+ 'you; your plain speech lands in the focused channel. The same hold as ' +
11017
+ 'tune_out with scope "all-except". mode "cancel" releases the held ' +
11018
+ 'backlog per channel; mode "check" reports what is held.',
11019
+ inputSchema: {
11020
+ type: 'object',
11021
+ properties: {
11022
+ channelId: { type: 'string', description: 'The one channel to keep.' },
11023
+ serverId: { type: 'string' },
11024
+ durationSeconds: { type: 'number', description: 'Required to enter; floors at 60.' },
11025
+ mode: { type: 'string', enum: ['enter', 'cancel', 'check'], description: 'Default: enter.' },
11026
+ cadenceSeconds: { type: 'number' },
11027
+ backlogCap: { type: 'number', description: 'Per held channel, at release.' },
11028
+ maxWakes: { type: 'number' },
11029
+ peek: { type: 'string' },
11030
+ peekLimit: { type: 'number' },
11031
+ },
11032
+ required: [],
9269
11033
  },
9270
11034
  };
9271
11035
  /** Synthesized sleep/wake tool definitions (present when a gate is wired). */
@@ -11053,17 +12817,20 @@ export class AgentFramework {
11053
12817
  }
11054
12818
  disposeEmptiedForks(failures) {
11055
12819
  const disposals = [];
11056
- for (const [agentName, channelId] of [...this.conversationAgentHomes]) {
12820
+ // The forks are the registry's conversation-fork records; a fork's home is
12821
+ // the channel on its server (homeOf), which the router's binding is keyed by.
12822
+ const forks = this.listAgents().filter((r) => r.kind === 'conversation-fork').map((r) => ({ agentName: r.name, home: this.homeOf(r.name) }));
12823
+ for (const { agentName, home } of forks) {
11057
12824
  const agent = this.agents.get(agentName);
11058
12825
  if (!agent || agent.getContextManager().getMessageCount() > 0)
11059
12826
  continue;
11060
12827
  const heldWrites = this.deferredMessages.filter((msg) => (msg.forAgent ?? this.primaryAgentName) === agentName).length;
11061
12828
  let cleanupFailed;
11062
12829
  try {
11063
- if (this.conversationRouter?.getBinding(channelId)?.agentName === agentName) {
11064
- this.conversationRouter.unbind(channelId);
12830
+ if (home && this.conversationRouter && this.conversationRouter.getBinding(home)?.agentName === agentName) {
12831
+ this.conversationRouter.unbind(home);
11065
12832
  }
11066
- this.disposeConversationAgent(agentName);
12833
+ this.disposeConversationAgent(agentName, 'context-emptied');
11067
12834
  }
11068
12835
  catch (error) {
11069
12836
  cleanupFailed = failureDescription(error);
@@ -11918,7 +13685,12 @@ export class AgentFramework {
11918
13685
  }
11919
13686
  }
11920
13687
  async createAgent(config) {
11921
- const sees = (metadata) => !metadata?.tuneOut
13688
+ // The residents' view (RESIDENT_VIEW_FILTER: held rows out, read by
13689
+ // heldFromResidents) composed with this agent's own: another agent's
13690
+ // stored context injections out. Main's boundary and arrival checks read
13691
+ // the same predicate (storeSees, agentSees), so they and the compiled
13692
+ // view agree.
13693
+ const sees = (metadata) => !heldFromResidents(metadata)
11922
13694
  && !isAnotherAgentsInjection(metadata, config.name);
11923
13695
  // Create context manager for this agent
11924
13696
  const contextManager = await ContextManager.open({
@@ -11939,7 +13711,8 @@ export class AgentFramework {
11939
13711
  // fetch_history, and audit.
11940
13712
  // Another agent's stored context injections (stored-injections.ts) are
11941
13713
  // that agent's context, not conversation: they stay out of this view,
11942
- // and so out of its folds, as the residents share this slot.
13714
+ // and so out of its folds, as the residents share this slot. The
13715
+ // resident filter keeps held (tuned-out) rows out as before (`sees`).
11943
13716
  viewFilter: (message) => sees(message.metadata),
11944
13717
  });
11945
13718
  this.trackManager(contextManager);
@@ -11948,6 +13721,8 @@ export class AgentFramework {
11948
13721
  this.adoptToolResultGuard(agent);
11949
13722
  agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
11950
13723
  this.sharedSlotAgents.add(agent);
13724
+ // The first resident declared owns default delivery.
13725
+ this.announceAgentCreated(this.registry.register({ ...residentSpec(config.name, { primary: !this.primaryAgentName }), model: config.model }));
11951
13726
  let restoredSettings;
11952
13727
  try {
11953
13728
  restoredSettings = this.readAgentRuntimeSettings(config.name);
@@ -12050,6 +13825,10 @@ export class AgentFramework {
12050
13825
  this.adoptToolResultGuard(agent);
12051
13826
  agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
12052
13827
  this.sharedSlotAgents.add(agent); // reads the shared slot through its merged view
13828
+ // An attention tenant of the resident it serves: reads the shared slot
13829
+ // through its merged view (held traffic included), never a broadcast
13830
+ // recipient, and outlives policy epochs.
13831
+ this.announceAgentCreated(this.registry.register({ ...subconsciousSpec(name, primaryName), model: agentConfig.model }));
12053
13832
  this.agents.set(name, agent);
12054
13833
  this.agentConfigs.set(name, agentConfig);
12055
13834
  this.subconsciousAgentName = name;
@@ -12057,6 +13836,188 @@ export class AgentFramework {
12057
13836
  this.subconsciousConfig = cfg;
12058
13837
  return agent;
12059
13838
  }
13839
+ /**
13840
+ * `subconscious.reader: 'forks'`: no persistent agent. The coordinator
13841
+ * asks for a fork per invocation (see invokeReaderFork); the config is
13842
+ * kept for the voice block, the speech switch and the strategy factory.
13843
+ */
13844
+ configureReaderForks(cfg) {
13845
+ const primaryName = this.primaryAgentName;
13846
+ const primaryConfig = primaryName ? this.agentConfigs.get(primaryName) : undefined;
13847
+ if (!primaryName || !primaryConfig) {
13848
+ throw new Error('subconscious requires a primary agent to attend to');
13849
+ }
13850
+ const strategy = primaryConfig.strategy;
13851
+ if (!cfg.strategyFactory && strategy && !(strategy instanceof PassthroughStrategy)) {
13852
+ throw new Error(`subconscious.reader "forks": the primary agent "${primaryName}" runs a ${strategy.name} strategy, ` +
13853
+ 'so a reader fork needs `subconscious.strategyFactory` returning a fresh instance of the same ' +
13854
+ 'class and configuration to reuse its fold state and rendering');
13855
+ }
13856
+ // Whose weights read the resident's prefix is never defaulted. The
13857
+ // resident's own model is the copy; another is allowed when stated, so
13858
+ // the resident's consent can name it (architecture channel, 10-09).
13859
+ if (typeof cfg.model !== 'string' || cfg.model.length === 0) {
13860
+ throw new Error(`subconscious.reader "forks": a reader fork holds the whole prefix of "${primaryName}", so \`model\` ` +
13861
+ `must say whose weights run it — "${primaryConfig.model}" for a copy of the resident, or another model ` +
13862
+ 'by name. It is not defaulted');
13863
+ }
13864
+ if (typeof cfg.voice !== 'string' || cfg.voice.length === 0) {
13865
+ throw new Error('subconscious.reader "forks": `voice` (the reader\'s framing block) is required');
13866
+ }
13867
+ this.readerForks = true;
13868
+ this.subconsciousConfig = cfg;
13869
+ }
13870
+ /**
13871
+ * One reader invocation: derive a fork of the resident at its head with
13872
+ * the resident's refusals, hand it the held traffic since the last look
13873
+ * as ordinary framing, and run it. It reports through `deliver_summary`
13874
+ * (attributed mail), may cancel the hold, and ends when its turn ends.
13875
+ */
13876
+ invokeReaderFork(invocation) {
13877
+ const primary = this.primaryAgentName;
13878
+ const cfg = this.subconsciousConfig;
13879
+ if (!primary || cfg?.reader !== 'forks' || !this.agents.has(primary))
13880
+ return;
13881
+ const name = `reader/${primary}/${invocation.epochId.slice(0, 8)}/${++this.readerForkCounter}`;
13882
+ void (async () => {
13883
+ try {
13884
+ // The queued messages are rows of the backlog a framing reads, after
13885
+ // the stored ones, so any framing that shows `backlog` shows them.
13886
+ const backlog = [...invocation.backlog, ...this.queuedReaderRows(primary, invocation.queued)];
13887
+ const content = (cfg.framing ?? defaultReaderFraming)({
13888
+ resident: primary,
13889
+ fork: name,
13890
+ voice: cfg.voice,
13891
+ channelId: invocation.channelId,
13892
+ epochId: invocation.epochId,
13893
+ trigger: invocation.trigger,
13894
+ notice: invocation.notice,
13895
+ dispositions: invocation.dispositions,
13896
+ backlog,
13897
+ queuedCount: invocation.queued.length,
13898
+ });
13899
+ const fork = await this.deriveAgent({
13900
+ name,
13901
+ from: primary,
13902
+ kind: 'subconscious-fork',
13903
+ ...(cfg.strategyFactory ? { strategy: cfg.strategyFactory() } : {}),
13904
+ // Whose weights, as the configuration states it; its report is a
13905
+ // tool call, so bare prose never routes anywhere.
13906
+ config: {
13907
+ model: cfg.model,
13908
+ proseRouting: 'disabled',
13909
+ },
13910
+ inheritRefusals: true,
13911
+ onParentEnd: 'end',
13912
+ resultTo: { to: primary, as: 'message' },
13913
+ idleTimeoutMs: cfg.forkIdleTimeoutMs ?? 10 * 60_000,
13914
+ framing: [{
13915
+ participant: 'user',
13916
+ content,
13917
+ metadata: {
13918
+ system: true,
13919
+ kind: 'tune-out-reader-framing',
13920
+ channelId: invocation.channelId,
13921
+ epochId: invocation.epochId,
13922
+ trigger: invocation.trigger,
13923
+ heldMessageIds: backlog.map((m) => m.id),
13924
+ },
13925
+ }],
13926
+ metadata: { epochId: invocation.epochId, channelId: invocation.channelId, trigger: invocation.trigger },
13927
+ });
13928
+ this.emitTrace({
13929
+ type: 'tune-out:reader-fork',
13930
+ agentName: name,
13931
+ serverId: invocation.serverId,
13932
+ channelId: invocation.channelId,
13933
+ epochId: invocation.epochId,
13934
+ trigger: invocation.trigger,
13935
+ held: invocation.backlog.length + invocation.queued.length,
13936
+ queued: invocation.queued.length,
13937
+ });
13938
+ const outcome = await this.runEphemeralToCompletion(fork.agent, fork.contextManager);
13939
+ // The report is the fork's whole job. A reader that ends in prose
13940
+ // without `deliver_summary` has still reported — its final prose is
13941
+ // delivered as its report, under its own name, instead of vanishing
13942
+ // into proseRouting=disabled (first live run, 2026-10-12, finding A).
13943
+ const prose = outcome.speech.trim();
13944
+ if (prose && !this.tuneOutCoordinator?.readerReported(name)) {
13945
+ this.deliverFromReader(name, [{ type: 'text', text: prose }], {
13946
+ kind: 'subconscious-summary',
13947
+ channelId: invocation.channelId,
13948
+ epochId: invocation.epochId,
13949
+ report: 'prose',
13950
+ });
13951
+ this.emitTrace({
13952
+ type: 'tune-out:reader-prose-delivered',
13953
+ agentName: name,
13954
+ serverId: invocation.serverId,
13955
+ channelId: invocation.channelId,
13956
+ epochId: invocation.epochId,
13957
+ chars: prose.length,
13958
+ });
13959
+ }
13960
+ }
13961
+ catch (error) {
13962
+ console.error(`[tune-out] reader fork ${name} failed:`, error instanceof Error ? error.message : error);
13963
+ // A fork that failed before it reported never read what it was
13964
+ // handed: those messages go back to the channel's backlog, so the
13965
+ // next reader for it is handed them instead of no reader ever being.
13966
+ const reported = this.tuneOutCoordinator?.readerReported(name) ?? false;
13967
+ const returned = reported ? 0 : (this.tuneOutCoordinator?.readerFailed(invocation) ?? 0);
13968
+ this.emitTrace({
13969
+ type: 'tune-out:reader-fork-failed',
13970
+ agentName: name,
13971
+ serverId: invocation.serverId,
13972
+ channelId: invocation.channelId,
13973
+ epochId: invocation.epochId,
13974
+ trigger: invocation.trigger,
13975
+ error: error instanceof Error ? error.message : String(error),
13976
+ reported,
13977
+ returned,
13978
+ });
13979
+ }
13980
+ finally {
13981
+ this.tuneOutCoordinator?.forgetReader(name);
13982
+ }
13983
+ })();
13984
+ }
13985
+ /**
13986
+ * Held messages still queued behind the resident's turn, as rows of a
13987
+ * reader fork's backlog: each named by its held handle, as module rows name
13988
+ * it until it lands (heldRows), at a provisional sequence past the
13989
+ * resident's last stored message, and timed when it was first held.
13990
+ */
13991
+ queuedReaderRows(primary, queued) {
13992
+ if (queued.length === 0)
13993
+ return [];
13994
+ const cm = this.agents.get(primary).getContextManager();
13995
+ const count = cm.getMessageCount();
13996
+ const last = count > 0 ? cm.getMessageWindow(count - 1, 1, { resolveBlobs: false }).messages[0]?.sequence ?? 0 : 0;
13997
+ return queued.map((m, i) => ({
13998
+ id: heldHandle(m.writeId),
13999
+ sequence: last + 1 + i,
14000
+ participant: m.participant,
14001
+ content: m.content,
14002
+ metadata: m.metadata,
14003
+ timestamp: new Date(m.heldAt ?? Date.now()),
14004
+ }));
14005
+ }
14006
+ /** A reader fork's own words to the resident: attributed mail when it has a route, else under its name. */
14007
+ deliverFromReader(reader, content, metadata) {
14008
+ const route = this.registry.inspect(reader)?.relationships.resultTo;
14009
+ if (route?.as === 'message') {
14010
+ const mail = this.registry.holdMail({
14011
+ kind: 'result',
14012
+ from: this.registry.ref(reader),
14013
+ to: route.to,
14014
+ content,
14015
+ });
14016
+ this.attemptMailDelivery(mail);
14017
+ return;
14018
+ }
14019
+ this.addMessage(reader, content, metadata);
14020
+ }
12060
14021
  async runLoop() {
12061
14022
  // A loop ends with its lifecycle. A stop() whose budget ran out while
12062
14023
  // this loop was in a call (a module's onProcess) returns before the call
@@ -12344,7 +14305,7 @@ export class AgentFramework {
12344
14305
  // stand between a tool's call and its results, so this clock
12345
14306
  // reads the time it's written (clock-rows.ts).
12346
14307
  const clock = this.storeDueClock(agent, 'tool-boundary');
12347
- const deferred = this.drainDeferredFor(agent.name, (msg) => !isInjectableMidTurn(msg, agent.name));
14308
+ const deferred = this.drainDeferredFor(agent.name, (msg) => !isInjectableMidTurn(msg, agent));
12348
14309
  // Stored in queue order, cut at the first that can't be stored: it
12349
14310
  // and what follows wait for the turn's end, and only what was
12350
14311
  // stored is injected (the round's results go on either way). One
@@ -12356,10 +14317,17 @@ export class AgentFramework {
12356
14317
  // counts as stored, as what landed of it (writeDrainedInOrder).
12357
14318
  const stored = this.writeDrainedInOrder(deferred, `tool boundary for ${agent.name}`, (msg) => agent.getContextManager().addMessage(msg.participant, msg.content, withDeferredWriteId(msg.metadata, msg.id)));
12358
14319
  for (const [msg, { id, content }] of stored) {
14320
+ this.releaseLandedMail(msg.metadata, agent.name);
12359
14321
  this.noteSpokenTo(agent.name, msg.metadata);
12360
14322
  // Every other resident reads what this boundary stores; for its
12361
14323
  // own agent, the boundary weighs what it injects below.
12362
14324
  this.noteForOtherResidents(agent, msg.metadata, id);
14325
+ // A message the agent's own view leaves out is stored and not
14326
+ // injected: a held message (the reader has it; admission's
14327
+ // predicate, heldFromResidents, is in every resident's view
14328
+ // filter) or another agent's stored context injection. Injecting
14329
+ // it here would put in the live turn what the next compile
14330
+ // hides (#232 gap 5; main's #853 reads the view itself).
12363
14331
  if (!this.agentSees(agent, msg.metadata))
12364
14332
  continue;
12365
14333
  heard.push({ msg, id, content });
@@ -12481,6 +14449,38 @@ export class AgentFramework {
12481
14449
  + agent.maxTokens
12482
14450
  : 0;
12483
14451
  const overPhysical = projectedRealTokens > (agent.physicalWindowTokens ?? Infinity);
14452
+ // Under `speech.locus: 'delivery'` incoming traffic never moves the
14453
+ // pin — so when someone addresses the agent mid-turn from another
14454
+ // channel, the agent must be told who and from where, or a neutral
14455
+ // message reads as part of the conversation it landed in and gets
14456
+ // answered there (first live run, 2026-10-12, finding C). One
14457
+ // notice per channel per turn; it rides the same injection batch.
14458
+ // The turn's own mode: a silent turn routes no prose, whatever the
14459
+ // agent's mode, so its boundary neither notices nor re-pins. The
14460
+ // trigger is this stream's: its start holds the agent's turn token
14461
+ // while the agent waits on this round's tools.
14462
+ const routesLocus = (agent.proseRouting === 'locus' || agent.proseRouting === 'hybrid')
14463
+ && !this.activeTurnTriggers.get(agent.name)?.suppressProse;
14464
+ if (this.speechLocus === 'delivery' && routesLocus && !shouldEndTurn && !overBudget && currentState.stream) {
14465
+ const routing = this.routingOf(agent.name);
14466
+ for (const inj of [...midTurnInjections]) {
14467
+ const m = inj.metadata;
14468
+ if (!isConversationalInjection(inj.metadata))
14469
+ continue;
14470
+ if (typeof m?.channelId !== 'string')
14471
+ continue;
14472
+ if (!isAddressedMessage(m.tags))
14473
+ continue;
14474
+ // Its channel as a re-pin reads it (repinChannel): on the
14475
+ // message's own server.
14476
+ const channel = this.channelNamed(m.channelId, typeof m.serverId === 'string' ? m.serverId : undefined);
14477
+ if (!channel)
14478
+ continue;
14479
+ const notice = this.addressedElsewhereNotice(agent, routing, channel, authorName(m));
14480
+ if (notice)
14481
+ midTurnInjections.push(notice);
14482
+ }
14483
+ }
12484
14484
  // Addressed re-pin (2026-07-31 Mythos misroutes, antra-ratified):
12485
14485
  // when someone ADDRESSES the agent mid-turn from another channel
12486
14486
  // (mention / reply / DM — the same chat:addressed signal the
@@ -12511,9 +14511,7 @@ export class AgentFramework {
12511
14511
  // agent's mode, so its boundary neither re-pins nor notes. The
12512
14512
  // trigger is this stream's: its start holds the agent's turn token
12513
14513
  // while the agent waits on this round's tools.
12514
- const routesLocus = (agent.proseRouting === 'locus' || agent.proseRouting === 'hybrid')
12515
- && !this.activeTurnTriggers.get(agent.name)?.suppressProse;
12516
- if (routesLocus && !shouldEndTurn && !overBudget && currentState.stream) {
14514
+ if (this.speechLocus === 'latest-inbound' && routesLocus && !shouldEndTurn && !overBudget && currentState.stream) {
12517
14515
  // Two signals qualify an injection to move the pin (n=6 + n=7):
12518
14516
  // - chat:addressed — someone explicitly spoke TO the agent
12519
14517
  // (mention / reply / DM);
@@ -12893,10 +14891,11 @@ export class AgentFramework {
12893
14891
  if (!decision.trigger)
12894
14892
  return;
12895
14893
  }
12896
- // Broadcast requests exclude conversation forks — they are driven by
12897
- // their own channel's messages, not by framework-wide events.
14894
+ // A broadcast request reaches the agents whose role says they take
14895
+ // untargeted inbound; channel-bound and bounded-job agents are driven
14896
+ // by their own channel or task, not by framework-wide events.
12898
14897
  const targetAgents = response.requestInference === true
12899
- ? Array.from(this.agents.keys()).filter((n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName)
14898
+ ? this.registry.untargetedRecipients()
12900
14899
  : response.requestInference;
12901
14900
  for (const agentName of targetAgents) {
12902
14901
  const agent = this.agents.get(agentName);
@@ -12942,6 +14941,24 @@ export class AgentFramework {
12942
14941
  * Convert an incoming MCPL channel message to a context message.
12943
14942
  * This replaces the old MCPLModule.onProcess() message conversion.
12944
14943
  */
14944
+ /**
14945
+ * A delivered inbound message becomes the latest delivered inbound (the
14946
+ * channel context's reply anchor; the legacy speech fallback) through the
14947
+ * registry's noteDelivered — at once for a plain channels/incoming, whose
14948
+ * acceptance the registry answers synchronously; parked for a coalesced one
14949
+ * until its acceptance is acknowledged (see parkedAnchors).
14950
+ */
14951
+ noteDeliveredOrPark(event) {
14952
+ // On the server the message came from (noteDelivered).
14953
+ const anchor = { channel: { serverId: event.serverId, channelId: event.channelId }, messageId: event.messageId, threadId: event.threadId, tags: event.tags, firstView: event.firstView === true };
14954
+ if (event.coalescingSubject && typeof event.eventId === 'string') {
14955
+ this.parkedAnchors.set(`${event.serverId}:${event.eventId}`, anchor);
14956
+ if (this.parkedAnchors.size > 1000)
14957
+ this.parkedAnchors.delete(this.parkedAnchors.keys().next().value);
14958
+ return;
14959
+ }
14960
+ this.channelRegistry?.noteDelivered(anchor.channel, anchor.messageId, anchor.threadId, anchor.tags, anchor.firstView);
14961
+ }
12945
14962
  async handleMcplChannelIncoming(event) {
12946
14963
  // context-manager won't store content that doesn't match ContentBlock
12947
14964
  // (malformedContentPlaces names where). Content from the wire can't be
@@ -13007,6 +15024,49 @@ export class AgentFramework {
13007
15024
  // before any host decoration. The stored copy's own digest is taken at
13008
15025
  // each storage site below, over exactly the blocks stored there.
13009
15026
  metadata.sourceBodyDigest = sourceBodyDigest(event.content);
15027
+ // Stored as the model reads it: `author: body`. The wire formatter
15028
+ // writes the participant, and channel traffic is stored under the
15029
+ // anonymous `user`, so a body stored bare reaches the model as
15030
+ // `user: <text>` with the author nowhere (Weft's rig, 2026-10-13). A
15031
+ // server that renders the name into the text itself is left as is.
15032
+ const content = presentAuthor({ participant: 'user', content: event.content, metadata }).content;
15033
+ // Admission (Dendrite §D, #232 root cause A): decided once, here, before
15034
+ // anything downstream acts — the hold, the wake gate, who may be woken,
15035
+ // whether this counts as the latest delivered inbound. Every path below
15036
+ // consumes `decision`; none re-asks the policy.
15037
+ const addressed = isAddressedMessage(event.tags);
15038
+ const decision = this.admitInbound({
15039
+ lane: 'channel',
15040
+ serverId: event.serverId,
15041
+ channelId: event.channelId,
15042
+ messageId: event.messageId,
15043
+ authorId: event.author?.id,
15044
+ tags: event.tags,
15045
+ addressed,
15046
+ vetoed: event.triggerInference === false,
15047
+ targetAgents: event.targetAgents,
15048
+ gateContent: event.content
15049
+ .filter((b) => b.type === 'text')
15050
+ .map((b) => b.text)
15051
+ .join('\n'),
15052
+ gateMetadata: {
15053
+ ...event.metadata,
15054
+ eventType: 'mcpl:channel-incoming',
15055
+ serverId: event.serverId,
15056
+ channelId: event.channelId,
15057
+ messageId: event.messageId,
15058
+ threadId: event.threadId,
15059
+ author: event.author,
15060
+ ...(event.tags ? { tags: event.tags } : {}),
15061
+ },
15062
+ });
15063
+ metadata.triggered = decision.wake;
15064
+ if (decision.held) {
15065
+ // The admission record rides the message; the hold's own bookkeeping
15066
+ // (cadence tally, suppressed-mention ack, reader wake) follows.
15067
+ metadata.tuneOut = { epochId: decision.held.epochId };
15068
+ this.tuneOutCoordinator?.recordHeld(event.serverId, event.channelId, event.messageId, event.tags, event.author?.id, decision.gatePass);
15069
+ }
13010
15070
  // Per-channel conversation routing: messages go to the channel's fork
13011
15071
  // agent (spawned from the template on first qualifying message), never
13012
15072
  // to the primary conversation.
@@ -13017,14 +15077,20 @@ export class AgentFramework {
13017
15077
  const target = this.agents.get(event.deliverTo);
13018
15078
  if (!target)
13019
15079
  return undefined;
13020
- if (this.conversationRouter && this.conversationAgentHomes.has(target.name)) {
15080
+ if (this.conversationRouter && this.registry.homeChannel(target.name) !== undefined) {
13021
15081
  metadata.triggered = event.triggerInference ?? false;
13022
15082
  // A fork's channel messages are stored past its deferral; a clock goes
13023
15083
  // ahead of one only while the fork has no turn alive (storeDueClock).
13024
15084
  if (!this.turnAlive(target) && takesClockBefore('user', event.content, metadata, target.name)) {
13025
15085
  this.storeDueClock(target, 'event');
13026
15086
  }
13027
- const placement = this.writeToFork(target, event.content, AgentFramework.withStoredDigest(metadata, event.content));
15087
+ // The stored copy carries the author as the fork reads them (presentAuthor), and its digest is of that copy.
15088
+ const placement = this.writeToFork(target, content, AgentFramework.withStoredDigest(metadata, content));
15089
+ // Delivered to the fork, so it is the latest delivered inbound too
15090
+ // (the channel context's `incoming`; the fallback locus under the
15091
+ // legacy rule), as a resident's delivery is below.
15092
+ if (decision.locus)
15093
+ this.noteDeliveredOrPark(event);
13028
15094
  // A correction may target an older engagement. Refresh only the
13029
15095
  // binding whose agent actually received the message.
13030
15096
  const channel = { serverId: event.serverId, channelId: event.channelId };
@@ -13032,12 +15098,12 @@ export class AgentFramework {
13032
15098
  this.conversationRouter.touch(channel);
13033
15099
  }
13034
15100
  this.emitTrace({ type: 'message:added', messageId: placement.messageId ?? '', source: 'mcpl:channel-incoming' });
13035
- if (event.triggerInference) {
15101
+ if (decision.wake) {
13036
15102
  this.pendingRequests.push({
13037
15103
  agentName: target.name, reason: 'mcpl:channel-incoming', source: event.serverId, timestamp: Date.now(),
13038
15104
  channelId: event.channelId, channelServerId: event.serverId,
13039
15105
  counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
13040
- addressed: isAddressedMessage(event.tags, event.metadata),
15106
+ addressed,
13041
15107
  ...systemNoticeMark(event.tags),
13042
15108
  ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
13043
15109
  });
@@ -13046,14 +15112,14 @@ export class AgentFramework {
13046
15112
  }
13047
15113
  if (this.conversationRouter)
13048
15114
  return undefined; // the trunk never holds channel traffic
13049
- event = { ...event, targetAgents: [target.name] };
15115
+ decision.recipients = [target.name];
13050
15116
  }
13051
15117
  else if (this.conversationRouter) {
13052
- return this.routeConversationIncoming(event, metadata);
15118
+ return this.routeConversationIncoming({ ...event, content }, metadata, decision);
13053
15119
  }
13054
15120
  // Addressed-while-closed invitation — parity with the push-event path
13055
15121
  // (channels.publish surfaces like portal-mcpl deliver mentions here).
13056
- const incomingContent = [...event.content];
15122
+ const incomingContent = [...content];
13057
15123
  {
13058
15124
  const invitation = this.buildClosedChannelInvitation({
13059
15125
  serverId: event.serverId,
@@ -13070,37 +15136,28 @@ export class AgentFramework {
13070
15136
  missedCharacters: typeof event.metadata?.missedCharacters === 'number'
13071
15137
  ? event.metadata.missedCharacters
13072
15138
  : undefined,
13073
- targetAgents: event.targetAgents,
15139
+ // A correction's one agent (deliverTo) is the agent whose window the
15140
+ // invitation goes into, so its mode is the one read (main's #852):
15141
+ // admission recorded it as the recipients, where main's code
15142
+ // rewrites the event's targetAgents.
15143
+ targetAgents: event.deliverTo ? decision.recipients : event.targetAgents,
13074
15144
  });
13075
15145
  if (invitation) {
13076
15146
  incomingContent.push(invitation.block);
13077
15147
  Object.assign(metadata, invitation.metadataPatch);
13078
15148
  }
13079
15149
  }
13080
- // Tune-out divert (issue #77): stamped, stored, no resident wake. The
13081
- // coordinator has already handled wake bookkeeping (coalesced
13082
- // subconscious invocation, durable count, suppression ack) before we
13083
- // stamp; the subconscious reads the message through its merged view.
13084
- const divert = this.tuneOutCoordinator?.onIncoming(event.serverId, event.channelId, event.messageId, event.tags, event.author?.id,
13085
- // The resident's gate verdict rides the event; it preconditions
13086
- // subconscious wakes (gate composes, it is not replaced).
13087
- event.triggerInference !== false) ?? null;
13088
- if (divert) {
13089
- metadata.tuneOut = { epochId: divert.epochId };
13090
- }
15150
+ // Captured where it arrived: a held message is stored with its admission
15151
+ // record and never enters a resident's view; a delivered one is also
15152
+ // the latest delivered inbound (the channel context's `incoming`, and
15153
+ // the fallback locus under the legacy rule) — a held one never is.
13091
15154
  const placement = { agent: '' };
13092
15155
  const id = this.addMessage('user', incomingContent, AgentFramework.withStoredDigest(metadata, incomingContent), { placement, bypassDeferralFor: event.assemblingFor });
13093
15156
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
13094
- if (event.triggerInference && !divert) {
13095
- const addressed = isAddressedMessage(event.tags, event.metadata);
13096
- // Honor explicit targeting, mirroring the push-event path: an event
13097
- // that names its agents wakes exactly those; an untargeted event
13098
- // keeps the historical broadcast. (targetAgents was declared on
13099
- // McplChannelIncomingEvent from the start but never honored here —
13100
- // tune-out's wake routing is the first setter.)
13101
- const targetAgents = event.targetAgents
13102
- ?? [...this.agents.keys()].filter((n) => n !== this.subconsciousAgentName);
13103
- for (const agentName of targetAgents) {
15157
+ if (decision.locus)
15158
+ this.noteDeliveredOrPark(event);
15159
+ if (decision.wake) {
15160
+ for (const agentName of decision.recipients) {
13104
15161
  if (!this.agents.has(agentName))
13105
15162
  continue;
13106
15163
  this.pendingRequests.push({
@@ -13131,7 +15188,7 @@ export class AgentFramework {
13131
15188
  * agent first when the bind policy matches. Unrouted messages are dropped —
13132
15189
  * the template ("trunk") is a dormant warm checkpoint, not a listener.
13133
15190
  */
13134
- async routeConversationIncoming(event, messageMetadata) {
15191
+ async routeConversationIncoming(event, messageMetadata, admission) {
13135
15192
  const router = this.conversationRouter;
13136
15193
  const channel = { serverId: event.serverId, channelId: event.channelId };
13137
15194
  const descriptor = this.channelRegistry?.getDescriptor(channel);
@@ -13191,15 +15248,20 @@ export class AgentFramework {
13191
15248
  return;
13192
15249
  }
13193
15250
  }
13194
- // Respect an explicit server-config-level veto (shouldTriggerInference)
13195
- // on top of the router's own trigger policy.
13196
- const trigger = decision.trigger && event.triggerInference !== false;
15251
+ // The admission decision (server veto, wake gate) composes with the
15252
+ // router's own trigger policy.
15253
+ const trigger = decision.trigger && admission.wake;
13197
15254
  messageMetadata.triggered = trigger;
13198
15255
  // As for a correction above: a clock ahead only while no turn is alive.
13199
15256
  if (!this.turnAlive(agent) && takesClockBefore('user', event.content, messageMetadata, agent.name)) {
13200
15257
  this.storeDueClock(agent, 'event');
13201
15258
  }
13202
15259
  const placement = this.writeToFork(agent, event.content, AgentFramework.withStoredDigest(messageMetadata, event.content));
15260
+ // Delivered to the channel's fork: the latest delivered inbound, as for a
15261
+ // resident (the channel context's `incoming`; the fallback locus under
15262
+ // the legacy rule). A held or dropped message never is.
15263
+ if (admission.locus)
15264
+ this.noteDeliveredOrPark(event);
13203
15265
  router.touch(channel);
13204
15266
  this.emitTrace({ type: 'message:added', messageId: placement.messageId ?? '', source: 'mcpl:channel-incoming' });
13205
15267
  if (trigger) {
@@ -13212,7 +15274,7 @@ export class AgentFramework {
13212
15274
  // triggering channel too so the trunk/active path stays consistent.
13213
15275
  channelId: event.channelId, channelServerId: event.serverId,
13214
15276
  counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined,
13215
- addressed: isAddressedMessage(event.tags, event.metadata),
15277
+ addressed: isAddressedMessage(event.tags),
13216
15278
  ...systemNoticeMark(event.tags),
13217
15279
  ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}),
13218
15280
  });
@@ -13350,10 +15412,17 @@ export class AgentFramework {
13350
15412
  const agent = new Agent(config, contextManager, this.membrane);
13351
15413
  this.adoptToolResultGuard(agent);
13352
15414
  agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
15415
+ this.announceAgentCreated(this.registry.register({
15416
+ ...conversationForkSpec(name, {
15417
+ template: router.templateAgent,
15418
+ homeChannel: home,
15419
+ idleTtlMs: router.idleTtlMs,
15420
+ }),
15421
+ model: config.model,
15422
+ }));
13353
15423
  this.managerReleases.set(agent, release);
13354
15424
  this.agents.set(name, agent);
13355
15425
  this.agentConfigs.set(name, config);
13356
- this.conversationAgentHomes.set(name, home);
13357
15426
  return agent;
13358
15427
  }
13359
15428
  catch (error) {
@@ -13496,63 +15565,28 @@ export class AgentFramework {
13496
15565
  * stop() has ended binds nothing (spawnConversationAgent). The fork's
13497
15566
  * context stays in Chronicle for investigation.
13498
15567
  */
13499
- disposeConversationAgent(agentName) {
13500
- this.closingConversationAgents.delete(agentName);
13501
- // A continuation of its turn still on its way goes with it.
13502
- this.dropContinuations(agentName, { kind: 'dropped', why: 'its agent was disposed' });
13503
- const home = this.conversationAgentHomes.get(agentName);
15568
+ disposeConversationAgent(agentName, reason = 'idle-ttl') {
15569
+ const home = this.homeOf(agentName);
13504
15570
  const agent = this.agents.get(agentName);
13505
- // A fork stop() ends can still be running: its stream, its tool round.
13506
- // Its name is never used again, so its end takes the stream's
13507
- // activeStreams and pendingAssistantBlocks entries, as a run's end does
13508
- // (runEphemeralToCompletion), and settles a batch its guard still holds,
13509
- // withheld as `aborted`: the stream's own finally does neither once the
13510
- // fork has gone. A fork the idle sweep ends has no stream entries left,
13511
- // and a batch still pending there is one whose successor never ran,
13512
- // which a run's end settles too.
13513
- if (agent?.toolResultGuard.hasPending) {
13514
- try {
13515
- agent.toolResultGuard.abandon('aborted');
13516
- }
13517
- catch (error) {
13518
- console.error(`[tool-result-guard] agent=${agentName} failed to record a batch the fork left:`, error);
13519
- }
13520
- }
13521
- this.activeStreams.delete(agentName);
13522
- this.pendingAssistantBlocks.delete(agentName);
13523
- this.agents.delete(agentName);
13524
- this.thinkingLossIncidents.forget(agentName);
13525
- this.reasoningLoss.forget(agentName);
13526
- // Its pending self-wake ends with it, rather than wake a name that's gone.
13527
- this.eventGate?.agentGone(agentName);
15571
+ // The one release every ended agent gets (releaseAgentRuntime), then the
15572
+ // registry's end, whose outcome settles what the fork spawned.
13528
15573
  if (agent) {
13529
- this.releaseManager(agent);
13530
- this.releaseWindowNowOrAfterResume(agent);
13531
- }
13532
- this.turnRouting.delete(agentName);
13533
- this.proseSpeechChains.delete(agentName);
13534
- this.openLogical.delete(agentName);
13535
- this.toolImageLedgers.delete(agentName);
13536
- this.agentConfigs.delete(agentName);
13537
- this.conversationAgentHomes.delete(agentName);
13538
- this.evictTurnCheckpoints(agentName);
13539
- if (agent)
13540
- this.logicalTurnToolCalls.delete(agent);
13541
- // Its scripts go with it, and leave the map once they've ended: no later
13542
- // run of a fork's name trims them.
13543
- this.codeExecution?.scripts.agentGone(agentName);
13544
- // A provider-wait hold (nonOwningProviderWait) goes with the agent: left,
13545
- // its timer would requeue held wakes for an agent that is gone, and a wait
13546
- // held until released would re-arm for the name indefinitely. The recorded
13547
- // wait stays, and binds the name if its channel binds again.
13548
- const cooldown = this.providerAccelerationCooldowns.get(agentName);
13549
- if (cooldown) {
13550
- clearTimeout(cooldown.timer);
13551
- this.providerAccelerationCooldowns.delete(agentName);
13552
- console.error(`[provider-wait] agent=${agentName} disposed while held` +
13553
- `${cooldown.waitModel !== undefined ? ` on ${cooldown.waitModel}` : ''}: ` +
13554
- `${cooldown.heldRequests.length} held request(s) dropped with it (the recorded wait stays)`);
15574
+ this.releaseAgentRuntime(agent, home, 'its agent was disposed');
15575
+ }
15576
+ else {
15577
+ // Nothing live under the name: only what the process keeps by name.
15578
+ this.closingConversationAgents.delete(agentName);
15579
+ this.thinkingLossIncidents.forget(agentName);
15580
+ this.reasoningLoss.forget(agentName);
15581
+ // A continuation of its turn still on its way goes with it.
15582
+ this.dropContinuations(agentName, { kind: 'dropped', why: 'its agent was disposed' });
15583
+ this.turnRouting.delete(agentName);
15584
+ this.proseSpeechChains.delete(agentName);
15585
+ this.openLogical.delete(agentName);
15586
+ this.forgetAnnouncedLocus(agentName);
15587
+ this.codeExecution?.scripts.agentGone(agentName);
13555
15588
  }
15589
+ this.settleEndedAgent(this.registry.end(agentName, reason, 'framework', { record: true }));
13556
15590
  this.emitTrace({
13557
15591
  type: 'mcpl:conversation-disposed',
13558
15592
  agentName,
@@ -13570,7 +15604,7 @@ export class AgentFramework {
13570
15604
  * its own fork.
13571
15605
  */
13572
15606
  scopeInjectionsForAgent(agentName, injections) {
13573
- const home = this.conversationAgentHomes.get(agentName);
15607
+ const home = this.homeOf(agentName);
13574
15608
  const registry = this.channelRegistry;
13575
15609
  if (!home || !registry || injections.length === 0) {
13576
15610
  return injections;
@@ -13614,7 +15648,7 @@ export class AgentFramework {
13614
15648
  // Agent vanished (external reset) — nothing to close, just make sure
13615
15649
  // its bookkeeping doesn't linger.
13616
15650
  this.agentConfigs.delete(binding.agentName);
13617
- this.conversationAgentHomes.delete(binding.agentName);
15651
+ this.settleEndedAgent(this.registry.end(binding.agentName, 'idle-ttl', 'framework', { record: true }));
13618
15652
  continue;
13619
15653
  }
13620
15654
  this.writeToFork(agent, [{ type: 'text', text: this.conversationRouter.closurePrompt }], { channelId: binding.channelId, conversationClosure: true });
@@ -13640,7 +15674,7 @@ export class AgentFramework {
13640
15674
  if (!this.hasTurn(agentName) &&
13641
15675
  !this.activeStreams.has(agentName) &&
13642
15676
  !this.pendingRequests.some((r) => r.agentName === agentName)) {
13643
- this.disposeConversationAgent(agentName);
15677
+ this.disposeConversationAgent(agentName, 'idle-ttl');
13644
15678
  }
13645
15679
  }
13646
15680
  }
@@ -14111,8 +16145,11 @@ export class AgentFramework {
14111
16145
  this.coalescingRecentReceipts = restored.recent;
14112
16146
  this.pushCoalescer = restored.coalescer;
14113
16147
  // Batches restored from a snapshot still owe the agent a wake (§14 v41).
14114
- for (const occ of restored.coalescer.pendingBatchOccurrences())
14115
- void this.wakeForCoalescedBatch(occ);
16148
+ for (const occ of restored.coalescer.pendingBatchOccurrences()) {
16149
+ void this.wakeForCoalescedBatch(occ).catch((error) => {
16150
+ console.error(`[coalescing] ${occ.serverId}: the wake a restored batch (${occ.eventId}) owes its agent failed:`, error);
16151
+ });
16152
+ }
14116
16153
  }
14117
16154
  /**
14118
16155
  * Durability barrier for coalescing (RFC-006 §3.1/§3.2): everything
@@ -14225,6 +16262,13 @@ export class AgentFramework {
14225
16262
  // counts what was stored before a restart as shown: every agent's
14226
16263
  // shown watermark starts at the head at boot.
14227
16264
  const firstView = this.pushCoalescer.receiptFirstView(serverId, this.coalescingBinding(serverId), message.eventId);
16265
+ // Acknowledged: the anchor the delivery parked is committed now (and a
16266
+ // retry answered from the receipt commits its first attempt's).
16267
+ const parked = this.parkedAnchors.get(`${serverId}:${message.eventId}`);
16268
+ if (parked) {
16269
+ this.parkedAnchors.delete(`${serverId}:${message.eventId}`);
16270
+ this.channelRegistry?.noteDelivered(parked.channel, parked.messageId, parked.threadId, parked.tags, parked.firstView || firstView === true);
16271
+ }
14228
16272
  return { messageId: message.messageId, accepted: true, coalesce: result.coalesce, ...(firstView ? { firstView: true } : {}) };
14229
16273
  });
14230
16274
  }
@@ -14367,7 +16411,7 @@ export class AgentFramework {
14367
16411
  return [];
14368
16412
  }
14369
16413
  }
14370
- return [...this.agents.keys()].filter((n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName);
16414
+ return this.registry.untargetedRecipients();
14371
16415
  }
14372
16416
  /** Deliver through the lane's ordinary path; report where it landed. */
14373
16417
  async deliverCoalesced(occ, materialized, assemblingFor) {
@@ -14448,11 +16492,59 @@ export class AgentFramework {
14448
16492
  const subject = coalescingSubjectKey(occ.serverId, occ.binding, occ.scope, occ.key);
14449
16493
  if (occ.event.lane !== 'push' || !occ.event.event.triggerInference)
14450
16494
  return;
14451
- const origin = occ.event.event.origin;
16495
+ const push = occ.event.event;
16496
+ const origin = push.origin;
14452
16497
  // The wake carries the batch's channel so the turn freezes its reply
14453
16498
  // locus there (the materialized delivery at assembly wakes nobody).
14454
16499
  const channelId = occ.scope.kind === 'channel' ? occ.scope.id : this.derivePushEventChannel(origin)?.channelId;
14455
16500
  const authorId = occ.identity?.author?.id ?? (typeof origin?.authorId === 'string' ? origin.authorId : undefined);
16501
+ // A deferred batch is admitted when it asks for its wake: the hold and
16502
+ // the gate decide now; its materialized delivery at assembly is vetoed
16503
+ // (`triggerInference: false`) and so is not shown to the gate again.
16504
+ const decision = this.admitInbound({
16505
+ lane: 'push',
16506
+ serverId: occ.serverId,
16507
+ channelId,
16508
+ messageId: occ.eventId,
16509
+ authorId,
16510
+ tags: occ.tags,
16511
+ addressed: isAddressedMessage(occ.tags),
16512
+ vetoed: false,
16513
+ targetAgents: push.targetAgents,
16514
+ gateContent: (push.content ?? [])
16515
+ .filter((b) => b.type === 'text')
16516
+ .map((b) => b.text)
16517
+ .join('\n'),
16518
+ gateMetadata: {
16519
+ ...(origin ?? {}),
16520
+ serverId: occ.serverId,
16521
+ featureSet: push.featureSet,
16522
+ eventId: occ.eventId,
16523
+ eventType: 'mcpl:push-event',
16524
+ ...(occ.tags ? { tags: occ.tags } : {}),
16525
+ },
16526
+ });
16527
+ if (decision.held && !plain && this.pushCoalescer) {
16528
+ // A held batch wakes nobody, so no turn's assembly would store it while
16529
+ // the hold lasts, and readers of held traffic and the release would
16530
+ // never see it. It's stored now, stamped with its hold as a plain push
16531
+ // under the hold is, and consumed, so the turn after the hold doesn't
16532
+ // take it again. Outside the coalescer's lock: this runs inside it.
16533
+ // A hold that ended before the batch was stored left it stored unheld:
16534
+ // it then takes the wake an unheld push gets, every recipient's.
16535
+ const coalescer = this.pushCoalescer;
16536
+ void this.coalescedAudience(occ)
16537
+ .then((audience) => (audience[0] !== undefined ? coalescer.materializeNow(audience[0], subject) : false))
16538
+ .then(async (stored) => {
16539
+ if (stored && !(channelId !== undefined && this.tuneOutCoordinator?.holdFor(occ.serverId, channelId))) {
16540
+ await this.queueCoalescedWakes(occ, true);
16541
+ }
16542
+ })
16543
+ .catch((err) => console.error(`[coalescing] a held batch for ${logValue(subject)} could not be stored:`, err));
16544
+ return;
16545
+ }
16546
+ if (!decision.wake)
16547
+ return;
14456
16548
  for (const agentName of await this.coalescedAudience(occ)) {
14457
16549
  this.pendingRequests.push({
14458
16550
  agentName,
@@ -14461,7 +16553,7 @@ export class AgentFramework {
14461
16553
  timestamp: Date.now(),
14462
16554
  channelId, ...(channelId !== undefined ? { channelServerId: occ.serverId } : {}),
14463
16555
  counterparty: authorId ? `${occ.serverId}:user:${authorId}` : undefined,
14464
- addressed: isAddressedMessage(occ.tags, origin),
16556
+ addressed: isAddressedMessage(occ.tags),
14465
16557
  // No systemNotice mark here: a render folds the batch's every
14466
16558
  // occurrence, and only its latest's tags are at hand, so marking it
14467
16559
  // would hide a folded message's channel whenever a notice came last.
@@ -14562,9 +16654,13 @@ export class AgentFramework {
14562
16654
  // not the agent that happened to be asked). A reader is one whose view
14563
16655
  // filter keeps the message: a tune-out-diverted message never enters a
14564
16656
  // resident's compiled view, and only the subconscious reads it through
14565
- // its merged view — no subconscious, nobody reads it.
14566
- const readers = (this.sharedSlotAgents.has(agent)
14567
- ? [...this.sharedSlotAgents].filter((a) => this.agents.get(a.name) === a)
16657
+ // its merged view — no subconscious, nobody reads it. The slot's readers
16658
+ // are the registry's (residents and the subconscious), each asked its own
16659
+ // view (agentSees, main's #853).
16660
+ const readers = (this.registry.readsSharedSlot(agent.name)
16661
+ ? this.registry.sharedSlotReaders()
16662
+ .map((name) => this.agents.get(name))
16663
+ .filter((reader) => reader !== undefined)
14568
16664
  : [agent]).filter((a) => this.agentSees(a, message.metadata));
14569
16665
  const branch = cm.currentBranch().name;
14570
16666
  const ts = message.timestamp instanceof Date ? message.timestamp.getTime() : Number(message.timestamp);
@@ -14715,6 +16811,37 @@ export class AgentFramework {
14715
16811
  // marker with an empty payload — arbitrary MCPL servers cannot hide
14716
16812
  // content merely by setting `origin.silent`.
14717
16813
  const silentHeartbeat = isSilentHeartbeatMarker({ ...event, content });
16814
+ // Admission (Dendrite §D): the same decision as the channel lane. A DM
16815
+ // arrives here, so this is where tuning out a DM takes effect (#232 gap
16816
+ // 1). An event with no channel provenance cannot be held.
16817
+ const decision = this.admitInbound({
16818
+ lane: 'push',
16819
+ serverId: event.serverId,
16820
+ channelId: triggerChannel?.channelId,
16821
+ messageId: event.eventId,
16822
+ authorId: typeof event.origin?.authorId === 'string' ? event.origin.authorId : undefined,
16823
+ tags: event.tags,
16824
+ addressed: isAddressedMessage(event.tags),
16825
+ vetoed: event.triggerInference === false,
16826
+ targetAgents: event.targetAgents,
16827
+ gateContent: content
16828
+ .filter((b) => b.type === 'text')
16829
+ .map((b) => b.text)
16830
+ .join('\n'),
16831
+ gateMetadata: {
16832
+ ...(event.origin ?? {}),
16833
+ serverId: event.serverId,
16834
+ featureSet: event.featureSet,
16835
+ eventId: event.eventId,
16836
+ eventType: 'mcpl:push-event',
16837
+ ...(event.tags ? { tags: event.tags } : {}),
16838
+ },
16839
+ });
16840
+ metadata.triggered = decision.wake;
16841
+ if (decision.held && triggerChannel) {
16842
+ metadata.tuneOut = { epochId: decision.held.epochId };
16843
+ this.tuneOutCoordinator?.recordHeld(event.serverId, triggerChannel.channelId, event.eventId, event.tags, typeof event.origin?.authorId === 'string' ? event.origin.authorId : undefined, decision.gatePass);
16844
+ }
14718
16845
  const placement = { agent: '' };
14719
16846
  if (!silentHeartbeat) {
14720
16847
  const id = this.addMessage('user', content, AgentFramework.withStoredDigest(metadata, content), { placement, bypassDeferralFor: event.assemblingFor });
@@ -14723,11 +16850,8 @@ export class AgentFramework {
14723
16850
  else {
14724
16851
  console.error(`[heartbeat] ${event.serverId}: accepted silent scheduled wake ${logValue(event.eventId ?? '')}`);
14725
16852
  }
14726
- if (event.triggerInference) {
14727
- // Default broadcast excludes conversation forks (channel-driven).
14728
- const targetAgents = event.targetAgents
14729
- ?? [...this.agents.keys()].filter((n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName);
14730
- for (const agentName of targetAgents) {
16853
+ if (decision.wake) {
16854
+ for (const agentName of decision.recipients) {
14731
16855
  this.pendingRequests.push({
14732
16856
  agentName,
14733
16857
  reason: 'mcpl:push-event',
@@ -14737,7 +16861,7 @@ export class AgentFramework {
14737
16861
  // DMs and addressed-while-closed messages arrive as push events;
14738
16862
  // they must outrank ambient chatter in a batched wake's locus
14739
16863
  // selection just like their channels/incoming counterparts.
14740
- addressed: isAddressedMessage(event.tags, event.origin),
16864
+ addressed: isAddressedMessage(event.tags),
14741
16865
  ...ambientMark(event.tags),
14742
16866
  ...systemNoticeMark(event.tags),
14743
16867
  ...(silentHeartbeat ? {
@@ -14763,11 +16887,11 @@ export class AgentFramework {
14763
16887
  * a fork is that fork's conversation, so no other agent takes it.
14764
16888
  */
14765
16889
  mayTakeGateRoute(agentName, route) {
14766
- const home = this.conversationAgentHomes.get(agentName);
16890
+ const home = this.homeOf(agentName);
14767
16891
  if (home !== undefined)
14768
16892
  return sameChannel(home, route);
14769
- for (const boundChannel of this.conversationAgentHomes.values()) {
14770
- if (sameChannel(boundChannel, route))
16893
+ for (const record of this.registry.list()) {
16894
+ if (sameChannel(this.channelOfHome(record.homeChannel), route))
14771
16895
  return false;
14772
16896
  }
14773
16897
  return true;
@@ -14786,6 +16910,35 @@ export class AgentFramework {
14786
16910
  const named = this.channelRegistry?.channelsNamed(channelId) ?? [];
14787
16911
  return named.length === 1 ? named[0] : undefined;
14788
16912
  }
16913
+ /**
16914
+ * A live agent's home channel, from its registry record (channelOfHome):
16915
+ * defined for a conversation fork, and only for one. Undefined for an agent
16916
+ * with none, and for one that has ended.
16917
+ */
16918
+ homeOf(agentName) {
16919
+ return this.channelOfHome(this.registry.homeChannel(agentName));
16920
+ }
16921
+ /**
16922
+ * The channel a registry record's `homeChannel` names: the channel on its
16923
+ * server. A bare channel id, as a record written before homes carried
16924
+ * their server keeps, is read as a wake that names its channel without its
16925
+ * server is (channelNamed): the one registered channel it names, read when
16926
+ * it's used, since the servers registered at that moment are what it can
16927
+ * mean. An id that names no one registered channel, as one no server
16928
+ * registers now or two servers do, is the home on no server (an empty
16929
+ * serverId, which channelNamed reads as none given), so the agent keeps a
16930
+ * home. A path that reads the pair finds no server registering it, and
16931
+ * refuses it; one that reads the name reads what the name means then: none
16932
+ * or two channels, which it refuses, or, once one server alone registers
16933
+ * the id, that server's channel.
16934
+ */
16935
+ channelOfHome(home) {
16936
+ if (home === undefined)
16937
+ return undefined;
16938
+ if (typeof home !== 'string')
16939
+ return home;
16940
+ return this.channelNamed(home) ?? { serverId: '', channelId: home };
16941
+ }
14789
16942
  /**
14790
16943
  * Explicit mode's typing channel for one start of a turn, which has no
14791
16944
  * locus, read once as the start opens (driveStream then shows what the
@@ -15358,6 +17511,43 @@ export class AgentFramework {
15358
17511
  await this.startAgentStream(agent, start);
15359
17512
  }
15360
17513
  }
17514
+ /**
17515
+ * `channel_publish` with no channel goes where the turn's plain speech
17516
+ * would: the frozen turn locus (the waking delivery's channel, or one
17517
+ * opened this turn), then the registry's own resolution. Under
17518
+ * `speech.locus: 'delivery'` the registry alone has no default, and the
17519
+ * tool failed on the very turn its own delivery woke (first live run,
17520
+ * 2026-10-12). Both dispatch paths — the stream driver's and
17521
+ * executeToolCall's — pass through here. Only a call made inside the turn
17522
+ * (callingTurnOf) takes the turn's locus: one made outside it, as a script
17523
+ * left running after its turn makes or a host's executeToolCall naming the
17524
+ * agent, takes none of the turn's channels. A conversation fork's home
17525
+ * isn't the turn's but the fork's own, so such a call from a fork naming
17526
+ * no channel still goes there (homeOf), as the fork's dispatched calls do
17527
+ * (dispatchChannelToolCall); anyone else's takes the registry's own
17528
+ * default.
17529
+ */
17530
+ publishInputWithTurnLocus(input, agentName, callingTurn) {
17531
+ const given = input?.channelId;
17532
+ if (typeof given === 'string')
17533
+ return input;
17534
+ const locus = callingTurn
17535
+ ? callingTurn.locus ?? this.channelRegistry?.resolveLocus(agentName) ?? null
17536
+ : this.homeOf(agentName) ?? null;
17537
+ if (!locus)
17538
+ return input;
17539
+ // The pair: a serverId beside a channelId names that server's channel.
17540
+ // A serverId given with no channelId names the locus's id on that
17541
+ // server, as the registry reads one given beside its own default: the
17542
+ // default's id on the server given (handleToolPublish). So a fork's call
17543
+ // naming another server and no channel reaches that server's channel
17544
+ // with its home's id, where one is registered: its dispatched calls are
17545
+ // refused that (dispatchChannelToolCall), and executeToolCall applies
17546
+ // none of that guard, on main as here.
17547
+ const record = input ?? {};
17548
+ const serverId = typeof record.serverId === 'string' && record.serverId ? record.serverId : locus.serverId;
17549
+ return { ...record, channelId: locus.channelId, serverId };
17550
+ }
15361
17551
  /**
15362
17552
  * Stamps for the rows the agent's reply stores in its current turn: its
15363
17553
  * assistant responses, its tool_result rows (through `storeResults`), and
@@ -16107,7 +18297,7 @@ export class AgentFramework {
16107
18297
  const registry = this.channelRegistry;
16108
18298
  if (!registry)
16109
18299
  return refuse('No channels are configured, so there is nowhere to resend to.');
16110
- const home = this.conversationAgentHomes.get(agentName);
18300
+ const home = this.homeOf(agentName);
16111
18301
  let spec = typeof input.destination === 'string' ? input.destination.trim() : '';
16112
18302
  if (present(input.destination) && typeof input.destination !== 'string') {
16113
18303
  return refuse('destination must be "#channel", "@person" or a channel id.');
@@ -16525,17 +18715,58 @@ export class AgentFramework {
16525
18715
  console.error('noticeHeartbeatTurn: failed to record the notice:', err);
16526
18716
  }
16527
18717
  }
18718
+ rememberAnnouncedLocus(agentName, locus) {
18719
+ this.lastAnnouncedLocus.set(agentName, locus);
18720
+ this.writeAnnouncedLocus(agentName, locus ? { serverId: locus.serverId, channelId: locus.channelId } : null);
18721
+ }
18722
+ forgetAnnouncedLocus(agentName) {
18723
+ this.lastAnnouncedLocus.delete(agentName);
18724
+ this.writeAnnouncedLocus(agentName, undefined);
18725
+ }
18726
+ /** The stored baseline's entry for the agent set (rememberAnnouncedLocus),
18727
+ * or removed (undefined) when the agent ends without coming back. */
18728
+ writeAnnouncedLocus(agentName, locus) {
18729
+ try {
18730
+ const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
18731
+ const state = (data && typeof data === 'object' ? data : {});
18732
+ const loci = { ...(state.announcedLoci ?? {}) };
18733
+ if (locus === undefined) {
18734
+ if (!Object.hasOwn(loci, agentName))
18735
+ return;
18736
+ delete loci[agentName];
18737
+ }
18738
+ else {
18739
+ loci[agentName] = locus;
18740
+ }
18741
+ state.announcedLoci = loci;
18742
+ this.store.setStateJson(FRAMEWORK_STATE_ID, state);
18743
+ }
18744
+ catch (err) {
18745
+ console.error(`[routing] ${agentName}: the announced locus wasn't ${locus === undefined ? 'removed from' : 'written to'} the store:`, err);
18746
+ }
18747
+ }
16528
18748
  /**
16529
18749
  * Announce-on-change locus notice (turn-frozen routing). Appends a durable
16530
18750
  * `[routing]` window message when the turn's effective outbound locus
16531
18751
  * differs from the last one announced — the agent must never have to guess
16532
18752
  * where its plain prose lands. Durable append (never retracted) keeps the
16533
- * KV prefix stable; change-only keeps it out of steady-state turns. The
16534
- * first resolution after boot announces only a non-null locus ("prose has
16535
- * no destination" is the unremarkable boot default). Never triggers
18753
+ * KV prefix stable; change-only keeps it out of steady-state turns. An
18754
+ * agent never told of a locus, here or before a restart, hears only of a
18755
+ * non-null one ("prose has no destination" is the unremarkable start).
18756
+ * Never triggers
16536
18757
  * inference; never locus-eligible itself (system + no channel metadata).
16537
18758
  */
16538
18759
  announceLocusIfChanged(agentName, locus) {
18760
+ if (!this.lastAnnouncedLocus.has(agentName)) {
18761
+ try {
18762
+ const stored = this.store.getStateJson(FRAMEWORK_STATE_ID)?.announcedLoci;
18763
+ if (stored && Object.hasOwn(stored, agentName))
18764
+ this.lastAnnouncedLocus.set(agentName, stored[agentName] ?? null);
18765
+ }
18766
+ catch (err) {
18767
+ console.error(`[routing] ${agentName}: the announced locus couldn't be read back, so this turn compares with none:`, err);
18768
+ }
18769
+ }
16539
18770
  const hasBaseline = this.lastAnnouncedLocus.has(agentName);
16540
18771
  const prev = this.lastAnnouncedLocus.get(agentName) ?? null;
16541
18772
  if (hasBaseline ? sameChannel(prev, locus) : locus === null) {
@@ -16565,7 +18796,7 @@ export class AgentFramework {
16565
18796
  const id = agent.getContextManager().addMessage('user', [{ type: 'text', text }], { system: true, kind: 'routing-notice' });
16566
18797
  // The baseline is what the agent was told: it moves once the notice is
16567
18798
  // stored, so a notice that fails is announced again at the next turn.
16568
- this.lastAnnouncedLocus.set(agentName, locus);
18799
+ this.rememberAnnouncedLocus(agentName, locus);
16569
18800
  this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' });
16570
18801
  console.error(`[routing] ${agentName}: locus ${prev ? qualifiedChannelName(prev) : '(none)'} -> ${locus ? qualifiedChannelName(locus) : '(none)'} (announced in window)`);
16571
18802
  }
@@ -16581,7 +18812,11 @@ export class AgentFramework {
16581
18812
  * as the agent's first view) that addressed the agent, or that follows up
16582
18813
  * in a channel the turn sent into (TurnRouting.engaged). Undefined for any
16583
18814
  * other message. A tool boundary weighs the messages it injects by it,
16584
- * and noteArrival the ones no boundary of the turn weighs.
18815
+ * and noteArrival the ones no boundary of the turn weighs. The rule is
18816
+ * pure; what is done with its answer is `speech.locus`'s: under
18817
+ * `'latest-inbound'` the locus moves, under `'delivery'` (the default) the
18818
+ * agent is told it was addressed from elsewhere and the locus stays
18819
+ * (addressedElsewhereNotice).
16585
18820
  */
16586
18821
  repinChannel(metadata, routing) {
16587
18822
  if (!isConversationalInjection(metadata))
@@ -16595,7 +18830,7 @@ export class AgentFramework {
16595
18830
  const channel = this.channelNamed(m.channelId, typeof m.serverId === 'string' ? m.serverId : undefined);
16596
18831
  if (!channel)
16597
18832
  return undefined;
16598
- if (isAddressedMessage(m.tags, m))
18833
+ if (isAddressedMessage(m.tags))
16599
18834
  return channel;
16600
18835
  return routing.engaged.has(destinationKey(channel)) ? channel : undefined;
16601
18836
  }
@@ -16839,8 +19074,10 @@ export class AgentFramework {
16839
19074
  * Weigh a message that no tool boundary of the agent's open logical turn
16840
19075
  * will weigh: one that would re-pin the locus becomes one of the turn's
16841
19076
  * arrivals (TurnRouting.arrivals). A message the agent's own view leaves
16842
- * out, such as one a tune-out diverted (metadata.tuneOut), never enters
16843
- * its compiled view, so the agent won't see it, and it moves nothing.
19077
+ * out (agentSees), such as one held from the residents (heldFromResidents,
19078
+ * the admission stage's one predicate, which every resident's view filter
19079
+ * reads) or another agent's stored context injection, never enters its
19080
+ * compiled view, so the agent won't see it, and it moves nothing.
16844
19081
  */
16845
19082
  noteArrival(agentName, metadata, messageId) {
16846
19083
  const open = this.openLogical.get(agentName);
@@ -16852,17 +19089,19 @@ export class AgentFramework {
16852
19089
  if (isConversationalInjection(metadata))
16853
19090
  (open.heard ??= new Map()).set(messageId, ++lastHeardAt);
16854
19091
  const channel = this.repinChannel(metadata, open);
16855
- if (channel)
16856
- open.arrivals.push({ messageId, channel });
19092
+ if (!channel)
19093
+ return;
19094
+ const tags = metadata?.tags;
19095
+ open.arrivals.push({ messageId, channel, addressed: isAddressedMessage(tags) });
16857
19096
  }
16858
19097
  /**
16859
19098
  * Whether `agent`'s store is the residents' one message slot, which every
16860
- * ordinary resident reads (sharedSlotAgents). The subconscious reads that
19099
+ * ordinary resident reads (`readsSharedSlot` in the registry). The subconscious reads that
16861
19100
  * slot through a merged view, but its own store is isolated, as a fork's
16862
19101
  * or an ephemeral agent's is.
16863
19102
  */
16864
19103
  isOrdinaryResident(agent) {
16865
- return this.sharedSlotAgents.has(agent) && agent.name !== this.subconsciousAgentName;
19104
+ return this.registry.readsSharedSlot(agent.name) && agent.name !== this.subconsciousAgentName;
16866
19105
  }
16867
19106
  /**
16868
19107
  * Weigh a message just stored in an ordinary resident's store for every
@@ -16873,8 +19112,9 @@ export class AgentFramework {
16873
19112
  noteForOtherResidents(agent, metadata, messageId) {
16874
19113
  if (!this.isOrdinaryResident(agent))
16875
19114
  return;
16876
- for (const reader of this.sharedSlotAgents) {
16877
- if (reader !== agent && this.agents.get(reader.name) === reader && this.isOrdinaryResident(reader)) {
19115
+ for (const name of this.registry.sharedSlotReaders()) {
19116
+ const reader = this.agents.get(name);
19117
+ if (reader && reader !== agent && this.isOrdinaryResident(reader)) {
16878
19118
  this.noteSpokenTo(reader.name, metadata);
16879
19119
  this.noteArrival(reader.name, metadata, messageId);
16880
19120
  }
@@ -16951,7 +19191,7 @@ export class AgentFramework {
16951
19191
  const metadata = { system: true, kind: 'routing-notice' };
16952
19192
  try {
16953
19193
  const id = agent.getContextManager().addMessage('user', content, metadata);
16954
- this.lastAnnouncedLocus.set(agent.name, channel);
19194
+ this.rememberAnnouncedLocus(agent.name, channel);
16955
19195
  this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' });
16956
19196
  console.error(`[routing] ${agent.name}: ${cause} re-pin ${prevPin ? qualifiedChannelName(prevPin) : '(none)'} -> ${qualifiedChannelName(channel)}`);
16957
19197
  return { participant: 'user', content, metadata, id };
@@ -16961,6 +19201,73 @@ export class AgentFramework {
16961
19201
  return undefined;
16962
19202
  }
16963
19203
  }
19204
+ /**
19205
+ * Under `speech.locus: 'delivery'`: tell the agent that someone addressed
19206
+ * it from another channel while its turn was busy, since incoming traffic
19207
+ * never moves the locus and a neutral message carries nothing that names
19208
+ * its channel (first live run, 2026-10-12, finding C). Once per channel
19209
+ * per turn (TurnRouting.addressedElsewhere); nothing for the turn's own
19210
+ * locus. Stored durably; returned as stored for a live stream to inject
19211
+ * with its boundary's other messages. Undefined when nothing was stored.
19212
+ */
19213
+ addressedElsewhereNotice(agent, routing, channel, who, when = 'while you were busy') {
19214
+ const pin = routing.locus ?? null;
19215
+ const key = destinationKey(channel);
19216
+ if (sameChannel(channel, pin) || routing.addressedElsewhere.has(key))
19217
+ return undefined;
19218
+ const where = pin ? `still lands in ${this.channelShown(pin)}` : 'stays in your archive (no channel)';
19219
+ const content = [{
19220
+ type: 'text',
19221
+ text:
19222
+ // The author's name is a value someone supplied (main's #852: one
19223
+ // line, its notice openings marked); the channel is shown as
19224
+ // channelShown gives it.
19225
+ `[${NOTICE_TAG.routing}] ${suppliedValue(who)} addressed you from ${this.channelShown(channel)} ${when}. ` +
19226
+ `Your plain speech ${where} this turn; reaching them takes an explicit send.`,
19227
+ }];
19228
+ const metadata = { system: true, kind: 'routing-notice' };
19229
+ const before = this.storedCountOf(agent);
19230
+ try {
19231
+ const id = agent.getContextManager().addMessage('user', content, metadata);
19232
+ routing.addressedElsewhere.add(key);
19233
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' });
19234
+ return { participant: 'user', content, metadata };
19235
+ }
19236
+ catch (err) {
19237
+ // A write can throw after its row landed, as storeInjections' can: a
19238
+ // notice that landed is the turn's notice for the channel, as one
19239
+ // stored without a throw is.
19240
+ const text = content[0].text;
19241
+ const landed = before === undefined ? undefined : this.storedSince(agent, before, (m) => m.metadata?.kind === 'routing-notice'
19242
+ && m.content.some((b) => b.type === 'text' && b.text === text), 'routing', 'the addressed-elsewhere notice');
19243
+ if (landed && landed.length > 0) {
19244
+ routing.addressedElsewhere.add(key);
19245
+ console.error(`addressed-elsewhere notice: ${agent.name}: the notice landed though its write threw:`, err);
19246
+ return { participant: 'user', content, metadata };
19247
+ }
19248
+ console.error(`addressed-elsewhere notice: failed to record routing notice${landed ? '' : ' (whether it landed is unknown)'}:`, err);
19249
+ return undefined;
19250
+ }
19251
+ }
19252
+ /**
19253
+ * The arrivals a start of the turn consumes under `speech.locus:
19254
+ * 'delivery'`: each one that addressed the agent, whose message is still
19255
+ * in the store, gets the addressed-elsewhere notice, oldest first. A
19256
+ * follow-up in an engaged channel addressed nobody and is not announced.
19257
+ */
19258
+ noticeAddressedArrivals(agent, routing, arrivals, when) {
19259
+ if (arrivals.length === 0)
19260
+ return;
19261
+ const cm = agent.getContextManager();
19262
+ for (const { messageId, channel, addressed } of arrivals) {
19263
+ if (!addressed)
19264
+ continue;
19265
+ const message = cm.getMessage(messageId);
19266
+ if (!message)
19267
+ continue;
19268
+ this.addressedElsewhereNotice(agent, routing, channel, authorName(message.metadata), when);
19269
+ }
19270
+ }
16964
19271
  /**
16965
19272
  * Durable window notice for a channel the agent did not ask to open: a
16966
19273
  * subscription-policy admission, announced as the decision before its
@@ -17142,7 +19449,7 @@ export class AgentFramework {
17142
19449
  canCloseChannels(who, channels) {
17143
19450
  if (who === undefined)
17144
19451
  return false;
17145
- const home = this.conversationAgentHomes.get(who);
19452
+ const home = this.homeOf(who);
17146
19453
  return channels.every((channel) => !home || sameChannel(home, channel)) && this.canAgentUseTool(who, 'channel_close');
17147
19454
  }
17148
19455
  /**
@@ -17165,7 +19472,7 @@ export class AgentFramework {
17165
19472
  holdsChannelTraffic(who, channels) {
17166
19473
  if (who === undefined)
17167
19474
  return false;
17168
- const home = this.conversationAgentHomes.get(who);
19475
+ const home = this.homeOf(who);
17169
19476
  if (home !== undefined) {
17170
19477
  return channels.every((channel) => sameChannel(channel, home) && this.conversationRouter?.getBinding(channel)?.agentName === who);
17171
19478
  }
@@ -17182,8 +19489,14 @@ export class AgentFramework {
17182
19489
  * otherwise the delivery agent and the readers of the residents' shared slot do. An ephemeral run holds none.
17183
19490
  */
17184
19491
  holdsChannelTrafficForTools(who) {
17185
- if (this.conversationAgentHomes.has(who))
19492
+ if (this.registry.homeChannel(who) !== undefined)
17186
19493
  return true;
19494
+ // An agent that shares its source's context (a reader fork) shares its
19495
+ // source's words too: its tool block is the source's prefix, and a
19496
+ // sentence that differed would send the whole window uncached.
19497
+ const inherit = this.registry.get(who)?.inherit;
19498
+ if (inherit?.mode === 'shared' && inherit.from !== who)
19499
+ return this.holdsChannelTrafficForTools(inherit.from);
17187
19500
  if (this.conversationRouter)
17188
19501
  return false;
17189
19502
  return this.residentHoldsTraffic(who);
@@ -17206,7 +19519,7 @@ export class AgentFramework {
17206
19519
  * notice passed on later, at resume or at boot, names it by its name.
17207
19520
  */
17208
19521
  agentForPrimary(agentName) {
17209
- const home = this.conversationAgentHomes.get(agentName);
19522
+ const home = this.homeOf(agentName);
17210
19523
  if (home === undefined)
17211
19524
  return { first: agentName, again: agentName };
17212
19525
  const label = this.channelRegistry?.getDescriptor(home)?.label;
@@ -17227,7 +19540,7 @@ export class AgentFramework {
17227
19540
  /** Whether `agentName` is a conversation fork made from the primary: the
17228
19541
  * router's template is the primary (connectome-host's is). */
17229
19542
  forkOfPrimary(agentName) {
17230
- return this.conversationAgentHomes.has(agentName) && this.primaryAgentName !== null &&
19543
+ return this.registry.homeChannel(agentName) !== undefined && this.primaryAgentName !== null &&
17231
19544
  this.conversationRouter?.templateAgent === this.primaryAgentName;
17232
19545
  }
17233
19546
  /**
@@ -17287,7 +19600,7 @@ export class AgentFramework {
17287
19600
  return [];
17288
19601
  }
17289
19602
  const who = ended ? primary : agentName || this.deliveryAgent()?.name;
17290
- const canOpen = who !== undefined && !this.conversationAgentHomes.has(who) && this.canAgentUseTool(who, 'channel_open');
19603
+ const canOpen = who !== undefined && this.registry.homeChannel(who) === undefined && this.canAgentUseTool(who, 'channel_open');
17291
19604
  const canClose = this.canCloseChannels(who, [{ serverId, channelId: channel.channelId }]);
17292
19605
  const selected = selectedForOpening === 'unrecorded'
17293
19606
  ? " It stays selected for opening, because the host couldn't record undoing that: a later delivery or reconnect tries again"
@@ -17329,7 +19642,7 @@ export class AgentFramework {
17329
19642
  };
17330
19643
  const notices = [];
17331
19644
  if (tellPrimary) {
17332
- const primaryCanOpen = !this.conversationAgentHomes.has(primary) && this.canAgentUseTool(primary, 'channel_open');
19645
+ const primaryCanOpen = this.registry.homeChannel(primary) === undefined && this.canAgentUseTool(primary, 'channel_open');
17333
19646
  const reader = this.agentForPrimary(who);
17334
19647
  notices.push({
17335
19648
  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.` +
@@ -17408,7 +19721,7 @@ export class AgentFramework {
17408
19721
  return;
17409
19722
  // A subagent's run ends with its result; a conversation fork answers
17410
19723
  // its home channel's message.
17411
- if (this.ephemeralRuns.has(name) || this.conversationAgentHomes.has(name))
19724
+ if (this.ephemeralRuns.has(name) || this.registry.homeChannel(name) !== undefined)
17412
19725
  return;
17413
19726
  if (this.providerAdmissionClosed || this.agents.get(name) !== agent) {
17414
19727
  console.error(`[continue] ${name}: not continued — ${this.providerAdmissionClosed ? 'the framework is stopping' : 'the agent is no longer registered'}`);
@@ -18302,7 +20615,7 @@ export class AgentFramework {
18302
20615
  }
18303
20616
  }
18304
20617
  async startAgentStream(agent, trigger, attempt = 0, providerGateAlreadyHeld = false) {
18305
- const ownsProviderGate = !this.ephemeralRuns.has(agent.name) && !this.conversationAgentHomes.has(agent.name);
20618
+ const ownsProviderGate = this.registry.ownsProviderScheduling(agent.name);
18306
20619
  // A stopping framework starts no turn, whoever owns provider admission
18307
20620
  // (conversation agents and ephemeral runs do not take the gate).
18308
20621
  if (this.providerAdmissionClosed) {
@@ -19175,12 +21488,29 @@ export class AgentFramework {
19175
21488
  // A turn in a mode with a locus reads its arrivals' messages, a turn
19176
21489
  // with no channel (noChannel) included: an addressed message, or a
19177
21490
  // follow-up where the turn sent, gives it its channel, as a re-pin does.
19178
- const arrival = turnProseRouting === 'locus' || turnProseRouting === 'hybrid'
21491
+ // Under `speech.locus: 'delivery'` an arrival moves nothing; one that
21492
+ // addressed the agent from another channel is announced instead, as a
21493
+ // boundary would have announced it (addressedElsewhereNotice).
21494
+ const routesLocus = turnProseRouting === 'locus' || turnProseRouting === 'hybrid';
21495
+ const arrivals = routing.arrivals;
21496
+ const arrival = routesLocus && this.speechLocus === 'latest-inbound'
19179
21497
  ? this.remainingArrival(agent, routing)
19180
21498
  : undefined;
19181
21499
  routing.arrivals = [];
19182
21500
  if (arrival)
19183
21501
  this.repinTurnLocus(agent, routing, arrival, 'arrival');
21502
+ else if (routesLocus && this.speechLocus === 'delivery') {
21503
+ // Which start this is, since from inside they look alike and the
21504
+ // reason says whether the earlier prose landed (Linn, 2026-10-14).
21505
+ const when = attempt > 0
21506
+ ? 'while your turn was retried'
21507
+ : trigger?.reason === 'context_budget_restart'
21508
+ ? 'at your budget restart'
21509
+ : isTurnContinuation(trigger?.reason ?? '')
21510
+ ? 'before this re-inference on your tool results'
21511
+ : 'while your turn waited on a provider hold';
21512
+ this.noticeAddressedArrivals(agent, routing, arrivals, when);
21513
+ }
19184
21514
  }
19185
21515
  // A silent start, or a disabled agent's, has no locus to show, whatever a
19186
21516
  // channel_open in a silent turn left on its object.
@@ -19379,7 +21709,7 @@ export class AgentFramework {
19379
21709
  // can record a wait while this turn compiles, after the last boundary
19380
21710
  // checked. Check its waits again here, where nothing has been sent, and
19381
21711
  // hold the turn as the boundary would rather than send it into the wait.
19382
- if (this.conversationAgentHomes.has(agent.name)) {
21712
+ if (this.registry.homeChannel(agent.name) !== undefined) {
19383
21713
  const wait = this.providerWaits?.active(agent.name, agent.model);
19384
21714
  // The turn has begun (inference:started), so the hold keeps its retry.
19385
21715
  if (wait && this.holdForProviderWait(agent, wait, trigger ? [asTurnRetry(withTurnCarry(trigger, carry))] : [], false, () => {
@@ -19401,8 +21731,9 @@ export class AgentFramework {
19401
21731
  })) {
19402
21732
  console.error(`[provider-wait] agent=${agent.name} model=${agent.model} binds after the compile ` +
19403
21733
  `(recorded while the turn compiled); ${this.providerAdmissionClosed ? 'the framework is stopping, so nothing is held'
19404
- : this.providerAccelerationCooldowns.get(agent.name)?.heldRequests.some(isTurnRetry) ? 'turn held'
19405
- : 'the wait was released as the failure was reported, so the retry is queued'}, nothing sent`);
21734
+ : this.agents.get(agent.name) !== agent ? 'the agent ended as the failure was reported, so nothing is held'
21735
+ : this.providerAccelerationCooldowns.get(agent.name)?.heldRequests.some(isTurnRetry) ? 'turn held'
21736
+ : 'the wait was released as the failure was reported, so the retry is queued'}, nothing sent`);
19406
21737
  this.endFailureAtStop(agent.name, turnToken, turnStartedAt, routing);
19407
21738
  this.activeStreams.delete(agent.name);
19408
21739
  this.pendingAssistantBlocks.delete(agent.name);
@@ -21032,7 +23363,7 @@ export class AgentFramework {
21032
23363
  .map((b) => b)
21033
23364
  .filter((c) => typeof c.name === 'string' && !cutIds.has(c.id)
21034
23365
  && markedInputRefusal({ ...c, name: c.name }) === undefined
21035
- && this.canAgentUseTool(agent.name, c.name));
23366
+ && this.mayCallTool(agent.name, c.name));
21036
23367
  const toolNames = toolCalls.map((c) => c.name);
21037
23368
  // A refused skip_reply isn't a silencing call; a plain one is.
21038
23369
  const fallbackCause = liveProseRouting
@@ -21909,7 +24240,7 @@ export class AgentFramework {
21909
24240
  // turn isn't over (pendingContinuationAgents).
21910
24241
  if (frameReachedTerminal && ownsPhysicalStream && this.closingConversationAgents.has(agent.name)
21911
24242
  && !this.pendingContinuationAgents().has(agent.name)) {
21912
- this.disposeConversationAgent(agent.name);
24243
+ this.disposeConversationAgent(agent.name, 'idle-ttl');
21913
24244
  }
21914
24245
  }
21915
24246
  }
@@ -21929,8 +24260,10 @@ export class AgentFramework {
21929
24260
  * (ctx.callTool → executeToolCallFrom with {kind:'module'}), which is exempt
21930
24261
  * from agent allowlists. Registered agents' allowedTools is enforced at
21931
24262
  * dispatch, including namespaced utility names. Genuinely unidentified public callers (including
21932
- * unknown legacy names) remain unrestricted. Framework-created identities
21933
- * are refused while unregistered, even if their configured list allows it.
24263
+ * unknown legacy names) remain unrestricted. A name the framework made in
24264
+ * this process is refused while it isn't registered, even if its configured
24265
+ * list allows the tool; after a restart, such a name is a host's caller name
24266
+ * like any other.
21934
24267
  */
21935
24268
  async executeToolCall(call) {
21936
24269
  return this.executeToolCallFrom(call, {
@@ -22188,7 +24521,11 @@ export class AgentFramework {
22188
24521
  // synthesized verbs (think, skip_reply) are deliberately not routed
22189
24522
  // here.
22190
24523
  if (call.name.startsWith('channel_') && this.channelRegistry) {
22191
- return this.channelRegistry.handleChannelToolCall(call.name, call.input, origin);
24524
+ const turn = call.name === 'channel_publish' && call.callerAgentName ? this.callingTurnOf(call.callerAgentName, call) : undefined;
24525
+ const input = call.name === 'channel_publish' && call.callerAgentName
24526
+ ? this.publishInputWithTurnLocus(call.input, call.callerAgentName, turn)
24527
+ : call.input;
24528
+ return this.channelRegistry.handleChannelToolCall(call.name, input, origin.kind === 'agent' && turn ? { ...origin, inTurn: true } : origin);
22192
24529
  }
22193
24530
  // drafts: the calling resident's own held drafts. A puppeted call has
22194
24531
  // agent origin and acts on that resident's drafts, as the model's own
@@ -22209,6 +24546,20 @@ export class AgentFramework {
22209
24546
  // Module tools. The caller uses the result in process, so it gets the
22210
24547
  // module's own result, as the module returned it, less a refusal it
22211
24548
  // claims, which is the dispatcher's to say.
24549
+ // The tune-out tool, in process: the same body the dispatcher runs, its
24550
+ // answer awaited here (it can wait for the tune-out's transport).
24551
+ if ((call.name === 'tune_out' || call.name === 'focus') && this.tuneOutCoordinator) {
24552
+ const agentName = call.callerAgentName ?? this.primaryAgentName ?? '__ephemeral__';
24553
+ const input = (call.input ?? {});
24554
+ // The calling turn's own channels, as the channel tools read them here (origin.own).
24555
+ const own = origin.kind === 'agent' ? origin.own ?? [] : [];
24556
+ const turn = this.callingTurnOf(agentName, call);
24557
+ return call.name === 'focus' ? this.handleFocusTool(agentName, input, own, turn) : this.handleTuneOutTool(agentName, input, own, turn);
24558
+ }
24559
+ if (SUBCONSCIOUS_TOOL_NAMES.includes(call.name)) {
24560
+ const routed = this.readerToolCall(origin.kind === 'agent' ? origin.agentName : call.callerAgentName ?? '__ephemeral__', call);
24561
+ return 'refused' in routed ? failed({ success: false, error: `${routed.refused}, so the call didn't run.`, isError: true }) : routed.result;
24562
+ }
22212
24563
  return this.moduleRegistry.handleToolCall(call);
22213
24564
  }
22214
24565
  /**
@@ -22249,6 +24600,39 @@ export class AgentFramework {
22249
24600
  (this.toolsetActCalls ??= new WeakMap()).set(inner, target.instance !== undefined ? { instance: target.instance } : {});
22250
24601
  return { ok: true, call: inner, serverId: target.serverId, prefix, ...(target.instance !== undefined ? { instance: target.instance } : {}) };
22251
24602
  }
24603
+ /**
24604
+ * A reader reads and reports: a reader fork (registry kind
24605
+ * 'subconscious-fork', in every incarnation) may call only its own tools,
24606
+ * and speak_in_channel where allowChannelSpeech allows it. Every other
24607
+ * tool the resident has stays in its block (the shared prefix) and is
24608
+ * refused here, so with speech off a fork can post nowhere (scout's
24609
+ * condition, 2026-10-19). It's decided at each call from what the reader
24610
+ * may call, not from what it is shown, so a tool added or moved out of
24611
+ * the block later is refused too.
24612
+ */
24613
+ readerRefuses(agentName, toolName) {
24614
+ if (this.registry.get(agentName)?.kind !== 'subconscious-fork')
24615
+ return false;
24616
+ return !this.readerMayCall(agentName, toolName);
24617
+ }
24618
+ /**
24619
+ * What a reader fork may call: exactly its own tools and the speech its
24620
+ * configuration allows, never widened or narrowed by its resident's
24621
+ * allowedTools. The resident's list bounds the resident's own surface;
24622
+ * the reader's tools are the framework's, for reading on its behalf. A
24623
+ * denyToolsAtDispatch entry the fork takes from its resident that names
24624
+ * one of them still refuses it (mayCallTool checks the deny first): a
24625
+ * resident never holds a reader tool, so such an entry is about readers.
24626
+ * The fork still shows its resident's block (the shared prefix): what it
24627
+ * may call is decided here, at each call, apart from what it is shown.
24628
+ */
24629
+ readerMayCall(agentName, toolName) {
24630
+ if (this.registry.get(agentName)?.kind !== 'subconscious-fork')
24631
+ return false;
24632
+ if (toolName === 'speak_in_channel')
24633
+ return this.subconsciousConfig?.allowChannelSpeech === true;
24634
+ return SUBCONSCIOUS_TOOL_NAMES.includes(toolName);
24635
+ }
22252
24636
  /** A toolsets--act call a host toolset resolved (HostPresentation.resolveAct):
22253
24637
  * its refusal answered, or the tool's own call dispatched in its place,
22254
24638
  * with the act's id, so its result is the act's. */
@@ -23857,24 +26241,40 @@ export class AgentFramework {
23857
26241
  }
23858
26242
  return best;
23859
26243
  }
23860
- /** Registered policy; framework-created identities have no dispatch authority
23861
- * while unregistered. Genuine legacy caller names retain their surface. */
26244
+ /** Registered policy. Within this process, a name the framework made has no
26245
+ * dispatch authority while it isn't registered; after a restart, a host call names
26246
+ * its caller as any host call does. Genuine legacy caller names retain
26247
+ * their surface. */
23862
26248
  canAgentUseTool(agentName, toolName) {
23863
26249
  const agent = this.agents.get(agentName);
23864
26250
  return agent ? agent.canUseTool(toolName) : !this.usedEphemeralAgentNames.has(agentName);
23865
26251
  }
26252
+ /** Whether a call of `toolName` as `agentName` is refused at dispatch (AgentConfig.denyToolsAtDispatch, or a reader's own rule). */
26253
+ deniedAtDispatch(agentName, toolName) {
26254
+ return this.agents.get(agentName)?.deniesAtDispatch(toolName) === true || this.readerRefuses(agentName, toolName);
26255
+ }
26256
+ /** Whether `agentName` may call `toolName`: the one rule dispatch applies (agentToolPermissionFailure), for whatever else judges a call. */
26257
+ mayCallTool(agentName, toolName) {
26258
+ return !this.deniedAtDispatch(agentName, toolName)
26259
+ && (this.canAgentUseTool(agentName, toolName) || this.readerMayCall(agentName, toolName));
26260
+ }
23866
26261
  agentToolPermissionFailure(agentName, call, reportRefusal = true) {
23867
- if (this.canAgentUseTool(agentName, call.name))
26262
+ // A tool refused at dispatch is refused on every path that runs the
26263
+ // agent's call (a toolset's act, executeToolCall), not only the stream's.
26264
+ if (this.mayCallTool(agentName, call.name))
23868
26265
  return undefined;
23869
- const why = !this.agents.has(agentName) && this.usedEphemeralAgentNames.has(agentName)
23870
- ? `Caller '${agentName}' is not a running agent; framework-created agents have tool authority only while registered`
23871
- : `Tool '${call.name}' is not permitted by this agent's allowedTools`;
26266
+ const denied = this.deniedAtDispatch(agentName, call.name);
26267
+ const why = denied
26268
+ ? `${call.name} is not available to ${agentName}`
26269
+ : !this.agents.has(agentName) && this.usedEphemeralAgentNames.has(agentName)
26270
+ ? `Caller '${agentName}' is not a running agent; framework-created agents have tool authority only while registered`
26271
+ : `Tool '${call.name}' is not permitted by this agent's allowedTools`;
23872
26272
  // Off the dispatcher, nothing opened the call to refuse it in RFC-007's
23873
26273
  // lifecycle, so it's a failure, not a refusal (ToolResult.refused), whose
23874
26274
  // text says the call didn't run.
23875
26275
  if (!reportRefusal)
23876
26276
  return { success: false, error: `${why}, so the call didn't run.`, isError: true };
23877
- const error = `${why}.`;
26277
+ const error = denied ? why : `${why}.`;
23878
26278
  const refusal = this.refusedResult(agentName, call.id, error);
23879
26279
  this.emitTrace({
23880
26280
  type: 'tool:failed', module: 'framework', tool: call.name, callId: call.id, error,
@@ -23937,7 +26337,7 @@ export class AgentFramework {
23937
26337
  * (`refusedHere`) counts as one the list refuses.
23938
26338
  */
23939
26339
  roundCallOf(agentName, call, refusedHere = false) {
23940
- const permitted = !refusedHere && markedInputRefusal(call) === undefined && this.canAgentUseTool(agentName, call.name);
26340
+ const permitted = !refusedHere && markedInputRefusal(call) === undefined && this.mayCallTool(agentName, call.name);
23941
26341
  const refused = bareToolName(call.name) === 'skip_reply' && (!permitted || this.refusesSelfWake(call));
23942
26342
  return { name: call.name, cause: permitted || refused ? silenceCauseOf([call]) : null, refused, permitted };
23943
26343
  }
@@ -23960,6 +26360,23 @@ export class AgentFramework {
23960
26360
  });
23961
26361
  return;
23962
26362
  }
26363
+ // Dispatch-time deny (AgentConfig.denyToolsAtDispatch): the tool stays
26364
+ // advertised so the agent's request prefix is unchanged; the call is
26365
+ // refused here, before anything runs.
26366
+ if (this.deniedAtDispatch(agentName, call.name)) {
26367
+ const error = `${call.name} is not available to ${agentName}`;
26368
+ const result = this.refusedResult(agentName, call.id, error);
26369
+ this.emitTrace({ type: 'tool:failed', module: 'dendrite', tool: call.name, callId: call.id, error });
26370
+ this.pushEvent({
26371
+ type: 'tool-result',
26372
+ callId: call.id,
26373
+ toolName: call.name,
26374
+ agentName,
26375
+ moduleName: 'dendrite',
26376
+ result,
26377
+ });
26378
+ return;
26379
+ }
23963
26380
  const refusal = this.agentToolPermissionFailure(agentName, call) ?? this.markedInputFailure(agentName, call);
23964
26381
  if (refusal) {
23965
26382
  this.pushEvent({
@@ -24037,18 +26454,22 @@ export class AgentFramework {
24037
26454
  return;
24038
26455
  }
24039
26456
  // Route the tune-out tool (residents) and the subconscious's surface
24040
- if (enrichedCall.name === 'tune_out' && this.tuneOutCoordinator) {
26457
+ if ((enrichedCall.name === 'tune_out' || enrichedCall.name === 'focus') && this.tuneOutCoordinator) {
24041
26458
  this.dispatchTuneOutToolCall(agentName, enrichedCall);
24042
26459
  return;
24043
26460
  }
24044
- if (this.tuneOutCoordinator &&
24045
- agentName === this.subconsciousAgentName &&
24046
- SUBCONSCIOUS_TOOL_NAMES.includes(enrichedCall.name)) {
24047
- void this.tuneOutCoordinator
24048
- .handleSubconsciousTool(enrichedCall.name, enrichedCall.input, this.callOwnChannels(agentName, enrichedCall))
24049
- // A rejection is answered with a failed result, as a framework tool's
24050
- // dispatch failure is (dispatchAgentSettingsToolCall), never left unhandled.
24051
- .catch((err) => ({ success: false, error: thrownText(err), isError: true }))
26461
+ if (SUBCONSCIOUS_TOOL_NAMES.includes(enrichedCall.name)) {
26462
+ const routed = this.readerToolCall(agentName, enrichedCall);
26463
+ if ('refused' in routed) {
26464
+ const error = routed.refused;
26465
+ const result = this.refusedResult(agentName, call.id, error);
26466
+ this.emitTrace({ type: 'tool:failed', module: 'tune-out', tool: call.name, callId: call.id, error });
26467
+ this.pushEvent({
26468
+ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'tune-out', result,
26469
+ });
26470
+ return;
26471
+ }
26472
+ void routed.result
24052
26473
  .then((result) => {
24053
26474
  // Through pushEvent, like every other tool's result, so one that
24054
26475
  // arrives after stop() is dropped rather than rejected unhandled.
@@ -24369,6 +26790,7 @@ export class AgentFramework {
24369
26790
  opts.placement.agent = agent.name;
24370
26791
  opts.placement.messageId = stored;
24371
26792
  }
26793
+ this.releaseLandedMail(metadata, agent.name);
24372
26794
  this.noteStored(agent, metadata, stored, opts?.bypassDeferralFor);
24373
26795
  return stored;
24374
26796
  }
@@ -24528,6 +26950,38 @@ export class AgentFramework {
24528
26950
  }
24529
26951
  this.noteForOtherResidents(agent, metadata, stored);
24530
26952
  }
26953
+ /**
26954
+ * Held agent mail is released only when its message has actually entered
26955
+ * the recipient's store — never on a deferral. Called at every site that
26956
+ * writes a message into a context: the direct path above and the two
26957
+ * deferred-write flushes (turn start, tool boundary). Both writes reach
26958
+ * disk in the same sync, so a crash keeps either both or neither.
26959
+ */
26960
+ releaseLandedMail(metadata, recipient) {
26961
+ const stamp = metadata?.dendrite;
26962
+ if (typeof stamp?.mailId !== 'string')
26963
+ return;
26964
+ // The message is in the recipient's store; the release is written only
26965
+ // once the store has synced it, so a hard exit between the two leaves
26966
+ // the mail held and the next boot's check of the recipient's store
26967
+ // settles it — never a release on disk for a message that isn't (nissa's
26968
+ // #546 review, round 2, K). A sync that fails leaves it held the same way.
26969
+ try {
26970
+ this.store.sync();
26971
+ }
26972
+ catch (error) {
26973
+ 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);
26974
+ return;
26975
+ }
26976
+ this.registry.releaseMail(stamp.mailId);
26977
+ this.emitTrace({
26978
+ type: 'dendrite:mail-delivered',
26979
+ mailId: stamp.mailId,
26980
+ kind: String(stamp.kind ?? 'message'),
26981
+ from: String(stamp.from?.agent ?? ''),
26982
+ to: recipient,
26983
+ });
26984
+ }
24531
26985
  /**
24532
26986
  * Splice out the deferred messages addressed to `agentName` (explicitly
24533
26987
  * via forAgent, or implicitly when it is the primary), leaving other
@@ -24968,6 +27422,8 @@ export class AgentFramework {
24968
27422
  flushForStart(agent, occasion) {
24969
27423
  const stored = this.writeDrainedInOrder(this.drainDeferredFor(agent.name), `${occasion} flush for ${agent.name}`, (msg) => agent.getContextManager().addMessage(msg.participant, msg.content, withDeferredWriteId(msg.metadata, msg.id)));
24970
27424
  for (const [msg, { id }] of stored) {
27425
+ // Held mail whose message just landed is released (Dendrite).
27426
+ this.releaseLandedMail(msg.metadata, agent.name);
24971
27427
  this.emitTrace({ type: 'message:added', messageId: id, source: `deferred-flush:${occasion}` });
24972
27428
  this.noteSpokenTo(agent.name, msg.metadata);
24973
27429
  this.noteArrival(agent.name, msg.metadata, id);
@@ -25540,7 +27996,7 @@ export class AgentFramework {
25540
27996
  // target never read it (passesToPrimary); addMessage takes it off any
25541
27997
  // other write.
25542
27998
  const forRun = opts?.forAgent !== undefined
25543
- && (this.ephemeralRuns.has(opts.forAgent) || this.conversationAgentHomes.has(opts.forAgent));
27999
+ && (this.ephemeralRuns.has(opts.forAgent) || this.registry.homeChannel(opts.forAgent) !== undefined);
25544
28000
  const placement = {};
25545
28001
  const id = this.addMessage(participant, content, forRun ? { ...metadata, [PRIMARY_IF_UNREAD_KEY]: true } : metadata, {
25546
28002
  placement, ...(opts?.forAgent !== undefined ? { forAgent: opts.forAgent } : {}), ...(forRun ? { markedForRun: true } : {}),
@@ -25551,7 +28007,7 @@ export class AgentFramework {
25551
28007
  * The writes the framework holds for the store `reader` reads, in queue
25552
28008
  * order: those addressed to it and, when it's an ordinary resident, those
25553
28009
  * addressed to any ordinary resident, since residents share one message
25554
- * slot (sharedSlotAgents). A write whose target is gone isn't among them:
28010
+ * slot (`readsSharedSlot` in the registry). A write whose target is gone isn't among them:
25555
28011
  * no module reads that agent's store, and the write waits for its window's
25556
28012
  * release (drainDeferredFor) or, at its flush, is dropped (addMessage), a
25557
28013
  * failed reply, or a channel notice its reader could act on, passing to the
@@ -25944,6 +28400,12 @@ export class AgentFramework {
25944
28400
  bounded.error = boundFailureText(bounded.error);
25945
28401
  if (typeof bounded.stack === 'string')
25946
28402
  bounded.stack = boundFailureText(bounded.stack);
28403
+ if (this.earlyTraces) {
28404
+ if (this.earlyTraces.length < 1000)
28405
+ this.earlyTraces.push(traceEvent);
28406
+ else
28407
+ this.earlyTracesDropped++;
28408
+ }
25947
28409
  // An event goes to the subscriptions present when its delivery starts,
25948
28410
  // each one only if it is still active when its turn comes, as the DOM's
25949
28411
  // EventTarget delivers: a subscription made during delivery waits for the
@@ -26565,10 +29027,11 @@ export class AgentFramework {
26565
29027
  this.mcplPrefixMap.set(prefix, config.id);
26566
29028
  this.setMcplServerConfig(config);
26567
29029
  }
26568
- // Find shouldTriggerInference callback:
26569
- // Per-server callback takes precedence; fall back to EventGate; fall back to no filter.
26570
- const triggerFilter = serverConfigs.find(c => c.shouldTriggerInference)?.shouldTriggerInference
26571
- ?? (this.eventGate ? this.eventGate.asShouldTriggerCallback() : undefined);
29030
+ // The server-level veto on waking (a per-server shouldTriggerInference).
29031
+ // The wake gate is deliberately NOT wired into the inbound lanes any
29032
+ // more: it runs in the admission stage, after the hold decision, so a
29033
+ // held message never forms a gate batch (#232 gap 3, root cause A).
29034
+ const triggerFilter = serverConfigs.find(c => c.shouldTriggerInference)?.shouldTriggerInference;
26572
29035
  // Push events handler (Step 6)
26573
29036
  this.installPushCoalescer(coalescing);
26574
29037
  this.pushHandler = new PushHandler(this.featureSetManager, (event) => this.pushEvent(event), (event) => this.emitTrace(event), triggerFilter, (serverId, params, event) => this.handleCoalescedPush(serverId, params, event),
@@ -26601,13 +29064,29 @@ export class AgentFramework {
26601
29064
  }
26602
29065
  },
26603
29066
  shouldTriggerInference: triggerFilter,
29067
+ latestInboundLocus: this.speechLocus === 'latest-inbound',
29068
+ // A tool close of the channel an all-except hold excepts ends that
29069
+ // hold through the cancel flow, since there is nothing left to speak
29070
+ // into. A tool close of a tuned-out channel is refused
29071
+ // before this hook is called (ChannelRegistry.tunedOutRefusal).
29072
+ onToolClose: (serverId, channelId) => {
29073
+ const coordinator = this.tuneOutCoordinator;
29074
+ if (!coordinator)
29075
+ return undefined;
29076
+ const scope = coordinator.getAllExcept();
29077
+ if (!scope || scope.params.except.serverId !== serverId || scope.params.except.channelId !== channelId)
29078
+ return undefined;
29079
+ coordinator.cancelAllExcept('channel-close', 'the excepted channel was closed');
29080
+ return 'The focus on it ended all the same, and what the other channels held was delivered.';
29081
+ },
29082
+ focusChannelResolver: () => this.tuneOutCoordinator?.focusChannel() ?? null,
26604
29083
  // Route a conversation fork's plain-text speech to its HOME channel, not
26605
29084
  // the process-global most-recent-inbound locus (item 3). The trunk agent
26606
29085
  // has no home entry, so this returns undefined and routeSpeech falls back
26607
- // to defaultPublishChannel. `conversationAgentHomes` is the permanent
29086
+ // to defaultPublishChannel. The registry's `homeChannel` is the permanent
26608
29087
  // spawn-time binding; `channelForAgent` is the router's live binding as a
26609
29088
  // belt-and-suspenders fallback.
26610
- homeChannelResolver: (agentName) => this.conversationAgentHomes.get(agentName)
29089
+ homeChannelResolver: (agentName) => this.homeOf(agentName)
26611
29090
  ?? this.conversationRouter?.channelForAgent(agentName),
26612
29091
  // A single TRUNK agent's channel for its CURRENT turn (item-3 redux):
26613
29092
  // its locus once the turn has one, a re-pin's or a channel_open's
@@ -27436,6 +29915,9 @@ export class AgentFramework {
27436
29915
  */
27437
29916
  makeHostPresentation(storePath) {
27438
29917
  return new HostPresentation({
29918
+ // A derived agent showing its parent's tool list (toolSurfaceOwners) is
29919
+ // shown its parent's presentation too, so the two blocks stay one.
29920
+ ownerOf: (agentName) => this.toolSurfaceOwners.get(agentName) ?? agentName,
27439
29921
  // As the agent is shown them: its description overrides applied.
27440
29922
  available: (agentName) => (this.agents.has(agentName) ? this.compressionToolsForAgent(agentName) : this.getAllTools()),
27441
29923
  placedInBuiltin: (name) => this.placedInBuiltin(name),
@@ -30445,7 +32927,7 @@ export class AgentFramework {
30445
32927
  // ones; channel_open is rejected outright — opening channels mutates
30446
32928
  // framework-global state (the open-channel set every agent's channel
30447
32929
  // context is built from), which is not a fork's call to make.
30448
- const home = this.conversationAgentHomes.get(agentName);
32930
+ const home = this.homeOf(agentName);
30449
32931
  if (home) {
30450
32932
  const reject = (error) => {
30451
32933
  // RFC-007: the guard refuses before the tool runs — no lifecycle events.
@@ -30547,8 +33029,11 @@ export class AgentFramework {
30547
33029
  const publisher = call.name === 'channel_publish' && callingTurn ? this.agents.get(agentName) : undefined;
30548
33030
  const readThrough = publisher ? this.publishReadThrough(publisher, call.id) : null;
30549
33031
  const published = {};
30550
- this.channelRegistry.handleChannelToolCall(call.name, call.input, {
30551
- kind: 'agent', agentName, own: callingTurn ? this.ownChannels(callingTurn) : [], ...(earlierWake ? { earlierWake } : {}),
33032
+ const channelInput = call.name === 'channel_publish'
33033
+ ? this.publishInputWithTurnLocus(call.input, agentName, callingTurn)
33034
+ : call.input;
33035
+ this.channelRegistry.handleChannelToolCall(call.name, channelInput, {
33036
+ kind: 'agent', agentName, own: callingTurn ? this.ownChannels(callingTurn) : [], ...(callingTurn ? { inTurn: true } : {}), ...(earlierWake ? { earlierWake } : {}),
30552
33037
  }, published)
30553
33038
  .then((result) => {
30554
33039
  const destination = published.outcome?.status === 'delivered' ? published.outcome.destination : undefined;
@@ -30597,7 +33082,7 @@ export class AgentFramework {
30597
33082
  && this.openLogical.get(agentName) === callingTurn) {
30598
33083
  callingTurn.locus = opened;
30599
33084
  noteTurnChannel(callingTurn, opened);
30600
- this.lastAnnouncedLocus.set(agentName, opened);
33085
+ this.rememberAnnouncedLocus(agentName, opened);
30601
33086
  const inboundOnlyServer = this.channelRegistry?.inboundOnlyServer(opened);
30602
33087
  // The routing word comes first, ahead of any backscroll, so a
30603
33088
  // result cut to its head for being long keeps it.
@@ -30847,6 +33332,26 @@ export class AgentFramework {
30847
33332
  finish({ success: false, error: msg, isError: true });
30848
33333
  }
30849
33334
  }
33335
+ /**
33336
+ * A reader's own tool (SUBCONSCIOUS_TOOL_NAMES), for both entries that run
33337
+ * an agent's call: the dispatcher's, for the model's call, and
33338
+ * executeToolCallFrom's, for a host's or the puppet's. The tools are
33339
+ * advertised to residents so reader forks share their prefix, and only a
33340
+ * registered reader may use them. A rejection is answered with a failed
33341
+ * result, never left unhandled.
33342
+ */
33343
+ readerToolCall(agentName, call) {
33344
+ const isReader = agentName === this.subconsciousAgentName || this.registry.get(agentName)?.kind === 'subconscious-fork';
33345
+ if (!this.tuneOutCoordinator || !isReader)
33346
+ return { refused: `${call.name} is available to the resident's reader, not to ${agentName}` };
33347
+ const epoch = this.registry.get(agentName)?.metadata?.epochId;
33348
+ const fork = agentName === this.subconsciousAgentName ? undefined : { epochId: typeof epoch === 'string' ? epoch : undefined };
33349
+ return {
33350
+ result: this.tuneOutCoordinator
33351
+ .handleSubconsciousTool(call.name, call.input, this.callOwnChannels(agentName, call), agentName, fork)
33352
+ .catch((err) => ({ success: false, error: thrownText(err), isError: true })),
33353
+ };
33354
+ }
30850
33355
  /**
30851
33356
  * Handle the synthesized `sleep` / `wake` tools. `sleep` arms the gate's
30852
33357
  * suppression window, optionally announces in the sticky channel, and ends
@@ -30854,123 +33359,266 @@ export class AgentFramework {
30854
33359
  */
30855
33360
  /** tune_out tool (residents): enter or cancel a channel tune-out. */
30856
33361
  dispatchTuneOutToolCall(agentName, call) {
30857
- const coordinator = this.tuneOutCoordinator;
30858
33362
  const input = (call.input ?? {});
30859
- const name = String(input.channelId ?? '');
30860
33363
  // Inside the calling turn, a name one of the turn's own channels answers
30861
33364
  // to by its id means that channel, as for the channel tools
30862
- // (ChannelRegistry.ownChannelFor). Otherwise the one channel the name
30863
- // means, which may be server-qualified; a name two servers' channels
30864
- // answer to is refused with them as candidates, never taken to be the
30865
- // first registered.
30866
- const mine = this.channelRegistry?.ownChannelFor(name, this.callOwnChannels(agentName, call));
30867
- const named = mine ? ('channel' in mine ? [mine.channel] : []) : this.channelRegistry?.channelsNamed(name) ?? [];
30868
- const shared = mine && 'error' in mine ? mine.error
30869
- : named.length > 1 ? this.channelRegistry?.sharedNameRefusalFor(name) : undefined;
30870
- const channel = named.length === 1 ? named[0] : undefined;
30871
- const channelId = channel?.channelId ?? name;
33365
+ // (ChannelRegistry.ownChannelFor): read here, while the call is the
33366
+ // turn's, before the answer can wait.
33367
+ const own = this.callOwnChannels(agentName, call);
33368
+ const turn = this.callingTurnOf(agentName, call);
33369
+ // The answer can wait for the tune-out's transport (openForTuneOut), so
33370
+ // the result goes through pushEvent when it is known, as every other
33371
+ // tool's does; a rejection is answered too, never left unhandled.
33372
+ void (call.name === 'focus' ? this.handleFocusTool(agentName, input, own, turn) : this.handleTuneOutTool(agentName, input, own, turn))
33373
+ .catch((err) => ({ success: false, error: thrownText(err), isError: true }))
33374
+ .then((result) => {
33375
+ this.pushEvent({ type: 'tool-result', callId: call.id, toolName: call.name, agentName, moduleName: 'tune-out', result });
33376
+ });
33377
+ }
33378
+ /** `focus` is `tune_out` with scope "all-except". */
33379
+ handleFocusTool(agentName, input, own = [], turn) {
33380
+ return this.handleTuneOutTool(agentName, { ...input, scope: 'all-except' }, own, turn);
33381
+ }
33382
+ /**
33383
+ * A tune-out call's refusal for a channel it can't resolve: main's words
33384
+ * for a name no registered channel, and none of the calling turn's own,
33385
+ * answers to, and the registry's own otherwise, as for an id that is
33386
+ * ambiguous across servers (#232 gap 8).
33387
+ */
33388
+ unknownChannelWords(channelId, resolution, own) {
33389
+ const known = (this.channelRegistry?.channelsNamed(channelId).length ?? 0) > 0
33390
+ || own.some((c) => c.channelId === channelId);
33391
+ return known ? resolution : `unknown channel: ${channelId}`;
33392
+ }
33393
+ /**
33394
+ * The tune_out tool's body. Channel resolution is server-qualified
33395
+ * (ChannelRegistry.resolveChannel): with a `serverId`, that server's
33396
+ * channel with the id; inside the calling turn, a name one of the turn's
33397
+ * own channels answers to by its id means that channel; otherwise the one
33398
+ * channel the name means, which may be server-qualified, and a name two
33399
+ * servers' channels answer to is refused with them as candidates (#232
33400
+ * gap 8). `turn` is the turn that made the call (callingTurnOf), the only
33401
+ * one whose speech an all-except entry moves.
33402
+ */
33403
+ async handleTuneOutTool(agentName, input, own = [], turn) {
33404
+ const coordinator = this.tuneOutCoordinator;
33405
+ const channelRegistry = this.channelRegistry;
33406
+ if (!coordinator || !channelRegistry) {
33407
+ return { success: false, error: 'tune-out is not available (no MCPL channels)', isError: true };
33408
+ }
33409
+ // An open that throws is answered too, never left unhandled.
33410
+ const openThrew = (err) => 'The tune-out is recorded, but its messages may not reach the subconscious, since opening its channel ' +
33411
+ `failed: ${thrownText(err)}`;
33412
+ const serverId = typeof input.serverId === 'string' && input.serverId ? input.serverId : undefined;
33413
+ const allExcept = input.scope === 'all-except';
33414
+ if (input.mode === 'check') {
33415
+ // A peek's name means what the other modes' names mean: the one
33416
+ // channel it picks, by its id or the server-qualified name the agent
33417
+ // is shown where another server shares the id, or inside the turn one
33418
+ // of the turn's own channels (resolveChannel), and its lines are that
33419
+ // channel's, on its server.
33420
+ let peek;
33421
+ if (typeof input.peek === 'string' && input.peek) {
33422
+ const resolved = channelRegistry.resolveChannel(input.peek, serverId, own);
33423
+ if ('error' in resolved)
33424
+ return { success: false, error: this.unknownChannelWords(input.peek, resolved.error, own), isError: true };
33425
+ peek = { serverId: resolved.serverId, channelId: resolved.channelId };
33426
+ }
33427
+ // Read as the other options are (readOptions): a numeric string is its
33428
+ // number, and a value that gives none is refused, named.
33429
+ const peekLimit = input.peekLimit == null ? undefined : Number(input.peekLimit);
33430
+ if (peekLimit !== undefined && !Number.isFinite(peekLimit)) {
33431
+ const got = typeof input.peekLimit === 'string' ? `the string ${JSON.stringify(input.peekLimit)}` : `the value ${JSON.stringify(input.peekLimit)}`;
33432
+ 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 };
33433
+ }
33434
+ return { success: true, data: coordinator.check({ ...(peek ? { peek } : {}), ...(peekLimit !== undefined ? { peekLimit } : {}) }) };
33435
+ }
33436
+ if (input.mode === 'cancel' && (allExcept || (!input.channelId && coordinator.getAllExcept()))) {
33437
+ const r = coordinator.cancelAllExcept('agent-tool', 'agent request');
33438
+ return r.ok ? { success: true, data: { cancelled: 'all-except' } } : { success: false, error: r.error, isError: true };
33439
+ }
33440
+ const channelId = String(input.channelId ?? '');
33441
+ const resolved = channelRegistry.resolveChannel(channelId, serverId, own);
33442
+ if ('error' in resolved)
33443
+ return { success: false, error: this.unknownChannelWords(channelId, resolved.error, own), isError: true };
30872
33444
  // The name the agent knows the channel by: its id, or where another
30873
33445
  // server shares the id, its server-qualified name. An answer that waits
30874
33446
  // for the transport reads it again then, since a registration meanwhile
30875
33447
  // can change which name picks the channel.
30876
- const shownNow = () => (channel ? this.channelRegistry?.nameShown(channel) ?? channelId : name);
30877
- const shown = shownNow();
30878
- let result;
30879
- // An open that throws is answered too, never left unhandled.
30880
- const openThrew = (err) => 'The tune-out is recorded, but its messages may not reach the subconscious, since opening its channel ' +
30881
- `failed: ${thrownText(err)}`;
30882
- if (shared) {
30883
- result = { success: false, error: shared, isError: true };
30884
- }
30885
- else if (!channel) {
30886
- result = { success: false, error: `unknown channel: ${name}`, isError: true };
30887
- }
30888
- else if (input.mode === 'cancel') {
30889
- const r = coordinator.cancel(channel.serverId, channelId, 'agent-tool', 'agent request');
30890
- result = r.ok
30891
- ? { success: true, data: { cancelled: shown } }
33448
+ const shownNow = () => channelRegistry.nameShown(resolved);
33449
+ if (input.mode === 'cancel') {
33450
+ const r = coordinator.cancel(resolved.serverId, resolved.channelId, 'agent-tool', 'agent request');
33451
+ return r.ok
33452
+ ? { success: true, data: { cancelled: shownNow() } }
30892
33453
  : { success: false, error: r.error, isError: true };
30893
33454
  }
30894
- else {
30895
- const r = coordinator.enter(channel.serverId, channelId, {
33455
+ if (allExcept) {
33456
+ const r = coordinator.enterAllExcept(resolved, {
30896
33457
  cadenceSeconds: input.cadenceSeconds,
30897
33458
  backlogCap: input.backlogCap,
30898
33459
  maxWakes: input.maxWakes,
30899
33460
  durationSeconds: input.durationSeconds,
30900
33461
  }, 'agent-tool');
30901
- if (r.ok) {
30902
- const data = {
33462
+ if (!r.ok)
33463
+ return { success: false, error: r.error, isError: true };
33464
+ const speaker = this.agents.get(agentName);
33465
+ const home = this.homeOf(agentName);
33466
+ const open = turn && this.openLogical.get(agentName) === turn ? turn : undefined;
33467
+ if (open && open.locus == null && !open.noChannel && !open.heartbeat && !home
33468
+ && (speaker?.proseRouting === 'locus' || speaker?.proseRouting === 'hybrid')) {
33469
+ open.locus = resolved;
33470
+ noteTurnChannel(open, resolved);
33471
+ this.rememberAnnouncedLocus(agentName, resolved);
33472
+ }
33473
+ const speech = home
33474
+ ? `Your plain speech stays in ${this.channelShown(home)}, your conversation's channel.`
33475
+ : open?.locus && !sameChannel(open.locus, resolved)
33476
+ ? `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.`
33477
+ : open && (open.noChannel || open.heartbeat)
33478
+ ? "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."
33479
+ : "Your plain speech lands in the excepted channel while this holds, except on a turn that has no channel, such as a heartbeat's.";
33480
+ return {
33481
+ success: true,
33482
+ data: {
33483
+ scope: 'all-except',
33484
+ except: shownNow(),
33485
+ announcedIn: coordinator.announces() ? shownNow() : null,
33486
+ expiresAtMs: r.params.expiresAtMs,
30903
33487
  cadenceSeconds: r.params.cadenceSeconds,
30904
33488
  backlogCap: r.params.backlogCap,
30905
33489
  maxWakes: r.params.maxWakes,
30906
- };
30907
- // A tuned-out channel's traffic must keep arriving for the
30908
- // subconscious, so the answer waits for its transport (openForTuneOut).
30909
- // The tune-out is recorded either way, so it's a success, and where
30910
- // the transport isn't open, openError says why its messages don't or
30911
- // may not reach the subconscious, and when they will where that's
30912
- // known, or that the tune-out isn't in effect any more. A rejection is
30913
- // answered too, never left unhandled, and the result goes through
30914
- // pushEvent, as every other tool's does.
30915
- void this.channelRegistry.openForTuneOut(channel.serverId, channelId)
30916
- .catch(openThrew)
30917
- .then((openError) => {
30918
- this.pushEvent({
30919
- type: 'tool-result',
30920
- callId: call.id,
30921
- toolName: call.name,
30922
- agentName,
30923
- moduleName: 'tune-out',
30924
- result: { success: true, data: { tunedOut: shownNow(), ...data, ...(openError ? { openError } : {}) } },
30925
- });
30926
- });
30927
- return;
30928
- }
33490
+ note: coordinator.hasReader()
33491
+ ? `${speech} Reader wakes pause, not cancel, when the budget is spent.`
33492
+ : `No subconscious is configured: this is a silent hold. ${speech} The backlog is delivered per channel when you cancel or the duration ends.`,
33493
+ },
33494
+ };
33495
+ }
33496
+ const r = coordinator.enter(resolved.serverId, resolved.channelId, {
33497
+ cadenceSeconds: input.cadenceSeconds,
33498
+ backlogCap: input.backlogCap,
33499
+ maxWakes: input.maxWakes,
33500
+ durationSeconds: input.durationSeconds,
33501
+ }, 'agent-tool');
33502
+ if (!r.ok) {
30929
33503
  // tune_out repeated on a channel already tuned out is where an agent
30930
33504
  // retries a transport the first open left closed, so where it isn't
30931
33505
  // open, or work is queued for it, the repeat goes through
30932
33506
  // openForTuneOut, which joins the open in flight or waits and, where
30933
33507
  // this host can open the channel, opens it again; the refusal says
30934
33508
  // what its transport is now.
30935
- const tuneOut = this.channelRegistry.getTuneOutState(channel.serverId, channelId);
30936
- if (r.alreadyTunedOut && tuneOut && !this.channelRegistry.tuneOutTransportOpen(channel.serverId, channelId)) {
30937
- void this.channelRegistry.openForTuneOut(channel.serverId, channelId)
30938
- .catch(openThrew)
30939
- .then((openError) => {
30940
- // The tune-out is read again: one that ended or was replaced while
30941
- // the open was out isn't described as standing, whatever the open
30942
- // answered for it.
30943
- const now = this.channelRegistry.getTuneOutState(channel.serverId, channelId);
30944
- const error = now?.params.epochId === tuneOut.params.epochId
30945
- ? alreadyTunedOutText(this.channelRegistry.quotedShown(channel), now.params, openError ?? 'Its transport is open now, so its messages reach the subconscious.')
30946
- : TUNE_OUT_REPLACED;
30947
- this.pushEvent({
30948
- type: 'tool-result',
30949
- callId: call.id,
30950
- toolName: call.name,
30951
- agentName,
30952
- moduleName: 'tune-out',
30953
- result: { success: false, error, isError: true },
30954
- });
30955
- });
30956
- return;
30957
- }
30958
- result = { success: false, error: r.error, isError: true };
30959
- }
30960
- this.pushEvent({
30961
- type: 'tool-result',
30962
- callId: call.id,
30963
- toolName: call.name,
30964
- agentName,
30965
- moduleName: 'tune-out',
30966
- result,
33509
+ const tuneOut = channelRegistry.getTuneOutState(resolved.serverId, resolved.channelId);
33510
+ if (r.alreadyTunedOut && tuneOut && !channelRegistry.tuneOutTransportOpen(resolved.serverId, resolved.channelId)) {
33511
+ const openError = await channelRegistry.openForTuneOut(resolved.serverId, resolved.channelId).catch(openThrew);
33512
+ // The tune-out is read again: one that ended or was replaced while
33513
+ // the open was out isn't described as standing, whatever the open
33514
+ // answered for it.
33515
+ const now = channelRegistry.getTuneOutState(resolved.serverId, resolved.channelId);
33516
+ const error = now?.params.epochId === tuneOut.params.epochId
33517
+ // A name in a sentence is quoted as tune_out takes it back (main's #852).
33518
+ ? alreadyTunedOutText(channelRegistry.quotedShown(resolved), now.params, openError ?? 'Its transport is open now, so its messages reach the subconscious.')
33519
+ : TUNE_OUT_REPLACED;
33520
+ return { success: false, error, isError: true };
33521
+ }
33522
+ return { success: false, error: r.error, isError: true };
33523
+ }
33524
+ // A tuned-out channel's traffic must keep arriving for its reader, so the
33525
+ // answer waits for its transport (openForTuneOut). The tune-out is
33526
+ // recorded either way, so it's a success, and where the transport isn't
33527
+ // open, openError says why its messages don't or may not reach the
33528
+ // reader, and when they will where that's known, or that the tune-out
33529
+ // isn't in effect any more.
33530
+ const openError = await channelRegistry.openForTuneOut(resolved.serverId, resolved.channelId).catch(openThrew);
33531
+ return {
33532
+ success: true,
33533
+ data: {
33534
+ tunedOut: shownNow(),
33535
+ cadenceSeconds: r.params.cadenceSeconds,
33536
+ backlogCap: r.params.backlogCap,
33537
+ maxWakes: r.params.maxWakes,
33538
+ ...(openError ? { openError } : {}),
33539
+ ...(coordinator.hasReader()
33540
+ ? {}
33541
+ : { note: 'No subconscious is configured: this is a silent hold. Nothing summarizes or wakes you; the backlog is delivered when you cancel.' }),
33542
+ },
33543
+ };
33544
+ }
33545
+ /**
33546
+ * The admission stage for one inbound event: the pure decision over the
33547
+ * framework's policy inputs (recipients, the hold in force, the wake
33548
+ * gate), traced. Callers apply it; nothing downstream re-asks.
33549
+ */
33550
+ admitInbound(env) {
33551
+ const gateInfo = (e) => ({
33552
+ content: e.gateContent,
33553
+ eventType: String(e.gateMetadata.eventType ?? 'unknown'),
33554
+ serverId: String(e.gateMetadata.serverId ?? e.serverId),
33555
+ channelId: typeof e.gateMetadata.channelId === 'string' ? e.gateMetadata.channelId : '',
33556
+ metadata: e.gateMetadata,
33557
+ tags: Array.isArray(e.gateMetadata.tags) ? e.gateMetadata.tags : undefined,
33558
+ });
33559
+ const decision = admit(env, {
33560
+ untargetedRecipients: () => this.registry.untargetedRecipients(),
33561
+ holdFor: (serverId, channelId) => {
33562
+ const hold = this.tuneOutCoordinator?.holdFor(serverId, channelId) ?? null;
33563
+ return hold ? { kind: 'tune-out', epochId: hold.epochId, serverId, channelId } : null;
33564
+ },
33565
+ ...(this.eventGate
33566
+ ? {
33567
+ gate: {
33568
+ evaluate: (e) => this.eventGate.evaluate(gateInfo(e)).trigger,
33569
+ // "Would the gate have woken them": a debounce policy says yes,
33570
+ // later — for the hold's reader that is a pass, not a mute.
33571
+ probe: (e) => {
33572
+ const d = this.eventGate.probe(gateInfo(e));
33573
+ return d.trigger || (typeof d.behavior === 'object' && d.behavior !== null && 'debounce' in d.behavior);
33574
+ },
33575
+ },
33576
+ }
33577
+ : {}),
30967
33578
  });
33579
+ this.emitTrace({
33580
+ type: 'admission:decided',
33581
+ lane: env.lane,
33582
+ serverId: env.serverId,
33583
+ channelId: env.channelId,
33584
+ messageId: env.messageId,
33585
+ recipients: decision.recipients,
33586
+ held: decision.held ? decision.held.epochId : null,
33587
+ wake: decision.wake,
33588
+ gatePass: decision.gatePass,
33589
+ });
33590
+ return decision;
33591
+ }
33592
+ /**
33593
+ * Make delivered messages durable now, including those the delivery left
33594
+ * in the deferred queue: a tune-out release flips durable epoch state
33595
+ * first, so its dump must not be lost to a crash in between (#232 gap 6).
33596
+ */
33597
+ commitDeliveries() {
33598
+ if (this.deferredMessages.length > 0) {
33599
+ this.deferredWritesPersisted = true;
33600
+ this.persistDeferredWrites();
33601
+ }
33602
+ try {
33603
+ this.store.sync();
33604
+ }
33605
+ catch (err) {
33606
+ console.error('[tune-out] could not sync the store after a release:', err);
33607
+ }
33608
+ }
33609
+ /** The history-reading tool the primary resident actually has, if any (#232 gap 11). */
33610
+ residentHistoryTool() {
33611
+ const primary = this.primaryAgentName ? this.agents.get(this.primaryAgentName) : undefined;
33612
+ if (!primary)
33613
+ return null;
33614
+ const names = this.getAllTools().map((t) => t.name).filter((n) => primary.canUseTool(n));
33615
+ return names.find((n) => /(^|--)(fetch_history|channel_read|read_history|history)$/.test(n)) ?? null;
30968
33616
  }
30969
33617
  dispatchSleepToolCall(agentName, call) {
30970
33618
  // The gate's sleep quiets the whole agent, every conversation of it, so a
30971
33619
  // conversation fork or an ephemeral run neither sets one nor ends the
30972
33620
  // agent's own. Refused before the tool runs, as a fork's channel_open is.
30973
- const fork = this.conversationAgentHomes.has(agentName);
33621
+ const fork = this.registry.homeChannel(agentName) !== undefined;
30974
33622
  if (fork || this.ephemeralRuns.has(agentName)) {
30975
33623
  const one = fork ? 'conversation' : 'run';
30976
33624
  // Only where the fork can follow it: skip_reply needs the channel