PR #579: Windows Electron desktop build compatibility (prasairaul-del); native worker process fallback for non-tmux platforms

This commit is contained in:
Aurora
2026-06-05 05:09:58 -04:00
parent 1e817d919c
commit 287eef5c62
6 changed files with 361 additions and 33 deletions

View File

@@ -23,3 +23,27 @@ This workspace uses semantic Hermes swarm workers, not numbered-only lanes. The
- Prefer GBrain-first lookup for context-sensitive RAZSOC/Hermes/workflow decisions.
- Builder implements; Reviewer gates; QA verifies behavior; Orchestrator routes and enforces greenlight.
- Do not enable optional Hermes plugins globally unless the task explicitly needs them; record plugin/toolset alignment in `swarm.yaml` first.
## Windows-specific notes (2026-06-01)
- **Three services required**: Gateway (:8642) + Dashboard (:9119) + Workspace (:3000). All must be running for full functionality.
- Gateway: `hermes gateway run`
- Dashboard: `hermes dashboard --port 9119 --host 127.0.0.1 --no-open`
- Workspace: `pnpm dev`
- Or use the Electron desktop app: `pnpm electron:dev` (auto-starts all three)
- **Desktop app**: Full Electron app (`electron/main.cjs`). Double-click to launch — no terminal needed. Auto-detects and spawns gateway (or dashboard if configured).
- **Build**: `electron:build:win` produces NSIS installer in `release/`.
- **Dev mode**: `electron:dev` launches Electron in dev mode (builds Vite client first, hot-reloads on change).
- **Running build output**: `release/win-unpacked/hermes-workspace.exe` (test builds).
- **Electron:dev fix**: `NODE_ENV=development` prefix doesn't work on Windows — script stripped to just `electron .`.
- **Windows spawn fixes** (in `electron/main.cjs`): `spawnDetached()` uses `cmd /c` on Windows (not `bash -lc`), log paths use `%TEMP%` (not `/tmp`), `isHermesInstalled()` uses `where hermes`, `installHermesInBackground()` uses `pip install` (not `curl|bash`).
- **Two `.env` files**: Gateway reads `C:\\Users\\<you>\\AppData\\Local\\hermes\\.env`; CLI reads `C:\\Users\\<you>\\.hermes\\.env`; workspace reads `hermes-workspace\\.env`. Keep API keys in sync across all three.
- **Gateway API server**: Requires `API_SERVER_ENABLED=true` + `API_SERVER_KEY` in the gateway's `.env`. Without these, the gateway starts with no connected platforms.
- **Workspace env vars**: Runtime reads `CLAUDE_API_URL` / `CLAUDE_API_TOKEN` / `CLAUDE_DASHBOARD_URL` (not `HERMES_*` variants).
- **sqlite3 CLI**: Not bundled on Windows. Install via `winget install SQLite.SQLite`, then copy `sqlite3.exe` to a Git Bash PATH directory (winget installs to a long path not in PATH).
- **claude CLI**: Required for Claude Tasks / Conductor features. Install via `npm install -g @anthropic-ai/claude-code`.
- **Port conflicts**: Use `netstat -ano | findstr :<port>` + `Stop-Process -Id <PID> -Force` (PowerShell) — `lsof` not available in Git Bash on Windows.
- **PWA install**: Dashboard at `http://127.0.0.1:3000` can be installed as PWA via Chrome/Edge address bar install icon. Prefer Electron build for production.
- **Slack invalid_auth**: Expected if Slack tokens aren't configured — ignore, doesn't affect core functionality.
- **Node version**: Requires Node.js 22+. Check with `node --version`.
- **`NODE_OPTIONS` stripped**: Windows doesn't support env var prefix in npm scripts — removed from `build` and `electron:dev` scripts.

114
docs/windows-setup-guide.md Normal file
View File

