description: "The scoped-registration library for plugin authors and maintainers building registries or event surfaces that isolate contributions per agent or per group."
kind: "package-library"
English | 中文
Summary
dsh-scope lets plugin authors give each agent or group an isolated contribution set with a shared lifetime. Child scopes inherit ancestor contributions, with the nearest definition taking precedence, while ancestor scopes can observe descendant activity; neither relationship works in reverse. Disposing a scope removes everything owned by it. Use this dependency-free library when per-agent or per-group isolation must work without depending on the agent loop or presets.
Table of Contents
Use this package
Plugin authors use dsh-scope to give one agent (or one group) its own registration world. The registries in the core group build on it — a tool registered through agent.ctx is visible only to that agent — and the same primitive serves any custom registry or scope-filtered event.
Mint a scope
createScope(ctx, key) creates a scope under ctx's fiber: its ctx carries the scope tag, and everything registered through it is both scope-visible and scope-lifetime. dispose() unwinds every registration through the scope; rawDispose is the exact Cordis disposer for nesting the teardown in an ordered composite effect.
const scope = createScope(ctx, agent)
scope.ctx.on('agent/status', ({ agent, status }) => track(agent, status))
// later:
await scope.dispose() // unwinds every registration made through scope.ctx
Route scoped events
scopeTarget(base, key) builds the opaque carrier a scope-filtered event dispatches with. Untagged listeners stay global; a listener tagged with key receives events for that key and its descendants. The carrier carries routing state only — the real subject travels in the event arguments.
Build a scoped registry layer
Registry authors use ScopedLayers, NamedEntries, and AnonymousEntries to hold one eager global layer plus lazily created exact-scope layers: reads never create layers, merge() materializes insertion-ordered named shadows along the scope chain, and effect() derives visibility and ownership from the same context. A scoped layer is reclaimed only when its whole aggregate is empty.
Understand the implementation
Implementation internals — click to expand
This section explains how the package realizes the behavior above; the observable contract is covered in [Use this package](#use-this-package).
### Design concept
The registration context determines both visibility and ownership: a registration made through a scoped context is visible in that scope and disposed with it, preventing a contribution from being visible in one scope but torn down with another. The primitive routes trusted same-process plugins; it is not a sandbox or an authority boundary. Handing out a scoped context also hands out the minting plugin's service-resolution API (resolution walks the minting fiber's dependency chain), so a scope is minted from the plugin whose dependencies the scoped registrations need.
### Source map
| File | Role |
|---|---|
| [`src/index.ts`](src/index.ts) | `createScope`, `scopeOf`, `scopeTarget`, `bindScopeParent`/`scopeParentOf`/`scopeChainOf`, carrier marks |
| [`src/store.ts`](src/store.ts) | `ScopedLayers`, `NamedEntries`, `AnonymousEntries`, `ScopeLayer` |
| [`src/invariant.ts`](src/invariant.ts) | Invariant companion over the generated scoped-event map |
| [`src/scoped-events.generated.ts`](src/scoped-events.generated.ts) | Generated resolver map of declared scoped events |
### The parent chain
One relation powers both directions: registration views inherit DOWN the chain (a child scope sees its ancestors' layers), while event admission extends UP it (a listener tagged with an ancestor receives events dispatched to a descendant key). Binding is once — a key that already has a parent throws, and only the returned binding may re-link it — and every link rejects a cycle. `scopeChainOf` returns `[key, parent, …]` nearest-first.
### Event filtering
`scopeTarget` composes the base's existing `Context.filter` with the scope predicate: an untagged listener is admitted; a tagged listener is admitted iff its tag is the dispatch key or an ancestor of it; `key === undefined` admits untagged listeners only. `{ global: true }` listeners bypass filtering. The `Scoped` brand demands the carrier as the `this` type of a scope-filtered event, so dispatching with a bare subject is a compile error.
Further Exploration
The package-level contract is enough for most consumers; read these when you need the surrounding domain and the design rationale.
Known Limitations and Deferred Work
These limits define when the primitive needs special care. They are current package constraints, not a task backlog.
- Only scope-aware APIs isolate state — registries must file by
scopeOf() and events must dispatch through scopeTarget(); an arbitrary Cordis service remains context-global merely because it is called through a scoped context.
- A context carries one nearest scope key — the hierarchy lives in the key-level parent relation, not in context tags; nested scope contexts still shadow to a single tag, and multi-membership policy sets remain unsupported.
- Service reachability comes from the scope minter — handing out
Scope.ctx also hands out the minting plugin's injected services, so a broader minter cannot later be narrowed by the holder.
Dev Note
Working context for maintainers — click to expand
None.