740 lines
24 KiB
TypeScript
740 lines
24 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. 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, string>): 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<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 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<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. 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
|