@@ -0,0 +1,114 @@
# Windows Setup Guide — Hermes Workspace
Last updated: 2026-05-28
## Architecture
Three services, three config files:
| Service | Port | Config file |
|---|---|---|
| Hermes Agent Gateway | 8642 | `C:\Users\<you>\AppData\Local\hermes\.env` |
| Hermes CLI tools | — | `C:\Users\<you>\.hermes\.env` |
| Workspace Dashboard | 3000 | `C:\Users\<you>\hermes-workspace\.env` |
## Required .env contents
### `AppData\Local\hermes\.env` (gateway)
```
OPENROUTER_API_KEY=<your-key>
OPENROUTER_API_KEY_1=<your-key-2>
OPENROUTER_API_KEY_2=<your-key-3>
API_SERVER_ENABLED=true
API_SERVER_HOST=0.0.0.0
API_SERVER_KEY=<generate-a-random-hex-string>
```
### `~/.hermes\.env` (CLI tools)
Same as above — same keys, same API_SERVER_KEY.
### `hermes-workspace\.env` (dashboard)
```
OPENROUTER_API_KEY=<your-key>
HERMES_API_URL=http://127.0.0.1:8642
HERMES_DASHBOARD_URL=http://127.0.0.1:9119
HERMES_API_TOKEN=<must-match-API_SERVER_KEY-above>
PORT=3000
HOST=127.0.0.1
```
**Critical:** `HERMES_API_TOKEN` must equal `API_SERVER_KEY` exactly.
## Prerequisites (Windows)
```powershell
# 1. sqlite3 CLI (for kanban/tasks)
winget install SQLite.SQLite --accept-package-agreements --accept-source-agreements
# Then copy sqlite3.exe to a Git Bash PATH dir:
# Source: C:\Users\<you>\AppData\Local\Microsoft\WinGet\Packages\SQLite.SQLite_...\sqlite3.exe
# Dest: C:\Users\<you>\bin\sqlite3.exe
# 2. Claude CLI (for Claude Tasks / Conductor)
npm install -g @anthropic-ai/claude-code
# 3. pnpm (if not installed)
npm install -g pnpm
```
## Start sequence
```bash
# Terminal 1 — Gateway
hermes gateway run
# Wait for: "Uvicorn running on http://127.0.0.1:8642"
# Terminal 2 — Dashboard
cd C:\Users\<you>\hermes-workspace
pnpm dev
# Open http://127.0.0.1:3000
```
## Port conflict resolution
```powershell
# Find what's holding a port
netstat -ano | findstr :8642
netstat -ano | findstr :3000
# Kill it
Stop-Process -Id <PID> -Force
```
## PWA Install
1. Open `http://127.0.0.1:3000` in Chrome or Edge
2. Click install icon (⊕) in address bar
3. Gets own window + taskbar icon
**Note:** PWA only works while `pnpm dev` is running.
## Common errors
| Error | Fix |
|---|---|
| `API_SERVER_KEY is required` | Add `API_SERVER_KEY=<value>` to `AppData\Local\hermes\.env` |
| `spawnSync sqlite3 ENOENT` | Install sqlite3 via winget, copy exe to PATH |
| `which: no claude in` | `npm install -g @anthropic-ai/claude-code` |
| `Port 3000 already in use` | Kill stale process via `netstat -ano` + `Stop-Process` |
| `Slack invalid_auth` | Expected if Slack not configured — ignore |
| Dashboard shows "not available on this backend" | Gateway API server not running or HERMES_API_TOKEN mismatch |
## File locations reference
| What | Path |
|---|---|
| Gateway env | `C:\Users\<you>\AppData\Local\hermes\.env` |
| CLI env | `C:\Users\<you>\.hermes\.env` |
| Workspace env | `C:\Users\<you>\hermes-workspace\.env` |
| Kanban DB | `C:\Users\<you>\AppData\Local\hermes\kanban.db` |
| Gateway code | `C:\Users\<you>\AppData\Local\hermes\hermes-agent\` |
| Workspace code | `C:\Users\<you>\hermes-workspace\` |
| Custom skills | `C:\Users\<you>\AppData\Local\hermes\skills\` |
| Hermes config | `C:\Users\<you>\.hermes\config.yaml` |

View File

@@ -38,7 +38,8 @@ module.exports = {
],
},
win: {
target: [{ target: 'nsis', arch: ['x64'] }],
target: ['portable', 'nsis'],
executableName: 'hermes-workspace',
},
nsis: {
oneClick: true,
@@ -54,4 +55,5 @@ module.exports = {
},
asar: false,
compression: 'maximum',
artifactName: 'hermes-workspace-setup-${version}.${ext}',
}

View File

@@ -1,6 +1,7 @@
const { app, BrowserWindow, dialog, ipcMain, shell } = require('electron')
const { join } = require('path')
const { existsSync } = require('fs')
const fs = require('fs')
const { existsSync } = fs
const { spawn, execSync } = require('child_process')
const http = require('http')
let autoUpdater = null
@@ -162,7 +163,8 @@ function checkHttp(url, timeoutMs = 2500) {
function isHermesInstalled() {
try {
execSync('which hermes || where hermes', {
const cmd = process.platform === 'win32' ? 'where hermes' : 'which hermes'
execSync(cmd, {
timeout: 5000,
stdio: 'ignore',
shell: true,
@@ -173,6 +175,10 @@ function isHermesInstalled() {
}
}
function getTempDir() {
return process.env.TEMP || process.env.TMP || (process.platform === 'win32' ? 'C:\\Windows\\Temp' : '/tmp')
}
async function getBootstrapStatus() {
return {
hermesInstalled: isHermesInstalled(),
@@ -184,16 +190,35 @@ async function getBootstrapStatus() {
}
}
function spawnDetached(command) {
const child = spawn('bash', ['-lc', command], {
detached: true,
stdio: 'ignore',
env: {
...process.env,
HERMES_WORKSPACE_DESKTOP: '1',
API_SERVER_ENABLED: process.env.API_SERVER_ENABLED || 'true',
},
})
function spawnDetached(command, label) {
const logDir = getTempDir()
const logFile = join(logDir, `hermes-workspace-${label}.log`)
let child
if (process.platform === 'win32') {
const logFd = fs.openSync(logFile, 'a')
child = spawn('cmd', ['/c', command], {
detached: true,
stdio: ['ignore', logFd, logFd],
env: {
...process.env,
HERMES_WORKSPACE_DESKTOP: '1',
API_SERVER_ENABLED: process.env.API_SERVER_ENABLED || 'true',
},
windowsHide: true,
})
fs.closeSync(logFd)
} else {
child = spawn('bash', ['-lc', `nohup ${command} >> '${logFile}' 2>&1 &`], {
detached: true,
stdio: 'ignore',
env: {
...process.env,
HERMES_WORKSPACE_DESKTOP: '1',
API_SERVER_ENABLED: process.env.API_SERVER_ENABLED || 'true',
},
})
}
child.unref()
return child
}
@@ -202,7 +227,13 @@ async function installHermesInBackground() {
if (installProcess) {
return { started: false, reason: 'already-running' }
}
installProcess = spawn('bash', ['-lc', HERMES_INSTALL_SCRIPT], {
// Windows: pip install (no curl|bash). macOS/Linux: use install script.
const installCmd = process.platform === 'win32'
? 'pip install hermes-agent'
: HERMES_INSTALL_SCRIPT
const shell = process.platform === 'win32' ? 'cmd' : 'bash'
const args = process.platform === 'win32' ? ['/c', installCmd] : ['-lc', installCmd]
installProcess = spawn(shell, args, {
detached: false,
stdio: 'ignore',
env: { ...process.env },
@@ -224,12 +255,13 @@ async function ensureHermesBackend() {
}
if (!gatewayReachable) {
spawnDetached('hermes gateway run >/tmp/hermes-workspace-gateway.log 2>&1')
spawnDetached('hermes gateway run', 'gateway')
}
if (!dashboardReachable) {
spawnDetached(
'hermes dashboard --no-open >/tmp/hermes-workspace-dashboard.log 2>&1',
)
const dashboardCmd = process.platform === 'win32'
? 'hermes dashboard --port 9119 --host 127.0.0.1 --no-open'
: 'hermes dashboard --port 9119 --host 127.0.0.1 --no-open'
spawnDetached(dashboardCmd, 'dashboard')
}
return {
@@ -359,7 +391,7 @@ ipcMain.handle('desktop:install-hermes', async () =>
)
ipcMain.handle('desktop:start-backend', async () => ensureHermesBackend())
ipcMain.handle('desktop:open-logs', async () => {
shell.openPath('/tmp')
shell.openPath(getTempDir())
return { ok: true }
})
ipcMain.handle('desktop:update-check', async () => checkForAppUpdates())

View File

@@ -8,11 +8,11 @@
"type": "module",
"main": "electron/main.cjs",
"scripts": {
"dev": "NODE_OPTIONS=\"--max-old-space-size=2048\" vite dev",
"build": "NODE_OPTIONS=\"--max-old-space-size=${NODE_MAX_OLD_SPACE_SIZE:-2048}\" vite build",
"dev": "vite dev",
"build": "vite build",
"start:all": "concurrently \"hermes gateway run\" \"pnpm dev\"",
"start": "NODE_OPTIONS=\"--max-old-space-size=2048\" node server-entry.js",
"start:dev": "NODE_OPTIONS=\"--max-old-space-size=2048\" vite dev",
"start": "node server-entry.js",
"start:dev": "vite dev",
"preview": "vite preview",
"test": "vitest run",
"smoke:managed": "node scripts/managed-companion-smoke.mjs",
@@ -20,7 +20,7 @@
"lint": "eslint",
"format": "prettier",
"check": "prettier --write . && eslint --fix",
"electron:dev": "NODE_ENV=development electron .",
"electron:dev": "electron .",
"electron:build": "pnpm build && pnpm electron:bundle-server && electron-builder --config electron-builder.config.cjs",
"electron:build:mac": "pnpm build && pnpm electron:bundle-server && electron-builder --mac --config electron-builder.config.cjs",
"electron:build:win": "pnpm build && pnpm electron:bundle-server && electron-builder --win --config electron-builder.config.cjs",

View File

@@ -1,5 +1,5 @@
import { execFile, execFileSync } from 'node:child_process'
import { existsSync, mkdirSync, readFileSync, statSync } from 'node:fs'
import { execFile, execFileSync, spawn, type ChildProcess } from 'node:child_process'
import { existsSync, mkdirSync, readFileSync, statSync, appendFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import { getProfilesDir } from './claude-paths'
@@ -141,15 +141,82 @@ export function getSwarmLifecycleStatus(workerId: string, policy = DEFAULT_POLIC
}
}
function tmuxBin(): string {
// ═══════════════════════════════════════════════════════════════
// Cross-platform worker process management
// Replaces tmux with native child_process.spawn so workers run on Windows.
// On Linux/macOS with tmux available, falls back to the tmux path.
// ═══════════════════════════════════════════════════════════════
// Active worker processes keyed by workerId
const workerProcesses = new Map<string, ChildProcess>()
function isWindows(): boolean {
return process.platform === 'win32'
}
function workerLogPath(workerId: string): string {
const dir = join(getProfilesDir(), workerId, 'logs')
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
return join(dir, 'worker.log')
}
function appendWorkerLog(workerId: string, text: string): void {
try {
appendFileSync(workerLogPath(workerId), text + '\n', 'utf8')
} catch {
// best-effort logging
}
}
function tmuxBin(): string | null {
if (isWindows()) return null
const local = join(homedir(), '.local', 'bin', 'tmux')
return existsSync(local) ? local : 'tmux'
}
function hasTmux(): boolean {
if (isWindows()) return false
try {
execFileSync(tmuxBin()!, ['list-sessions'], { stdio: 'ignore' })
return true
} catch {
return false
}
}
// Use native process spawn on Windows, tmux on Linux/macOS when available
function useNativeProcess(): boolean {
return isWindows() || !hasTmux()
}
/** Send a prompt to a worker's stdin (native) or tmux pane (Unix fallback) */
export function sendToWorker(workerId: string, prompt: string): Promise<{ ok: boolean; error?: string }> {
if (useNativeProcess()) {
return sendToWorkerProcess(workerId, prompt)
}
return sendTmux(workerId, prompt)
}
function sendToWorkerProcess(workerId: string, prompt: string): Promise<{ ok: boolean; error?: string }> {
return new Promise((resolve) => {
const proc = workerProcesses.get(workerId)
if (!proc || !proc.stdin?.writable) {
resolve({ ok: false, error: `Worker ${workerId} process not running or stdin not writable` })
return
}
appendWorkerLog(workerId, `[dispatch] ${prompt}`)
proc.stdin.write(prompt + '\n', (err) => {
if (err) resolve({ ok: false, error: err.message })
else resolve({ ok: true })
})
})
}
function sendTmux(workerId: string, prompt: string): Promise<{ ok: boolean; error?: string }> {
const session = `swarm-${workerId}`
return new Promise((resolve) => {
const tmux = tmuxBin()
if (!tmux) return resolve({ ok: false, error: 'tmux not available on this platform' })
const child = execFile(tmux, ['load-buffer', '-b', `swarm-lifecycle-${workerId}`, '-'], (loadErr, _stdout, stderr) => {
if (loadErr) return resolve({ ok: false, error: stderr?.toString() || loadErr.message })
execFile(tmux, ['send-keys', '-t', session, 'C-u'], () => {
@@ -166,6 +233,95 @@ function sendTmux(workerId: string, prompt: string): Promise<{ ok: boolean; erro
})
}
async function killWorkerProcess(workerId: string): Promise<{ ok: boolean; error?: string }> {
const proc = workerProcesses.get(workerId)
if (!proc) return { ok: false, error: 'No active process' }
return new Promise((resolve) => {
proc.kill('SIGTERM')
const timeout = setTimeout(() => {
try { proc.kill('SIGKILL') } catch { /* */ }
workerProcesses.delete(workerId)
resolve({ ok: true })
}, 2000)
proc.on('exit', () => {
clearTimeout(timeout)
workerProcesses.delete(workerId)
resolve({ ok: true })
})
})
}
async function startWorkerProcess(workerId: string): Promise<{ ok: boolean; error?: string }> {
if (useNativeProcess()) {
return startWorkerProcessNative(workerId)
}
return tmuxStart(workerId)
}
async function stopWorkerProcess(workerId: string): Promise<{ ok: boolean; error?: string }> {
if (useNativeProcess()) {
return killWorkerProcess(workerId)
}
return tmuxKill(workerId)
}
function startWorkerProcessNative(workerId: string): { ok: boolean; error?: string } {
if (workerProcesses.has(workerId)) {
return { ok: false, error: `Worker ${workerId} already has an active process` }
}
const profilesDir = getProfilesDir()
const profilePath = join(profilesDir, workerId)
if (!existsSync(profilePath)) {
return { ok: false, error: `Profile not found: ${profilePath}` }
}
// Build wrapper command: use hermes-agent CLI with the worker profile
const hermesCmd = process.env.HERMES_CLI_PATH || 'hermes'
const args = ['--tui', '--profile', workerId]
const logPath = workerLogPath(workerId)
const logDir = join(profilePath, 'logs')
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true })
const proc = spawn(hermesCmd, args, {
cwd: profilePath,
env: {
...process.env,
HERMES_PROFILE: workerId,
},
detached: isWindows(), // Windows needs detached for independent process tree
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: isWindows(), // Don't show terminal window on Windows
})
if (!proc.pid) {
return { ok: false, error: 'Failed to spawn worker process' }
}
workerProcesses.set(workerId, proc)
// Log stdout/stderr
proc.stdout?.on('data', (data: Buffer) => {
appendWorkerLog(workerId, `[stdout] ${data.toString().trimEnd()}`)
})
proc.stderr?.on('data', (data: Buffer) => {
appendWorkerLog(workerId, `[stderr] ${data.toString().trimEnd()}`)
})
proc.on('exit', (code, signal) => {
appendWorkerLog(workerId, `[exit] code=${code} signal=${signal}`)
workerProcesses.delete(workerId)
})
proc.on('error', (err) => {
appendWorkerLog(workerId, `[error] ${err.message}`)
workerProcesses.delete(workerId)
})
return { ok: true }
}
function readRuntimeMissionContext(workerId: string): { missionId: string | null; assignmentId: string | null } {
const runtimePath = join(getProfilesDir(), workerId, 'runtime.json')
if (!existsSync(runtimePath)) return { missionId: null, assignmentId: null }
@@ -184,8 +340,8 @@ export async function requestWorkerHandoff(workerId: string): Promise<{ ok: bool
const hp = handoffPath(workerId)
mkdirSync(dirname(hp), { recursive: true })
const localHandoff = join(getProfilesDir(), workerId, 'memory', 'handoffs', 'latest.md')
const prompt = `CONTEXT_HANDOFF_REQUIRED. Stop current work and write a durable handoff.\n\nWrite the handoff to BOTH of these exact paths:\n${localHandoff}\n${hp}\n\nUse this template (fill it in, do not just copy):\n# Handoff — ${workerId} — <missionId>\n\nGenerated: <ISO timestamp>\n\n## Current state\n## Objective\n## Completed\n## In progress\n## Files touched\n## Commands run\n## Blockers\n## Next exact action\n## Resume prompt\nWhen this worker restarts, load this handoff and continue from \"Next exact action\".\n\nThen reply in the required checkpoint format:\nSTATE: HANDOFF\nFILES_CHANGED: exact files or none\nCOMMANDS_RUN: exact commands or none\nRESULT: concise current state and what landed\nBLOCKER: blocker or none\nNEXT_ACTION: exact next action after /new or restart\n\nDo not continue implementation until renewed.`
const sent = await sendTmux(workerId, prompt)
const prompt = `CONTEXT_HANDOFF_REQUIRED. Stop current work and write a durable handoff.\n\nWrite the handoff to BOTH of these exact paths:\n${localHandoff}\n${hp}\n\nUse this template (fill it in, do not just copy):\n# Handoff — ${workerId} — <missionId>\n\nGenerated: <ISO timestamp>\n\n## Current state\n## Objective\n## Completed\n## In progress\n## Files touched\n## Commands run\n## Blockers\n## Next exact action\n## Resume prompt\nWhen this worker restarts, load this handoff and continue from "Next exact action".\n\nThen reply in the required checkpoint format:\nSTATE: HANDOFF\nFILES_CHANGED: exact files or none\nCOMMANDS_RUN: exact commands or none\nRESULT: concise current state and what landed\nBLOCKER: blocker or none\nNEXT_ACTION: exact next action after /new or restart\n\nDo not continue implementation until renewed.`
const sent = await sendToWorker(workerId, prompt)
const ctx = readRuntimeMissionContext(workerId)
try {
appendSwarmMemoryEvent({
@@ -245,17 +401,17 @@ export async function renewWorker(workerId: string): Promise<{ ok: boolean; rest
if (!existsSync(hp)) {
return { ok: false, restarted: false, resumeSent: false, error: 'Handoff missing; request handoff first', handoffPath: hp }
}
const killed = await tmuxKill(workerId)
const killed = await stopWorkerProcess(workerId)
if (!killed.ok) {
// Session may already be gone; continue.
// Process may already be gone; continue.
}
await new Promise((resolve) => setTimeout(resolve, 600))
const started = await tmuxStart(workerId)
const started = await startWorkerProcess(workerId)
if (!started.ok) return { ok: false, restarted: false, resumeSent: false, error: started.error, handoffPath: hp }
// Wait for shell prompt to appear before sending the resume message.
await new Promise((resolve) => setTimeout(resolve, 1500))
const resumePrompt = `RESUME_AFTER_HANDOFF. Read your latest handoff at ${hp} and the local copy under ~/.hermes/profiles/${workerId}/memory/handoffs/, plus your runtime.json, then continue from "Next exact action". Reply with a fresh checkpoint when you have re-grounded.`
const sent = await sendTmux(workerId, resumePrompt)
const sent = await sendToWorker(workerId, resumePrompt)
const ctx = readRuntimeMissionContext(workerId)
try {
appendSwarmMemoryEvent({