Files
deepseek-harness/packages/core/scope
Tianyi Cui 06cebf1bf4 fix(scope): make the dispatch carrier method-transparent for native-private subjects
ds-review-bot delta-round finding, verified: cordis hands the carrier to
listeners as `this`, and the event declarations type it Scoped<Agent> — so
driving the subject through it (this.send(...) in an agent/* listener) is a
SUPPORTED shape. The withProps-based carrier delegated gets with the PROXY
as receiver, so ReactLoopAgent's send/steer/cancel — which read the
native-private #carrier through a getter — threw TypeError when called that
way (private members do not exist on proxy receivers).

scopeTarget now builds its own proxy: overlay props (the composed filter and
the carrier mark) answer from a null-shadowed literal via hasOwn (`in` would
let Object.prototype's toString/constructor shadow the subject's), every
other get delegates with the BASE as receiver (getters see the real object)
and returns functions bound to the base (method calls execute on the real
receiver), sets land on the base. A proxy-invariant guard reports frozen own
function props unchanged (binding them would violate the get invariant).
This kills the class at the seam — any subject with native privates works,
today's agents and whatever carries them next — instead of patching the one
#carrier field.

Pinned both ways: a scope.spec matrix (native-#private method/getter through
the carrier mutates the real object; set delegation; frozen-own-prop
invariant; overlay non-shadowing) and the bot's exact end-to-end scenario
(an agent/session-start listener calling this.send drives a real turn) —
both fail with TypeError against the withProps carrier.
2026-07-09 14:21:58 +08:00
..

dsh-scope

Scoped-context registration primitive. createScope(ctx, key) mints a Cordis context that TAGS everything registered through it with an opaque ScopeKey and OWNS those registrations' lifetime (one backing fiber drives both facts); scopeOf(ctx) reads the tag; scopeTarget(base, key) builds the dispatch carrier that makes an event scope-filtered — listeners registered through a scoped context fire only for their key's subject, while plain plugin listeners keep firing for every subject. The agent loop is the one scope minter today (one scope per live agent, key = the Agent object — the Agent.ctx contract in dsh-agent), but the mechanism is key-agnostic so packages below the agent layer (dsh-session, dsh-system-prompt) depend on it without a dependency cycle.

Public API

  • createScope(ctx: Context, key: ScopeKey): Scope Mint a scope under ctx's fiber. Usable synchronously (effect collection is uid-gated; service resolution falls through to the minting plugin's dependency surface). Throws on a primitive key, or when ctx's fiber is disposing (INACTIVE_EFFECT).
  • Scope.ctx The tagged context: registrations through it are scope-visible AND scope-lifetime. Derived contexts (an extend, a fiber mounted under it) inherit the tag; nested scopes shadow (nearest tag wins).
  • Scope.rawDispose The 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, always-awaitable teardown of every registration made through the scope.
  • scopeOf(ctx: Context): ScopeKey | undefined The tag a context (or any context derived from it) carries; undefined = context-global.
  • scopeTarget(base: T, key?: ScopeKey): Scoped<T> Build the dispatch thisArg for a scope-filtered event: composes base's own Context.filter with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; key === undefined ⇒ untagged only). Listener this stays base-shaped. { global: true } listeners bypass filtering (Cordis semantics).
  • Scoped<T> The compile-time carrier brand: scope-filtered events demand it as their this type, so dispatching with a bare subject is a compile error.
  • 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.

Design contract

Ownership and visibility derive from ONE fact — which context a registration went through. An explicit { scope } registration parameter could express "visible to X, disposed with Y", which is almost always a bug; the scoped context makes it unrepresentable. Rationale and alternatives: the agent-scoped-registration RFC (docs/rfc/implemented/architecture/2026-07-08-agent-scoped-registration.md, landing with this change set).

Handing out a scoped context hands out the minting plugin's service-resolution capability (resolution walks the minting fiber's dependency chain, not the holder's) — mint scopes from a plugin whose inject surface is what scope holders should reach.