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.
|
||||
- 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
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: {
|
||||
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}',
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
10
package.json
10
package.json
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user