Plugin Kernel & Event System

Plugin Kernel & Event System

In One Sentence

Upstream uses the Cordis runtime for “everything is a plugin”: a plugin exports apply, registers capabilities through ctx, and communicates loosely via events; dsh-go does not copy Cordis but splits the same contract into three Go primitives — registry.Freezable for registering and freezing capabilities, eventbus.Bus for four synchronous dispatch modes, and waterfall.Chain for onion-style delegation.

Three Plugin Forms Upstream

In the TypeScript version, a plugin is a module invoked by the framework on load with a ctx, through which it registers listeners, tools, and LLM adapters. It has three forms:

FormWhen to useKey trait
FunctionMost pluginsexport (ctx) => { ... }
ObjectSeparate name/inject{ name, inject, apply(ctx) }
ClassProvide a named serviceextend Service, super(ctx, 'name')

Go has no runtime module-loading layer, so a “plugin” in dsh-go becomes compile-time wiring: you register constructed capabilities into a registry at the composition root (main / wire function). We replicate the contract and lifecycle semantics, not the Cordis module loader.

Registry: The Single Entry to Capabilities

Upstream plugins look up capabilities through named services like ctx.tools, ctx.llm, ctx.agents — by key, never by importing an implementation. The matching storage primitive in dsh-go is pkg/registry.Freezable:

1reg := registry.NewFreezable[string, *tools.Tool]()
2_ = reg.Put("bash", bashTool)       // hot registration during startup
3tool, ok := reg.Get("bash")         // before freeze: read lock
4reg.Freeze()                        // wiring done, build read-only snapshot
5_ = reg.Put("x", xTool)             // after freeze: ErrFrozen
6tool, ok = reg.Get("bash")          // after freeze: lock-free snapshot read

Its two-phase lifecycle matches the assembly phase versus the running phase of an Agent:

  • Before freeze: reads and writes take sync.RWMutex, allowing hot registration and dynamic loading;
  • Freeze(): builds a one-time read-only snapshot (irreversible); afterwards reads are fully lock-free and writes return ErrFrozen.

Event System: Four Synchronous Modes

How do plugins communicate loosely? Events. Cordis offers a dispatch method per interaction contract, and each event has exactly one mode. dsh-go fixes the contracts via three methods of pkg/eventbus.Bus[T,R]:

ModeSemanticsdsh-go methodTypical use
emitbroadcast; all listeners run; results ignoredEmit(payload)change notifications, metrics
bailsequential; first hit short-circuitsBail(payload, shouldShort)capability lookup, policy hit
serialsequential; aggregate results into a sliceSerial(payload)gather candidates
waterfallonion next delegationpkg/waterfall.Chainmulti-level interception

The key difference: in emit/bail/serial listeners are independent and unaware of each other; in waterfall every middleware holds next() and actively decides whether to delegate, enabling enter-dive-return two-layer interception.

Automatic Cleanup

A key Cordis rule: listeners registered via ctx.on are removed automatically when the plugin unloads; every registration is reversible. In dsh-go, On returns a dispose closure you must keep and invoke on your cleanup path:

1dispose := bus.On(handler)
2defer dispose()          // removed on teardown; idempotent

This “register returns its inverse” pattern runs throughout the project: registering a tool returns its removal, subscribing returns unsubscription — giving every resource a clear owner and reclamation point.

Scope Parent Chain

Beyond layer-stack overlay, a scope can explicitly declare its enclosing (parent) scope. scope.ParentTree:

  • Bind(key, parent) binds once; a repeat returns ErrAlreadyBound;
  • Every bind runs cycle detection: walking parents back to the key itself returns ErrCycle;
  • ChainOf(key) returns the nearest-first ancestor chain, with a visited guard.

The parent relation is thus a DAG: “walk parents to the root” can never loop.

Why These Trade-offs?

Copying Cordis’s dynamic plugin container into Go would introduce runtime reflection and global mutable state, against Go’s “explicit over implicit” habit. dsh-go keeps replaceability (interface + key lookup), keeps reversible lifecycle (dispose), and drops dynamic module loading in favor of compile-time wiring — trading runtime hot-swap for stronger type safety and zero reflection.

Source Map

ConceptGo implementationUpstream TypeScript
Named service storagepkg/registry/registry.go — FreezableCordis service
Read-only freezepkg/registry/registry.go — Freeze()(dsh-go addition)
Emit/bail/serialpkg/eventbus/eventbus.goCordis emit/bail/serial
Onion delegationpkg/waterfall/waterfall.go — Chain.RunCordis waterfall
Auto cleanuppkg/eventbus/eventbus.go — On returns disposeCordis unload cleanup
Parent chainpkg/scope/parent.go — ParentTreescope parent binding

Next Steps