Files
hermes-workspace/vite.config.ts
outsourc-e 96eb2655b0 feat: auto-start hermes-agent from pnpm dev + simplify connection screen
- vite.config.ts: resolveHermesAgentDir() finds hermes-agent via HERMES_AGENT_PATH env,
  ../hermes-agent sibling (standard README clone), or ../../hermes-agent fallback
- startHermesAgent() spawns uvicorn on pnpm dev start, reuses if already running,
  logs output to vite console, stops when dev server stops
- start-hermes.ts: same path resolution logic, polls health 10s instead of 2s,
  returns clear error + clone hint if hermes-agent not found, removed Eric-specific path
- connection-startup-screen.tsx: full rewrite — single useEffect, no stale closures,
  no settledRef vs useCallback dependency bugs. Simple: try once, succeed = dismiss,
  fail = show screen + poll every 3s. Start Server button kicks poll loop on success.
- webapi/app.py: CORS now covers localhost:3000-3010 by default + HERMES_CORS_ORIGINS env override
2026-03-19 15:37:32 -04:00

572 lines
18 KiB
TypeScript

import { URL, fileURLToPath } from 'node:url'
import { execSync, spawn } from 'node:child_process'
import type { ChildProcess } from 'node:child_process'
import { copyFileSync, existsSync, mkdirSync } from 'node:fs'
import net from 'node:net'
import { resolve, dirname } from 'node:path'
import os from 'node:os'
// devtools removed
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// nitro plugin removed (tanstackStart handles server runtime)
import { defineConfig, loadEnv } from 'vite'
import viteTsConfigPaths from 'vite-tsconfig-paths'
// ---------------------------------------------------------------------------
// Hermes Agent auto-start helpers
// ---------------------------------------------------------------------------
/** Resolve the hermes-agent directory using a priority-ordered fallback chain:
* 1. HERMES_AGENT_PATH env var (explicit override)
* 2. ../hermes-agent — sibling clone (standard README setup)
* 3. ../../hermes-agent — one level up (monorepo / nested workspace)
* Returns null if none found.
*/
function resolveHermesAgentDir(env: Record<string, string>): string | null {
const candidates: string[] = []
if (env.HERMES_AGENT_PATH?.trim()) {
candidates.push(env.HERMES_AGENT_PATH.trim())
}
// Resolve relative to the workspace root (parent of clawsuite/)
const workspaceRoot = dirname(resolve('.')) // clawsuite/ → parent
candidates.push(
resolve(workspaceRoot, 'hermes-agent'), // sibling of clawsuite/
resolve(workspaceRoot, '..', 'hermes-agent'), // one level up
)
for (const candidate of candidates) {
if (existsSync(resolve(candidate, 'webapi'))) return candidate
}
return null
}
/** Resolve the Python executable to use for uvicorn.
* Prefers .venv/bin/python inside agentDir, falls back to system python3.
*/
function resolveHermesPython(agentDir: string): string {
const venvPython = resolve(agentDir, '.venv', 'bin', 'python')
if (existsSync(venvPython)) return venvPython
// uv creates 'venv' not '.venv' sometimes
const uvVenv = resolve(agentDir, 'venv', 'bin', 'python')
if (existsSync(uvVenv)) return uvVenv
return 'python3'
}
/** Check if hermes-agent health endpoint is responding */
async function isHermesAgentHealthy(port = 8642): Promise<boolean> {
try {
const r = await fetch(`http://127.0.0.1:${port}/health`, {
signal: AbortSignal.timeout(2000),
})
return r.ok
} catch {
return false
}
}
const config = defineConfig(({ mode, command }) => {
const env = loadEnv(mode, process.cwd(), '')
const hermesApiUrl = env.HERMES_API_URL?.trim() || 'http://127.0.0.1:8642'
// Hermes Agent auto-start state
let hermesAgentChild: ChildProcess | null = null
let hermesAgentStarted = false
const startHermesAgent = async () => {
if (hermesAgentStarted) return
if (await isHermesAgentHealthy()) {
console.log('[hermes-agent] Already running — reusing existing process')
hermesAgentStarted = true
return
}
const agentDir = resolveHermesAgentDir(env)
if (!agentDir) {
console.warn(
'[hermes-agent] Could not find hermes-agent directory.\n' +
' Set HERMES_AGENT_PATH in .env or clone hermes-agent as a sibling:\n' +
' git clone https://github.com/outsourc-e/hermes-agent.git ../hermes-agent',
)
return
}
const python = resolveHermesPython(agentDir)
console.log(`[hermes-agent] Starting from ${agentDir} using ${python}`)
const child = spawn(
python,
['-m', 'uvicorn', 'webapi.app:app', '--host', '0.0.0.0', '--port', '8642'],
{
cwd: agentDir,
detached: false, // keep tied to vite process — stops when dev server stops
stdio: 'pipe',
env: {
...process.env,
PATH: `${resolve(agentDir, '.venv', 'bin')}:${resolve(agentDir, 'venv', 'bin')}:${process.env.PATH || ''}`,
},
},
)
hermesAgentChild = child
hermesAgentStarted = true
child.stdout?.on('data', (d: Buffer) => {
const line = d.toString().trim()
if (line) console.log(`[hermes-agent] ${line}`)
})
child.stderr?.on('data', (d: Buffer) => {
const line = d.toString().trim()
if (line) console.log(`[hermes-agent] ${line}`)
})
child.on('exit', (code) => {
hermesAgentChild = null
hermesAgentStarted = false
if (code !== 0 && code !== null) {
console.warn(`[hermes-agent] Exited with code ${code}`)
}
})
// Wait for healthy
for (let i = 0; i < 15; i++) {
await new Promise((r) => setTimeout(r, 1000))
if (await isHermesAgentHealthy()) {
console.log('[hermes-agent] ✓ Ready on http://127.0.0.1:8642')
return
}
}
console.warn('[hermes-agent] Started but health check timed out — may still be loading')
}
let workspaceDaemonStarted = false
let workspaceDaemonStarting = false
let workspaceDaemonShuttingDown = false
let workspaceDaemonRestarting = false
let workspaceDaemonChild: ChildProcess | null = null
let workspaceDaemonRetryCount = 0
const workspaceDaemonPort = '3099'
const daemonCwd = resolve('workspace-daemon')
const daemonSrcEntry = resolve('workspace-daemon/src/server.ts')
const daemonDistEntry = resolve('workspace-daemon/dist/server.js')
const workspaceDaemonDbPath = resolve(
'workspace-daemon/.workspaces/workspace.db',
)
const getWorkspaceDaemonDelayMs = (attempt: number) =>
Math.min(1000 * 2 ** Math.max(attempt - 1, 0), 30000)
const startWorkspaceDaemon = () => {
if (workspaceDaemonShuttingDown) return
if (workspaceDaemonStarted || workspaceDaemonStarting) return
const spawnCommand = existsSync(daemonSrcEntry)
? {
commandName: 'npx',
args: ['tsx', 'watch', 'src/server.ts'],
options: {
cwd: daemonCwd,
env: {
...process.env,
PORT: workspaceDaemonPort,
DB_PATH: workspaceDaemonDbPath,
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY ?? '',
},
stdio: 'inherit' as const,
},
}
: existsSync(daemonDistEntry)
? {
commandName: 'node',
args: ['dist/server.js'],
options: {
cwd: daemonCwd,
env: {
...process.env,
PORT: workspaceDaemonPort,
DB_PATH: workspaceDaemonDbPath,
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY ?? '',
},
stdio: 'inherit' as const,
},
}
: null
if (!spawnCommand) {
workspaceDaemonStarting = false
console.error('[workspace-daemon] no server entry found to spawn.')
return
}
workspaceDaemonStarted = true
workspaceDaemonStarting = false
const child = spawn(
spawnCommand.commandName,
spawnCommand.args,
spawnCommand.options,
)
workspaceDaemonChild = child
child.on('exit', (code) => {
if (workspaceDaemonChild === child) {
workspaceDaemonChild = null
}
if (workspaceDaemonShuttingDown || workspaceDaemonRestarting) {
workspaceDaemonStarted = false
workspaceDaemonStarting = false
return
}
if (code === 0) {
workspaceDaemonStarted = false
workspaceDaemonStarting = false
return
}
if (workspaceDaemonRetryCount >= 20) {
workspaceDaemonStarted = false
workspaceDaemonStarting = false
console.error(
`[workspace-daemon] crashed with code ${code ?? 'unknown'}; max restart attempts reached.`,
)
return
}
workspaceDaemonRetryCount += 1
const delayMs = getWorkspaceDaemonDelayMs(workspaceDaemonRetryCount)
console.error(
`[workspace-daemon] crashed with code ${code ?? 'unknown'}; restarting in ${Math.round(
delayMs / 1000,
)}s (${workspaceDaemonRetryCount}/20).`,
)
workspaceDaemonStarting = true
workspaceDaemonStarted = false
setTimeout(() => {
startWorkspaceDaemon()
}, delayMs)
})
child.on('error', (error) => {
console.error(`[workspace-daemon] failed to spawn: ${error.message}`)
})
}
const stopWorkspaceDaemon = async () => {
const child = workspaceDaemonChild
if (!child) {
workspaceDaemonStarted = false
workspaceDaemonStarting = false
return
}
workspaceDaemonRestarting = true
await new Promise<void>((resolve) => {
const exitTimer = setTimeout(() => {
if (!child.killed && child.pid) {
try {
process.kill(child.pid, 'SIGKILL')
} catch {
// ignore
}
}
}, 5000)
child.once('exit', () => {
clearTimeout(exitTimer)
resolve()
})
if (child.pid) {
try {
process.kill(child.pid, 'SIGTERM')
} catch {
clearTimeout(exitTimer)
resolve()
}
} else {
clearTimeout(exitTimer)
resolve()
}
})
workspaceDaemonStarted = false
workspaceDaemonStarting = false
workspaceDaemonRestarting = false
}
const restartWorkspaceDaemon = async () => {
workspaceDaemonRetryCount = 0
await stopWorkspaceDaemon()
workspaceDaemonStarted = false
workspaceDaemonStarting = false
startWorkspaceDaemon()
}
const isPortInUse = (port: number) =>
new Promise<boolean>((resolvePortCheck) => {
const socket = net.createConnection({ port, host: '127.0.0.1' })
socket.once('connect', () => {
socket.destroy()
resolvePortCheck(true)
})
socket.once('error', () => resolvePortCheck(false))
})
const hasHealthyWorkspaceDaemon = async () => {
try {
const response = await fetch(
`http://127.0.0.1:${workspaceDaemonPort}/api/workspace/version`,
{
signal: AbortSignal.timeout(2000),
},
)
return response.ok
} catch {
return false
}
}
// Allow access from Tailscale, LAN, or custom domains via env var
// e.g. HERMES_ALLOWED_HOSTS=my-server.tail1234.ts.net,192.168.1.50
const _allowedHosts: string[] | true = (env.HERMES_ALLOWED_HOSTS)?.trim()
? (env.HERMES_ALLOWED_HOSTS)!.split(',')
.map((h) => h.trim())
.filter(Boolean)
: []
let proxyTarget = 'http://127.0.0.1:18789'
try {
const parsed = new URL(hermesApiUrl)
parsed.protocol = parsed.protocol === 'wss:' ? 'https:' : 'http:'
parsed.pathname = ''
proxyTarget = parsed.toString().replace(/\/$/, '')
} catch {
// fallback
}
return {
define: {
// Note: Do NOT set 'process.env': {} here — TanStack Start uses environment-based
// builds where isSsrBuild is unreliable. Blanket process.env replacement breaks
// server-side code in Docker (kills runtime env var access).
// Client-side process.env is handled per-environment below.
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
ssr: {
external: [
'playwright',
'playwright-core',
'playwright-extra',
'puppeteer-extra-plugin-stealth',
],
},
optimizeDeps: {
exclude: [
'playwright',
'playwright-core',
'playwright-extra',
'puppeteer-extra-plugin-stealth',
],
},
server: {
// Force IPv4 — 'localhost' resolves to ::1 (IPv6) on Windows, breaking connectivity
host: '0.0.0.0',
port: 3000,
strictPort: false, // allow fallback if 3000 is taken, but log clearly
allowedHosts: true,
watch: {
// Exclude generated route tree — TanStack Router's file watcher
// detects its own output as a change → infinite regeneration loop
ignored: ['**/routeTree.gen.ts'],
},
proxy: {
// WebSocket proxy: clients connect to /ws-hermes on the Hermes Workspace
// server (any IP/port), which internally forwards to the local server.
// This means phone/LAN/Docker users never need to reach port 18789 directly.
'/ws-hermes': {
target: proxyTarget,
changeOrigin: false,
ws: true,
rewrite: (path) => path.replace(/^\/ws-hermes/, ''),
},
// REST API proxy: API proxy for Hermes backend
'/api/hermes-proxy': {
target: proxyTarget,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/hermes-proxy/, ''),
},
'/hermes-ui': {
target: proxyTarget,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/hermes-ui/, ''),
ws: true,
configure: (proxy) => {
proxy.on('proxyRes', (_proxyRes) => {
// Strip iframe-blocking headers so we can embed
delete _proxyRes.headers['x-frame-options']
delete _proxyRes.headers['content-security-policy']
})
},
},
'/workspace-api': {
target: 'http://127.0.0.1:3099',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/workspace-api/, ''),
},
},
},
plugins: [
// devtools(),
// this is the plugin that enables path aliases
viteTsConfigPaths({
projects: ['./tsconfig.json'],
}),
tailwindcss(),
tanstackStart(),
viteReact(),
{
name: 'workspace-daemon',
buildStart() {
if (command !== 'serve') return
},
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
const requestPath = req.url?.split('?')[0]
if (req.method === 'GET' && requestPath === '/api/healthcheck') {
res.statusCode = 200
res.setHeader('content-type', 'application/json')
res.end(JSON.stringify({ ok: true }))
return
}
if (
req.method !== 'POST' ||
requestPath !== '/api/workspace/daemon/restart'
) {
next()
return
}
try {
await restartWorkspaceDaemon()
res.statusCode = 200
res.setHeader('content-type', 'application/json')
res.end(JSON.stringify({ ok: true }))
} catch (error) {
res.statusCode = 500
res.setHeader('content-type', 'application/json')
res.end(
JSON.stringify({
error:
error instanceof Error ? error.message : 'Internal error',
}),
)
}
})
server.httpServer?.on('close', () => {
workspaceDaemonShuttingDown = true
workspaceDaemonStarted = false
workspaceDaemonStarting = false
if (workspaceDaemonChild) {
workspaceDaemonChild.kill()
workspaceDaemonChild = null
}
})
// Auto-start hermes-agent when dev server launches
if (command === 'serve') {
void startHermesAgent()
}
// Shutdown hermes-agent when dev server stops
server.httpServer?.on('close', () => {
if (hermesAgentChild) {
console.log('[hermes-agent] Stopping...')
hermesAgentChild.kill('SIGTERM')
hermesAgentChild = null
hermesAgentStarted = false
}
})
if (command !== 'serve' || workspaceDaemonStarted || workspaceDaemonStarting) return
workspaceDaemonStarting = true
void (async () => {
const running = await isPortInUse(Number(workspaceDaemonPort))
if (workspaceDaemonStarted) {
workspaceDaemonStarting = false
return
}
if (running) {
const healthy = await hasHealthyWorkspaceDaemon()
if (healthy) {
workspaceDaemonStarting = false
console.log('[workspace-daemon] Reusing existing daemon')
return
}
try {
execSync(
`lsof -ti:${workspaceDaemonPort} | xargs kill -9 2>/dev/null || true`,
)
} catch {
// ignore stale cleanup failures and continue with a fresh spawn
}
}
startWorkspaceDaemon()
})()
},
},
// Client-only: replace process.env references in client bundles
// Server bundles must keep real process.env for Docker runtime env vars
{
name: 'client-process-env',
enforce: 'pre',
transform(code, _id) {
const envName = this.environment?.name
if (envName !== 'client') return null
if (!code.includes('process.env') && !code.includes('process.platform')) return null
// Replace specific env vars first, then the generic fallback
let result = code
result = result.replace(/process\.env\.HERMES_API_URL/g, JSON.stringify(hermesApiUrl))
result = result.replace(/process\.env\.HERMES_API_TOKEN/g, JSON.stringify(env.HERMES_API_TOKEN || ''))
result = result.replace(/process\.env\.NODE_ENV/g, JSON.stringify(mode))
result = result.replace(/process\.env/g, '{}')
result = result.replace(/process\.platform/g, '"browser"')
return result
},
},
// Copy pty-helper.py into the server assets directory after build
{
name: 'copy-pty-helper',
closeBundle() {
const src = resolve('src/server/pty-helper.py')
const destDir = resolve('dist/server/assets')
const dest = resolve(destDir, 'pty-helper.py')
if (existsSync(src)) {
mkdirSync(destDir, { recursive: true })
copyFileSync(src, dest)
}
},
},
],
}
})
export default config