Skip to main content
PathDocs

Dynamic Plugin Runtime: the Host/Client Halves

In short: @deepseek-ai/dsh-cordis-host-runner keeps immutable Packages in process memory, runs Host halves in a node:vm sandbox, and routes browser calls through one handler table; @deepseek-ai/dsh-cordis-client-runner evaluates the closure in a page and mounts the plugin through the loader behind a whitelisting facade; @deepseek-ai/dsh-client-ui-cordis renders definitions, run state, and approval entry points as a panel, keyed cards, and @pluginId completion.

Audit baseline 0.1.5-alpha.1 @ 5dda764ed3: package names, ctx keys, event names, config keys, error codes, and sandbox symbols are all verified point-by-point against the official source.

Runtime Inspection and Dynamic Cordis Plugins covers what the model sees: the seven tool-cordis tools, the cordis_define argument shape, and the run/update/stop/undefine semantics. This page goes one level down into the machinery: who stores a definition, where a Host half executes, how a Client half reaches the browser, and how the UI reports state. The seven-tool catalogue is not repeated here — read that page for it.

1. The four packages

PackageFacectx key / registrationResponsibility
@deepseek-ai/dsh-tool-cordisHostregisters on ctx.toolsThe seven model tools plus @pluginId reference injection (see Runtime inspection)
@deepseek-ai/dsh-cordis-host-runnerHostctx.dynamicCordisRunner, ctx.cordisInspectDefinition registry, vm sandbox, Host-half fiber lifecycle, invoke handler table
@deepseek-ai/dsh-cordis-client-runnerClientbrowser ctx.dynamicCordisRunnerEvaluates and mounts/unmounts Client halves, answers run requests, guard facade
@deepseek-ai/dsh-client-ui-cordisClientregisters slots and an input sourceFrame-wide panel, four keyed tool cards, @pluginId completion

The web bundle loads the first three from packages/bundle/web-app/cordis.patch.yml (the cordis-host-runner / cordis-client-runner / ui-cordis rows). tool-cordis is not in any shipped composition — it is an explicit opt-in, and the tool-cordis row in packages/preset/agent-presets/presets/cordis/agent.cordis.yml is how you enable it. Without the host runner, the tools never see ctx.dynamicCordisRunner and never activate.

2. cordis-host-runner: definition registry and the Host-half sandbox

The service is DynamicCordisRunnerService (static inject = ['tools']), exposed as ctx.dynamicCordisRunner through a declare module '@deepseek-ai/cordis' merge. It accepts exactly one config field:

Config keyDefaultMeaning
vmTimeoutMs5000Milliseconds one synchronous vm evaluation may run (z.number().min(1).default(5000))

The definition registry

DynamicCordisRegistry is a plain in-memory Map with no persistence: a DSH restart clears it. Four counters mint every identity, with predictable shapes that stay globally unique:

IdentityMintShape
Plugin IDmintPluginId(prefix)<prefix>-<n>, skipping used suffixes
Package IDmintPackageId()pkg-<n>
Plugin Run IDmintPluginRunId()run-<n>
Approval IDmintApprovalRequestId()approval-<n>

One DynamicCordisPlugin holds immutable packages, currentPackageId (the committed version), nextPackageId (the target), the live run, and the latest latestRun attempt record. Pending approvals live in a separate pendingRequests index managed by armRequest / peekRequest / claimRequest / disarmRequest / pendingRequestForthe first answer wins.

The Host-half sandbox

A Host half is not an ordinary import. createSandbox(pluginId, { handle }) builds a fresh node:vm realm, and evaluateHostCode(sandbox, code, id, vmTimeoutMs) runs the source as an async function body. At define time, precheckCode(code, 'code.host' | 'code.client') compiles without running, surfacing syntax errors early.

The symbols exposed to the sandbox are the fixed HOST_BUILTIN_INSPECTION list:

SymbolPurpose
ctxRestricted Cordis Context: ctx.get / ctx.on / ctx.provide / ctx.effect
harnesshandle(method, fn), defineTool(definition), registerTool(ctx, tool)
consoleWrite-through logging tagged [cordis:<id>]
btoa / atobUTF-8 base64 encode/decode
TextEncoder / TextDecoderStandard codec constructors

