From d787c5f1bccc0ec7c1ed89c3bbd1714f3fd5825c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 24 Dec 2025 08:25:17 +0000 Subject: [PATCH] refactor daemon bootstrap and add smoke test --- .../server/src/server/agent/agent-registry.ts | 5 +- .../server/src/server/agent/mcp-server.ts | 2 +- .../server/agent/providers/claude-agent.ts | 15 +- .../server/src/server/bootstrap.smoke.test.ts | 86 +++++ packages/server/src/server/bootstrap.ts | 320 ++++++++++++++++ packages/server/src/server/config.ts | 112 +++++- packages/server/src/server/index.ts | 358 ++---------------- 7 files changed, 531 insertions(+), 367 deletions(-) create mode 100644 packages/server/src/server/bootstrap.smoke.test.ts create mode 100644 packages/server/src/server/bootstrap.ts diff --git a/packages/server/src/server/agent/agent-registry.ts b/packages/server/src/server/agent/agent-registry.ts index b47c12e8d..9e84919e2 100644 --- a/packages/server/src/server/agent/agent-registry.ts +++ b/packages/server/src/server/agent/agent-registry.ts @@ -4,7 +4,6 @@ import path from "node:path"; import { z } from "zod"; import { AgentStatusSchema } from "../messages.js"; -import { resolvePaseoHome } from "../config.js"; import { toStoredAgentRecord } from "./agent-projections.js"; import type { ManagedAgent } from "./agent-manager.js"; import type { AgentSessionConfig } from "./agent-sdk-types.js"; @@ -68,8 +67,8 @@ export class AgentRegistry { private loaded = false; private filePath: string; - constructor(filePath?: string) { - this.filePath = filePath ?? path.join(resolvePaseoHome(), "agents.json"); + constructor(filePath: string) { + this.filePath = filePath; } async load(): Promise { diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index f538c9140..1c8c2e4bb 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -50,7 +50,7 @@ const AgentStatusEnum = z.enum([ "closed", ]); -const AGENT_WAIT_TIMEOUT_MS = 60000; // 60 seconds +const AGENT_WAIT_TIMEOUT_MS = 50000; // 50 seconds (surface friendly message before tool timeout) function expandPath(path: string): string { if (path.startsWith("~/") || path === "~") { diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 7679e8aa8..3a7900517 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -30,7 +30,6 @@ import type { AgentPromptInput, AgentRunOptions, AgentRunResult, - AgentControlMcpConfig, AgentSession, AgentSessionConfig, AgentStreamEvent, @@ -40,7 +39,6 @@ import type { ListPersistedAgentsOptions, PersistedAgentDescriptor, } from "../agent-sdk-types.js"; -import { resolvePaseoPort } from "../../config.js"; import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js"; const CLAUDE_CAPABILITIES: AgentCapabilityFlags = { @@ -93,13 +91,6 @@ type ClaudeAgentSessionOptions = { handle?: AgentPersistenceHandle; }; -const DEFAULT_AGENT_CONTROL_MCP: AgentControlMcpConfig = { - url: `http://127.0.0.1:${resolvePaseoPort()}/mcp/agents`, - headers: { - Authorization: "Basic bW86Ym8=", - }, -}; - function appendCallerAgentId(url: string, agentId: string): string { try { const parsed = new URL(url); @@ -563,8 +554,10 @@ class ClaudeAgentSession implements AgentSession { }; // Always include the agent-control MCP server so agents can launch other agents - const agentControlConfig = - this.config.agentControlMcp ?? DEFAULT_AGENT_CONTROL_MCP; + if (!this.config.agentControlMcp) { + throw new Error("agentControlMcp is required for ClaudeAgentSession"); + } + const agentControlConfig = this.config.agentControlMcp; const agentControlUrl = this.managedAgentId ? appendCallerAgentId(agentControlConfig.url, this.managedAgentId) : agentControlConfig.url; diff --git a/packages/server/src/server/bootstrap.smoke.test.ts b/packages/server/src/server/bootstrap.smoke.test.ts new file mode 100644 index 000000000..c60e21d66 --- /dev/null +++ b/packages/server/src/server/bootstrap.smoke.test.ts @@ -0,0 +1,86 @@ +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, rm } from "node:fs/promises"; +import { describe, expect, test } from "vitest"; + +import { createPaseoDaemon, type PaseoDaemonConfig } from "./bootstrap.js"; + +async function getAvailablePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", reject); + server.listen(0, () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Failed to acquire port"))); + return; + } + const { port } = address; + server.close(() => resolve(port)); + }); + }); +} + +describe("paseo daemon bootstrap", () => { + test("starts and serves health endpoint", async () => { + const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-")); + const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-")); + const port = await getAvailablePort(); + const basicUsers = { test: "pass" }; + const [agentMcpUser, agentMcpPassword] = + Object.entries(basicUsers)[0] ?? []; + const agentMcpAuthHeader = + agentMcpUser && agentMcpPassword + ? `Basic ${Buffer.from(`${agentMcpUser}:${agentMcpPassword}`).toString("base64")}` + : undefined; + const agentMcpBearerToken = + agentMcpUser && agentMcpPassword + ? Buffer.from(`${agentMcpUser}:${agentMcpPassword}`).toString("base64") + : undefined; + + const daemonConfig: PaseoDaemonConfig = { + port, + paseoHome, + agentMcpRoute: "/mcp/agents", + agentMcpAllowedHosts: [`127.0.0.1:${port}`, `localhost:${port}`], + auth: { + basicUsers, + agentMcpAuthHeader, + agentMcpBearerToken, + realm: "Voice Assistant", + }, + staticDir, + mcpDebug: false, + agentClients: {}, + agentRegistryPath: path.join(paseoHome, "agents.json"), + agentControlMcp: { + url: `http://127.0.0.1:${port}/mcp/agents`, + ...(agentMcpAuthHeader + ? { headers: { Authorization: agentMcpAuthHeader } } + : {}), + }, + }; + + const daemon = await createPaseoDaemon(daemonConfig); + await new Promise((resolve) => { + daemon.httpServer.listen(port, () => resolve()); + }); + + try { + const response = await fetch(`http://127.0.0.1:${port}/api/health`, { + headers: agentMcpAuthHeader + ? { Authorization: agentMcpAuthHeader } + : undefined, + }); + expect(response.ok).toBe(true); + const payload = await response.json(); + expect(payload.status).toBe("ok"); + expect(typeof payload.timestamp).toBe("string"); + } finally { + await daemon.close().catch(() => undefined); + await rm(paseoHome, { recursive: true, force: true }); + await rm(staticDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts new file mode 100644 index 000000000..5e42f0773 --- /dev/null +++ b/packages/server/src/server/bootstrap.ts @@ -0,0 +1,320 @@ +import express, { type Express } from "express"; +import basicAuth from "express-basic-auth"; +import { createServer as createHTTPServer, type Server as HTTPServer } from "http"; +import { randomUUID } from "node:crypto"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; + +import { VoiceAssistantWebSocketServer } from "./websocket-server.js"; +import { initializeSTT, type STTConfig } from "./agent/stt-openai.js"; +import { initializeTTS, type TTSConfig } from "./agent/tts-openai.js"; +import { listConversations, deleteConversation } from "./persistence.js"; +import { AgentManager } from "./agent/agent-manager.js"; +import { AgentRegistry } from "./agent/agent-registry.js"; +import { ClaudeAgentClient } from "./agent/providers/claude-agent.js"; +import { CodexAgentClient } from "./agent/providers/codex-agent.js"; +import { initializeTitleGenerator } from "../services/agent-title-generator.js"; +import { attachAgentRegistryPersistence } from "./persistence-hooks.js"; +import { createAgentMcpServer } from "./agent/mcp-server.js"; +import type { + AgentClient, + AgentControlMcpConfig, + AgentProvider, +} from "./agent/agent-sdk-types.js"; + +type AgentMcpTransportMap = Map; + +export type PaseoAuthConfig = { + basicUsers: Record; + realm?: string; + agentMcpBearerToken?: string; + agentMcpAuthHeader?: string; +}; + +export type PaseoOpenAIConfig = { + apiKey?: string; + stt?: Partial & { apiKey?: string }; + tts?: Partial & { apiKey?: string }; +}; + +export type PaseoDaemonConfig = { + port: number; + paseoHome: string; + agentMcpRoute: string; + agentMcpAllowedHosts: string[]; + auth: PaseoAuthConfig; + staticDir: string; + mcpDebug: boolean; + agentClients: Partial>; + agentRegistryPath: string; + agentControlMcp: AgentControlMcpConfig; + openai?: PaseoOpenAIConfig; +}; + +export type PaseoDaemonHandles = { + httpServer: HTTPServer; + app: Express; + wsServer: VoiceAssistantWebSocketServer; + agentManager: AgentManager; + agentRegistry: AgentRegistry; + close: () => Promise; +}; + +export async function createPaseoDaemon( + config: PaseoDaemonConfig +): Promise { + const agentMcpRoute = config.agentMcpRoute; + const basicAuthUsers = config.auth.basicUsers; + const staticDir = config.staticDir; + const authRealm = config.auth.realm ?? "Voice Assistant"; + + const agentMcpBearerToken = config.auth.agentMcpBearerToken; + + const app = express(); + + // Serve static files from public directory (no auth required for APK downloads) + app.use("/public", express.static(staticDir)); + + // Basic authentication (skip for /public routes) + const basicAuthMiddleware = basicAuth({ + users: basicAuthUsers, + challenge: true, + realm: authRealm, + }); + app.use((req, res, next) => { + if (agentMcpBearerToken && req.path.startsWith(agentMcpRoute)) { + const authHeader = req.header("authorization") ?? ""; + if (authHeader.startsWith("Bearer ")) { + const token = authHeader.slice("Bearer ".length).trim(); + if (token === agentMcpBearerToken) { + return next(); + } + } + } + return basicAuthMiddleware(req, res, next); + }); + + // Middleware + app.use(express.json()); + + // Health check endpoint + app.get("/api/health", (_req, res) => { + res.json({ status: "ok", timestamp: new Date().toISOString() }); + }); + + // Conversation management endpoints + app.get("/api/conversations", async (_req, res) => { + try { + const conversations = await listConversations(); + res.json(conversations); + } catch (error) { + console.error("[API] Failed to list conversations:", error); + res.status(500).json({ error: "Failed to list conversations" }); + } + }); + + app.delete("/api/conversations/:id", async (req, res) => { + try { + const { id } = req.params; + await deleteConversation(id); + res.json({ success: true }); + } catch (error) { + console.error("[API] Failed to delete conversation:", error); + res.status(500).json({ error: "Failed to delete conversation" }); + } + }); + + const httpServer = createHTTPServer(app); + + const agentRegistry = new AgentRegistry(config.agentRegistryPath); + const agentManager = new AgentManager({ + clients: { + claude: new ClaudeAgentClient(), + codex: new CodexAgentClient(), + ...config.agentClients, + }, + registry: agentRegistry, + agentControlMcp: config.agentControlMcp, + }); + + attachAgentRegistryPersistence(agentManager, agentRegistry); + const persistedRecords = await agentRegistry.list(); + console.log( + `✓ Agent registry loaded (${persistedRecords.length} record${persistedRecords.length === 1 ? "" : "s"}); agents will initialize on demand` + ); + + const agentMcpTransports: AgentMcpTransportMap = new Map(); + const allowedHosts = config.agentMcpAllowedHosts; + + const createAgentMcpTransport = async (callerAgentId?: string) => { + const agentMcpServer = await createAgentMcpServer({ + agentManager, + agentRegistry, + callerAgentId, + }); + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sessionId) => { + agentMcpTransports.set(sessionId, transport); + console.log(`[Agent MCP] Session initialized: ${sessionId}`); + }, + onsessionclosed: (sessionId) => { + agentMcpTransports.delete(sessionId); + console.log(`[Agent MCP] Session closed: ${sessionId}`); + }, + enableDnsRebindingProtection: true, + allowedHosts, + }); + transport.onclose = () => { + if (transport.sessionId) { + agentMcpTransports.delete(transport.sessionId); + } + }; + transport.onerror = (error) => { + console.error("[Agent MCP] Transport error:", error); + }; + + await agentMcpServer.connect(transport); + + return transport; + }; + + const handleAgentMcpRequest: express.RequestHandler = async (req, res) => { + if (config.mcpDebug) { + console.log("[Agent MCP] request", { + method: req.method, + url: req.originalUrl, + sessionId: req.header("mcp-session-id"), + authorization: req.header("authorization"), + body: req.body, + }); + } + try { + const sessionId = req.header("mcp-session-id"); + let transport = sessionId ? agentMcpTransports.get(sessionId) : undefined; + + if (!transport) { + if (req.method !== "POST") { + res.status(400).json({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Missing or invalid MCP session", + }, + id: null, + }); + return; + } + if (!isInitializeRequest(req.body)) { + res.status(400).json({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Initialization request expected", + }, + id: null, + }); + return; + } + const callerAgentIdRaw = req.query.callerAgentId; + const callerAgentId = + typeof callerAgentIdRaw === "string" + ? callerAgentIdRaw + : Array.isArray(callerAgentIdRaw) && typeof callerAgentIdRaw[0] === "string" + ? callerAgentIdRaw[0] + : undefined; + transport = await createAgentMcpTransport(callerAgentId); + } + + await transport.handleRequest(req as any, res as any, req.body); + } catch (error) { + console.error("[Agent MCP] Failed to handle request:", error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { + code: -32603, + message: "Internal MCP server error", + }, + id: null, + }); + } + } + }; + + app.post(agentMcpRoute, handleAgentMcpRequest); + app.get(agentMcpRoute, handleAgentMcpRequest); + app.delete(agentMcpRoute, handleAgentMcpRequest); + console.log(`✓ Agent MCP server mounted at ${agentMcpRoute}`); + + const wsServer = new VoiceAssistantWebSocketServer( + httpServer, + agentManager, + agentRegistry, + { + agentMcpUrl: config.agentControlMcp.url, + agentMcpHeaders: config.agentControlMcp.headers, + } + ); + + const openaiApiKey = config.openai?.apiKey; + if (openaiApiKey) { + console.log("✓ OpenAI client initialized"); + + const sttApiKey = config.openai?.stt?.apiKey ?? openaiApiKey; + if (sttApiKey) { + const { apiKey: _sttApiKey, ...sttConfig } = config.openai?.stt ?? {}; + initializeSTT({ + apiKey: sttApiKey, + ...sttConfig, + }); + } + + const ttsApiKey = config.openai?.tts?.apiKey ?? openaiApiKey; + if (ttsApiKey) { + const { apiKey: _ttsApiKey, ...ttsConfig } = config.openai?.tts ?? {}; + initializeTTS({ + apiKey: ttsApiKey, + voice: "alloy", + model: "tts-1", + responseFormat: "pcm", + ...ttsConfig, + }); + } + + initializeTitleGenerator(openaiApiKey); + } else { + console.warn( + "⚠ OPENAI_API_KEY not set - LLM, STT, and TTS features will not work" + ); + } + + const close = async () => { + await closeAllAgents(agentManager); + await wsServer.close(); + await new Promise((resolve) => { + httpServer.close(() => resolve()); + }); + }; + + return { + httpServer, + app, + wsServer, + agentManager, + agentRegistry, + close, + }; +} + +async function closeAllAgents(agentManager: AgentManager): Promise { + const agents = agentManager.listAgents(); + for (const agent of agents) { + try { + await agentManager.closeAgent(agent.id); + } catch (error) { + console.error(`[Agents] Failed to close agent ${agent.id}:`, error); + } + } +} diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 9bdce5138..5100eb89e 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -2,8 +2,9 @@ import os from "node:os"; import path from "node:path"; import { mkdirSync } from "node:fs"; -let cachedHomeDir: string | null = null; -let cachedPort: number | null = null; +import type { PaseoDaemonConfig } from "./bootstrap.js"; +import type { STTConfig } from "./agent/stt-openai.js"; +import type { TTSConfig } from "./agent/tts-openai.js"; function expandHomeDir(input: string): string { if (input.startsWith("~/")) { @@ -15,23 +16,102 @@ function expandHomeDir(input: string): string { return input; } -export function resolvePaseoHome(): string { - if (cachedHomeDir) { - return cachedHomeDir; - } - const raw = process.env.PASEO_HOME ?? process.env.PASEO_HOME_DIR ?? "~/.paseo"; +export function readPaseoHomeFromEnv( + env: NodeJS.ProcessEnv = process.env +): string { + const raw = env.PASEO_HOME ?? env.PASEO_HOME_DIR ?? "~/.paseo"; const expanded = path.resolve(expandHomeDir(raw)); mkdirSync(expanded, { recursive: true }); - cachedHomeDir = expanded; - return cachedHomeDir; + return expanded; } -export function resolvePaseoPort(): number { - if (cachedPort !== null) { - return cachedPort; - } - const raw = process.env.PASEO_PORT ?? process.env.PORT ?? "6767"; +export function readPaseoPortFromEnv( + env: NodeJS.ProcessEnv = process.env +): number { + const raw = env.PASEO_PORT ?? env.PORT ?? "6767"; const parsed = Number.parseInt(raw, 10); - cachedPort = Number.isFinite(parsed) ? parsed : 6767; - return cachedPort; + return Number.isFinite(parsed) ? parsed : 6767; +} + +const DEFAULT_BASIC_AUTH_USERS = { mo: "bo" } as const; +const DEFAULT_AGENT_MCP_ROUTE = "/mcp/agents"; + +function readOpenAIConfigFromEnv(env: NodeJS.ProcessEnv = process.env) { + const apiKey = env.OPENAI_API_KEY; + if (!apiKey) { + return undefined; + } + + const sttConfidenceThreshold = env.STT_CONFIDENCE_THRESHOLD + ? parseFloat(env.STT_CONFIDENCE_THRESHOLD) + : undefined; + const sttModel = env.STT_MODEL as STTConfig["model"]; + const ttsVoice = (env.TTS_VOICE || "alloy") as + | "alloy" + | "echo" + | "fable" + | "onyx" + | "nova" + | "shimmer"; + const ttsModel = (env.TTS_MODEL || "tts-1") as "tts-1" | "tts-1-hd"; + + return { + apiKey, + stt: { + apiKey, + confidenceThreshold: sttConfidenceThreshold, + ...(sttModel ? { model: sttModel } : {}), + }, + tts: { + apiKey, + voice: ttsVoice, + model: ttsModel, + responseFormat: "pcm" as TTSConfig["responseFormat"], + }, + }; +} + +export function buildPaseoDaemonConfigFromEnv( + env: NodeJS.ProcessEnv = process.env +): PaseoDaemonConfig { + const paseoHome = readPaseoHomeFromEnv(env); + const port = readPaseoPortFromEnv(env); + + const basicUsers = DEFAULT_BASIC_AUTH_USERS; + const [agentMcpUser, agentMcpPassword] = + Object.entries(basicUsers)[0] ?? []; + const agentMcpAuthHeader = + agentMcpUser && agentMcpPassword + ? `Basic ${Buffer.from(`${agentMcpUser}:${agentMcpPassword}`).toString("base64")}` + : undefined; + const agentMcpBearerToken = + agentMcpUser && agentMcpPassword + ? Buffer.from(`${agentMcpUser}:${agentMcpPassword}`).toString("base64") + : undefined; + + const openai = readOpenAIConfigFromEnv(env); + + return { + port, + paseoHome, + agentMcpRoute: DEFAULT_AGENT_MCP_ROUTE, + agentMcpAllowedHosts: [`127.0.0.1:${port}`, `localhost:${port}`], + auth: { + basicUsers, + agentMcpAuthHeader, + agentMcpBearerToken, + realm: "Voice Assistant", + }, + mcpDebug: env.MCP_DEBUG === "1", + agentControlMcp: { + url: `http://127.0.0.1:${port}${DEFAULT_AGENT_MCP_ROUTE}`, + ...(agentMcpAuthHeader + ? { headers: { Authorization: agentMcpAuthHeader } } + : {}), + }, + agentRegistryPath: path.join(paseoHome, "agents.json"), + staticDir: "public", + agentClients: {}, + openai, + }; } diff --git a/packages/server/src/server/index.ts b/packages/server/src/server/index.ts index 212ba5655..beba9ec77 100644 --- a/packages/server/src/server/index.ts +++ b/packages/server/src/server/index.ts @@ -1,355 +1,41 @@ import "dotenv/config"; -import express from "express"; -import basicAuth from "express-basic-auth"; -import { createServer as createHTTPServer } from "http"; -import { randomUUID } from "node:crypto"; -import { VoiceAssistantWebSocketServer } from "./websocket-server.js"; -import { initializeSTT, type STTConfig } from "./agent/stt-openai.js"; -import { initializeTTS } from "./agent/tts-openai.js"; -import { listConversations, deleteConversation } from "./persistence.js"; -import { AgentManager } from "./agent/agent-manager.js"; -import { AgentRegistry } from "./agent/agent-registry.js"; -import { ClaudeAgentClient } from "./agent/providers/claude-agent.js"; -import { CodexAgentClient } from "./agent/providers/codex-agent.js"; -import { resolvePaseoPort } from "./config.js"; -import { initializeTitleGenerator } from "../services/agent-title-generator.js"; -import { attachAgentRegistryPersistence } from "./persistence-hooks.js"; -import { createAgentMcpServer } from "./agent/mcp-server.js"; -import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; -import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; - -type AgentMcpTransportMap = Map< - string, - StreamableHTTPServerTransport ->; - -const BASIC_AUTH_USERS = { mo: "bo" } as const; - -function createServer() { - const app = express(); - - const [agentMcpUser, agentMcpPassword] = - Object.entries(BASIC_AUTH_USERS)[0] ?? []; - const agentMcpBearerToken = - agentMcpUser && agentMcpPassword - ? Buffer.from(`${agentMcpUser}:${agentMcpPassword}`).toString("base64") - : undefined; - - // Serve static files from public directory (no auth required for APK downloads) - app.use("/public", express.static("public")); - - // Basic authentication (skip for /public routes) - const basicAuthMiddleware = basicAuth({ - users: BASIC_AUTH_USERS, - challenge: true, - realm: "Voice Assistant", - }); - app.use((req, res, next) => { - if (agentMcpBearerToken && req.path.startsWith("/mcp/agents")) { - const authHeader = req.header("authorization") ?? ""; - if (authHeader.startsWith("Bearer ")) { - const token = authHeader.slice("Bearer ".length).trim(); - if (token === agentMcpBearerToken) { - return next(); - } - } - } - return basicAuthMiddleware(req, res, next); - }); - - // Middleware - app.use(express.json()); - - // Health check endpoint - app.get("/api/health", (_req, res) => { - res.json({ status: "ok", timestamp: new Date().toISOString() }); - }); - - // Conversation management endpoints - app.get("/api/conversations", async (_req, res) => { - try { - const conversations = await listConversations(); - res.json(conversations); - } catch (error) { - console.error("[API] Failed to list conversations:", error); - res.status(500).json({ error: "Failed to list conversations" }); - } - }); - - app.delete("/api/conversations/:id", async (req, res) => { - try { - const { id } = req.params; - await deleteConversation(id); - res.json({ success: true }); - } catch (error) { - console.error("[API] Failed to delete conversation:", error); - res.status(500).json({ error: "Failed to delete conversation" }); - } - }); - - return app; -} +import { createPaseoDaemon } from "./bootstrap.js"; +import { buildPaseoDaemonConfigFromEnv } from "./config.js"; async function main() { - const port = resolvePaseoPort(); - const agentMcpRoute = "/mcp/agents"; - const agentMcpUrl = `http://127.0.0.1:${port}${agentMcpRoute}`; - const [agentMcpUser, agentMcpPassword] = - Object.entries(BASIC_AUTH_USERS)[0] ?? []; - const agentMcpAuthHeader = - agentMcpUser && agentMcpPassword - ? `Basic ${Buffer.from( - `${agentMcpUser}:${agentMcpPassword}` - ).toString("base64")}` - : undefined; + const daemonConfig = buildPaseoDaemonConfigFromEnv(); + const daemon = await createPaseoDaemon(daemonConfig); - const app = createServer(); - const httpServer = createHTTPServer(app); - - // Initialize global agent manager + registry - const agentRegistry = new AgentRegistry(); - const agentManager = new AgentManager({ - clients: { - claude: new ClaudeAgentClient(), - codex: new CodexAgentClient(), - }, - registry: agentRegistry, - agentControlMcp: { - url: agentMcpUrl, - ...(agentMcpAuthHeader - ? { headers: { Authorization: agentMcpAuthHeader } } - : {}), - }, - }); - - attachAgentRegistryPersistence(agentManager, agentRegistry); - const persistedRecords = await agentRegistry.list(); - console.log( - `✓ Agent registry loaded (${persistedRecords.length} record${ - persistedRecords.length === 1 ? "" : "s" - }); agents will initialize on demand` - ); - - const agentMcpTransports: AgentMcpTransportMap = new Map(); - - const createAgentMcpTransport = async (callerAgentId?: string) => { - // Create a NEW McpServer instance per session (not shared across sessions) - // Pass the caller agent ID so create_agent can auto-set parentAgentId - const agentMcpServer = await createAgentMcpServer({ - agentManager, - agentRegistry, - callerAgentId, - }); - - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: (sessionId) => { - agentMcpTransports.set(sessionId, transport); - console.log(`[Agent MCP] Session initialized: ${sessionId}`); - }, - onsessionclosed: (sessionId) => { - agentMcpTransports.delete(sessionId); - console.log(`[Agent MCP] Session closed: ${sessionId}`); - }, - enableDnsRebindingProtection: true, - allowedHosts: [`127.0.0.1:${port}`, `localhost:${port}`], - }); - transport.onclose = () => { - if (transport.sessionId) { - agentMcpTransports.delete(transport.sessionId); - } - }; - transport.onerror = (error) => { - console.error("[Agent MCP] Transport error:", error); - }; - - await agentMcpServer.connect(transport); - - return transport; - }; - - const handleAgentMcpRequest: express.RequestHandler = async ( - req, - res - ) => { - if (process.env.MCP_DEBUG === "1") { - console.log("[Agent MCP] request", { - method: req.method, - url: req.originalUrl, - sessionId: req.header("mcp-session-id"), - authorization: req.header("authorization"), - body: req.body, - }); - } - try { - const sessionId = req.header("mcp-session-id"); - let transport = sessionId - ? agentMcpTransports.get(sessionId) - : undefined; - - if (!transport) { - if (req.method !== "POST") { - res.status(400).json({ - jsonrpc: "2.0", - error: { - code: -32000, - message: "Missing or invalid MCP session", - }, - id: null, - }); - return; - } - if (!isInitializeRequest(req.body)) { - res.status(400).json({ - jsonrpc: "2.0", - error: { - code: -32000, - message: "Initialization request expected", - }, - id: null, - }); - return; - } - // Extract optional caller agent ID from query string (sent by agents when connecting) - const callerAgentIdRaw = req.query.callerAgentId; - const callerAgentId = - typeof callerAgentIdRaw === "string" - ? callerAgentIdRaw - : Array.isArray(callerAgentIdRaw) && - typeof callerAgentIdRaw[0] === "string" - ? callerAgentIdRaw[0] - : undefined; - transport = await createAgentMcpTransport(callerAgentId); - } - - await transport.handleRequest( - req as any, - res as any, - req.body - ); - } catch (error) { - console.error("[Agent MCP] Failed to handle request:", error); - if (!res.headersSent) { - res.status(500).json({ - jsonrpc: "2.0", - error: { - code: -32603, - message: "Internal MCP server error", - }, - id: null, - }); - } - } - }; - - app.post(agentMcpRoute, handleAgentMcpRequest); - app.get(agentMcpRoute, handleAgentMcpRequest); - app.delete(agentMcpRoute, handleAgentMcpRequest); - console.log(`✓ Agent MCP server mounted at ${agentMcpRoute}`); - - // Initialize WebSocket server - const wsServer = new VoiceAssistantWebSocketServer( - httpServer, - agentManager, - agentRegistry, - { - agentMcpUrl, - agentMcpHeaders: agentMcpAuthHeader - ? { - Authorization: agentMcpAuthHeader, - } - : undefined, - } - ); - - // Initialize OpenAI client - const apiKey = process.env.OPENAI_API_KEY; - if (apiKey) { - console.log("✓ OpenAI client initialized"); - - // Initialize STT (Whisper) - const sttConfidenceThreshold = process.env.STT_CONFIDENCE_THRESHOLD - ? parseFloat(process.env.STT_CONFIDENCE_THRESHOLD) - : undefined; // Will default to -3.0 in stt-openai.ts - - const sttModel = process.env.STT_MODEL as STTConfig["model"]; - - initializeSTT({ - apiKey, - confidenceThreshold: sttConfidenceThreshold, - ...(sttModel ? { model: sttModel } : {}), - }); - - // Initialize TTS - const ttsVoice = (process.env.TTS_VOICE || "alloy") as - | "alloy" - | "echo" - | "fable" - | "onyx" - | "nova" - | "shimmer"; - const ttsModel = (process.env.TTS_MODEL || "tts-1") as "tts-1" | "tts-1-hd"; - initializeTTS({ - apiKey, - voice: ttsVoice, - model: ttsModel, - responseFormat: "pcm", - }); - - // Initialize agent title generator - initializeTitleGenerator(apiKey); - } else { - console.warn( - "⚠ OPENAI_API_KEY not set - LLM, STT, and TTS features will not work" - ); - } - - httpServer.listen(port, () => { + daemon.httpServer.listen(daemonConfig.port, () => { console.log( - `\n✓ Voice Assistant server running on http://localhost:${port}` + `\n✓ Voice Assistant server running on http://localhost:${daemonConfig.port}` ); }); - - // Graceful shutdown const handleShutdown = async (signal: string) => { console.log(`\n${signal} received, shutting down gracefully...`); - // Wait for agents to finish work - await closeAllAgents(agentManager); - - // Close WebSocket and HTTP servers - wsServer.close(); - httpServer.close(() => { - console.log("Server closed"); - process.exit(0); - }); - - // Force exit after 10 seconds if HTTP server doesn't close - // This runs AFTER agent shutdown completes - setTimeout(() => { + const forceExit = setTimeout(() => { console.log("Forcing shutdown - HTTP server didn't close in time"); process.exit(1); }, 10000); + + try { + await daemon.close(); + clearTimeout(forceExit); + console.log("Server closed"); + process.exit(0); + } catch (error) { + clearTimeout(forceExit); + console.error("Shutdown failed:", error); + process.exit(1); + } }; process.on("SIGTERM", () => handleShutdown("SIGTERM")); process.on("SIGINT", () => handleShutdown("SIGINT")); } -main(); - -async function closeAllAgents(agentManager: AgentManager): Promise { - const agents = agentManager.listAgents(); - for (const agent of agents) { - try { - await agentManager.closeAgent(agent.id); - } catch (error) { - console.error( - `[Agents] Failed to close agent ${agent.id}:`, - error - ); - } - } - - // All agents have been asked to stop; let the caller finish shutdown -} +main().catch((error) => { + console.error("Failed to start server:", error); + process.exit(1); +});