Files
deepseek-harness/packages/client/runtime
imccyu 01ecb43ebc docs: state the Host-face rule for the browser e2e and settle the follow-ups
apps/web/tests/README.md records why these e2e type-check in the Host aggregate
and why importing a Client package there pulls its project tree into the Host
build graph, with mirroring as the standing answer. The Agent Note drops the
directory-picker face split (assessed and declined) and the grep-level gate in
favour of that README.

docs: regenerate the catalogs and retarget the moved declarations

The forwarded-event change moved three owner packages' cordis `Events`
declarations and their branded types into client-safe `./types` modules, and
the settings-scope split moves the shell spec into ui-settings-general. Point
the type-equivalence manifest and the affected Agent Note at those homes,
register the new `remote/*` event scope and the `ctx.settingsScope` service in
the catalog partition, and re-run the generators.

`$on` joins the documented `TypeRTClientRemote` surface, and the two Agent Note
fences that quote a bare member signature are marked `ignore-check`: they are
declaration fragments, not compilable units.

refactor(client): make ui-settings the settings domain's base layer

The settings-namespace transport lived in client/runtime, where every feature
could value-import it because runtime is a platform module. It belongs to the
settings domain, but moving it into ui-settings as a shared function fails
twice: the client bundle purity gate forbids cross-plugin value imports, and
ui-settings reached ui-sidebar for its shell, so any feature depending on it
closed a cycle through ui-layout and ui-theme.

Both halves move. `ctx.settingsScope` is now a cordis service — the
collaboration shape the purity gate prescribes, and the service proxy binds
`this.ctx` to the caller, so a bound scope's disposer belongs to the calling
fiber. The shell ui-settings used to own (the `sidebar.settings` occupant, its
navigation, and the nav-row projection) moves to ui-settings-general, which
already owns the chrome and the General section. What stays in ui-settings is
what carries no `ui-*` dependency: the scope service and the canonical settings
slot types, `settings.general.item` included. That type was parked in the locale
package precisely because the declarer was unreachable without a cycle; every
registrant now depends on this base layer, so it comes home.

The scope CONTRACT stays in client/runtime: a feature service accepts a scope
through its own signature without depending on the surface that binds it.

The forwarded settings invalidation replaces the deleted client-side
`settings/changed` event, so the transport reads `ctx.remote.$on`. It reaches
`$on` through the gateway's Client half plus the allowlist's type-only subpath
rather than api-remotes' Client face: that face imports a Host-tsdown-generated
artifact, and this package is reachable from the Host build graph through its
callers.

refactor(client): reach the settings transport through ctx.settingsScope

Every feature that owns a preference row switches from value-importing a shared
binder to the settings domain's service, and declares the two injections that
binding needs: `settingsScope` for the transport and `remote` for the forwarded
invalidation it subscribes to on the caller's own context.

The rows stay with the features that own the preferences — Language with locale,
Appearance with ui-theme, Composer Enter with ui-conversation. Only their route
to the transport changes, so no settings surface moves and no feature gains a
dependency on the shell.

The `settings.general.item` slot type now arrives from ui-settings, the base
layer every registrant already depends on, which retires the re-export outlet
ui-theme kept and the parked declaration in the locale package.

client/runtime drops its settings-form and schemastery dependencies with the
transport that used them.

test(client): bind the settings transport in the specs that boot a preference row

Every bench that activates a plugin owning a preference row now supplies the two
services that plugin injects: the forwarded-event port and the scope service.
Specs that exercise no settings path get the minimal doubles; the ones that do
drive their refresh chains through `remote/host-event`, the same signal
client/runtime republishes from a forwarded frame, replacing the deleted
client-side `settings/changed` event.

Also fixes a publication defect the built-invariant gate catches once it runs:
api-remotes' invariant companion shared the allowlist module with the package
index, so rolldown hoisted it into a third chunk beside the two bundled entries
— a file the mechanically derived publication list does not carry, leaving an
installed companion unable to import it. The companion now reads the allowlist
through this package's own published `./types` subpath, which the bundle keeps
external, so each entry stays self-contained.