Node globals are deliberately withheld, and calling one throws a teaching redirect (NODE_API_REDIRECTS): require → use the cordis services on ctx; setTimeout / setInterval / setImmediate / clearTimeout / clearInterval → declare inject: ['timer'] and use ctx.timeout / ctx.interval; fetch → declare inject: ['web']. Separately, DUAL_REALM_INSTANCEOF_PRELUDE patches only the vm's Object / Array / Error constructors with Symbol.hasInstance so host values passed in as arguments, events, or service results still satisfy instanceof.

The sandbox isolates globals but is not a security boundary: Node globals are absent or redirected to Cordis services, and a Host half receives a facade without framework internals — but the services it declares reach the live runtime. As the README puts it, treat a dynamic package like bash access.

The registration boundary (guard)

guard.ts is the only gate from "sandbox product" to "real runtime":

  • isPlugin(value) / pluginName(plugin): plugin-shape checks.
  • normalizeHandler(method, fn): normalizes a harness.handle argument into { method, handler } and stores it in this run's handler table.
  • sandboxDefineTool / sandboxRegisterTool: harness.defineTool runs the real schema DSL and stamps DYNAMIC_TOOL; harness.registerTool accepts only a stamped definition.
  • sandboxContext(ctx, reportFailure): the Host half's ctx facade — CTX_VERBS (effect / on / once / provide / timer family) pass through, ctx.get(name) is an optional lookup, and direct ctx.serviceName access requires the service in the fiber's inject (declaredInjects reads the keys of ctx.fiber.inject); otherwise it throws a guard error with the fix.
  • guardedPlugin(plugin, reportFailure): wraps registration so post-activation rejections travel the same reporting path.

Fiber lifecycle

startHostHalf(group, plugin, reportGuardFailure) mounts the sandbox's plugin under the cordis-dynamic group fiber (lazily created by requireGroup() as rootCtx.plugin({ name: 'cordis-dynamic', apply: () => {} })), awaits fiber.await(), and returns. A startup failure disposes the fiber before rethrowing, so a failed run never lingers. missingServices(ctx, fiber) compares the fiber's inject keys against ctx.get(name) to produce the waitingFor list — a settled-but-inactive fiber is legal cordis semantics (it activates when the service appears), not an error.

Stopping needs no helper: everything a Host half registers is an effect on its own fiber, so fiber.dispose() unwinds it. retract(plugin) deletes plugin.run, runs every handlerDisposers entry, awaits fiber.dispose(), and finally emits cordis/dynamic-retract.

The invoke handler table

Handlers registered with harness.handle(method, fn) live in run.handlers: Map<string, DynamicCordisHandler>, with handlerDisposers recording each undo. The browser's host.call(method, args) arrives over Typert Remote at @Remote('invoke'):

@Remote('invoke')
async invoke(pluginId, pluginRunId, method, args): Promise<DynamicCordisInvokeResult>

It answers with four failure codes (DynamicCordisInvokeResult):

codeMeaning
plugin-not-runningThe plugin has no active run
stale-runThe call belongs to a replaced activation
method-not-foundThat method was never registered
handler-errorThe handler threw (the owning Session is steered too)

Events and Remote verbs

Events a Host half emits (interface Events in types.ts):

EventWhen
cordis/request-runA Client-bearing activation needs a page (and possibly a human decision)
cordis/request-run-resolvedA pending request left the answerable state
cordis/dynamic-packageOne exact activation is now live in the Host
cordis/dynamic-retractOne exact activation was withdrawn
cordis/inspect-queryRequest one read-only query from a browser-side Client inspect provider
cordis/inspect-query-resolvedA query settled or was cancelled

The @Remote verbs exposed to the browser:

VerbPurpose
runHostHalfStart the Host half after approval (or on a direct panel gesture)
getClientCodeHand the page the Client source of one exact run
resolveRequestRunSettle a model-driven Client activation (first answer wins)
settleUserRunSettle a panel-driven run
stopFromPanel / undefineFromPanelStop/remove from the panel and inject the outcome into the owning Session
inventoryThe frame-wide inventory (no source code)
syncInspectManifestReplace the Host-side mirror of the Client provider directory
resolveInspectQuerySettle one cross-plane inspect query with a page result
reportRenderFailureRecord a post-load render crash
reportClientGuardFailureRecord a post-activation guard rejection
invokeRoute a Client → Host handler call

