import { execSync } from "node:child_process";
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 express from "express";
import { createReadStream, existsSync, realpathSync, unlinkSync } from "fs";
import { stat } from "fs/promises";
import { createServer as createHTTPServer } from "http";
function resolveBoundListenTarget(listenTarget, httpServer) {
    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,
    };
}
function identifyPortHolder(port) {
    try {
        const cmd = process.platform === "win32"
            ? `netstat -ano | findstr :${port}`
            : `lsof -i :${port} -sTCP:LISTEN -Fn -Fp 2>/dev/null | head -6`;
        const out = execSync(cmd, { timeout: 3000, encoding: "utf-8" }).trim();
        if (!out)
            return null;
        if (process.platform !== "win32") {
            const pidMatch = out.match(/^p(\d+)$/m);
            const nameMatch = out.match(/^n(.+)$/m);
            const pid = pidMatch?.[1];
            const name = nameMatch?.[1];
            if (pid && name)
                return `"${name}" (PID ${pid})`;
            if (pid)
                return `PID ${pid}`;
        }
        return out.split("\n")[0] ?? null;
    }
    catch {
        return null;
    }
}
// Matches a Windows drive-letter path like C:\ or D:\
const WINDOWS_DRIVE_RE = /^[A-Za-z]:\\/;
export function parseListenString(listen) {
    // 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) {
    if (!listenTarget) {
        return null;
    }
    if (listenTarget.type === "tcp") {
        return `${listenTarget.host}:${listenTarget.port}`;
    }
    return listenTarget.path;
}
import { createTerminalManager } from "../terminal/terminal-manager.js";
import { setProjectWorktreeSettingsProvider } from "../utils/worktree.js";
import { AgentManager } from "./agent/agent-manager.js";
import { AgentStorage } from "./agent/agent-storage.js";
import { createAgentMcpServer } from "./agent/mcp-server.js";
import { buildProviderRegistry, createAllClients, shutdownProviders, } from "./agent/provider-registry.js";
import { isHostAllowed } from "./allowed-hosts.js";
import { FileBackedChatService } from "./chat/chat-service.js";
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
import { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js";
import { DaemonConfigStore } from "./daemon-config-store.js";
import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
import { resolveDaemonVersion } from "./daemon-version.js";
import { DownloadTokenStore } from "./file-download/token-store.js";
import { LoopService } from "./loop-service.js";
import { attachAgentStoragePersistence } from "./persistence-hooks.js";
import { startRelayTransport } from "./relay-transport.js";
import { ScheduleService } from "./schedule/service.js";
import { getOrCreateServerId } from "./server-id.js";
import { createSpeechNativeGuard } from "./speech/native-runtime-guard.js";
import { createSpeechService } from "./speech/speech-runtime.js";
import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js";
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
function formatHostForHttpUrl(host) {
    return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
}
function createAgentMcpBaseUrl(listenTarget) {
    if (!listenTarget || listenTarget.type !== "tcp") {
        return null;
    }
    return new URL("/mcp/agents", `http://${formatHostForHttpUrl(listenTarget.host)}:${listenTarget.port}`).toString();
}
export async function createSeaworkDaemon(config, rootLogger) {
    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.seaworkHome, {
        mcp: { injectIntoAgents: config.mcpInjectIntoAgents ?? true },
    }, logger);
    try {
        const serverId = getOrCreateServerId(config.seaworkHome, { logger });
        const daemonKeyPair = await loadOrCreateDaemonKeyPair(config.seaworkHome, logger);
        let relayTransport = 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 = null;
        // 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 (!isHostAllowed(hostHeader, config.allowedHosts)) {
                    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 seawork:// protocol scheme.
            "seawork://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();
        });
        // 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),
            });
        });
        app.get("/api/files/download", async (req, res) => {
            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;
            }
            try {
                const fileStats = await stat(entry.absolutePath);
                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", entry.size.toString());
                const stream = createReadStream(entry.absolutePath);
                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" });
                }
            }
        });
        const httpServer = createHTTPServer(app);
        const agentStorage = new AgentStorage(config.agentStoragePath, logger);
        const projectRegistry = new FileBackedProjectRegistry(path.join(config.seaworkHome, "projects", "projects.json"), logger);
        const workspaceRegistry = new FileBackedWorkspaceRegistry(path.join(config.seaworkHome, "projects", "workspaces.json"), logger);
        const chatService = new FileBackedChatService({
            seaworkHome: config.seaworkHome,
            logger,
        });
        const agentManager = new AgentManager({
            clients: {
                ...createAllClients(logger, {
                    runtimeSettings: config.agentProviderSettings,
                }),
                ...config.agentClients,
            },
            registry: agentStorage,
            logger,
        });
        const providerRegistry = buildProviderRegistry(logger, {
            runtimeSettings: config.agentProviderSettings,
        });
        const terminalManager = createTerminalManager();
        const detachAgentStoragePersistence = attachAgentStoragePersistence(logger, agentManager, agentStorage);
        await agentStorage.initialize();
        logger.info({ elapsed: elapsed() }, "Agent storage initialized");
        await bootstrapWorkspaceRegistries({
            seaworkHome: config.seaworkHome,
            agentStorage,
            projectRegistry,
            workspaceRegistry,
            logger,
        });
        logger.info({ elapsed: elapsed() }, "Workspace registries bootstrapped");
        // Compare project rootPaths via realpath so a project recorded under one
        // path still matches when accessed via a symlinked checkout (or vice
        // versa). Persisted rootPath stays as the original user-visible path —
        // we only canonicalize for the lookup here.
        const canonicalize = (input) => {
            try {
                return realpathSync(input);
            }
            catch {
                return path.resolve(input);
            }
        };
        setProjectWorktreeSettingsProvider({
            async getCustomParentDir(repoRoot) {
                const canonicalQuery = canonicalize(repoRoot);
                const records = await projectRegistry.list();
                const match = records.find((record) => {
                    if (record.archivedAt || record.kind !== "git") {
                        return false;
                    }
                    if (record.rootPath === repoRoot) {
                        return true;
                    }
                    return canonicalize(record.rootPath) === canonicalQuery;
                });
                return match?.worktreeParentDir ?? null;
            },
        });
        await chatService.initialize();
        logger.info({ elapsed: elapsed() }, "Chat service initialized");
        const checkoutDiffManager = new CheckoutDiffManager({
            logger,
            seaworkHome: config.seaworkHome,
        });
        const loopService = new LoopService({
            seaworkHome: config.seaworkHome,
            logger,
            agentManager,
        });
        await loopService.initialize();
        logger.info({ elapsed: elapsed() }, "Loop service initialized");
        const scheduleService = new ScheduleService({
            seaworkHome: config.seaworkHome,
            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");
        let wsServer = null;
        const mcpEnabled = config.mcpEnabled ?? true;
        let agentMcpBaseUrl = null;
        if (mcpEnabled) {
            const agentMcpRoute = "/mcp/agents";
            const agentMcpTransports = new Map();
            const createAgentMcpTransport = async (callerAgentId) => {
                const agentMcpServer = await createAgentMcpServer({
                    agentManager,
                    agentStorage,
                    terminalManager,
                    scheduleService,
                    providerRegistry,
                    seaworkHome: config.seaworkHome,
                    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,
                });
                transport.onclose = () => {
                    if (transport.sessionId) {
                        agentMcpTransports.delete(transport.sessionId);
                    }
                };
                transport.onerror = (err) => {
                    logger.error({ err }, "Agent MCP transport error");
                };
                await agentMcpServer.connect(transport);
                return transport;
            };
            const handleAgentMcpRequest = async (req, res) => {
                if (config.mcpDebug) {
                    logger.debug({
                        method: req.method,
                        url: req.originalUrl,
                        sessionId: req.header("mcp-session-id"),
                        authorization: req.header("authorization"),
                        body: 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;
                        const callerAgentId = typeof callerAgentIdRaw === "string"
                            ? callerAgentIdRaw
                            : Array.isArray(callerAgentIdRaw) && typeof callerAgentIdRaw[0] === "string"
                                ? callerAgentIdRaw[0]
                                : undefined;
                        transport = await createAgentMcpTransport(callerAgentId);
                    }
                    await transport.handleRequest(req, res, 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,
                        });
                    }
                }
            };
            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 speechNativeGuard = createSpeechNativeGuard({
            seaworkHome: config.seaworkHome,
            logger,
        });
        const speechService = createSpeechService({
            logger,
            openaiConfig: config.openai,
            speechConfig: config.speech,
            nativeGuard: speechNativeGuard,
        });
        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((resolve, reject) => {
                const onError = (err) => {
                    httpServer.off("listening", onListening);
                    if (err.code === "EADDRINUSE" && listenTarget.type === "tcp") {
                        const detail = identifyPortHolder(listenTarget.port);
                        const hint = detail
                            ? `Port ${listenTarget.port} is already in use by ${detail}. Stop that process or set SEAWORK_LISTEN to use a different port.`
                            : `Port ${listenTarget.port} is already in use. Stop the process occupying it or set SEAWORK_LISTEN to use a different port.`;
                        reject(new Error(hint));
                    }
                    else {
                        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);
                        });
                        const relayEnabled = config.relayEnabled ?? true;
                        const relayEndpoint = config.relayEndpoint ?? "relay.seawork.ai:443";
                        const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint;
                        const appBaseUrl = config.appBaseUrl ?? "https://app.seawork.ai";
                        if (boundListenTarget.type === "tcp") {
                            logger.info({
                                host: boundListenTarget.host,
                                port: boundListenTarget.port,
                                elapsed: elapsed(),
                            }, `Server listening on http://${boundListenTarget.host}:${boundListenTarget.port}`);
                        }
                        else {
                            logger.info({ path: boundListenTarget.path, elapsed: elapsed() }, `Server listening on ${boundListenTarget.path}`);
                        }
                        wsServer = new VoiceAssistantWebSocketServer(httpServer, logger, serverId, agentManager, agentStorage, downloadTokenStore, config.seaworkHome, daemonConfigStore, mcpBaseUrl, { allowedOrigins, allowedHosts: config.allowedHosts }, speechService, terminalManager, {
                            finalTimeoutMs: config.dictationFinalTimeoutMs,
                        }, config.agentProviderSettings, 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);
                        if (typeof process.send === "function" && process.env.SEAWORK_SUPERVISED === "1") {
                            process.send({
                                type: "seawork:ready",
                                listen: boundListenTarget.type === "tcp"
                                    ? `${boundListenTarget.host}:${boundListenTarget.port}`
                                    : boundListenTarget.path,
                            });
                        }
                        if (relayEnabled) {
                            const offer = await createConnectionOfferV2({
                                serverId,
                                daemonPublicKeyB64: daemonKeyPair.publicKeyB64,
                                relay: { endpoint: relayPublicEndpoint },
                            });
                            const url = encodeOfferToFragmentUrl({ offer, appBaseUrl });
                            logger.info({ url }, "pairing_offer");
                            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,
                                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();
        };
        const stop = async () => {
            await closeAllAgents(logger, agentManager);
            await agentManager.flush().catch(() => undefined);
            detachAgentStoragePersistence();
            await agentStorage.flush().catch(() => undefined);
            await shutdownProviders(logger, {
                runtimeSettings: config.agentProviderSettings,
            });
            terminalManager.killAll();
            speechService.stop();
            await scheduleService.stop().catch(() => undefined);
            await relayTransport?.stop().catch(() => undefined);
            if (wsServer) {
                await wsServer.close();
            }
            await new Promise((resolve) => {
                httpServer.close(() => resolve());
            });
            // Clean up socket files
            if (listenTarget.type === "socket" && existsSync(listenTarget.path)) {
                unlinkSync(listenTarget.path);
            }
        };
        return {
            config,
            agentManager,
            agentStorage,
            terminalManager,
            start,
            stop,
            getListenTarget: () => boundListenTarget,
        };
    }
    catch (err) {
        throw err;
    }
}
async function closeAllAgents(logger, agentManager) {
    const agents = agentManager.listAgents();
    for (const agent of agents) {
        try {
            await agentManager.closeAgent(agent.id);
        }
        catch (err) {
            logger.error({ err, agentId: agent.id }, "Failed to close agent");
        }
    }
}
//# sourceMappingURL=bootstrap.js.map