Add a branded AgentMessageId assigned to each accepted send message and returned from send/followup/steer/inject (was void). Rename the inbox event payload InboxItemInfo to AgentMessage, carrying that id so a caller can correlate a queued item with its enqueue/dequeue/discard events.
dsh-scope
Scoped registration primitive. createScope(ctx, key) creates a tagged Cordis context whose backing fiber owns every registration made through it. scopeOf(ctx) reads the tag, and scopeTarget(base, key) routes scoped events to listeners with the same key while leaving unscoped listeners global. The agent loop creates one scope per live agent, but the mechanism is key-agnostic so lower-level packages can use it without depending on agents.
Public API
createScope(ctx: Context, key: ScopeKey): ScopeMint a scope underctx's fiber. Usable synchronously (effect collection is uid-gated; service resolution falls through to the minting plugin's dependency surface). The typed, same-process key is trusted; an inactive minting context still fails through Cordis (INACTIVE_EFFECT).Scope.ctxThe tagged context: registrations through it are scope-visible AND scope-lifetime. Derived contexts (anextend, a fiber mounted under it) inherit the tag; nested scopes shadow (nearest tag wins).Scope.rawDisposeThe EXACT Cordis disposer for the backing fiber — a composite (generator) effect yields THIS function to nest the scope's teardown at that yield position (Cordis dedupes nested effects by function identity; yielding a wrapper leaves the scope disposing as a concurrent sibling).Scope.dispose(): Promise<void>Idempotent, shared quiescence boundary for every registration made through the scope. Racing/repeat calls await the same teardown, including whenrawDisposeinvoked the underlying single-shot Cordis disposer first.scopeOf(ctx: Context): ScopeKey | undefinedThe tag a context (or any context derived from it) carries;undefined= context-global.scopeTarget(base: T, key: ScopeKey | undefined): Scoped<T>Build the opaque dispatchthisArgfor a scope-filtered event. It composesbase's existingContext.filterwith the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key;key === undefined⇒ untagged only). The carrier contains routing state only; the real subject is carried by the event arguments.{ global: true }listeners bypass filtering (Cordis semantics).Scoped<T>The compile-time opaque carrier brand: scope-filtered events demand it as theirthistype, so dispatching with a bare subject is a compile error. The type parameter records the subject type but does not expose its properties.isScopeCarrier(value)/carrierKeyOf(value)Runtime carrier marks, used by the dev invariants to assert every scope-filtered dispatch carries a carrier keyed to the subject its arguments name.ScopeLayerAggregate contract for one registry's complete global or exact-scope contribution;isEmpty()controls scoped-layer reclamation.ScopedLayers<L>Own one eager global layer and lazy exact-scope layers.peek()never creates,merge()materializes insertion-ordered named shadows, andeffect()derives visibility and ownership from the same context while returning the exact Cordis disposer.NamedEntries<V>Insertion-ordered named storage with caller-owned duplicate diagnostics, lookup, and live iteration within one nonempty table generation; draining the table detaches existing iterators from later insertions, andinsert()returns an idempotent exact-entry undo.AnonymousEntries<V>Insertion-ordered anonymous storage whose unique internal keys keep equal values as independent registrations; it uses the same drained-generation iterator boundary, andappend()returns an idempotent exact-entry undo.
The optional @deepseek-ai/dsh-scope/invariant companion owns that runtime assertion. It uses the generated scoped-events.generated.ts resolver map to require a carrier for every declared scoped event and, when the payload exposes its routing subject, require identity with the carrier key. The Program-backed generator derives the map from event declarations and real scopeTarget(base, key) calls.
Design contract
The registration context determines both visibility and ownership, preventing a registration from being visible in one scope but disposed with another. Scopes route trusted same-process plugins; they are not sandboxes or authority boundaries. See the agent-scope Agent Note for rationale and security non-goals.
Scope-aware services define a concrete ScopeLayer that aggregates their heterogeneous tables and domain helpers. ScopedLayers.effect() accepts one synchronous action returning one synchronous undo, installs that undo before optional notification, and reclaims an exact-scope layer only when the complete aggregate is empty. notify defaults to true; the supplied callback owns whether observer failures throw or are contained. EntryValues remains internal, the storage classes are imported from the package root rather than a /store subpath, and the shared storage does not define registry-specific filtering or iteration policy. See the shared scoped-layer storage Agent Note.
Handing out a scoped context hands out the minting plugin's service-resolution surface (resolution walks the minting fiber's dependency chain, not the holder's) — mint it from the plugin whose dependencies the scoped registrations need to resolve.
Known Limitations and Deferred Work
- Only scope-aware surfaces isolate state — registries must file by
scopeOf()and events must dispatch throughscopeTarget(); an arbitrary Cordis service remains context-global merely because it is called through a scoped context. - A context carries one nearest scope key — nested scopes shadow their parent's tag rather than forming hierarchical or multi-membership policy sets.
- Service reachability comes from the scope minter — handing out
Scope.ctxalso hands out the minting plugin's injected service surface, so a broader minter cannot later be narrowed by the holder.