The tools call these non-Remote methods directly: define, run, stop, undefine, snapshot, reference, listPlugins, inspectPlugin, inspectPackage.

The Host-side inspect directory is CordisInspectRegistryService, exposed as ctx.cordisInspect: register takes Host providers, syncClientManifest mirrors the Client directory, list merges both planes, and query runs locally or turns into a cordis/inspect-query. The first-party Host providers do not live here — they are in tool-cordis/src/providers.ts, documented in Runtime inspection.

3. cordis-client-runner: the browser half

The plugin name is cordis-client-runner, with inject = ['loader', 'modules', 'slots', 'remote', 'remote.dynamicCordisRunner']. Declaring remote.dynamicCordisRunner keeps the plugin parked until the Host namespace exists, so a page never loads a browser half whose host half it could not reach.

It provides ctx.dynamicCordisRunner: CordisRunnerFace on the browser context:

MemberPurpose
activeRunsPer-plugin in-flight approval or activation, observable
lastRunErrorThis page's latest run failure
renderFailuresThis page's latest render crash per package
reconcileApprovals(rows)Align pending approval rows with the Host inventory
approve(requestId, approveFutureVersions) / decline(requestId)Answer a model request
startUserRun(request)Run directly from a user gesture (the gesture authorizes it)
subscribe / getSnapshot / isLoadedObserve what this page has loaded

Event subscriptions

