PR #579: Windows Electron desktop build compatibility (prasairaul-del); native worker process fallback for non-tmux platforms
This commit is contained in:
24
AGENTS.md
24
AGENTS.md
@@ -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.
|
- Prefer GBrain-first lookup for context-sensitive RAZSOC/Hermes/workflow decisions.
|
||||||
- Builder implements; Reviewer gates; QA verifies behavior; Orchestrator routes and enforces greenlight.
|
- 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.
|
- 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
114
docs/windows-setup-guide.md
Normal 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` |
|
||||||
@@ -38,7 +38,8 @@ module.exports = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
win: {
|
win: {
|
||||||
target: [{ target: 'nsis', arch: ['x64'] }],
|
target: ['portable', 'nsis'],
|
||||||
|
executableName: 'hermes-workspace',
|
||||||
},
|
},
|
||||||
nsis: {
|
nsis: {
|
||||||
oneClick: true,
|
oneClick: true,
|
||||||
@@ -54,4 +55,5 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
asar: false,
|
asar: false,
|
||||||
compression: 'maximum',
|
compression: 'maximum',
|
||||||
|
artifactName: 'hermes-workspace-setup-${version}.${ext}',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const { app, BrowserWindow, dialog, ipcMain, shell } = require('electron')
|
const { app, BrowserWindow, dialog, ipcMain, shell } = require('electron')
|
||||||
const { join } = require('path')
|
const { join } = require('path')
|
||||||
const { existsSync } = require('fs')
|
const fs = require('fs')
|
||||||
|
const { existsSync } = fs
|
||||||
const { spawn, execSync } = require('child_process')
|
const { spawn, execSync } = require('child_process')
|
||||||
const http = require('http')
|
const http = require('http')
|
||||||
let autoUpdater = null
|
let autoUpdater = null
|
||||||
@@ -162,7 +163,8 @@ function checkHttp(url, timeoutMs = 2500) {
|
|||||||
|
|
||||||
function isHermesInstalled() {
|
function isHermesInstalled() {
|
||||||
try {
|
try {
|
||||||
execSync('which hermes || where hermes', {
|
const cmd = process.platform === 'win32' ? 'where hermes' : 'which hermes'
|
||||||
|
execSync(cmd, {
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
stdio: 'ignore',
|
stdio: 'ignore',
|
||||||
shell: true,
|
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() {
|
async function getBootstrapStatus() {
|
||||||
return {
|
return {
|
||||||
hermesInstalled: isHermesInstalled(),
|
hermesInstalled: isHermesInstalled(),
|
||||||
@@ -184,16 +190,35 @@ async function getBootstrapStatus() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function spawnDetached(command) {
|
function spawnDetached(command, label) {
|
||||||
const child = spawn('bash', ['-lc', command], {
|
const logDir = getTempDir()
|
||||||
detached: true,
|
const logFile = join(logDir, `hermes-workspace-${label}.log`)
|
||||||
stdio: 'ignore',
|
|
||||||
env: {
|
let child
|
||||||
...process.env,
|
if (process.platform === 'win32') {
|
||||||
HERMES_WORKSPACE_DESKTOP: '1',
|
const logFd = fs.openSync(logFile, 'a')
|
||||||
API_SERVER_ENABLED: process.env.API_SERVER_ENABLED || 'true',
|
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()
|
child.unref()
|
||||||
return child
|
return child
|
||||||
}
|
}
|
||||||
@@ -202,7 +227,13 @@ async function installHermesInBackground() {
|
|||||||
if (installProcess) {
|
if (installProcess) {
|
||||||
return { started: false, reason: 'already-running' }
|
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,
|
detached: false,
|
||||||
stdio: 'ignore',
|
stdio: 'ignore',
|
||||||
env: { ...process.env },
|
env: { ...process.env },
|
||||||
@@ -224,12 +255,13 @@ async function ensureHermesBackend() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!gatewayReachable) {
|
if (!gatewayReachable) {
|
||||||
spawnDetached('hermes gateway run >/tmp/hermes-workspace-gateway.log 2>&1')
|
spawnDetached('hermes gateway run', 'gateway')
|
||||||
}
|
}
|
||||||
if (!dashboardReachable) {
|
if (!dashboardReachable) {
|
||||||
spawnDetached(
|
const dashboardCmd = process.platform === 'win32'
|
||||||
'hermes dashboard --no-open >/tmp/hermes-workspace-dashboard.log 2>&1',
|
? '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 {
|
return {
|
||||||
@@ -359,7 +391,7 @@ ipcMain.handle('desktop:install-hermes', async () =>
|
|||||||
)
|
)
|
||||||
ipcMain.handle('desktop:start-backend', async () => ensureHermesBackend())
|
ipcMain.handle('desktop:start-backend', async () => ensureHermesBackend())
|
||||||
ipcMain.handle('desktop:open-logs', async () => {
|
ipcMain.handle('desktop:open-logs', async () => {
|
||||||
shell.openPath('/tmp')
|
shell.openPath(getTempDir())
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
ipcMain.handle('desktop:update-check', async () => checkForAppUpdates())
|
ipcMain.handle('desktop:update-check', async () => checkForAppUpdates())
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -8,11 +8,11 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "electron/main.cjs",
|
"main": "electron/main.cjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "NODE_OPTIONS=\"--max-old-space-size=2048\" vite dev",
|
"dev": "vite dev",
|
||||||
"build": "NODE_OPTIONS=\"--max-old-space-size=${NODE_MAX_OLD_SPACE_SIZE:-2048}\" vite build",
|
"build": "vite build",
|
||||||
"start:all": "concurrently \"hermes gateway run\" \"pnpm dev\"",
|
"start:all": "concurrently \"hermes gateway run\" \"pnpm dev\"",
|
||||||
"start": "NODE_OPTIONS=\"--max-old-space-size=2048\" node server-entry.js",
|
"start": "node server-entry.js",
|
||||||
"start:dev": "NODE_OPTIONS=\"--max-old-space-size=2048\" vite dev",
|
"start:dev": "vite dev",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"smoke:managed": "node scripts/managed-companion-smoke.mjs",
|
"smoke:managed": "node scripts/managed-companion-smoke.mjs",
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"format": "prettier",
|
"format": "prettier",
|
||||||
"check": "prettier --write . && eslint --fix",
|
"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": "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: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",
|
"electron:build:win": "pnpm build && pnpm electron:bundle-server && electron-builder --win --config electron-builder.config.cjs",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { execFile, execFileSync } from 'node:child_process'
|
import { execFile, execFileSync, spawn, type ChildProcess } from 'node:child_process'
|
||||||
import { existsSync, mkdirSync, readFileSync, statSync } from 'node:fs'
|
import { existsSync, mkdirSync, readFileSync, statSync, appendFileSync } from 'node:fs'
|
||||||
import { homedir } from 'node:os'
|
import { homedir } from 'node:os'
|
||||||
import { dirname, join } from 'node:path'
|
import { dirname, join } from 'node:path'
|
||||||
import { getProfilesDir } from './claude-paths'
|
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')
|
const local = join(homedir(), '.local', 'bin', 'tmux')
|
||||||
return existsSync(local) ? local : '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 }> {
|
function sendTmux(workerId: string, prompt: string): Promise<{ ok: boolean; error?: string }> {
|
||||||
const session = `swarm-${workerId}`
|
const session = `swarm-${workerId}`
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const tmux = tmuxBin()
|
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) => {
|
const child = execFile(tmux, ['load-buffer', '-b', `swarm-lifecycle-${workerId}`, '-'], (loadErr, _stdout, stderr) => {
|
||||||
if (loadErr) return resolve({ ok: false, error: stderr?.toString() || loadErr.message })
|
if (loadErr) return resolve({ ok: false, error: stderr?.toString() || loadErr.message })
|
||||||
execFile(tmux, ['send-keys', '-t', session, 'C-u'], () => {
|
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 } {
|
function readRuntimeMissionContext(workerId: string): { missionId: string | null; assignmentId: string | null } {
|
||||||
const runtimePath = join(getProfilesDir(), workerId, 'runtime.json')
|
const runtimePath = join(getProfilesDir(), workerId, 'runtime.json')
|
||||||
if (!existsSync(runtimePath)) return { missionId: null, assignmentId: null }
|
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)
|
const hp = handoffPath(workerId)
|
||||||
mkdirSync(dirname(hp), { recursive: true })
|
mkdirSync(dirname(hp), { recursive: true })
|
||||||
const localHandoff = join(getProfilesDir(), workerId, 'memory', 'handoffs', 'latest.md')
|
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 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 sent = await sendToWorker(workerId, prompt)
|
||||||
const ctx = readRuntimeMissionContext(workerId)
|
const ctx = readRuntimeMissionContext(workerId)
|
||||||
try {
|
try {
|
||||||
appendSwarmMemoryEvent({
|
appendSwarmMemoryEvent({
|
||||||
@@ -245,17 +401,17 @@ export async function renewWorker(workerId: string): Promise<{ ok: boolean; rest
|
|||||||
if (!existsSync(hp)) {
|
if (!existsSync(hp)) {
|
||||||
return { ok: false, restarted: false, resumeSent: false, error: 'Handoff missing; request handoff first', handoffPath: 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) {
|
if (!killed.ok) {
|
||||||
// Session may already be gone; continue.
|
// Process may already be gone; continue.
|
||||||
}
|
}
|
||||||
await new Promise((resolve) => setTimeout(resolve, 600))
|
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 }
|
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.
|
// Wait for shell prompt to appear before sending the resume message.
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
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 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)
|
const ctx = readRuntimeMissionContext(workerId)
|
||||||
try {
|
try {
|
||||||
appendSwarmMemoryEvent({
|
appendSwarmMemoryEvent({
|
||||||
|
|||||||
Reference in New Issue
Block a user