refactor daemon bootstrap and add smoke test

This commit is contained in:
Mohamed Boudra
2025-12-24 08:25:17 +00:00
parent 37da13a38b
commit d787c5f1bc
7 changed files with 531 additions and 367 deletions

View File

@@ -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<StoredAgentRecord[]> {

View File

@@ -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 === "~") {

View File

@@ -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;

View File

@@ -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<number> {
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<void>((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 });
}
});
});

View File

@@ -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<string, StreamableHTTPServerTransport>;
export type PaseoAuthConfig = {
basicUsers: Record<string, string>;
realm?: string;
agentMcpBearerToken?: string;
agentMcpAuthHeader?: string;
};
export type PaseoOpenAIConfig = {
apiKey?: string;
stt?: Partial<STTConfig> & { apiKey?: string };
tts?: Partial<TTSConfig> & { apiKey?: string };
};
export type PaseoDaemonConfig = {
port: number;
paseoHome: string;
agentMcpRoute: string;
agentMcpAllowedHosts: string[];
auth: PaseoAuthConfig;
staticDir: string;
mcpDebug: boolean;
agentClients: Partial<Record<AgentProvider, AgentClient>>;
agentRegistryPath: string;
agentControlMcp: AgentControlMcpConfig;
openai?: PaseoOpenAIConfig;
};
export type PaseoDaemonHandles = {
httpServer: HTTPServer;
app: Express;
wsServer: VoiceAssistantWebSocketServer;
agentManager: AgentManager;
agentRegistry: AgentRegistry;
close: () => Promise<void>;
};
export async function createPaseoDaemon(
config: PaseoDaemonConfig
): Promise<PaseoDaemonHandles> {
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<void>((resolve) => {
httpServer.close(() => resolve());
});
};
return {
httpServer,
app,
wsServer,
agentManager,
agentRegistry,
close,
};
}
async function closeAllAgents(agentManager: AgentManager): Promise<void> {
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);
}
}
}

View File

@@ -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,
};
}

View File

@@ -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<void> {
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);
});