The dynamic-subscription cast in apiproxy is gone: after the vendored cordis
rescope, `on` accepts the rest-parameter handler directly, and the allowlist's
shape assertion still carries the safety argument.

fix(client): carry the settings-scope move across the release manifests

Rebasing onto the publishable release set replaced every manifest's dependency
block, so the packages this change touches restate their additions in the
workspace-protocol form: the base layer's own transport dependencies, and the
`ui-settings` plus `remote` edges each preference-row owner now needs.

ui-settings-general takes clsx with the shell it received, and client/runtime
drops the settings-form and schemastery dependencies that left with the
transport.

fix(api-gateway): give each $on subscription its own registration and containment

Two defects in the forwarded-event subscription table, both raised in review:

A set keyed on listener identity stored one entry when two callers subscribed the
same function object to the same event, so the first frame reached it once instead
of twice and either disposer silenced the surviving registration. Subscriptions are
now records addressed by registration, which is what "the disposer belongs to the
calling fiber" requires.

A listener declared void may still be `async`, and the synchronous `try/catch`
could not see its rejection: the promise was dropped and surfaced as an unhandled
rejection outside the documented containment. Delivery now attaches a rejection
handler when a listener returns a promise, so both failure modes are logged and
isolated alike.

Delivery also iterates a snapshot, so a listener that subscribes or disposes during
a frame no longer changes who receives that frame, and production matches the
TestRemote double instead of relying on live Set iteration order.

Both fixes are pinned by tests that fail against the previous implementation. The
double gains its own spec for the `$mount` refusal and the unsubscribed-name drop —
per-file coverage reaches it — plus a note that it propagates a throwing listener
where production contains one, so no spec mistakes it for the containment guarantee.

