* Align MCP provider controls with app provider state * Deepen provider snapshot routing * Make provider snapshots the daemon authority * Move provider shutdown behind a generic AgentClient seam OpenCodeServerManager is now owned entirely by the OpenCode provider. ProviderSnapshotManager.shutdown() and provider-registry.shutdownProviders() materialize enabled clients and call an optional shutdown() per client; the OpenCode client forwards to its runtime. Other providers ignore it. Also wires providerSnapshotManager into the remaining Session-constructing tests that were missing it (server-tests CI failure). * Use platform-native cwd in change-event test
1069 lines
38 KiB
TypeScript
1069 lines
38 KiB
TypeScript
import express from "express";
|
|
import { createServer as createHTTPServer, type IncomingMessage, type ServerResponse } from "http";
|
|
import { constants, existsSync, unlinkSync } from "fs";
|
|
import { open } from "fs/promises";
|
|
import { randomUUID } from "node:crypto";
|
|
import { hostname as getHostname } from "node:os";
|
|
import path from "node:path";
|
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
import type { Logger } from "pino";
|
|
import { createBranchChangeRouteHandler } from "./script-route-branch-handler.js";
|
|
|
|
export type ListenTarget =
|
|
| { type: "tcp"; host: string; port: number }
|
|
| { type: "socket"; path: string }
|
|
| { type: "pipe"; path: string };
|
|
|
|
function resolveBoundListenTarget(
|
|
listenTarget: ListenTarget,
|
|
httpServer: ReturnType<typeof createHTTPServer>,
|
|
): ListenTarget {
|
|
if (listenTarget.type !== "tcp") {
|
|
return listenTarget;
|
|
}
|
|
|
|
const address = httpServer.address();
|
|
if (!address || typeof address === "string") {
|
|
throw new Error("HTTP server did not expose a TCP address after listening");
|
|
}
|
|
|
|
return {
|
|
type: "tcp",
|
|
host: listenTarget.host,
|
|
port: address.port,
|
|
};
|
|
}
|
|
|
|
// Matches a Windows drive-letter path like C:\ or D:\
|
|
const WINDOWS_DRIVE_RE = /^[A-Za-z]:\\/;
|
|
|
|
export function parseListenString(listen: string): ListenTarget {
|
|
// 1. Windows named pipes: \\.\pipe\... or pipe://...
|
|
if (listen.startsWith("\\\\.\\pipe\\") || listen.startsWith("pipe://")) {
|
|
return {
|
|
type: "pipe",
|
|
path: listen.startsWith("pipe://") ? listen.slice("pipe://".length) : listen,
|
|
};
|
|
}
|
|
// 2. Explicit unix:// prefix
|
|
if (listen.startsWith("unix://")) {
|
|
return { type: "socket", path: listen.slice(7) };
|
|
}
|
|
// 3. Reject Windows absolute drive paths — they are not Unix sockets
|
|
if (WINDOWS_DRIVE_RE.test(listen)) {
|
|
throw new Error(`Invalid listen string (Windows path is not a valid listen target): ${listen}`);
|
|
}
|
|
// 4. POSIX absolute path (/ or ~) — Unix socket
|
|
if (listen.startsWith("/") || listen.startsWith("~")) {
|
|
return { type: "socket", path: listen };
|
|
}
|
|
// 5. Pure numeric — TCP port on 127.0.0.1
|
|
const trimmed = listen.trim();
|
|
if (/^\d+$/.test(trimmed)) {
|
|
const port = parseInt(trimmed, 10);
|
|
return { type: "tcp", host: "127.0.0.1", port };
|
|
}
|
|
// 6. host:port — TCP
|
|
if (listen.includes(":")) {
|
|
const [host, portStr] = listen.split(":");
|
|
const parsedPort = parseInt(portStr, 10);
|
|
if (!Number.isFinite(parsedPort)) {
|
|
throw new Error(`Invalid port in listen string: ${listen}`);
|
|
}
|
|
return { type: "tcp", host: host || "127.0.0.1", port: parsedPort };
|
|
}
|
|
throw new Error(`Invalid listen string: ${listen}`);
|
|
}
|
|
|
|
function formatListenTarget(listenTarget: ListenTarget | null): string | null {
|
|
if (!listenTarget) {
|
|
return null;
|
|
}
|
|
if (listenTarget.type === "tcp") {
|
|
return `${listenTarget.host}:${listenTarget.port}`;
|
|
}
|
|
return listenTarget.path;
|
|
}
|
|
|
|
import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
|
|
import { createGitHubService } from "../services/github-service.js";
|
|
import { createPaseoWorktree as createRegisteredPaseoWorktree } from "./paseo-worktree-service.js";
|
|
import { createPaseoWorktreeWorkflow } from "./worktree-session.js";
|
|
import { DownloadTokenStore } from "./file-download/token-store.js";
|
|
import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js";
|
|
import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.js";
|
|
import type { RequestedSpeechProviders } from "./speech/speech-types.js";
|
|
import { createSpeechService } from "./speech/speech-runtime.js";
|
|
import { AgentManager } from "./agent/agent-manager.js";
|
|
import { AgentStorage } from "./agent/agent-storage.js";
|
|
import { attachAgentStoragePersistence } from "./persistence-hooks.js";
|
|
import { createAgentMcpServer } from "./agent/mcp-server.js";
|
|
import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
|
|
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
|
|
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
|
|
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js";
|
|
import { FileBackedChatService } from "./chat/chat-service.js";
|
|
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
|
import { LoopService } from "./loop-service.js";
|
|
import { ScheduleService } from "./schedule/service.js";
|
|
import { DaemonConfigStore } from "./daemon-config-store.js";
|
|
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
|
|
import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js";
|
|
import { setupAutoArchiveOnMerge } from "./auto-archive-on-merge/index.js";
|
|
import { wrapSessionMessage, type SessionOutboundMessage } from "./messages.js";
|
|
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
|
import { createConfiguredTerminalManager } from "../terminal/terminal-manager-factory.js";
|
|
import { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js";
|
|
import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
|
|
import { startRelayTransport, type RelayTransportController } from "./relay-transport.js";
|
|
import type { PushNotificationSender } from "./push/notifications.js";
|
|
import { getOrCreateServerId } from "./server-id.js";
|
|
import { resolveDaemonVersion } from "./daemon-version.js";
|
|
import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js";
|
|
import type {
|
|
AgentProviderRuntimeSettingsMap,
|
|
ProviderOverride,
|
|
} from "./agent/provider-launch-config.js";
|
|
import type { PersistedConfig } from "./persisted-config.js";
|
|
import {
|
|
ScriptRouteStore,
|
|
createScriptProxyMiddleware,
|
|
createScriptProxyUpgradeHandler,
|
|
} from "./script-proxy.js";
|
|
import { ScriptHealthMonitor } from "./script-health-monitor.js";
|
|
import { createScriptStatusEmitter } from "./script-status-projection.js";
|
|
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
|
|
import { isHostnameAllowed, type HostnamesConfig } from "./hostnames.js";
|
|
import { createRequireBearerMiddleware, type DaemonAuthConfig } from "./auth.js";
|
|
|
|
type AgentMcpTransportMap = Map<string, StreamableHTTPServerTransport>;
|
|
|
|
const MAX_MCP_DEBUG_BATCH_ITEMS = 10;
|
|
const REDACTED_LOG_VALUE = "[redacted]";
|
|
const DOWNLOAD_OPEN_FLAGS =
|
|
process.platform === "win32" ? constants.O_RDONLY : constants.O_RDONLY | constants.O_NOFOLLOW;
|
|
|
|
function formatHostForHttpUrl(host: string): string {
|
|
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
}
|
|
|
|
function resolveAgentMcpClientHost(host: string): string {
|
|
if (host === "0.0.0.0") {
|
|
return "127.0.0.1";
|
|
}
|
|
if (host === "::" || host === "[::]") {
|
|
return "::1";
|
|
}
|
|
return host;
|
|
}
|
|
|
|
function createAgentMcpBaseUrl(listenTarget: ListenTarget | null): string | null {
|
|
if (!listenTarget || listenTarget.type !== "tcp") {
|
|
return null;
|
|
}
|
|
const host = resolveAgentMcpClientHost(listenTarget.host);
|
|
return new URL(
|
|
"/mcp/agents",
|
|
`http://${formatHostForHttpUrl(host)}:${listenTarget.port}`,
|
|
).toString();
|
|
}
|
|
|
|
function summarizeAgentMcpDebugMessage(body: unknown): Record<string, unknown> {
|
|
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
return {
|
|
type: body === null ? "null" : typeof body,
|
|
};
|
|
}
|
|
|
|
const record = body as Record<string, unknown>;
|
|
const method = typeof record.method === "string" ? record.method : undefined;
|
|
return {
|
|
type: "object",
|
|
...(typeof record.jsonrpc === "string" ? { jsonrpc: record.jsonrpc } : {}),
|
|
...(method ? { method } : {}),
|
|
hasId: Object.prototype.hasOwnProperty.call(record, "id"),
|
|
hasParams: Object.prototype.hasOwnProperty.call(record, "params"),
|
|
};
|
|
}
|
|
|
|
function summarizeAgentMcpDebugBody(body: unknown): Record<string, unknown> {
|
|
if (!Array.isArray(body)) {
|
|
return summarizeAgentMcpDebugMessage(body);
|
|
}
|
|
|
|
const messages = body.slice(0, MAX_MCP_DEBUG_BATCH_ITEMS).map(summarizeAgentMcpDebugMessage);
|
|
return {
|
|
type: "batch",
|
|
count: body.length,
|
|
messages,
|
|
...(body.length > messages.length ? { omitted: body.length - messages.length } : {}),
|
|
};
|
|
}
|
|
|
|
export type PaseoOpenAIConfig = OpenAiSpeechProviderConfig;
|
|
export type PaseoLocalSpeechConfig = LocalSpeechProviderConfig;
|
|
|
|
export interface PaseoSpeechSttLanguages {
|
|
dictation: string;
|
|
voice: string;
|
|
}
|
|
|
|
export interface PaseoSpeechConfig {
|
|
providers: RequestedSpeechProviders;
|
|
sttLanguages?: PaseoSpeechSttLanguages;
|
|
local?: PaseoLocalSpeechConfig;
|
|
}
|
|
|
|
export type DaemonLifecycleIntent =
|
|
| {
|
|
type: "shutdown";
|
|
clientId: string;
|
|
requestId: string;
|
|
}
|
|
| {
|
|
type: "restart";
|
|
clientId: string;
|
|
requestId: string;
|
|
reason?: string;
|
|
};
|
|
|
|
export interface PaseoDaemonConfig {
|
|
listen: string;
|
|
paseoHome: string;
|
|
corsAllowedOrigins: string[];
|
|
allowedHosts?: HostnamesConfig;
|
|
hostnames?: HostnamesConfig;
|
|
mcpEnabled?: boolean;
|
|
mcpInjectIntoAgents?: boolean;
|
|
autoArchiveAfterMerge?: boolean;
|
|
appendSystemPrompt?: string;
|
|
staticDir: string;
|
|
mcpDebug: boolean;
|
|
isDev?: boolean;
|
|
agentClients: Partial<Record<AgentProvider, AgentClient>>;
|
|
agentStoragePath: string;
|
|
relayEnabled?: boolean;
|
|
relayEndpoint?: string;
|
|
relayPublicEndpoint?: string;
|
|
relayUseTls?: boolean;
|
|
relayPublicUseTls?: boolean;
|
|
appBaseUrl?: string;
|
|
auth?: DaemonAuthConfig;
|
|
openai?: PaseoOpenAIConfig;
|
|
speech?: PaseoSpeechConfig;
|
|
voiceLlmProvider?: AgentProvider | null;
|
|
voiceLlmProviderExplicit?: boolean;
|
|
voiceLlmModel?: string | null;
|
|
dictationFinalTimeoutMs?: number;
|
|
downloadTokenTtlMs?: number;
|
|
agentProviderSettings?: AgentProviderRuntimeSettingsMap;
|
|
providerOverrides?: Record<string, ProviderOverride>;
|
|
log?: PersistedConfig["log"];
|
|
onLifecycleIntent?: (intent: DaemonLifecycleIntent) => void;
|
|
pushNotificationSender?: PushNotificationSender;
|
|
}
|
|
|
|
export interface PaseoDaemon {
|
|
config: PaseoDaemonConfig;
|
|
agentManager: AgentManager;
|
|
agentStorage: AgentStorage;
|
|
terminalManager: TerminalManager;
|
|
scriptRouteStore: ScriptRouteStore;
|
|
scriptRuntimeStore: WorkspaceScriptRuntimeStore;
|
|
start(): Promise<void>;
|
|
stop(): Promise<void>;
|
|
getListenTarget(): ListenTarget | null;
|
|
}
|
|
|
|
export async function createPaseoDaemon(
|
|
config: PaseoDaemonConfig,
|
|
rootLogger: Logger,
|
|
): Promise<PaseoDaemon> {
|
|
const logger = rootLogger.child({ module: "bootstrap" });
|
|
const bootstrapStart = performance.now();
|
|
const elapsed = () => `${(performance.now() - bootstrapStart).toFixed(0)}ms`;
|
|
const daemonVersion = resolveDaemonVersion(import.meta.url);
|
|
const daemonConfigStore = new DaemonConfigStore(
|
|
config.paseoHome,
|
|
{
|
|
mcp: { injectIntoAgents: config.mcpInjectIntoAgents ?? true },
|
|
providers: Object.fromEntries(
|
|
Object.entries(config.providerOverrides ?? {}).map(([providerId, override]) => [
|
|
providerId,
|
|
{
|
|
...(override.enabled !== undefined ? { enabled: override.enabled } : {}),
|
|
...(override.additionalModels ? { additionalModels: override.additionalModels } : {}),
|
|
},
|
|
]),
|
|
),
|
|
autoArchiveAfterMerge: config.autoArchiveAfterMerge ?? false,
|
|
appendSystemPrompt: config.appendSystemPrompt ?? "",
|
|
},
|
|
logger,
|
|
);
|
|
|
|
const serverId = getOrCreateServerId(config.paseoHome, { logger });
|
|
const daemonKeyPair = await loadOrCreateDaemonKeyPair(config.paseoHome, logger);
|
|
let relayTransport: RelayTransportController | null = null;
|
|
|
|
const staticDir = config.staticDir;
|
|
const downloadTokenTtlMs = config.downloadTokenTtlMs ?? 60000;
|
|
|
|
const downloadTokenStore = new DownloadTokenStore({
|
|
ttlMs: downloadTokenTtlMs,
|
|
});
|
|
|
|
const listenTarget = parseListenString(config.listen);
|
|
|
|
const app = express();
|
|
let boundListenTarget: ListenTarget | null = null;
|
|
let workspaceRegistry: FileBackedWorkspaceRegistry | null = null;
|
|
|
|
const scriptRouteStore = new ScriptRouteStore();
|
|
const scriptRuntimeStore = new WorkspaceScriptRuntimeStore();
|
|
const configuredHostnames = config.hostnames ?? config.allowedHosts;
|
|
let wsServer: VoiceAssistantWebSocketServer | null = null;
|
|
const scriptHealthMonitor = new ScriptHealthMonitor({
|
|
routeStore: scriptRouteStore,
|
|
onChange: createScriptStatusEmitter({
|
|
sessions: () =>
|
|
wsServer?.listActiveSessions().map((session) => ({
|
|
emit: (message) => session.emitServerMessage(message),
|
|
})) ?? [],
|
|
routeStore: scriptRouteStore,
|
|
runtimeStore: scriptRuntimeStore,
|
|
daemonPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
|
|
resolveWorkspaceDirectory: async (workspaceId) =>
|
|
(await workspaceRegistry?.get(workspaceId))?.cwd ?? null,
|
|
logger,
|
|
}),
|
|
});
|
|
const handleBranchChange = createBranchChangeRouteHandler({
|
|
routeStore: scriptRouteStore,
|
|
onRoutesChanged: (workspaceId) => {
|
|
scriptHealthMonitor.invalidateWorkspace(workspaceId);
|
|
},
|
|
logger,
|
|
});
|
|
|
|
// Host allowlist / DNS rebinding protection (vite-like semantics).
|
|
// For non-TCP (unix sockets), skip host validation.
|
|
if (listenTarget.type === "tcp") {
|
|
app.use((req, res, next) => {
|
|
const hostHeader = typeof req.headers.host === "string" ? req.headers.host : undefined;
|
|
if (!isHostnameAllowed(hostHeader, configuredHostnames)) {
|
|
res.status(403).json({ error: "Invalid Host header" });
|
|
return;
|
|
}
|
|
next();
|
|
});
|
|
}
|
|
|
|
// CORS - allow same-origin + configured origins
|
|
const allowedOrigins = new Set([
|
|
...config.corsAllowedOrigins,
|
|
// Packaged desktop renderers use the custom paseo:// protocol scheme.
|
|
"paseo://app",
|
|
// For TCP, add localhost variants
|
|
...(listenTarget.type === "tcp"
|
|
? [
|
|
`http://${listenTarget.host}:${listenTarget.port}`,
|
|
`http://localhost:${listenTarget.port}`,
|
|
`http://127.0.0.1:${listenTarget.port}`,
|
|
]
|
|
: []),
|
|
]);
|
|
|
|
app.use((req, res, next) => {
|
|
const origin = req.headers.origin;
|
|
if (origin && (allowedOrigins.has("*") || allowedOrigins.has(origin))) {
|
|
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
res.setHeader("Access-Control-Allow-Credentials", "true");
|
|
}
|
|
if (req.method === "OPTIONS") {
|
|
res.status(204).end();
|
|
return;
|
|
}
|
|
next();
|
|
});
|
|
|
|
app.use(
|
|
createRequireBearerMiddleware(config.auth, (context) => {
|
|
logger.warn(context, "Rejected HTTP request with invalid daemon password");
|
|
}),
|
|
);
|
|
|
|
// Script proxy — intercepts requests for registered *.localhost hostnames
|
|
// and forwards them to the corresponding local script port. Placed after
|
|
// host/CORS/auth checks but before the rest of the routes.
|
|
app.use(createScriptProxyMiddleware({ routeStore: scriptRouteStore, logger }));
|
|
|
|
// Serve static files from public directory
|
|
app.use("/public", express.static(staticDir));
|
|
|
|
// Middleware
|
|
app.use(express.json());
|
|
|
|
// Health check endpoint
|
|
app.get("/api/health", (_req, res) => {
|
|
res.json({ status: "ok", timestamp: new Date().toISOString() });
|
|
});
|
|
|
|
app.get("/api/status", (_req, res) => {
|
|
res.json({
|
|
status: "server_info",
|
|
serverId,
|
|
hostname: getHostname(),
|
|
version: daemonVersion,
|
|
listen: formatListenTarget(boundListenTarget ?? listenTarget),
|
|
});
|
|
});
|
|
|
|
const handleFileDownload = async (req: express.Request, res: express.Response): Promise<void> => {
|
|
const token =
|
|
typeof req.query.token === "string" && req.query.token.trim().length > 0
|
|
? req.query.token.trim()
|
|
: null;
|
|
|
|
if (!token) {
|
|
res.status(400).json({ error: "Missing download token" });
|
|
return;
|
|
}
|
|
|
|
const entry = downloadTokenStore.consumeToken(token);
|
|
if (!entry) {
|
|
res.status(403).json({ error: "Invalid or expired token" });
|
|
return;
|
|
}
|
|
|
|
let fileHandle: Awaited<ReturnType<typeof open>> | null = null;
|
|
try {
|
|
fileHandle = await open(entry.absolutePath, DOWNLOAD_OPEN_FLAGS);
|
|
const fileStats = await fileHandle.stat();
|
|
if (!fileStats.isFile()) {
|
|
res.status(404).json({ error: "File not found" });
|
|
return;
|
|
}
|
|
|
|
const safeFileName = entry.fileName.replace(/["\r\n]/g, "_");
|
|
res.setHeader("Content-Type", entry.mimeType);
|
|
res.setHeader("Content-Disposition", `attachment; filename="${safeFileName}"`);
|
|
res.setHeader("Content-Length", fileStats.size.toString());
|
|
|
|
const stream = fileHandle.createReadStream();
|
|
fileHandle = null;
|
|
stream.on("error", (err) => {
|
|
logger.error({ err }, "Failed to stream download");
|
|
if (!res.headersSent) {
|
|
res.status(500).json({ error: "Failed to read file" });
|
|
} else {
|
|
res.end();
|
|
}
|
|
});
|
|
stream.pipe(res);
|
|
} catch (err) {
|
|
logger.error({ err }, "Failed to download file");
|
|
if (!res.headersSent) {
|
|
res.status(404).json({ error: "File not found" });
|
|
}
|
|
} finally {
|
|
await fileHandle?.close().catch(() => undefined);
|
|
}
|
|
};
|
|
|
|
app.get("/api/files/download", (req, res) => {
|
|
void handleFileDownload(req, res);
|
|
});
|
|
|
|
const httpServer = createHTTPServer(app);
|
|
|
|
// Script proxy WebSocket upgrade handler — must be registered before the
|
|
// VoiceAssistantWebSocketServer attaches its own "upgrade" listener so that
|
|
// script-bound upgrades are forwarded first. The handler is a no-op for
|
|
// requests that don't match a registered script route.
|
|
const scriptProxyUpgradeHandler = createScriptProxyUpgradeHandler({
|
|
routeStore: scriptRouteStore,
|
|
logger,
|
|
});
|
|
httpServer.on("upgrade", scriptProxyUpgradeHandler);
|
|
|
|
const agentStorage = new AgentStorage(config.agentStoragePath, logger);
|
|
const projectRegistry = new FileBackedProjectRegistry(
|
|
path.join(config.paseoHome, "projects", "projects.json"),
|
|
logger,
|
|
);
|
|
workspaceRegistry = new FileBackedWorkspaceRegistry(
|
|
path.join(config.paseoHome, "projects", "workspaces.json"),
|
|
logger,
|
|
);
|
|
const chatService = new FileBackedChatService({
|
|
paseoHome: config.paseoHome,
|
|
logger,
|
|
});
|
|
const terminalManager = createConfiguredTerminalManager();
|
|
const github = createGitHubService();
|
|
const workspaceGitService = new WorkspaceGitServiceImpl({
|
|
logger,
|
|
paseoHome: config.paseoHome,
|
|
deps: {
|
|
github,
|
|
},
|
|
});
|
|
const providerSnapshotLogger = logger.child({ module: "provider-snapshot-manager" });
|
|
const providerSnapshotManager = new ProviderSnapshotManager({
|
|
logger: providerSnapshotLogger,
|
|
runtimeSettings: config.agentProviderSettings,
|
|
providerOverrides: config.providerOverrides,
|
|
workspaceGitService,
|
|
isDev: config.isDev === true,
|
|
extraClients: config.agentClients,
|
|
});
|
|
const initialAgentManagerState = providerSnapshotManager.getAgentManagerProviderState();
|
|
const agentManager = new AgentManager({
|
|
clients: initialAgentManagerState.clients,
|
|
providerDefinitions: initialAgentManagerState.providerDefinitions,
|
|
registry: agentStorage,
|
|
appendSystemPrompt: config.appendSystemPrompt,
|
|
logger,
|
|
});
|
|
|
|
const detachAgentStoragePersistence = attachAgentStoragePersistence(
|
|
logger,
|
|
agentManager,
|
|
agentStorage,
|
|
);
|
|
await agentStorage.initialize();
|
|
logger.info({ elapsed: elapsed() }, "Agent storage initialized");
|
|
await bootstrapWorkspaceRegistries({
|
|
paseoHome: config.paseoHome,
|
|
agentStorage,
|
|
projectRegistry,
|
|
workspaceRegistry,
|
|
workspaceGitService,
|
|
logger,
|
|
});
|
|
logger.info({ elapsed: elapsed() }, "Workspace registries bootstrapped");
|
|
const workspaceReconciliation = new WorkspaceReconciliationService({
|
|
projectRegistry,
|
|
workspaceRegistry,
|
|
logger,
|
|
workspaceGitService,
|
|
});
|
|
void (async () => {
|
|
try {
|
|
const result = await workspaceReconciliation.runOnce();
|
|
logger.info(
|
|
{
|
|
elapsed: elapsed(),
|
|
changeCount: result.changesApplied.length,
|
|
},
|
|
"Workspace registries reconciled",
|
|
);
|
|
} catch (error) {
|
|
logger.error({ err: error }, "Background workspace reconciliation failed");
|
|
}
|
|
})();
|
|
await chatService.initialize();
|
|
logger.info({ elapsed: elapsed() }, "Chat service initialized");
|
|
const checkoutDiffManager = new CheckoutDiffManager({
|
|
logger,
|
|
paseoHome: config.paseoHome,
|
|
workspaceGitService,
|
|
});
|
|
const loopService = new LoopService({
|
|
paseoHome: config.paseoHome,
|
|
logger,
|
|
agentManager,
|
|
});
|
|
await loopService.initialize();
|
|
logger.info({ elapsed: elapsed() }, "Loop service initialized");
|
|
const scheduleService = new ScheduleService({
|
|
paseoHome: config.paseoHome,
|
|
logger,
|
|
agentManager,
|
|
agentStorage,
|
|
});
|
|
await scheduleService.start();
|
|
logger.info({ elapsed: elapsed() }, "Schedule service initialized");
|
|
logger.info({ elapsed: elapsed() }, "Loading persisted agent registry");
|
|
const persistedRecords = await agentStorage.list();
|
|
logger.info(
|
|
{ elapsed: elapsed() },
|
|
`Agent registry loaded (${persistedRecords.length} record${persistedRecords.length === 1 ? "" : "s"}); agents will initialize on demand`,
|
|
);
|
|
logger.info(
|
|
"Voice mode configured for agent-scoped resume flow (no dedicated voice assistant provider)",
|
|
);
|
|
logger.info({ elapsed: elapsed() }, "Preparing voice and MCP runtime");
|
|
|
|
const archiveWorkspaceRecordExternal = async (workspaceId: string) => {
|
|
const sessions = wsServer?.listActiveSessions() ?? [];
|
|
if (sessions.length > 0) {
|
|
await Promise.all(
|
|
sessions.map((session) => session.archiveWorkspaceRecordForExternalMutation(workspaceId)),
|
|
);
|
|
return;
|
|
}
|
|
|
|
await archivePersistedWorkspaceRecord({
|
|
workspaceId,
|
|
workspaceRegistry,
|
|
projectRegistry,
|
|
});
|
|
};
|
|
const markWorkspaceArchivingExternal = (workspaceIds: Iterable<string>, archivingAt: string) => {
|
|
const workspaceIdList = Array.from(workspaceIds);
|
|
for (const session of wsServer?.listActiveSessions() ?? []) {
|
|
session.markWorkspaceArchivingForExternalMutation(workspaceIdList, archivingAt);
|
|
}
|
|
};
|
|
const clearWorkspaceArchivingExternal = (workspaceIds: Iterable<string>) => {
|
|
const workspaceIdList = Array.from(workspaceIds);
|
|
for (const session of wsServer?.listActiveSessions() ?? []) {
|
|
session.clearWorkspaceArchivingForExternalMutation(workspaceIdList);
|
|
}
|
|
};
|
|
const emitWorkspaceUpdatesExternal = async (workspaceIds: Iterable<string>) => {
|
|
const workspaceIdList = Array.from(workspaceIds);
|
|
await Promise.all(
|
|
(wsServer?.listActiveSessions() ?? []).map((session) =>
|
|
session.emitWorkspaceUpdatesForExternalWorkspaceIds(workspaceIdList),
|
|
),
|
|
);
|
|
};
|
|
const emitExternalSessionMessage = (message: SessionOutboundMessage) => {
|
|
wsServer?.broadcast(wrapSessionMessage(message));
|
|
};
|
|
|
|
setupAutoArchiveOnMerge({
|
|
paseoHome: config.paseoHome,
|
|
daemonConfigStore,
|
|
workspaceGitService,
|
|
github,
|
|
agentManager,
|
|
agentStorage,
|
|
terminalManager,
|
|
logger,
|
|
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
|
|
markWorkspaceArchiving: markWorkspaceArchivingExternal,
|
|
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
|
|
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
|
|
emitSessionMessage: emitExternalSessionMessage,
|
|
});
|
|
|
|
const mcpEnabled = config.mcpEnabled ?? true;
|
|
let agentMcpBaseUrl: string | null = null;
|
|
if (mcpEnabled) {
|
|
const agentMcpRoute = "/mcp/agents";
|
|
const agentMcpTransports: AgentMcpTransportMap = new Map();
|
|
|
|
const createAgentMcpTransport = async (callerAgentId?: string) => {
|
|
const agentMcpServer = await createAgentMcpServer({
|
|
agentManager,
|
|
agentStorage,
|
|
terminalManager,
|
|
getDaemonTcpPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
|
|
scheduleService,
|
|
providerSnapshotManager,
|
|
github,
|
|
workspaceGitService,
|
|
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
|
|
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
|
|
markWorkspaceArchiving: markWorkspaceArchivingExternal,
|
|
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
|
|
emitSessionMessage: emitExternalSessionMessage,
|
|
createPaseoWorktree: async (input, serviceOptions) => {
|
|
return createPaseoWorktreeWorkflow(
|
|
{
|
|
paseoHome: config.paseoHome,
|
|
createPaseoWorktree: async (workflowInput, workflowOptions) => {
|
|
return createRegisteredPaseoWorktree(workflowInput, {
|
|
github,
|
|
...(workflowOptions?.resolveDefaultBranch
|
|
? {
|
|
resolveDefaultBranch: workflowOptions.resolveDefaultBranch,
|
|
}
|
|
: {}),
|
|
projectRegistry,
|
|
workspaceRegistry,
|
|
workspaceGitService,
|
|
});
|
|
},
|
|
warmWorkspaceGitData: async (workspace) => {
|
|
await Promise.all(
|
|
wsServer
|
|
?.listActiveSessions()
|
|
.map((session) => session.warmWorkspaceGitDataForWorkspace(workspace)) ?? [],
|
|
);
|
|
},
|
|
emitWorkspaceUpdateForCwd: async (cwd, emitOptions) => {
|
|
await Promise.all(
|
|
wsServer
|
|
?.listActiveSessions()
|
|
.map((session) => session.emitWorkspaceUpdatesForExternalCwds([cwd])) ?? [],
|
|
);
|
|
void emitOptions;
|
|
},
|
|
cacheWorkspaceSetupSnapshot: () => {},
|
|
emit: emitExternalSessionMessage,
|
|
sessionLogger: logger,
|
|
terminalManager,
|
|
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
|
|
scriptRouteStore,
|
|
scriptRuntimeStore,
|
|
getDaemonTcpPort: () =>
|
|
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
|
|
getDaemonTcpHost: () =>
|
|
boundListenTarget?.type === "tcp" ? boundListenTarget.host : null,
|
|
onScriptsChanged: null,
|
|
},
|
|
input,
|
|
serviceOptions,
|
|
);
|
|
},
|
|
paseoHome: config.paseoHome,
|
|
callerAgentId,
|
|
enableVoiceTools: false,
|
|
resolveSpeakHandler: (agentId) => wsServer?.resolveVoiceSpeakHandler(agentId) ?? null,
|
|
resolveCallerContext: (agentId) => wsServer?.resolveVoiceCallerContext(agentId) ?? null,
|
|
logger,
|
|
});
|
|
|
|
const transport = new StreamableHTTPServerTransport({
|
|
sessionIdGenerator: () => randomUUID(),
|
|
onsessioninitialized: (sessionId) => {
|
|
agentMcpTransports.set(sessionId, transport);
|
|
logger.debug({ sessionId }, "Agent MCP session initialized");
|
|
},
|
|
onsessionclosed: (sessionId) => {
|
|
agentMcpTransports.delete(sessionId);
|
|
logger.debug({ sessionId }, "Agent MCP session closed");
|
|
},
|
|
// NOTE: We enforce a Vite-like host allowlist at the app/websocket layer.
|
|
// StreamableHTTPServerTransport's built-in check requires exact Host header matches.
|
|
enableDnsRebindingProtection: false,
|
|
});
|
|
|
|
Object.assign(transport, {
|
|
onclose: () => {
|
|
if (transport.sessionId) {
|
|
agentMcpTransports.delete(transport.sessionId);
|
|
}
|
|
},
|
|
onerror: (err: Error) => {
|
|
logger.error({ err }, "Agent MCP transport error");
|
|
},
|
|
});
|
|
|
|
await agentMcpServer.connect(transport);
|
|
return transport;
|
|
};
|
|
|
|
const runAgentMcpRequest = async (
|
|
req: express.Request,
|
|
res: express.Response,
|
|
): Promise<void> => {
|
|
if (config.mcpDebug) {
|
|
logger.debug(
|
|
{
|
|
method: req.method,
|
|
url: req.originalUrl,
|
|
sessionId: req.header("mcp-session-id"),
|
|
authorization: req.header("authorization") ? REDACTED_LOG_VALUE : undefined,
|
|
body: summarizeAgentMcpDebugBody(req.body),
|
|
},
|
|
"Agent MCP request",
|
|
);
|
|
}
|
|
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;
|
|
let callerAgentId: string | undefined;
|
|
if (typeof callerAgentIdRaw === "string") {
|
|
callerAgentId = callerAgentIdRaw;
|
|
} else if (Array.isArray(callerAgentIdRaw) && typeof callerAgentIdRaw[0] === "string") {
|
|
callerAgentId = callerAgentIdRaw[0];
|
|
}
|
|
transport = await createAgentMcpTransport(callerAgentId);
|
|
}
|
|
|
|
await transport.handleRequest(
|
|
req as unknown as IncomingMessage,
|
|
res as unknown as ServerResponse,
|
|
req.body,
|
|
);
|
|
} catch (err) {
|
|
logger.error({ err }, "Failed to handle Agent MCP request");
|
|
if (!res.headersSent) {
|
|
res.status(500).json({
|
|
jsonrpc: "2.0",
|
|
error: {
|
|
code: -32603,
|
|
message: "Internal MCP server error",
|
|
},
|
|
id: null,
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleAgentMcpRequest: express.RequestHandler = (req, res) => {
|
|
void runAgentMcpRequest(req, res);
|
|
};
|
|
|
|
app.post(agentMcpRoute, handleAgentMcpRequest);
|
|
app.get(agentMcpRoute, handleAgentMcpRequest);
|
|
app.delete(agentMcpRoute, handleAgentMcpRequest);
|
|
logger.info({ route: agentMcpRoute }, "Agent MCP server mounted on main app");
|
|
} else {
|
|
logger.info("Agent MCP HTTP endpoint disabled");
|
|
}
|
|
|
|
const speechService = createSpeechService({
|
|
logger,
|
|
openaiConfig: config.openai,
|
|
speechConfig: config.speech,
|
|
});
|
|
logger.info({ elapsed: elapsed() }, "Speech service created");
|
|
|
|
logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening");
|
|
|
|
const start = async () => {
|
|
// Start main HTTP server
|
|
await new Promise<void>((resolve, reject) => {
|
|
const onError = (err: Error) => {
|
|
httpServer.off("listening", onListening);
|
|
reject(err);
|
|
};
|
|
const onListening = () => {
|
|
httpServer.off("error", onError);
|
|
const logAndResolve = async () => {
|
|
boundListenTarget = resolveBoundListenTarget(listenTarget, httpServer);
|
|
const mcpBaseUrl = mcpEnabled ? createAgentMcpBaseUrl(boundListenTarget) : null;
|
|
agentMcpBaseUrl = config.mcpInjectIntoAgents === false ? null : mcpBaseUrl;
|
|
agentManager.setMcpBaseUrl(agentMcpBaseUrl);
|
|
daemonConfigStore.onFieldChange("mcp.injectIntoAgents", (value) => {
|
|
agentManager.setMcpBaseUrl(value ? mcpBaseUrl : null);
|
|
});
|
|
daemonConfigStore.onFieldChange("appendSystemPrompt", (value) => {
|
|
agentManager.setAppendSystemPrompt(typeof value === "string" ? value : "");
|
|
});
|
|
const relayEnabled = config.relayEnabled ?? true;
|
|
const relayEndpoint = config.relayEndpoint ?? "relay.paseo.sh:443";
|
|
const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint;
|
|
const relayUseTls = config.relayUseTls ?? relayEndpoint === "relay.paseo.sh:443";
|
|
const relayPublicUseTls = config.relayPublicUseTls ?? relayUseTls;
|
|
const appBaseUrl = config.appBaseUrl ?? "https://app.paseo.sh";
|
|
|
|
if (boundListenTarget.type === "tcp") {
|
|
logger.info(
|
|
{
|
|
host: boundListenTarget.host,
|
|
port: boundListenTarget.port,
|
|
authRequired: !!config.auth?.password,
|
|
elapsed: elapsed(),
|
|
},
|
|
`Server listening on http://${boundListenTarget.host}:${boundListenTarget.port}`,
|
|
);
|
|
} else {
|
|
logger.info(
|
|
{
|
|
path: boundListenTarget.path,
|
|
authRequired: !!config.auth?.password,
|
|
elapsed: elapsed(),
|
|
},
|
|
`Server listening on ${boundListenTarget.path}`,
|
|
);
|
|
}
|
|
if (config.auth?.password) {
|
|
logger.info("Daemon password authentication enabled");
|
|
}
|
|
|
|
wsServer = new VoiceAssistantWebSocketServer(
|
|
httpServer,
|
|
logger,
|
|
serverId,
|
|
agentManager,
|
|
agentStorage,
|
|
downloadTokenStore,
|
|
config.paseoHome,
|
|
daemonConfigStore,
|
|
mcpBaseUrl,
|
|
{ allowedOrigins, hostnames: configuredHostnames },
|
|
config.auth,
|
|
speechService,
|
|
terminalManager,
|
|
{
|
|
finalTimeoutMs: config.dictationFinalTimeoutMs,
|
|
},
|
|
daemonVersion,
|
|
(intent) => {
|
|
try {
|
|
config.onLifecycleIntent?.(intent);
|
|
} catch (error) {
|
|
logger.error({ err: error, intent }, "Failed to handle daemon lifecycle intent");
|
|
}
|
|
},
|
|
projectRegistry,
|
|
workspaceRegistry,
|
|
chatService,
|
|
loopService,
|
|
scheduleService,
|
|
checkoutDiffManager,
|
|
scriptRouteStore,
|
|
scriptRuntimeStore,
|
|
handleBranchChange,
|
|
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
|
|
() => (boundListenTarget?.type === "tcp" ? boundListenTarget.host : null),
|
|
(hostname) => scriptHealthMonitor.getHealthForHostname(hostname),
|
|
workspaceGitService,
|
|
github,
|
|
config.pushNotificationSender,
|
|
providerSnapshotManager,
|
|
{
|
|
listen: formatListenTarget(boundListenTarget ?? listenTarget),
|
|
relay: {
|
|
enabled: relayEnabled,
|
|
endpoint: relayEndpoint,
|
|
publicEndpoint: relayPublicEndpoint,
|
|
useTls: relayUseTls,
|
|
publicUseTls: relayPublicUseTls,
|
|
},
|
|
},
|
|
);
|
|
|
|
if (relayEnabled) {
|
|
const offer = await createConnectionOfferV2({
|
|
serverId,
|
|
daemonPublicKeyB64: daemonKeyPair.publicKeyB64,
|
|
relay: {
|
|
endpoint: relayPublicEndpoint,
|
|
useTls: relayPublicUseTls,
|
|
},
|
|
});
|
|
|
|
encodeOfferToFragmentUrl({ offer, appBaseUrl });
|
|
|
|
relayTransport?.stop().catch(() => undefined);
|
|
relayTransport = startRelayTransport({
|
|
logger,
|
|
attachSocket: (ws, metadata) => {
|
|
if (!wsServer) {
|
|
throw new Error("WebSocket server not initialized");
|
|
}
|
|
return wsServer.attachExternalSocket(ws, metadata);
|
|
},
|
|
relayEndpoint,
|
|
relayUseTls,
|
|
serverId,
|
|
daemonKeyPair: daemonKeyPair.keyPair,
|
|
});
|
|
}
|
|
};
|
|
|
|
logAndResolve().then(resolve, reject);
|
|
};
|
|
httpServer.once("error", onError);
|
|
httpServer.once("listening", onListening);
|
|
|
|
if (listenTarget.type === "tcp") {
|
|
httpServer.listen(listenTarget.port, listenTarget.host);
|
|
} else {
|
|
if (listenTarget.type === "socket" && existsSync(listenTarget.path)) {
|
|
unlinkSync(listenTarget.path);
|
|
}
|
|
httpServer.listen(listenTarget.path);
|
|
}
|
|
});
|
|
|
|
// Start speech service after listening so synchronous Sherpa native
|
|
// model loading doesn't block the server from accepting connections.
|
|
speechService.start();
|
|
scriptHealthMonitor.start();
|
|
};
|
|
|
|
const stop = async () => {
|
|
scriptHealthMonitor.stop();
|
|
await closeAllAgents(logger, agentManager);
|
|
await agentManager.flush().catch(() => undefined);
|
|
detachAgentStoragePersistence();
|
|
await agentStorage.flush().catch(() => undefined);
|
|
await providerSnapshotManager.shutdown();
|
|
terminalManager.killAll();
|
|
speechService.stop();
|
|
await scheduleService.stop().catch(() => undefined);
|
|
await relayTransport?.stop().catch(() => undefined);
|
|
if (wsServer) {
|
|
await wsServer.close();
|
|
}
|
|
// Force-drop remaining sockets so httpServer.close() resolves promptly.
|
|
// We've already closed wsServer (which sent ws-layer close frames) and
|
|
// stopped every other service, so anything still attached is a TCP
|
|
// socket whose higher-level shutdown hasn't fully released it (e.g.
|
|
// upgraded WS sockets in the closing handshake, or HTTP keep-alive
|
|
// sockets in CLOSE_WAIT). closeIdleConnections() does not catch
|
|
// upgraded sockets, so we use closeAllConnections() here.
|
|
httpServer.closeAllConnections();
|
|
await new Promise<void>((resolve) => {
|
|
httpServer.close(() => resolve());
|
|
});
|
|
// Clean up socket files
|
|
if (listenTarget.type === "socket" && existsSync(listenTarget.path)) {
|
|
unlinkSync(listenTarget.path);
|
|
}
|
|
};
|
|
|
|
return {
|
|
config,
|
|
agentManager,
|
|
agentStorage,
|
|
terminalManager,
|
|
scriptRouteStore,
|
|
scriptRuntimeStore,
|
|
start,
|
|
stop,
|
|
getListenTarget: () => boundListenTarget,
|
|
};
|
|
}
|
|
|
|
async function closeAllAgents(logger: Logger, agentManager: AgentManager): Promise<void> {
|
|
const agents = agentManager.listAgents();
|
|
await Promise.all(
|
|
agents.map(async (agent) => {
|
|
try {
|
|
await agentManager.closeAgent(agent.id);
|
|
} catch (err) {
|
|
logger.error({ err, agentId: agent.id }, "Failed to close agent");
|
|
}
|
|
}),
|
|
);
|
|
}
|