Status: implemented
English | 中文
Scope: how
dsh webcomposes (cordis.yml + pre-cordis boot classes + config sources) and how the web transport splits across packages (gateway / carrier / binding / graph / dev-reload). The client plugin loading note owns the browser-side loading chain this composition feeds.
dsh web was the only hand-assembled surface left: bootHost mounted 32 plugins with configs pinned in code (violating no-hardcoded-tunables), the client roster was a web.ts constant, and TUI/headless had long been yml compositions. The transport layer misplaced responsibilities to match: the webserver self-described as a dumb carrier yet knew the __DSH_BOOT__ graph, owned the SSE channel, and hard-coded the /api/* prefix; the dev bundle watch lived inside the prod registry behind a watch? flag with no lifecycle owner; the graph registry rescanned everything on every internal/plugin emission; per-request errors and fatal server errors shared one sink that always exited the process. One user-visible defect rode along: the web path never loaded $DSH_HOME/.env, so DSH_HOME=… dsh web could not find an API key living there.
Composition is one flat assembled tree. apps/cli/config/base.cordis.yml plus apps/cli/config/web.cordis.yml holds every row — the host runtime (32 rows), the api-gateway row, the webserver row, and the dsh.client rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into apps/cli/config/base.cordis.yml and each surface reduced to an overlay (shared-base overlays). The dsh-client-hmr row is an ordinary always-on bundle row (originally appended in code by --dev; the flag is retired). Row order carries no load semantics; activation is service-availability driven. The shared audit rejects imports with no fiber, awaits only failed fibers to recover original activation errors, and reports services that leave a fiber PENDING; before throwing, it marks those exact rejection reasons through one process checkpoint so installFailLoud coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. The Node app-boot artifact embeds @cordisjs/plugin-include while leaving @cordisjs/plugin-loader external, so the include's EntryTree and the host bind to one Loader peer instead of splitting a config tree across two Loader implementations.
Boot glue is a class pair. AppCLIEntry (apps/cli) and AppWebEntry (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. AppCLIEntry.run() is three stages: layered env (ambient > cwd .env > $DSH_HOME/.env, closing the defect above) → patch composition → Loader include boot plus the activation audit. AppWebEntry.run() mirrors it browser-side: parse window.__DSH_BOOT__ into a BootManifest (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the immediately tier in parallel with Context/Loader setup, await the prefetch before creating entries (materialization is tree.import's synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep.
Config sources have one declaration place each. Bundle yml values are engineering defaults, Settings sections are writable user preferences, CLI flags address their owning launcher rows, and env values enter through yml !!js expressions. Patches replace a row's config wholesale. The resolved frontend distIndex uses that patch channel as an assembly fact. The transport-independent provider/model default belongs to ctx.agentDefaultModel; the direct headless entry point and the Web gateway consume the same state.
The transport splits five ways. dsh-host-apiproxy is the gateway plugin (api-gateway row): it default-exports ApiProxyService, configures only {nativeOpen?}, consumes the base layer's entry-point-neutral ctx.agentDefaultModel, provides ctx.apiProxy, remains transport-agnostic, and registers no routes. dsh-host-webserver is a plain route-registration plugin: WebServer provides ctx.webServer (register(route) → disposer with duplicate-pattern throw, tapIndex transforms applied in registration order, port), listens on activation, answers per-request failures with 400 and logging, and knows no harness concepts. The connection node half owns the /api binding from ctx.apiProxy through toFetchHandler. The modules node half (ClientModuleRegistry, providing ctx.clientModules) owns incremental package scanning, the bundle route, the index tap, and onRebuilt/onGraphChanged notification. The hmr node half owns dev reload through fs.watchFile membership and the /plugins/events SSE route.
Package export discipline. The modules package exposes exactly . (node half) and ./client (the complete browser half: ClientModuleSystem, parseBootManifest, the adoption plugin face) — no bespoke subpaths; wire types re-export through the root for host-side consumers. The adoption handshake: the kernel writes the constructed instance to window.__DSH_MODULES__ before cordis exists; the ./client apply reads the slot (missing = loud throw) and provides ctx.modules.
- Recomposing a web deployment is a yml/patch edit; the retired pieces (
mountWebPlugins,CLIENT_PACKAGES,createHostWebPluginRegistry,startWebServer, the webserver's graph/SSE/api knowledge) are deleted. - Headless is a direct core entry point: its shipped profile contains the shared base Agent capabilities and omits Host, HTTP, Web, and browser layers. The transport split in this note is the browser surface's contract.
- A TypeScript pitfall worth remembering: a
declare module 'cordis'augmentation in a file with no cordis import is demoted to a standalone module declaration and silently shatters the program-wideContextmerge (ctx.on/ctx.effectvanish across the program). Anchor withimport type {} from 'cordis'.
| Rejected | One-line reason |
|---|---|
Dedicated dsh-host-profile receiver package |
User model state belongs to the Settings-backed ctx.agentDefaultModel; an extra Host receiver would duplicate ownership and exclude direct entry points |
Runtime assembly shim plugin providing an apiHandler service |
Existed only because createApiProxy lived in runtime; moving it into apiproxy made the gateway self-hosting, and toFetchHandler is a pure function the binding side calls |
| Full-rescan + incremental scan coexisting | Two implementations, two semantics; the single per-package path covers the activation pass too |
A bespoke ./impl export on the modules package |
Non-uniform exports; the standard ./client carries the whole browser half |
dev overlay / cordis.dev.yml |
One yml; !!js cannot conditionalize row existence, and --dev appending one row is the entire difference |
| env vars in the mapping table | The same field would gain env/json double sourcing and need an invented precedence |
Unbarriered create-after-prefetch (arrive() dedup as safety) |
Disproved by a 10–25% boot race: in-flight dedup covers same-package double-fetch, not cross-package synchronous require edges |
| json file used directly as loader patches | json keys would couple to yml row structure; profile writers would need cordis knowledge |