mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
refactor daemon bootstrap and add smoke test
This commit is contained in:
@@ -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[]> {
|
||||
|
||||
@@ -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 === "~") {
|
||||
|
||||
@@ -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;
|
||||
|
||||
86
packages/server/src/server/bootstrap.smoke.test.ts
Normal file
86
packages/server/src/server/bootstrap.smoke.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
320
packages/server/src/server/bootstrap.ts
Normal file
320
packages/server/src/server/bootstrap.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user