Three prose corrections: `assertJsonArgs` states where its throw actually surfaces
(the emitter's listener containment, not load or emit time), the browser e2e README
names every standing Client import rather than claiming one exception, and two
comments and a test title state the forwarded event instead of the deleted
client-side one.

refactor(remote): deliver forwarded frames through ctx.remote.$dispatch

The carrier used to relay each decoded frame over an internal
`remote/host-event` cordis event so the delivery port could stay off the Remote
contract. The relay was the wrong shape twice over: it put a client-face event
into a scan whose subject is the Host vocabulary, forcing a walk exemption for
something that is not a Host event at all, and it made a direct handoff between
two Client plugins look like a broadcast any plugin participates in.

`TypeRTClientRemote` now carries both roles of one surface — consumers subscribe
with `$on`, and whoever owns the Host frame sink hands frames over with
`$dispatch` — so client/runtime calls the Remote service directly and the event
declaration is gone. A cordis service method is the collaboration shape the
client bundle purity gate prescribes, and it needs no relay to satisfy it.

The trade is that the handoff is now developer-visible: any plugin holding
`ctx.remote` can synthesize a forwarded event. That is the exposure the relay
already had — `ctx.emit` was equally reachable — stated in the contract instead
of hidden behind a private subscriber.

runtime reaches `ctx.remote` through the gateway's Client face rather than
api-remotes': that face imports a Host-tsdown-generated artifact, and this
project sits in the Host build graph.

refactor(api-remotes): keep the allowlist value out of types.ts

`src/types.ts` carries only types by package convention, but it held the
forwarded-event array, so the type-only subpath published runtime code. The
array moves to `src/remote-events.ts` and `types.ts` derives its projection from
it; both compiler faces list both files, so the Host forwarding loop and the
consumer key face still read one declaration and the package's exports are
unchanged.

The invariant companion returns to an empty installer. Its dispatch-shape check
was the only reason the companion imported the allowlist, which made the two
bundled entries share a module: rolldown hoisted it into a third chunk that the
mechanically derived publication list does not carry, so an installed companion
could not import it. Dropping the check retires that coupling along with the
subpath-import and bundle-external workarounds it needed, and the shape the
check enforced at runtime is the part the Host face's `TypeRTForwardableEvent`
assertion already refuses at compile time.

test(ui-task): bind the locale plugin's new injections in its bench

The bench boots the real locale plugin, which now injects the settings-scope
service and the forwarded-event port, so it stayed pending and left `ctx.locale`
undefined. Supplies both doubles like the other benches that boot a plugin
owning a preference row.

docs: close the documentation gates for the forwarded-event surface

Regenerates the two graph catalogs and re-records every bilingual pair this
branch edited. Several pairs needed real work beyond the record:

- The generators write only the English side, so the Chinese sides of
  `event-producer-consumer` and `module-graph` had drifted: the former still
  listed the three deleted client-face events and pointed at declaration sites
  this branch moved into `types.ts` modules, and the latter carried a stale
  dependency graph.
- `TypeRTClientRemote`'s documented declaration gains `$dispatch` on both sides.
- The pairing contract requires both sides to link the same target, so the
  apiproxy README and the design note now link the English note from both
  languages, and the note's code blocks are byte-identical across the pair
  (a translated comment inside a fence counts as divergence).
- `apps/web/tests/README.md` gains its Chinese counterpart; the browser e2e lane
  documents a discipline reviewers apply, so it belongs in the bilingual corpus
  rather than in the pairing exemption list.
- Four fences in the design note are marked `ignore-check`: each quotes a member
  signature, a union arm, or a snippet that names symbols it does not import, so
  none is a compilable unit.

docs(agent-note): transition the forwarded-event note to implemented

The design shipped in this PR, so the pair moves into `implemented/` and takes
that folder's skeleton: `## Proposal` becomes a present-tense `## Decision`,
and `## Acceptance criteria` plus `## Risks` fold into `## Verification` (what
pins the behavior) and `## Consequences` (what the shipped shape costs).

Facts that moved after the proposal are corrected rather than preserved: the
allowlist value now lives in `remote-events.ts` beside a type-only `types.ts`,
the delivery port is `$dispatch` rather than an internal cordis event, and the
invariant companion is an explained empty installer. `Verification` states the
two `$on` defects the review found — independent registration identity and
async-rejection containment — since those are now the properties tests pin.

Supersession is partial, so five active notes stay active and gain a
cross-link each: `web-config-plane`, `web-client-session-scope`,
`config-plane-boundaries`, `versioned-gui-welcome-onboarding`, and
`permission-default-for-new-sessions` each described a frame this change
replaced. Only the mechanism sentence is annotated; every conclusion those
notes own is untouched, and `host/models-changed` remains apiproxy's own
derived frame in all of them.

Also pins the disposer's idempotence: calling one `$on` disposer twice must not
splice a surviving twin registration out from under its owner.

fix: docs

fix: test
2026-08-11 19:25:41 +08:00
..

@deepseek-ai/dsh-client-runtime

English | 中文

Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list and scope state, and the shared event window and history paging used by registered conversation view targets. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (connectWorkspace). The runtime fans the shared Host stream into Session and Workspace owners and bridges the registry-invalidation frames to typed ctx events (commands/changed, session/preset-changed, settings/changed, credentials/changed, models/changed) so surface caches refetch without touching the stream. host/session-preset-changed also folds its preset into the session row, because the switch's RPC echo reaches only the client that issued it. Client sessions are always Host-born (Session+Agent+cwd in one session.create); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each Session holds a generic ProjectionValueStore seeded from the history-tail projections block and updated by session/projection frames under higher-seq-wins; domain keys (including todos) are read via projections.faceOf / useProjection, not via ConversationSnapshot. The store also publishes one reference-stable whole-value map through SessionSummary.projectionValues, allowing global list consumers to reuse the same projections without creating per-session subscriptions.

bindSettingsScope is the browser mirror of the Host-side settings owner seam for one domain-owned namespace. It subscribes before starting a nonblocking initial read, publishes a uSES snapshot (status, section value, revision, writability, host/memory mode), serializes set writes with the latest known namespace revision, suppresses stale publications, recovers a rejected latest write from Host state, and reaches quiescence on plugin disposal. The default decoder validates each section against the namespace's own serialized wire schema (rehydrated through dsh-client-schema-form), so a domain adds a decoder only to narrow beyond that schema. Loopback pages use the Host settings API; remote pages stay in memory mode. Domain packages own the namespace schema, default, and live service rather than putting product policy in runtime.

Slot declaration injection

ctx.slots.inject(name, callback) makes a full SlotMap key the dependency for a contribution whose plugin can activate independently from the declaring entry. It runs callback synchronously when the declaration exists, otherwise waits; declaration collapse disposes the callback effect, and redeclaration reruns it. The controller belongs to the caller's plugin fiber, so unloading the contributor cancels either the wait or its active registrations. A direct slots.register() into an undeclared slot still throws.

The callback returns one synchronous disposer or an iterable of disposers. A generator can therefore yield several slots.register() calls as one transaction: setup failure rolls earlier yields back and teardown runs them in reverse order. Declaration lifetimes use a dedicated monotonic epoch, so a collapse and redeclaration batched into one renderer notification still restarts the callback, while ordinary entry changes do not. Declaration-bound teardown runs synchronously with the ledger mutation, releasing runtime resources before subsequent same-tick registrations. See the declaration-injection decision.

Workspace and Session lists

Workspace and Session lists have independent monotone pendingready baseline phases and separate refresh activity/error state. Incremental upsert/removal frames and unary mutation echoes arriving during a list request replay over its response. The first successful baseline establishes Host order; later refreshes update rows and membership without changing the relative order of identities already shown. Removed Workspace ids retain process-local tombstones so late changed frames cannot resurrect them; reconnect still takes workspace.list as the baseline. Workspace recency is derived only after both baselines are ready and never changes Workspace list order.

SessionSummary.pendingInteraction classifies the live user action blocking a Session as approval, plan-review, or question. SessionManager tracks answerable requested/resolved mux frames by their stable request identities even before a Session object is instantiated; pre-instantiation buffering retains every live request, replaces replay duplicates, and removes resolved requests so the list status always has a matching answerable PendingWait when the Session is opened. The first pending question takes presentation priority over concurrent approvals to match composer routing, while only a request that satisfies the plan-review composer's binary rendering constraints keeps the distinct plan-review status. The state is connection-generation scoped: disconnect clears it, and mux-open replay restores only requests that remain pending.

WorkspacesService.delete(workspaceId) removes the registration from the client projection after the successful unary response; the matching host/workspace-removed frame is idempotent and synchronizes other tabs. Session state and the current Session selection are independent, so accounted Sessions immediately project under Ungrouped after their Workspace disappears.

WorkspaceListState.archivedSessionIds mirrors the Host's registry-global archive set (a readonly SessionId[] in Host order, replaced only when membership changes; consumers needing O(1) lookups build a transient Set). It is full-snapshot state: the workspace.list baseline, the archiveSession unary echo, and the host/archived-sessions-changed frame each install the complete set. WorkspacesService.archiveSession(sessionId) archives over the wire; the projection sweep clears the current selection into the New Session view state whenever it lands in the archive set — one rule covering the local echo, another tab's frame, and a reconnect baseline restoring a selection archived while this client was away. A set installed while a workspace.list request is in flight also supersedes that stale baseline's set. Grouping surfaces hide members everywhere while the session rows stay in the list store.

SlotsService gives the renderer separate bare observables for useSessions and useWorkspaces; web-react creates the hooks. Workspace business state does not enter SessionListState or an entry store.

indexSubagentDescendants() derives per-parent total and running descendant counts from the retained list mirror. It follows only uninterrupted origin: 'subagent' ancestry, so an ordinary fork starts a separate ownership subtree; cycles stop without throwing, and a missing parent remains a harmless key until its summary arrives.

SessionListState.tasksBySession mirrors the Host's session/tasks frames last-wins, keyed by session and needing no Session instance. An emptied set is stored as an absent key, so absence and [] are one representation and consumers never test a sentinel. Two clears keep it from outliving its truth: session/subscribed drops the session's mirror, because a fresh generation sends a baseline only for a non-empty set and a retained list would survive as a phantom, and host/session-removed drops it again, because owner disposal removed the records on the mux stream while the removal frame rides the host stream, leaving the two with no relative order.

SessionsService.search(query, signal) is a stateless one-shot action over the session.search RPC. It returns ranked session/snippet pairs without putting query, loading, or error state into the shared Session list, so each UI owner controls debounce, cancellation, stale-response suppression, and fallback presentation. searchResultLimit re-exposes SESSION_SEARCH_RESULT_LIMIT — the bound the response schema itself enforces — as injected presentation data, so client plugins do not duplicate it. It is a protocol constant rather than per-connection state, so the connection handle does not carry it.

New Session and the blank mirror

WorkspacesService.connectWorkspace(workspaceId) resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (blank && cwd == workspace.path && sessionIds.includes(id) — the host's own membership rule, never cwd alone, so a cwd-matching unaccounted blank session is never hijacked) or calls session.create({workspaceId}), returning the session id for the caller to open. SessionSummary.blank mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by session.list / the host/session-added frame, flipped false by the first ACCEPTED local prompt() (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any running: true status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. SessionsService.create accepts an optional caller-preallocated SessionId and throws SessionCreateError (carrying requestedSessionId) on failure.

Session.composerPhase treats any visible non-command Chat Node as conversation content, so a client plugin can project durable human input without opening a turn while a window containing only generic command rows retains the Host blank posture. List hiding and blank-session reuse still follow the Host blank bit. A history window that lacks the plugin-owned input Node returns to that blank posture until an older page restores it.

Pending queue projection

ConversationSnapshot.queue is the Host's authoritative transient snapshot of agent.inbox.nextTurn; pending next-step steering stays outside this projection. Each row carries its MessageId, complete editable text when every content block is text, and a flattened preview. The Host derives whole session/queue snapshots from durable agent/inbox/spliced mutations and sends a baseline on reconnect; the message-local agent/inbox/inserted, claimed, and discarded notifications are not used to reconstruct this projection. Session.updateQueue() sends edit/remove operations through Host-side Inbox.splice() without optimistic client mutation, so the next Host snapshot is the sole visible commit and a claim race can surface queue-item-not-found.

Conversation assembly

Each Session gives its contiguous event window to a ConversationNodeAssembler. Plugins register business Definitions that map one event to a stable {kind, id}, create State at the unique start event, fold correlated updates, and build final nodes for registered view targets. The assembler owns the Context index, read-only predecessor lookup, and a reference-stable Turn/Step Location index. A live append evaluates each Definition once and updates only the matched Context; loading an older page preserves existing Context and node identities, matches only the newly prepended events, and replays Contexts whose predecessor or Location facts changed. Full replacement is reserved for open, resync, and gap repair.

Definition authors keep matching local to the current event, give every correlated event a stable business id, and make updates replayable by log seq; renderers consume final Node data and constrained Location values rather than scanning Session or Chat collections. The Conversation Node cookbook gives the complete registration and pagination path.

ui-conversation registers the built-in Chat Definitions and the keyed Chat snapshot builder. Append-origin user, assistant, and Tool results remain the human record; model-only replacement copies stay out, except that a compaction checkpoint becomes its own marker and resolves missing summary provenance when an older page supplies it. Durable inbox splice Contexts classify next-step user messages as steering without making inbox state a Session special case. Context messages retain producer provenance and form. StatsLine reads ConversationSnapshot.chat.legacy.nodes, while Session mirrors that legacy slice into the top-level nodes, partial, and runningCalls public compatibility fields without running a second business fold. ui-trajectory registers independent Definitions and a target builder over the same Session window; it preserves the existing stage-oriented view model without consuming the Chat compatibility fields or running another history fold.

The Chat builder keeps one mutable keyed store per Session. Content updates notify only the affected node key, structural changes rebuild order and Location membership, and a prepend adds rows without replacing existing keyed values. Assistant chunks update Definition State for every event but request at most one materialization per animation frame; final messages and Turn/Step closure publish immediately. See the client Tool presentation decision.

Trajectory request data

Trajectory Definitions assemble one chronological, purpose-discriminated provider-request stream. Assistant requests always carry their numeric turn and step; compaction requests carry step: 0 and a turn owner that may be null. That null owner means a manual compaction ran standalone between turns, not that it belongs to either adjacent turn. A session/end-seed boundary closes an unmatched compaction request as an error at the boundary time with Compaction was interrupted before completion.; a later start projects as an independent request instead of overwriting the orphan.

Code Mode child-call tree

Every ToolCallBlock recursively owns its children through subCalls, in start order. Chat's Tool Definition correlates root calls and results by call id, folds Code Dispatch start/settlement records into that root Context, and projects one keyed recursive tree; child calls never become independent Chat roots. When a start falls outside the loaded window, its settlement remains renderable with callTime: null. A child update copies only its ancestor path, so unchanged siblings retain object identity. Edges that introduce a cycle or exceed the fixed 256-call depth limit are consumed without mutating the tree. Trajectory's Tool Definition independently assembles the same nested data contract for its target.

Session title projection

SessionManager retains the latest validated session/title control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its lastSeq before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing SessionSummary.title is therefore only the actual durable title; displayTitle is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title. ISession.rename settles the title projection cell directly from the unary response's {title, seq} under the same higher-seq-wins rule — the list row and every useProjection('title') reader update ahead of the push frame, whose later replay of the same seq is a no-op.

Model retry projection

The Host-owned LLM retry invariant validates provider-routed llm/retry and llm/retry-started records at the durable append boundary, including their identity, ordering, timer, integer, status, provider-delay, and non-empty diagnostic contracts. In the client, the Retry, Assistant, and Turn Error Definitions fold those records with Assistant and Turn/Step events: a failed step's streaming partial is removed and a durable retry notice appears at the retry event's sequence position. The notice is scheduled until the matching started record arrives; closing its owning Step or Turn first marks it cancelled, while the started record marks it started. Normal-mode notices carry their finite maximum; always-mode notices remain explicitly unbounded. A terminal turn/end error without a retry projects one turn-error node from its durable message and optional code; AUTH projections replace provider copy that may echo credential fragments with API key is invalid, while the raw diagnostic remains in the session log. A retried failure keeps only the retry notice for that attempt. Window rebuild and history replay use the same Definitions, so refresh neither resurrects discarded chunks nor loses terminal failure feedback. Visible unfinalized output is frozen as an interrupted Assistant node beside the terminal error.

Session forking

ISessions.fork({sessionId, atSeq?, increaseTitle?}) resolves only after the child summary is locally addressable, carrying source lineage and cwd with blank: false; callers choose whether to open it. With increaseTitle: true, the client renames the child from the source session's persisted title: a trailing (N) or N is incremented without changing bracket style, while any other title gets (1) appended; the rename is skipped when the source has no persisted title, and a rename failure rejects the promise but leaves the created child in place. This option is not sent in the Host fork request. A workspace-attach-failed response still identifies a child already published by the Host, so SessionManager reconciles that partial success before SessionForkError reaches the caller instead of making a retry create a duplicate child.

Session model selection

Each resident Session owns a modelSelection snapshot containing the current ModelSelection, provider-grouped directory, provider-local failures, and the idle/loading/ready/selecting/error state. History establishes or refreshes the current selection, opening a selector refreshes the directory, and selection failures preserve the last selection and usable groups. Directory and selection operations share a monotonically increasing generation so an older response cannot overwrite a newer selection. A reconnect rebuild restores the selection reported by the Host without replacing unchanged selection substructure.

Model Experience

None, as the session object layer selects the provider/model route used by a later Host request but adds no model-visible content.

KV Cache effect

Changing the model selection can change or invalidate provider-side cache reuse; this package does not alter the prompt prefix itself.

Known Limitations and Deferred Work

  • loader.unload is a stub — it throws not-implemented; the client has no unload chain from fiber disposal through registration and style removal.
  • Scope teardown is stage-driven, single-occupant today — the staged session follows list.current exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (binding()/scope()) is pure addressing, render-safe; the render layer reads the current bundle through the currentProvideInfo observable. The staged state can widen to a multi-pane list when concurrent panes land.
  • Value imports of this package from plugin bundles must use the /client subpath — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches.