{ lib, stdenv, fetchFromGitHub, fetchurl, fetchPnpmDeps, pnpmConfigHook, nodejs_22, nodejs-slim_22, pnpm_10, python3, sqlite, makeWrapper, copyDesktopItems, makeDesktopItem, ripgrep, git, curl, procps, bashInteractive, coreutils, findutils, gnugrep, gnused, electron_42, libx11, libxi, libxtst, libxext, libxkbcommon, wayland, at-spi2-core, }: let version = "2026.10.17"; # apps/desktop/package.json carries the Electron application version used # by the UI build marker and the desktop package manifest. desktopVersion = "0.1.0"; uiVersion = "0.1.0"; # Keep the source, LFS asset, pnpm dependency, and Cua driver hashes in # sync with the upstream release when updating the version. pnpm = pnpm_10.override { nodejs-slim = nodejs-slim_22; }; # Git LFS objects used by the web UI and the memory-core package. The # upstream tarball contains pointer files, so fetch these paths through # git-lfs and overlay them on the source tree. lfsAssets = fetchFromGitHub { owner = "OpenBMB"; repo = "PilotDeck"; tag = "v${version}"; fetchLFS = true; sparseCheckout = [ "ui/public" "src/context/memory/edgeclaw-memory-core/ui-source/assets" ]; hash = "sha256-R0bMxC6B2XqJMzuBnHmlNPX65tIwxL5FUc6Vv5M3O7w="; }; cuaDriver = { x86_64-linux = fetchurl { url = "https://github.com/trycua/cua/releases/download/cua-driver-rs-v0.34.1/cua-driver-rs-0.34.1-linux-x86_64-binary.tar.gz"; hash = "sha256-pB/ivcvupFAdxZTAdPFrCXVyjgzHsIuQrOqFZNSPr+4="; }; aarch64-linux = fetchurl { url = "https://github.com/trycua/cua/releases/download/cua-driver-rs-v0.34.1/cua-driver-rs-0.34.1-linux-arm64-binary.tar.gz"; hash = "sha256-HIOdNUAbveog8+HPUKzpJu8Xvm8yzqfoIbArJjxJM9w="; }; } .${stdenv.hostPlatform.system} or (throw "pilotdeck: unsupported system ${stdenv.hostPlatform.system}"); # The desktop runtime installs its production dependencies from the # standalone lockfile in apps/desktop/runtime. That lockfile refers to the # memory-core package as `file:../../../src/...`, which resolves three # directories above the runtime package, so the fetcher needs a source tree # with exactly that layout. runtimeManifestSrc = stdenv.mkDerivation { name = "pilotdeck-desktop-runtime-manifest"; src = fetchFromGitHub { owner = "OpenBMB"; repo = "PilotDeck"; tag = "v${version}"; hash = "sha256-VgsMoaaAtLIieBr4TchP61u5xV3PnVqjviak6DN7TPY="; }; dontConfigure = true; dontBuild = true; installPhase = '' mkdir -p "$out/apps/desktop/runtime" "$out/src/context/memory/edgeclaw-memory-core" cp apps/desktop/runtime/package.json "$out/apps/desktop/runtime/" cp apps/desktop/runtime/pnpm-lock.yaml "$out/apps/desktop/runtime/" cp src/context/memory/edgeclaw-memory-core/package.json \ "$out/src/context/memory/edgeclaw-memory-core/" ''; }; in stdenv.mkDerivation (finalAttrs: { pname = "pilotdeck"; inherit version; src = fetchFromGitHub { owner = "OpenBMB"; repo = "PilotDeck"; tag = "v${version}"; hash = "sha256-VgsMoaaAtLIieBr4TchP61u5xV3PnVqjviak6DN7TPY="; }; pnpmDeps = fetchPnpmDeps { inherit (finalAttrs) pname version src ; inherit pnpm; pnpmWorkspaces = [ "pilotdeck" "pilotdeck-ui" "pilotdeck-desktop" ]; fetcherVersion = 4; hash = "sha256-AvKOMzkYQvdjWO4FNkIwScdws4HxNhBXTuMNy9QIBbM="; }; runtimePnpmDeps = fetchPnpmDeps { pname = "${finalAttrs.pname}-desktop-runtime"; inherit version; src = runtimeManifestSrc; sourceRoot = "${runtimeManifestSrc.name}/apps/desktop/runtime"; inherit pnpm; fetcherVersion = 4; hash = "sha256-1SBgYsX1nZiuWYy8UG1vTNst9KrJOAHsrriOfJZLlhE="; }; pnpmWorkspaces = [ "pilotdeck" "pilotdeck-ui" "pilotdeck-desktop" ]; nativeBuildInputs = [ nodejs_22 pnpm pnpmConfigHook python3 sqlite makeWrapper copyDesktopItems ]; env = { # node-gyp must use the headers of the Node.js runtime that will execute # the native addons, rather than downloading headers from nodejs.org. npm_config_nodedir = nodejs_22; npm_config_offline = "true"; pnpm_config_offline = "true"; }; postUnpack = '' cp -r ${lfsAssets}/ui/public/. source/ui/public/ cp -r ${lfsAssets}/src/context/memory/edgeclaw-memory-core/ui-source/assets/. \ source/src/context/memory/edgeclaw-memory-core/ui-source/assets/ ''; preConfigure = '' export HOME=$TMPDIR ''; desktopItems = [ (makeDesktopItem { name = "pilotdeck"; desktopName = "PilotDeck"; genericName = "AI agent desktop"; exec = "pilotdeck %U"; icon = "pilotdeck"; comment = "Task-oriented AI agent desktop application"; categories = [ "Utility" "Development" ]; startupWMClass = "pilotdeck-desktop"; terminal = false; }) ]; buildPhase = '' runHook preBuild # pnpmConfigHook installs dependencies with --ignore-scripts. Run the # allowed lifecycle scripts now. better-sqlite3 and node-pty fall back to # a source build when their prebuilt download is unavailable, while sharp # uses its prebuilt @img/sharp-* optional dependency. pnpm rebuild pnpm --filter pilotdeck-ui rebuild # The build script verifies and stages the pinned Cua computer-use driver. # Point it at the Nix store instead of letting it download the archive. export PILOTDECK_CUA_ARCHIVE=${cuaDriver} npm run build # The UI build gets the desktop marker the upstream desktop packaging # sets (apps/desktop/scripts/build-runtime.mjs). VITE_PILOTDECK_DESKTOP_BUILD=${desktopVersion} pnpm --filter pilotdeck-ui run build # Compile the Electron main process. pnpm --filter pilotdeck-desktop run compile # Desktop runtime staging (apps/desktop/scripts/build-runtime.mjs). The # runtime installs its production dependencies from their own lockfile # in a hoisted layout and must never contain the TypeScript sources or # tsx. runtimeStore=$(mktemp -d) tar --zstd -xf ${finalAttrs.runtimePnpmDeps}/pnpm-store.tar.zst -C "$runtimeStore" chmod -R u+w "$runtimeStore" if [ -f "$runtimeStore/v11/index.db.sql" ]; then sqlite3 "$runtimeStore/v11/index.db" < "$runtimeStore/v11/index.db.sql" rm -f "$runtimeStore/v11/index.db.sql" fi mkdir -p apps/desktop/.runtime-install cp apps/desktop/runtime/package.json apps/desktop/.runtime-install/ cp apps/desktop/runtime/pnpm-lock.yaml apps/desktop/.runtime-install/ pushd apps/desktop/.runtime-install PLAYWRIGHT_BROWSERS_PATH=0 pnpm \ --config.store-dir="$runtimeStore" \ install \ --prod \ --ignore-workspace \ --config.node-linker=hoisted \ --frozen-lockfile \ --ignore-scripts popd # The build sandbox has no /usr/bin/env, so rewrite the interpreter # lines the way pnpmConfigHook does for the workspace install. patchShebangs apps/desktop/.runtime-install/node_modules/{*,.*} pushd apps/desktop/.runtime-install # Linux ships no prebuilds for these native addons; compile them against # the bundled Node.js headers. pnpm \ --config.store-dir="$runtimeStore" \ rebuild \ better-sqlite3 \ bcrypt \ node-pty \ --ignore-workspace \ --config.node-linker=hoisted popd # pnpm caches file: dependency packs in its store, which would keep the # pre-build content without lib/. Replace the cached copy with the # freshly built package. rm -rf apps/desktop/.runtime-install/node_modules/edgeclaw-memory-core cp -r src/context/memory/edgeclaw-memory-core \ apps/desktop/.runtime-install/node_modules/edgeclaw-memory-core # Assemble the runtime tree exactly like upstream build-runtime.mjs: # compiled gateway, skills, UI server with source imports rewritten to # the compiled tree, UI assets, the pinned Cua driver, and the # computer-use scripts. The memory-core source snapshot is replaced by # the node_modules copy, matching the upstream staging layout. runtimeInstall=$PWD/apps/desktop/.runtime-install cp -r dist $runtimeInstall/dist cp -r skills $runtimeInstall/skills mkdir -p $runtimeInstall/src/context/memory cp -r src/context/memory/edgeclaw-memory-core $runtimeInstall/src/context/memory/ mkdir -p $runtimeInstall/ui $runtimeInstall/resources $runtimeInstall/scripts cp -r ui/server $runtimeInstall/ui/server cp -r ui/shared $runtimeInstall/ui/shared cp -r ui/public $runtimeInstall/ui/public cp -r ui/dist $runtimeInstall/ui/dist cp -r resources/cua-driver $runtimeInstall/resources/cua-driver cp -r scripts/computer-use $runtimeInstall/scripts/computer-use cp -r scripts/lib $runtimeInstall/scripts/lib install scripts/check-node-runtime.mjs $runtimeInstall/scripts/ install scripts/install-asr.mjs $runtimeInstall/scripts/ install scripts/prepare-computer-use.mjs $runtimeInstall/scripts/ # The UI server runs as plain JavaScript, so rewrite its source imports # to the compiled gateway (rewriteUiServerSourceImports). find $runtimeInstall/ui/server -type f -name '*.js' -exec sed -i \ -e 's|\.\./\.\./src/|../../dist/src/|g' \ -e 's|\.\./\.\./\.\./src/|../../../dist/src/|g' \ {} \; cat > $runtimeInstall/ui/package.json <<'EOF' { "name": "pilotdeck-ui-runtime", "version": "${uiVersion}", "private": true, "type": "module" } EOF rm -rf $runtimeInstall/src # Verify the staged runtime the way upstream build-runtime does. export NODE_PATH=$runtimeInstall/node_modules LD_LIBRARY_PATH=${lib.makeLibraryPath [ stdenv.cc.cc.lib ]} \ node -e 'const Database = require("better-sqlite3"); new Database(":memory:");' node apps/desktop/scripts/verify-runtime-pty.cjs "$runtimeInstall" # The CLI entry starts a proxy server on import (top-level await), so # each probe must exit explicitly like the upstream verification does. PILOTDECK_SKIP_CLI_MAIN=1 node --input-type=module \ -e "await import('file://$runtimeInstall/dist/src/cli/pilotdeck.js'); process.exit(0);" node --input-type=module \ -e "await import('file://$runtimeInstall/ui/server/services/spreadsheetPreview.js'); process.exit(0);" node --input-type=module \ -e "await import('file://$runtimeInstall/ui/server/services/standaloneComputerUse.js'); process.exit(0);" unset NODE_PATH runHook postBuild ''; installPhase = '' runHook preInstall appRoot=$out/share/pilotdeck # Staged web runtime: compiled gateway, UI server with source imports # rewritten to the compiled tree, hoisted production node_modules, the # pinned Cua driver, and the computer-use scripts. mkdir -p $out/share $appRoot mv apps/desktop/.runtime-install $appRoot/runtime rm -f $appRoot/runtime/pnpm-lock.yaml # Electron main process: the app directory the Electron launcher loads, # with PILOTDECK_DESKTOP_RUNTIME_ROOT pointing at the staged runtime. mkdir -p $appRoot/dist $appRoot/resources cp -r apps/desktop/dist/. $appRoot/dist/ cp apps/desktop/package.json $appRoot/package.json cp -r apps/desktop/resources/icons $appRoot/resources/icons cp apps/desktop/resources/build-metadata.json $appRoot/resources/ # electron-updater is imported by the Electron main process, so the app # needs it with its full dependency closure. pnpm keeps each package's # deps as sibling symlinks around it; expand every reachable virtual # store scope into a self-contained, hoisted node_modules directory # while preserving the exact dependency versions pnpm resolved. appNodeModules=$appRoot/node_modules mkdir -p "$appNodeModules" pendingScopes=( "$(dirname "$(readlink -f apps/desktop/node_modules/electron-updater)")" ) expandedScopes=" $pendingScopes " while [ ''${#pendingScopes[@]} -gt 0 ]; do scope=''${pendingScopes[0]} pendingScopes=("''${pendingScopes[@]:1}") # Copy every package in the scope into the app node_modules. Each # dependency symlink points at a sibling scope in pnpm's virtual # store; dereference it and queue that scope for expansion. for entry in "$scope"/* "$scope"/.[!.]*; do name=$(basename "$entry") case "$name" in .bin|.pnpm) continue ;; esac [ -e "$appNodeModules/$name" ] && continue if [ -h "$entry" ]; then tgt=$(readlink -f "$entry") cp -Lr "$tgt" "$appNodeModules/$name" realScope=$(dirname "$tgt") case "$expandedScopes" in *" $realScope "*) ;; *) expandedScopes="$expandedScopes$realScope " pendingScopes+=("$realScope") ;; esac else cp -Lr "$entry" "$appNodeModules/$name" fi done 2>/dev/null done unset pendingScopes expandedScopes scope entry tgt realScope name # The dev launcher resolves the Cua driver at /resources/cua-driver. mkdir -p $out/resources ln -s $appRoot/runtime/resources/cua-driver $out/resources/cua-driver # Application icon. mkdir -p $out/share/icons/hicolor/256x256/apps install -m 444 apps/desktop/resources/icons/icon.png \ $out/share/icons/hicolor/256x256/apps/pilotdeck.png # Desktop client: launch Electron with the app directory and the # packaged-style runtime root. Chromium's setuid sandbox is unavailable # inside the Nix store, so disable it the way other store-based # launchers do. makeWrapper ${electron_42}/libexec/electron/electron $out/bin/pilotdeck \ --add-flags "$appRoot" \ --add-flags "--no-sandbox" \ --add-flags "--ozone-platform-hint=auto" \ --set PILOTDECK_DESKTOP_RUNTIME_ROOT $appRoot/runtime \ --set PILOTDECK_DESKTOP_NODE ${nodejs_22}/bin/node \ --prefix PATH : ${ lib.makeBinPath [ nodejs_22 ripgrep git curl procps bashInteractive coreutils findutils gnugrep gnused ] } \ --prefix LD_LIBRARY_PATH : ${ lib.makeLibraryPath [ libx11 libxi libxtst libxext libxkbcommon wayland at-spi2-core stdenv.cc.cc.lib ] } # Headless alternative: the same staged runtime as the plain web server. makeWrapper ${lib.getExe nodejs_22} $out/bin/pilotdeck-server \ --add-flags "$appRoot/runtime/ui/server/webRuntimeSupervisor.js start-built" \ --prefix PATH : ${ lib.makeBinPath [ nodejs_22 ripgrep git curl procps bashInteractive coreutils findutils gnugrep gnused ] } \ --prefix LD_LIBRARY_PATH : ${ lib.makeLibraryPath [ libx11 libxi libxtst libxext libxkbcommon wayland at-spi2-core stdenv.cc.cc.lib ] } runHook postInstall ''; passthru.category = "AI Agents"; meta = { description = "PilotDeck Electron desktop application and headless web server, built from source"; homepage = "https://github.com/OpenBMB/PilotDeck"; license = lib.licenses.agpl3Only; mainProgram = "pilotdeck"; platforms = [ "x86_64-linux" "aarch64-linux" ]; }; })