When DeepSeek open-sourced DeepSeek Harness (dsh)—a modular, Cordis-powered agent runtime with a web UI, headless CLI, MCP bridge, and extensible profile system—I immediately wanted it running natively on my NixOS machines.
Not just “installed imperatively in a mutable folder with npm install -g”, either. On an impermanent NixOS setup where / is wiped on every boot, I wanted:
- A reproducible
pkgs.dshpackage built from the upstream pnpm monorepo inside the Nix sandbox. - Declarative profile and plugin management in Home Manager (
programs.dsh)—where adding a TUI, a theme, or an MCP server happens in Nix, resolution failures fail atnix buildtime rather than runtime, and removing a plugin from my flake cleanly removes it from the profile. - Zero state loss across rebuilds and reboots, even when
dshwrites custom providers and model configurations directly into profile files at runtime.
That effort became dsh-nix. Getting from a raw pnpm monorepo to a working Home Manager module with build-time boot checks took solving a series of genuinely fun rabbit holes. Here is the full story of everything that broke along the way—and how we fixed it.
Hurdle 1: V8 Memory Scanning vs. Nixpkgs Node.js (node-addon-require-builtin)#
Getting the monorepo dependencies fetched with fetchPnpmDeps and pnpmConfigHook was only the warm-up. First, node-pty ships no prebuilt Linux binaries in its npm tarball, so we had to run node-gyp rebuild explicitly inside buildPhase with python3 and the nodejs headers so both pty.node and spawn-helper landed in build/Release.
Then came the first real boss fight. When booting dsh with Nixpkgs’ Node.js binary, it immediately crashed during host preparation:
| |
What was happening?#
To intercept internal Node module loading for its plugin package router and hot-module reload (HMR) engine, dsh uses a native C++ addon (node-addon-native-custom-loader / node-addon-require-builtin). At startup, that addon literally disassembles V8’s compiled machine code in memory looking for a specific x64 sysv this->field accessor instruction sequence so it can grab internal/modules/esm/loader.
Because Nixpkgs compiles nodejs with different compiler flags and optimization passes than upstream Node’s official binary tarballs, the machine code layout of that getter didn’t match the addon’s byte-pattern scanner.
The Fix#
If you launch Node with --expose-internals, Node exposes internal/* builtins directly to standard require() without needing any V8 memory introspection at all. By wrapping bin/dsh with makeBinaryWrapper to pass --expose-internals and patching requireBuiltin to try require(moduleId) first before falling back to the native addon, dsh booted cleanly on Nixpkgs Node:
| |
Hurdle 2: Replacing Imperative dsh plugin add with Build-Time Profiles#
Out of the box, dsh manages profiles under ~/.dsh/profiles/<name>. When you run dsh plugin --profile web add <spec>, dsh invokes pnpm at runtime, mutates package.json, and reconciles dsh.profile.bundles.
For dsh-nix, we replaced that runtime mutation with a pure build-time profile packager (lib/plugins.nix and lib/profiles.nix) that accepts three kinds of plugins in one ordered list:
- In-box bundles (
"@deepseek-ai/dsh-base","@deepseek-ai/dsh-web-app","@deepseek-ai/dsh-headless"): shipped insidepkgs.dsh, requiring zero network fetches. - pnpm spec strings (
"@deepseek-harness-tui/dsh-tui@0.16.1","@nonamelego/dsh-catppuccin@0.6.2","github:owner/repo#tag"): resolved at build time in a fixed-output derivation (fetchSpecs) pinned by a singlespecsHash. - Nix paths or derivations (
./my-plugin,pkgs.fetchFromGitHub { ... }): symlinked directly from the Nix store.
To make sure a composed profile actually works before Home Manager ever activates it, we added scripts/check-profile.mjs and wired it into nix flake check. Inside the Nix build sandbox, it boots the profile through dsh’s own boot() and assertEntriesActivated audit—meaning a missing service or broken plugin layer fails nix build immediately.
Hurdle 3: The @deepseek-ai/* Peer Dependency Trap#
The moment we tried installing third-party community plugins—specifically dsh-TUI (@deepseek-harness-tui/dsh-tui@0.16.1) to give dsh a full terminal UI—pnpm’s peer dependency resolution blew up inside fetchSpecs.
Why it broke:#
- Third-party DSH plugins declare
"@deepseek-ai/cordis": "^4.0.1-rc.1"and various"@deepseek-ai/dsh-*"packages inpeerDependencies. - In pnpm,
autoInstallPeersdefaults totrue. Whenpnpm addtried to auto-install@deepseek-ai/dsh-scopeor@deepseek-ai/dsh-typert-protocolfrom npm, it failed withERR_PNPM_NO_MATCHING_VERSIONbecause npm’slatestdist-tag pointed to0.0.1-rc.1while the plugin asked for>=0.1.7-alpha.1(published under a prerelease tag). - Even worse:
@deepseek-ai/*packages must never be installed into a profile’s ownnode_modules! They have to resolve from the hostpkgs.dshinstallation at runtime so that@deepseek-ai/cordiskeeps its singleton service identity. - Simply setting
autoInstallPeers = falseglobally would break plugins that legitimately rely on pnpm auto-installing third-party peers (likereact).
The Fix#
In fetchSpecs (lib/plugins.nix), we combined pnpm-workspace.yaml rules with a .pnpmfile.cjs readPackage hook that marks only @deepseek-ai/* peers as optional: true:
| |
| |
That kept autoInstallPeers active for normal third-party dependencies while completely skipping @deepseek-ai/* packages so the host dsh installation supplies them at runtime.
Hurdle 4: Symlinks vs. Real Directories in DSH’s Module Resolver#
With dsh-tui fetched and built, we hit the most subtle bug of the entire journey when booting the tui profile:
| |
And right after fixing that:
| |
Digging into dsh’s runtime module resolver (packages/boot/app-boot/src/profile-resolution/resolver.ts) revealed three subtle invariants:
- Hoisted
node_modules: BecausefetchSpecsdeletesnode_modules/.pnpmafter runningpnpm addto keep the fixed-output derivation compact, pnpm’s default isolated linker left transitive dependencies (likedsh-working-activity) dangling. SettingnodeLinker: hoisted(matching upstreamdsh’s owninitProfile) placed every transitive dependency directly at the top level ofnode_modules/. - Real
@scopedirectories: Bothdsh’ssymlinksUnderscanner and its HMR watcher only look for symlinks one level inside real@scopedirectories. Symlinking@scopeas a whole directory hid scoped plugins from HMR;buildProfileBundleneededmkdir -p "$out/node_modules/@scope"with symlinks at$out/node_modules/@scope/<pkg>. routeLinkedvs.routeScoped(cp -avs.cp -rL): Whendshresolves imports from a plugin inside~/.dsh/profiles/<name>/node_modules, it checksrealpathSync(pkgDir).- If the plugin is a symlink pointing outside the profile directory (into
/nix/store),dshclassifies it as a developergit link(kind: "linked") and routes it throughrouteLinked—which only intercepts@deepseek-ai/*packages explicitly listed in the plugin’speerDependencies! - If the plugin is a real directory inside the profile,
dshclassifies it askind: "profile"and routes it throughrouteScoped, which intercepts all host@deepseek-ai/*packages.
- If the plugin is a symlink pointing outside the profile directory (into
Because dsh-TUI imports @deepseek-ai/dsh-session-persistence-jsonl without listing it in peerDependencies, symlinking profile node_modules into /nix/store triggered the strict routeLinked path. Switching Home Manager activation from cp -a to cp -rL materialised real directories under ~/.dsh/profiles/<name>, switching dsh to routeScoped and making dsh --profile tui boot with zero warnings.
Hurdle 5: Bundled Cordis Plugins (@deepseek-ai/dsh-mcp-client)#
Next, I wanted to wire up two MCP servers across all my profiles (headless, web, and tui):
dbcode(stdio): running the DBCode CLI bundled inside my VSCodium extension directory.rivalsearch(streamable-http): connecting tohttps://RivalSearchMCP.fastmcp.app/mcp.
My first instinct was to add "@deepseek-ai/dsh-mcp-client" to plugins. That immediately failed:
- On npm,
@deepseek-ai/dsh-mcp-client’slatesttag points to an ancient0.0.1-rc.1build that doesn’t matchdsh0.2.1-rc.2. - More importantly,
@deepseek-ai/dsh-mcp-clientis already bundled insidepkgs.dsh! Unlike profile bundles (dsh-base,dsh-web-app), it has nodsh.bundle.patch—it is a plain Cordis plugin meant to be instantiated once per MCP server insidecordis.patch.yml.
That meant we didn’t need to fetch anything at all. We just needed a shared mcpPatches list passed to each profile’s userPatches option in Home Manager!
Hurdle 6: Why Did My Custom Models Disappear After Rebooting?#
Everything worked—until I added a new plugin to profiles.web, ran nixos-rebuild switch, rebooted, and discovered all my custom providers and models were gone (even though ~/.dsh was persisted on /persist!).
Inspecting ~/.dsh solved the mystery:
- Earlier versions of
dshstored user settings in~/.dsh/settings.yaml. - In
dsh0.2.1-rc.2, the settings engine (@deepseek-ai/dsh-settingsand@deepseek-ai/dsh-config-editor) migratessettings.yamlonce tosettings.yaml.importedand writes all UI settings, custom providers (llm-pi-ai), and custom models directly into the active profile’s~/.dsh/profiles/<name>/cordis.patch.yml! - Meanwhile, whenever a profile’s Nix derivation changed,
dsh-nix’s activation script saw a new.dsh-nix-stamp, ranrm -rf ~/.dsh/profiles/<name>, and copied the fresh Nix store artifact over it—wiping the runtime entriesdshhad saved intocordis.patch.yml.
The Fix: A 3-Way YAML Merge on Activation#
We still wanted Nix to own declarative userPatches (so adding, updating, or removing an entry in Nix updates cordis.patch.yml without cluttering or duplicating lines), while letting dsh own runtime UI entries.
We updated activateProfile in modules/home-manager/dsh.nix to store a copy of the raw Nix-generated patch at ~/.dsh/profiles/<name>/.dsh-nix-patch.yml and run a 3-way merge using yq-go whenever a profile is re-materialised:
$decl(fi == 0): The new declarativecordis.patch.ymlfrom/nix/store.$prev(fi == 1): The existing live~/.dsh/profiles/<name>/cordis.patch.yml.$oldDecl(fi == 2): The previous Nix-declared.dsh-nix-patch.yml.
| |
Now:
- Nix
insertblocks (likemcpPatches) are always taken fresh from$decl—never duplicated. - Nix-owned
{ id, ... }overrides are tracked in$oldDecl+$decl; updating or removing one in Nix updates or removes it cleanly. - Runtime UI entries (
llm-pi-ai,llm-deepseek,ui-settings-*, including!!jstags) haveids outside$nixIds, so they survive every profile rebuild and reboot untouched.
The Final Result: My Declarative dsh Config#
Here is the complete Home Manager configuration I now run in ultix, including headless, web (with the Catppuccin theme), tui (dsh-TUI), shared MCP servers, and an XDG .desktop launcher that re-opens the active web session without spawning duplicate daemons:
| |
And for NixOS Impermanence (environment.persistence."/persist"), only three user directories are needed:
.dsh— core DSH single-root home (.credentials.yaml,sessions/,storages/,profiles/).dsh-tui—dsh-TUIpreferences (theme.json,themes/,effort.json,extension-grants.json).agents— shared user-level agent/skills root ($DSH_AGENTS_HOME)
Check out the flake repository here: github.com/fumoctl/dsh-nix.
Happy hacking!