apply subscribes with ctx.remote.$on(...) ($on hands the listener the Host's own argument list):

EventPage action
cordis/request-runorchestrator.open(request) records the pending activity
cordis/request-run-resolvedorchestrator.close(requestId)
cordis/dynamic-retractrunner.retract(pluginId, pluginRunId) unloads
cordis/inspect-queryRun the query on this page's provider and send the result back
cordis/inspect-query-resolvedClose the pending query

Closure evaluation

evaluateClientHalf(pluginId, clientCode, env, styles) runs the source as an async function body whose parameters are a fixed symbol surface:

ParameterContent
ReactThe React runtime (no JSX transform — build elements with React.createElement)
console[cordis:<id>]-tagged console; console.error is additionally copied into the load report
stylesDynamicCordisStyles; insert(css) returns a disposer, and unload cleans up automatically
host{ call(method, args = null) }, routed to this package's Host-half handler
harnessA teaching trap: harness.* belongs to the Host half
setTimeout / setInterval / clearTimeout / clearIntervalTeaching traps → declare inject: ['timer']
fetch / requireTeaching traps → use a Host handler or a ctx service
process / Bufferundefined (so typeof process probes stay safe)

DYNAMIC_CLIENT_REDIRECTS is the single home of those redirect messages. The closure must return a plugin — either (ctx) => {…} or { name, inject, apply(ctx) } — or it throws a teaching error; isDynamicCordisPlugin performs the host-side mirror of that shape check.

Mounting: loader plus module table

DynamicCordisPackageRunner is the page-side load engine:

  1. Evaluate the closure; failure → cause: 'evaluate'.
  2. moduleId = 'dyn/<pluginId>' (also the loader entry name and fiber name).
  3. modules.invalidate(moduleId), then write the factory into the window.__ModuleLoader__ sink (a missing sink means the page booted outside the web shell).
  4. loader.create({ name: moduleId }); no resolvable fiber → cause: 'module-import'.
  5. await fiber.await(); a throw → cause: 'activate'.
  6. Record waitingFor = Object.keys(fiber.inject).filter(name => ctx.get(name) === undefined) — settled but waiting on services this page lacks counts as success (parked).

Teardown is loader.remove(entryId) + modules.invalidate(moduleId) + styles.dispose(): removing the entry cascades fiber disposal, so slot entries and facade effects go with it. Each package id has a serial queue (enqueue) so a slow load cannot be overtaken; loading the same revision twice is idempotent, a newer revision replaces the old one, and the same revision loads afresh after a retract.

The guard facade

dynamicCordisContext(ctx, env) is the browser twin of the Host sandboxContext:

  • ctx.get(name): optional lookup returning the real service (Context returns are denied).
  • ctx.<service>: allowed only when the fiber's inject declared it, otherwise a "declare it in inject" teaching error.
  • ctx.<verb>: CTX_VERBS (effect / on / once / provide / timer family) pass; timer verbs additionally require inject: ['timer'].
  • The slots seat: register assigns a page-local shadowing priority, records a ledger row, and registers component ownership (render-crash attribution depends on it).
  • The theme seat: overrideTokens' source is forced to <pluginId>.<packageId>, so a dynamic package can never impersonate or evict another theme layer.

Render crashes are observed through slots.onEntryError: only components this runner seated count. The report carries the slot, abdicated (whether the crash retired the entry), and an author-facing message. One observation feeds two outlets — reportRenderFailure goes to the Host (for the model and cordis_inspect_self), while the page's renderFailures feeds the panel.

Orchestration: Host first, then Client

CordisRunOrchestrator.drive(plan) has a fixed order:

Starting the Host half first means a host-half failure short-circuits before the browser moves; then the source is fetched, the Client half loads, and exactly one resolution is sent. Success carries the loaded pluginRunId and any parked service names; failure carries one reason — rejected (user refusal), host-half-failed, or client-half-failed — the latter two with the failing stage (evaluate / module-import / activate) and message. One plugin has at most one in-flight orchestration (inFlight deduplication).

4. ui-cordis: the browser surfaces

inject = ['slots', 'locale', 'inputTriggers', 'remote', 'remote.dynamicCordisRunner', 'dynamicCordisRunner'], registering five surfaces:

SeatKeyContent
sidebar.footer.actionid cordis-panelFrame-wide panel: badge count plus one row per definition
tool.call.toolviewcordis_defineRead-only definition card (Host/Client source tabs)
tool.call.toolviewcordis_runRun card plus the tool.view.cordis child seat (kind: 'keyed', scope: 'session')
tool.call.toolviewcordis_stop / cordis_undefineCompact action rows
inputTriggers sourcetrigger @, name cordis@pluginId completion

The run/stop switch lives on the panel rows, not on the definition card. CordisDefineRow is a pure record: the name, purpose, source, and current state the model wrote — no switch, no approval button, just a pointer to the panel. The panel row's onRun / onStop / onRemove / onApprove / onDecline are the lifecycle operations, reaching stopFromPanel / undefineFromPanel / inventory through CordisDynamicPort, while run and approval go through the browser runner.

Each row shows two independent facts — what the Host is running and what this page has loaded. cordisVisibleStatus(row, packageId, loaded) yields exactly three readings: idle (no active run for that package), client-pending (Host up, this page not loaded), and running (no Client half, or this page loaded that pluginRunId). A reloaded page therefore offers "load back into this page", while a host-only definition reads plainly running and offers the stop alone.

Panel data comes from createCordisInventory: one global inventory read, single-flight, re-read on announcements rather than patched in place. cordis/dynamic-package / cordis/dynamic-retract / cordis/request-run / cordis/request-run-resolved all trigger a refresh, and connection/reset calls reset() to discard an in-flight read so a reconnect never publishes the old host's rows. The card view models (cordisDefineCard / cordisRunCard / cordisActionCard) derive from frozen call/result slices, so replay renders the same card.

5. The dual-half contract: what a Package must export

Host half (code.host)

  • A plain JavaScript async function body that must return a plugin: (ctx) => {…} or { name, inject, apply(ctx) {…} }.
  • Only the sandbox symbols are available: ctx, harness, console, btoa/atob, TextEncoder/TextDecoder.
  • Expose a call surface with harness.handle(method, fn) for the Client half's host.call.
  • For model-visible tools: harness.defineTool(definition) + harness.registerTool(ctx, tool).
  • For host capabilities: declare inject: ['fs' | 'web' | 'bash' | 'timer' | …] on the returned plugin, then use ctx.get / ctx.<service>; reading an undeclared service is rejected by the facade.

Client half (code.client)

  • It must also return a plugin; the object form's inject is the only declaration site for services, so a plain function reaches none.
  • The closure symbols are fixed: React, console, styles, host; harness / browser timers / fetch / require are teaching traps, and process / Buffer are undefined.
  • Call the Host half with host.call(method, args); when the argument is omitted the handler receives null (the wire carries JSON, and undefined is not JSON).
  • Contribute UI by registering into slots; use styles.insert(css) for CSS and theme.overrideTokens for theme layers (whose source is forced).

How this differs from a static cordis plugin

Static cordis pluginDynamic Package
Code locationsrc/index.ts in a package, compiled into lib/A function body submitted by cordis_define, in process memory only
MountingA cordis.yml / patch row, mounted at startupActivated per Package version by cordis_run
LifecycleMounted/unmounted with the composition, restored on restartstop ends the run, undefine removes it; a restart loses everything
Permission boundaryFull ctx, may import any moduleHost half: vm sandbox + Node API redirects; Client half: closure + whitelisting facade
ScopeProcess/composition scopeOwned by the Session that defined it; other sessions read it as absent
Versioningnpm package versionImmutable Packages plus currentPackageId / nextPackageId
TrustEqual to the deploymentTreated like bash access

Both forms converge on the same Cordis lifecycle: a static plugin goes through ctx.plugin(...), a dynamic Host half through group.ctx.plugin(guardedPlugin(...)) under the cordis-dynamic group, and a Client half through loader.create into the module table — so activation gating, fiber-effect cleanup, and status projection share one semantics. For plugin forms and patch application order see Plugin Anatomy; for writing a static plugin see Write your first plugin; for the dual-face transport layer see Web UI Architecture.

Sources

LocationSymbol / fact
packages/extensions/cordis-host-runner/src/index.tsDynamicCordisRunnerService (static inject = ['tools']), Config.vmTimeoutMs default 5000, the ctx.dynamicCordisRunner merge, define / run / stop / undefine / snapshot / reference / listPlugins / inspectPlugin / inspectPackage, requireGroup()'s cordis-dynamic, and retract's handlerDisposers → fiber.dispose → cordis/dynamic-retract
packages/extensions/cordis-host-runner/src/index.ts:226,324,383,412,437,479,497,510,524,683,717,740The twelve @Remote verbs: undefineFromPanel, runHostHalf, getClientCode, resolveRequestRun, settleUserRun, stopFromPanel, syncInspectManifest, resolveInspectQuery, inventory, reportRenderFailure, reportClientGuardFailure, invoke
packages/extensions/cordis-host-runner/src/types.tsCordisDynamicPluginId / CordisDynamicPackageId / CordisDynamicPluginRunId / ApprovalRequestId / CordisInspectRequestId, CordisDynamicRunMode, CordisRunStatus, the four DynamicCordisInvokeResult codes, and the six cordis/* events in interface Events
packages/extensions/cordis-host-runner/src/registry.tsDynamicCordisRegistry, mintPluginId (<prefix>-<n>), mintPackageId (pkg-<n>), mintPluginRunId (run-<n>), mintApprovalRequestId (approval-<n>), armRequest / peekRequest / claimRequest / disarmRequest / pendingRequestFor, DynamicCordisHandler
packages/extensions/cordis-host-runner/src/sandbox.tsHOST_BUILTIN_INSPECTION (ctx / harness / console / btoa / atob / TextEncoder / TextDecoder), createSandbox, evaluateHostCode, precheckCode, NODE_API_REDIRECTS, DUAL_REALM_INSTANCEOF_PRELUDE
packages/extensions/cordis-host-runner/src/guard.tsnormalizeHandler, sandboxDefineTool, sandboxRegisterTool, sandboxContext, declaredInjects, guardedPlugin, isPlugin, pluginName, CTX_VERBS / TIMER_VERBS, DYNAMIC_TOOL
packages/extensions/cordis-host-runner/src/lifecycle.tsstartHostHalf (a guarded child under the group fiber, disposed before rethrow on failure) and missingServices
packages/extensions/cordis-host-runner/src/inspect-registry.tsCordisInspectRegistryService, the ctx.cordisInspect merge, register / syncClientManifest / list / query / resolveClientQuery
packages/extensions/cordis-client-runner/src/client/index.tsname = 'cordis-client-runner', inject = ['loader','modules','slots','remote','remote.dynamicCordisRunner'], CordisRunnerFace, the browser ctx.dynamicCordisRunner, and the five ctx.remote.$on('cordis/…') subscriptions
packages/extensions/cordis-client-runner/src/client/evaluator.tsevaluateClientHalf, the closure parameters ['React','console','styles','host','harness', …traps, 'process','Buffer'], DYNAMIC_CLIENT_REDIRECTS, DynamicCordisStyles.insert, isDynamicCordisPlugin
packages/extensions/cordis-client-runner/src/client/runtime.tsDynamicCordisPackageRunner, moduleIdOfdyn/<id>, ModuleLoaderSink.__ModuleLoader__, loader.create / loader.remove, modules.invalidate, DynamicCordisLoadErrorCause (evaluate / module-import / activate), waitingFor
packages/extensions/cordis-client-runner/src/client/guard.tsdynamicCordisContext, CTX_VERBS / TIMER_VERBS, guardedSlots (priority + ledger + claim), guardedTheme (source forced to <pluginId>.<packageId>)
packages/extensions/cordis-client-runner/src/client/orchestrator.tsCordisRunOrchestrator, activeRuns / lastRunError, approve / decline / startUserRun / reconcileApprovals, and drive()'s Host → source → Client → single-resolution order
packages/extensions/cordis-client-runner/src/client/providers.tsThe five clientInspectProviders ids Service / Event / Builtin / Slots / Theme and CLIENT_BUILTIN_INSPECTION
packages/extensions/cordis-client-runner/src/client/timer.tsClientTimerService, provideClientTimer, timeout / interval / throttle / debounce / setTimeout / setInterval
packages/extensions/ui-cordis/src/client/index.tsThe six-entry inject, sidebar.footer.action id cordis-panel, the tool.call.toolview keys cordis_define / cordis_run / cordis_stop / cordis_undefine, children: { 'tool.view.cordis': { kind: 'keyed', scope: 'session' } }, and the @ input source
packages/extensions/ui-cordis/src/client/slots.tsSlotMap['tool.view.cordis'], CordisPanelFace, CordisRunCardFace, CordisToolViewOwnerProps
packages/extensions/ui-cordis/src/client/CordisDefineRow.tsxRead-only definition card, SourceTab = 'client' | 'host', no run switch
packages/extensions/ui-cordis/src/client/status.tscordisVisibleStatus'idle' | 'client-pending' | 'running', packageOf
packages/extensions/ui-cordis/src/client/dynamic-port.tsCordisDynamicPort.stop / remove / inventory
packages/extensions/ui-cordis/src/client/inventory.tscreateCordisInventory, CordisInventorySnapshot, single-flight refresh, reset, retire
packages/extensions/ui-cordis/src/client/card-model.tscordisDefineCard / cordisRunCard / cordisActionCard, CordisToolState
packages/bundle/web-app/cordis.patch.yml:122,198,269The three rows loading @deepseek-ai/dsh-cordis-host-runner, @deepseek-ai/dsh-cordis-client-runner, and @deepseek-ai/dsh-client-ui-cordis
packages/preset/agent-presets/presets/cordis/agent.cordis.yml:246The explicit opt-in row for tool-cordis

Verification

# 1. Are the three runner/UI packages in the web composition? (tool-cordis is opt-in)
dsh web --dump-config | grep -E "cordis-(host|client)-runner|client-ui-cordis"

# 2. Host-side Remote verbs and event names
grep -n "@Remote(" packages/extensions/cordis-host-runner/src/index.ts
grep -rn "'cordis/" packages/extensions/cordis-host-runner/src

# 3. Sandbox symbols and Node API redirects
grep -n "name: '" packages/extensions/cordis-host-runner/src/sandbox.ts
grep -n "NODE_API_REDIRECTS" -A 12 packages/extensions/cordis-host-runner/src/sandbox.ts

# 4. Browser-side closure symbols and guard whitelist
grep -n "const parameters" packages/extensions/cordis-client-runner/src/client/evaluator.ts
grep -n "DYNAMIC_CLIENT_REDIRECTS" -A 10 packages/extensions/cordis-client-runner/src/client/evaluator.ts
grep -n "CTX_VERBS" packages/extensions/cordis-client-runner/src/client/guard.ts

# 5. Runtime check: open the web UI; the Cordis badge at the sidebar footer counts
# running plus awaiting-approval. The panel lists one row per definition, and the
# run / stop / remove switches live on those rows. After a page reload a host-only
# definition still reads running, while a Client-bearing one first offers to load here.

Next steps