From 5310b47924834547960d5fad71b68bea9ae749b1 Mon Sep 17 00:00:00 2001 From: Alexander Miroshnichenko Date: Tue, 20 Oct 2026 00:13:31 +0300 Subject: [PATCH] feat(pilotdeck): build Electron desktop app from source Replace the prebuilt .deb web-only package with the real Electron application built from upstream v2026.10.17: - compile apps/desktop main process (tsc) and launch it with nixpkgs electron_42 in app-dir mode; upstream env overrides (PILOTDECK_DESKTOP_RUNTIME_ROOT, PILOTDECK_DESKTOP_NODE) select the staged runtime and bundled Node.js - replicate the upstream runtime staging (build-runtime.mjs): prod-only hoisted node_modules from the dedicated runtime lockfile via a second fetchPnpmDeps, dist/skills/ui tree, source-import rewrite, pinned Cua driver, upstream runtime verification probes - share electron-updater into the app by expanding pnpm virtual store scopes of its dependency closure - wrap bin/pilotdeck (desktop, --no-sandbox, ozone hint auto) and keep bin/pilotdeck-server (headless web server) on the same runtime - add pilotdeck.desktop entry, hicolor icon, startupWMClass Verified: nix build passes, app launches under Xvfb reaching "PilotDeck Web UI ready" with the window mapped as WM_CLASS pilotdeck-desktop; headless server answers on :3001. --- README.md | 4 +- packages/pilotdeck/package.nix | 306 ++++++++++++++++++++++++++++++--- 2 files changed, 281 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index d855e13..5639b0f 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ A custom Nix overlay and flake providing additional packages not found in upstre | `radar` | Modern Kubernetes visibility — topology, event timeline, service traffic, resource browsing, Helm management, and GitOps support | Kubernetes | | `omniroute` | Unified AI router with 160+ providers, auto fallback, MCP/A2A, OpenAI-compatible APIs | AI LLM Gateway | | `open-code-review` | AI-powered code review CLI combining deterministic pipelines with an LLM agent for line-level review comments | AI Coding Agents | -| `pilotdeck` | Task-oriented AI Agent productivity platform (source-built web UI and gateway) with WorkSpace isolation, white-box memory, and smart routing | AI Agents | +| `pilotdeck` | Task-oriented AI Agent productivity platform (source-built Electron desktop app, plus a headless server command) with WorkSpace isolation, white-box memory, and smart routing | AI Agents | | `relay-free-llm` | RESTful API to route user prompts to various AI model providers with automatic failover and intent-based routing | AI LLM Gateway | | `stakpak` | DevOps AI agent that generates infrastructure code, debugs Kubernetes, configures CI/CD, and automates deployments | AI Agents | | `traycer` | Open-source AI orchestration app for agentic coding | AI Coding Agents | @@ -230,7 +230,7 @@ nix-overlay/ │ ├── mcp-gateway/ # MCP protocol gateway │ ├── omniroute/ # Unified AI router with 160+ providers │ ├── open-code-review/ # AI code review CLI (deterministic + LLM agent) -│ ├── pilotdeck/ # Task-oriented AI Agent platform (source-built web UI) +│ ├── pilotdeck/ # Task-oriented AI Agent platform (source-built Electron desktop app) │ ├── radar/ # Kubernetes UI (topology, timeline, Helm, GitOps) │ ├── relay-free-llm/ # AI model provider routing gateway │ ├── skillsmcp/ # MCP server for Agent Skills diff --git a/packages/pilotdeck/package.nix b/packages/pilotdeck/package.nix index dd5e9de..421b68d 100644 --- a/packages/pilotdeck/package.nix +++ b/packages/pilotdeck/package.nix @@ -9,7 +9,10 @@ nodejs-slim_22, pnpm_10, python3, + sqlite, makeWrapper, + copyDesktopItems, + makeDesktopItem, ripgrep, git, curl, @@ -19,6 +22,7 @@ findutils, gnugrep, gnused, + electron_42, libx11, libxi, libxtst, @@ -31,6 +35,11 @@ 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; }; @@ -63,6 +72,30 @@ let } .${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"; @@ -85,14 +118,26 @@ stdenv.mkDerivation (finalAttrs: { pnpmWorkspaces = [ "pilotdeck" "pilotdeck-ui" + "pilotdeck-desktop" ]; fetcherVersion = 4; - hash = "sha256-yhLx1PGSGoyds8lUe3cKpqMWgITwic5YTxugATARV7Y="; + 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 = [ @@ -100,7 +145,9 @@ stdenv.mkDerivation (finalAttrs: { pnpm pnpmConfigHook python3 + sqlite makeWrapper + copyDesktopItems ]; env = { @@ -121,6 +168,23 @@ stdenv.mkDerivation (finalAttrs: { 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 @@ -134,7 +198,115 @@ stdenv.mkDerivation (finalAttrs: { # 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} - pnpm run build:web + 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 ''; @@ -142,32 +314,112 @@ stdenv.mkDerivation (finalAttrs: { installPhase = '' runHook preInstall - mkdir -p $out/lib/pilotdeck - cp -r \ - package.json \ - tsconfig.json \ - node_modules \ - dist \ - src \ - scripts \ - skills \ - resources \ - $out/lib/pilotdeck/ + appRoot=$out/share/pilotdeck - mkdir -p $out/lib/pilotdeck/ui - cp -r \ - ui/package.json \ - ui/server \ - ui/dist \ - ui/public \ - ui/scripts \ - ui/shared \ - ui/vite.config.js \ - ui/node_modules \ - $out/lib/pilotdeck/ui/ + # 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 - makeWrapper ${lib.getExe nodejs_22} $out/bin/pilotdeck \ - --add-flags "$out/lib/pilotdeck/ui/server/webRuntimeSupervisor.js start-built" \ + # 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 @@ -201,7 +453,7 @@ stdenv.mkDerivation (finalAttrs: { passthru.category = "AI Agents"; meta = { - description = "Task-oriented AI Agent productivity platform (source-built web UI and gateway)"; + description = "PilotDeck Electron desktop application and headless web server, built from source"; homepage = "https://github.com/OpenBMB/PilotDeck"; license = lib.licenses.agpl3Only; mainProgram = "pilotdeck";