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. CLAUDE_AGENT_PATH env var (legacy override) * 3. ../hermes-agent — sibling clone (standard README setup) * 4. ../../hermes-agent — one level up (monorepo / nested workspace) * Returns null if none found. */ function resolveClaudeAgentDir(env: Record): string | null { const candidates: string[] = [] const explicitAgentPath = env.HERMES_AGENT_PATH?.trim() || env.CLAUDE_AGENT_PATH?.trim() if (explicitAgentPath) { candidates.push(explicitAgentPath) } // Resolve relative to the workspace root (parent of hermes-workspace/) const workspaceRoot = dirname(resolve('.')) candidates.push( resolve(workspaceRoot, 'hermes-agent'), // sibling (old README) resolve(workspaceRoot, '..', 'hermes-agent'), // one level up resolve(os.homedir(), '.claude', 'hermes-agent'), // Nous installer default resolve(os.homedir(), 'hermes-agent'), // ~/hermes-agent ) for (const candidate of candidates) { if (existsSync(resolve(candidate, 'webapi'))) return candidate } return null } /** Find the Hermes CLI binary used to start the local gateway. */ function resolveClaudeBinary(): string | null { const candidates = [ process.env.HERMES_CLI_BIN || '', resolve(os.homedir(), '.hermes', 'hermes-agent', 'venv', 'bin', 'hermes'), resolve(os.homedir(), '.claude', 'bin', 'claude'), resolve(os.homedir(), '.local', 'bin', 'claude'), ] for (const c of candidates) { if (existsSync(c)) return c } return null } /** Resolve the Python executable to use for Hermes backend startup. * Prefers .venv/bin/python inside agentDir, falls back to system python3. */ function resolveClaudePython(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 isClaudeAgentHealthy(port = 8642): Promise { 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 claudeApiUrl = env.CLAUDE_API_URL?.trim() || 'http://127.0.0.1:8642' // /api/connection-status is handled by the real route file at // src/routes/api/connection-status.ts; the dev server no longer // intercepts that path with a slim shortcut. See #285. // Hermes Agent auto-start state let claudeAgentChild: ChildProcess | null = null let claudeAgentStarted = false const startClaudeAgent = async () => { if (claudeAgentStarted) return // Skip auto-start when CLAUDE_API_URL is explicitly set to a non-local endpoint const explicitUrl = env.CLAUDE_API_URL || process.env.CLAUDE_API_URL || claudeApiUrl || '' if ( explicitUrl && explicitUrl !== 'http://127.0.0.1:8642' && explicitUrl !== 'http://localhost:8642' ) { console.log( `[hermes-agent] Skipping auto-start — using external API: ${explicitUrl}`, ) claudeAgentStarted = true return } if (await isClaudeAgentHealthy()) { console.log('[hermes-agent] Already running — reusing existing process') claudeAgentStarted = true return } const claudeBin = resolveClaudeBinary() const agentDir = resolveClaudeAgentDir(env) // Prefer the `hermes gateway run` binary path (Nous installer's canonical // entrypoint). Fall back to launching uvicorn against the source tree if // only a directory is present (dev / cloned-in-place setups). let launchCmd: string let commandArgs: string[] let launchCwd: string | undefined if (claudeBin) { launchCmd = claudeBin commandArgs = ['gateway', 'run'] launchCwd = agentDir ?? undefined console.log(`[hermes-agent] Starting ${claudeBin} gateway run`) } else if (agentDir) { launchCmd = resolveClaudePython(agentDir) const useGatewayRun = existsSync(resolve(agentDir, 'gateway', 'run.py')) commandArgs = useGatewayRun ? ['-m', 'gateway.run'] : [ '-m', 'uvicorn', 'webapi.app:app', '--host', '0.0.0.0', '--port', '8642', ] launchCwd = agentDir console.log( `[hermes-agent] Starting from ${agentDir} using ${launchCmd} (${useGatewayRun ? 'gateway.run' : 'uvicorn'})`, ) } else { console.warn( '[hermes-agent] Could not find hermes-agent installation.\n' + ' Run the installer:\n' + ' curl -fsSL https://hermes-workspace.com/install.sh | bash\n' + ' Or set HERMES_AGENT_PATH (or legacy CLAUDE_AGENT_PATH) in .env to point at your hermes-agent clone.', ) return } const child = spawn(launchCmd, commandArgs, { cwd: launchCwd, detached: false, // keep tied to vite process — stops when dev server stops stdio: 'pipe', env: { ...process.env, PATH: [ resolve(os.homedir(), '.claude', 'bin'), resolve(os.homedir(), '.local', 'bin'), agentDir ? resolve(agentDir, '.venv', 'bin') : '', agentDir ? resolve(agentDir, 'venv', 'bin') : '', process.env.PATH || '', ] .filter(Boolean) .join(':'), }, }) claudeAgentChild = child claudeAgentStarted = 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) => { claudeAgentChild = null claudeAgentStarted = 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 isClaudeAgentHealthy()) { 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((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((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. CLAUDE_ALLOWED_HOSTS=my-server.tail1234.ts.net,192.168.1.50 const _allowedHosts: string[] | true = env.CLAUDE_ALLOWED_HOSTS?.trim() ? env .CLAUDE_ALLOWED_HOSTS!.split(',') .map((h) => h.trim()) .filter(Boolean) : ['.ts.net'] // allow all Tailscale hostnames by default let proxyTarget = 'http://127.0.0.1:18789' try { const parsed = new URL(claudeApiUrl) parsed.protocol = parsed.protocol === 'wss:' ? 'https:' : 'http:' parsed.pathname = '' proxyTarget = parsed.toString().replace(/\/$/, '') } catch { // fallback } return { test: { exclude: [ '**/node_modules/**', '**/dist/**', '**/skills-bundle/**', '**/.{idea,git,cache,output,temp}/**', ], // Force vitest to run React through its own transform pipeline so ESM // `import` and CJS `require('react')` share a single module instance. // Without this, react-dom sets the dispatcher on its CJS React copy while // components call hooks on the ESM React copy → null dispatcher → crash. deps: { inline: [ 'react', 'react-dom', '@testing-library/react', '@testing-library/dom', ], }, }, 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 precedence: // 1. --port CLI flag (wins, but we no longer hardcode it in package.json) // 2. $PORT env var (for containers, reverse proxies, WhatsApp bridge collisions, etc. — see #96) // 3. default 3000 (matches README/docs/docker-compose expectations) port: process.env.PORT ? Number(process.env.PORT) : 3000, // Managed Workspace launchers expect a stable port. Fail loudly instead // of silently hopping to 3001+ so launchctl/service health matches the // actual listening socket. strictPort: true, allowedHosts: true, watch: { ignored: [ // NOTE: the generated TanStack route tree must NOT be added to this // ignore list — doing so causes route changes to require a full // dev-server restart. See src/router-route-resolution.test.ts. // Real fix for HMR thrash on the generated tree is to ensure only // ONE vite dev server runs against this source tree at a time. // Local portable session store, rewritten on every chat send. // Without this, the watcher fires on every message → spurious // server-side reload events / test churn during development. '**/.runtime/**', // Internal TanStack Start state cache. '**/.tanstack/**', // Local plan/notes/scratch state used by OMC tooling — never // imported by the module graph, but file events still spam logs. '**/.omc/**', '**/.omx/**', // Build artifacts. '**/dist/**', '**/.output/**', // Test/coverage outputs. '**/coverage/**', '**/playwright-report/**', '**/test-results/**', // Editor / agent metadata. '**/.vscode/**', '**/.claude/**', '**/.cursor/**', // Loose log files. '**/*.log', ], }, proxy: { // WebSocket proxy: clients connect to /ws-claude 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-claude': { target: proxyTarget, changeOrigin: false, ws: true, rewrite: (path) => path.replace(/^\/ws-claude/, ''), }, // REST API proxy: API proxy for Hermes backend '/api/claude-proxy': { target: proxyTarget, changeOrigin: true, rewrite: (path) => path.replace(/^\/api\/claude-proxy/, ''), }, '/claude-ui': { target: proxyTarget, changeOrigin: true, rewrite: (path) => path.replace(/^\/claude-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 } // /api/connection-status is handled by the real route file at // src/routes/api/connection-status.ts — it returns the full // ConnectionStatus payload including capabilities and chatMode // that downstream feature gates depend on. Earlier versions // had an inline shortcut handler here that returned a slim // body ({ok, mode, backend}) which silently broke things like // useFeatureCapability/useFeatureAvailable in dev mode. See #285. 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', }), ) } }) // Dev-only: disable Node's default 5-minute request timeout so // long-running SSE streams (agent runs that go silent for minutes // during heavy reasoning / tool calls) don't get killed mid-stream // by the HTTP layer. Heartbeats handle keep-alive at the application // layer. Production servers should keep their default timeouts to // avoid slowloris exposure. if (command === 'serve' && server.httpServer) { const httpServer = server.httpServer as unknown as { requestTimeout?: number headersTimeout?: number timeout?: number } httpServer.requestTimeout = 0 httpServer.headersTimeout = 0 httpServer.timeout = 0 } 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 startClaudeAgent() } // Shutdown hermes-agent when dev server stops server.httpServer?.on('close', () => { if (claudeAgentChild) { console.log('[hermes-agent] Stopping...') claudeAgentChild.kill('SIGTERM') claudeAgentChild = null claudeAgentStarted = 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\.CLAUDE_API_URL/g, JSON.stringify(claudeApiUrl), ) result = result.replace( /process\.env\.CLAUDE_API_TOKEN/g, JSON.stringify(env.CLAUDE_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