import { WebSocketServer } from "ws";
import { basename, join } from "path";
import { hostname as getHostname } from "node:os";
import { WSInboundMessageSchema, wrapSessionMessage, } from "./messages.js";
import { asUint8Array, decodeTerminalStreamFrame } from "@kong-link/protocol/binary-frames/index";
import { isHostnameAllowed } from "./hostnames.js";
import { Session } from "./session.js";
import { buildWorkspaceGitMetadataFromSnapshot } from "./workspace-git-metadata.js";
import { PushTokenStore } from "./push/token-store.js";
import { createPushNotificationSender } from "./push/notifications.js";
import { computeNotificationPlan } from "./agent-attention-policy.js";
import { buildAgentAttentionNotificationPayload, findLatestPermissionRequest, } from "@kong-link/protocol/agent-attention-notification";
import { createGitHubService } from "../services/github-service.js";
import { extractWsBearerProtocol, extractWsBearerToken, isBearerTokenValid, } from "./auth.js";
import { WebSocketRuntimeMetricsWindow, } from "./websocket/runtime-metrics.js";
const WS_CLOSE_DAEMON_AUTH_FAILED = 4401;
function createFallbackWorkspaceGitSnapshot(cwd) {
    return {
        cwd,
        git: {
            isGit: false,
            repoRoot: null,
            mainRepoRoot: null,
            currentBranch: null,
            remoteUrl: null,
            isPaseoOwnedWorktree: false,
            isDirty: null,
            baseRef: null,
            aheadBehind: null,
            aheadOfOrigin: null,
            behindOfOrigin: null,
            hasRemote: false,
            diffStat: null,
        },
        github: {
            featuresEnabled: false,
            pullRequest: null,
            error: null,
        },
    };
}
function createFallbackWorkspaceGitService() {
    return {
        registerWorkspace: () => ({
            unsubscribe: () => { },
        }),
        onSnapshotUpdated: () => ({
            unsubscribe: () => { },
        }),
        peekSnapshot: () => null,
        getCheckout: async (cwd) => ({
            cwd,
            isGit: false,
            currentBranch: null,
            remoteUrl: null,
            worktreeRoot: null,
            isPaseoOwnedWorktree: false,
            mainRepoRoot: null,
        }),
        getSnapshot: async (cwd) => createFallbackWorkspaceGitSnapshot(cwd),
        getCheckoutDiff: async () => ({ diff: "" }),
        validateBranchRef: async () => ({ kind: "not-found" }),
        hasLocalBranch: async () => false,
        suggestBranchesForCwd: async () => [],
        listStashes: async () => [],
        listWorktrees: async () => [],
        getWorkspaceGitMetadata: async (cwd, options) => {
            const snapshot = createFallbackWorkspaceGitSnapshot(cwd);
            return buildWorkspaceGitMetadataFromSnapshot({
                cwd,
                directoryName: options?.directoryName ?? basename(cwd),
                isGit: snapshot.git.isGit,
                repoRoot: snapshot.git.repoRoot,
                mainRepoRoot: snapshot.git.mainRepoRoot,
                currentBranch: snapshot.git.currentBranch,
                remoteUrl: snapshot.git.remoteUrl,
            });
        },
        resolveRepoRoot: async (cwd) => cwd,
        resolveDefaultBranch: async () => "main",
        resolveRepoRemoteUrl: async () => null,
        refresh: async () => { },
        requestWorkingTreeWatch: async () => ({
            repoRoot: null,
            unsubscribe: () => { },
        }),
        scheduleRefreshForCwd: () => { },
        dispose: () => { },
    };
}
function createNoopProjectRegistry() {
    return {
        initialize: async () => { },
        existsOnDisk: async () => true,
        list: async () => [],
        get: async () => null,
        upsert: async () => { },
        archive: async () => { },
        remove: async () => { },
    };
}
function createNoopWorkspaceRegistry() {
    return {
        initialize: async () => { },
        existsOnDisk: async () => true,
        list: async () => [],
        get: async () => null,
        upsert: async () => { },
        archive: async () => { },
        remove: async () => { },
    };
}
function toServerCapabilityState(params) {
    const { state, reason } = params;
    return {
        enabled: state.enabled,
        reason,
    };
}
function resolveCapabilityReason(params) {
    const { state, readiness } = params;
    if (state.available) {
        return "";
    }
    if (readiness.voiceFeature.reasonCode === "model_download_in_progress") {
        const baseMessage = readiness.voiceFeature.message.trim();
        if (baseMessage.includes("Try again in a few minutes")) {
            return baseMessage;
        }
        return `${baseMessage} Try again in a few minutes.`;
    }
    return state.message;
}
function buildServerCapabilities(params) {
    const readiness = params.readiness;
    if (!readiness) {
        return undefined;
    }
    return {
        voice: {
            dictation: toServerCapabilityState({
                state: readiness.dictation,
                reason: resolveCapabilityReason({
                    state: readiness.dictation,
                    readiness,
                }),
            }),
            voice: toServerCapabilityState({
                state: readiness.realtimeVoice,
                reason: resolveCapabilityReason({
                    state: readiness.realtimeVoice,
                    readiness,
                }),
            }),
        },
    };
}
function areServerCapabilitiesEqual(current, next) {
    return JSON.stringify(current ?? null) === JSON.stringify(next ?? null);
}
function bufferFromWsData(data) {
    if (typeof data === "string")
        return Buffer.from(data, "utf8");
    if (Array.isArray(data)) {
        return Buffer.concat(data.map((item) => (Buffer.isBuffer(item) ? item : Buffer.from(item))));
    }
    if (Buffer.isBuffer(data))
        return data;
    return Buffer.from(data);
}
const SLOW_REQUEST_THRESHOLD_MS = 500;
const EXTERNAL_SESSION_DISCONNECT_GRACE_MS = 90000;
const HELLO_TIMEOUT_MS = 15000;
const WS_CLOSE_HELLO_TIMEOUT = 4001;
const WS_CLOSE_INVALID_HELLO = 4002;
const WS_CLOSE_INCOMPATIBLE_PROTOCOL = 4003;
const WS_PROTOCOL_VERSION = 1;
const WS_RUNTIME_METRICS_FLUSH_MS = 30000;
export class MissingDaemonVersionError extends Error {
    constructor() {
        super("VoiceAssistantWebSocketServer requires a non-empty daemonVersion.");
        this.name = "MissingDaemonVersionError";
    }
}
function requireWebSocketServices(params) {
    const { chatService, loopService, scheduleService, checkoutDiffManager } = params;
    if (!chatService) {
        throw new Error("VoiceAssistantWebSocketServer requires a chat service.");
    }
    if (!loopService) {
        throw new Error("VoiceAssistantWebSocketServer requires a loop service.");
    }
    if (!scheduleService) {
        throw new Error("VoiceAssistantWebSocketServer requires a schedule service.");
    }
    if (!checkoutDiffManager) {
        throw new Error("VoiceAssistantWebSocketServer requires a checkout diff manager.");
    }
    return { chatService, loopService, scheduleService, checkoutDiffManager };
}
/**
 * WebSocket server that only accepts sockets + parses/forwards messages to the session layer.
 */
export class VoiceAssistantWebSocketServer {
    constructor(server, logger, serverId, agentManager, agentStorage, downloadTokenStore, paseoHome, daemonConfigStore, mcpBaseUrl, wsConfig, auth, speech, terminalManager, dictation, daemonVersion, onLifecycleIntent, projectRegistry, workspaceRegistry, chatService, loopService, scheduleService, checkoutDiffManager, serviceProxy, scriptRuntimeStore, onBranchChanged, getDaemonTcpPort, getDaemonTcpHost, resolveScriptHealth, workspaceGitService, github, pushNotificationSender, providerSnapshotManager, daemonRuntimeConfig, serviceProxyPublicBaseUrl) {
        this.pendingConnections = new Map();
        this.sessions = new Map();
        this.externalSessionsByKey = new Map();
        this.voiceSpeakHandlers = new Map();
        this.voiceCallerContexts = new Map();
        this.workspaceSetupSnapshots = new Map();
        this.runtimeMetrics = new WebSocketRuntimeMetricsWindow();
        this.runtimeMetricsInterval = null;
        this.unsubscribeSpeechReadiness = null;
        this.unsubscribeDaemonConfigChange = null;
        this.logger = logger.child({ module: "websocket-server" });
        this.serverId = serverId;
        if (typeof daemonVersion !== "string" || daemonVersion.trim().length === 0) {
            throw new MissingDaemonVersionError();
        }
        this.daemonVersion = daemonVersion.trim();
        this.daemonRuntimeConfig = daemonRuntimeConfig;
        this.agentManager = agentManager;
        this.agentStorage = agentStorage;
        this.projectRegistry = projectRegistry ?? createNoopProjectRegistry();
        this.workspaceRegistry = workspaceRegistry ?? createNoopWorkspaceRegistry();
        const requiredServices = requireWebSocketServices({
            chatService,
            loopService,
            scheduleService,
            checkoutDiffManager,
        });
        this.chatService = requiredServices.chatService;
        this.loopService = requiredServices.loopService;
        this.scheduleService = requiredServices.scheduleService;
        this.checkoutDiffManager = requiredServices.checkoutDiffManager;
        this.github = github ?? createGitHubService();
        this.workspaceGitService = workspaceGitService ?? createFallbackWorkspaceGitService();
        this.downloadTokenStore = downloadTokenStore;
        this.paseoHome = paseoHome;
        this.worktreesRoot = daemonRuntimeConfig?.worktreesRoot;
        this.daemonConfigStore = daemonConfigStore;
        this.mcpBaseUrl = mcpBaseUrl;
        this.assignOptionalServices({
            speech,
            terminalManager,
            dictation,
            onLifecycleIntent,
            serviceProxy,
            scriptRuntimeStore,
            onBranchChanged,
            getDaemonTcpPort,
            getDaemonTcpHost,
            serviceProxyPublicBaseUrl,
            resolveScriptHealth,
        });
        if (!providerSnapshotManager) {
            throw new Error("providerSnapshotManager is required");
        }
        this.providerSnapshotManager = providerSnapshotManager;
        this.serverCapabilities = buildServerCapabilities({
            readiness: this.speech?.getReadiness() ?? null,
        });
        this.unsubscribeSpeechReadiness =
            this.speech?.onReadinessChange((snapshot) => {
                this.publishSpeechReadiness(snapshot);
            }) ?? null;
        this.unsubscribeDaemonConfigChange = this.daemonConfigStore.onChange((config) => {
            const nextAgentManagerState = this.providerSnapshotManager.applyMutableProviderConfig(config.providers);
            this.agentManager.updateProviderRegistry(nextAgentManagerState);
            this.broadcastDaemonConfigChanged(config);
        });
        const pushLogger = this.logger.child({ module: "push" });
        this.pushTokenStore = new PushTokenStore(pushLogger, join(paseoHome, "push-tokens.json"));
        this.pushNotificationSender =
            pushNotificationSender ?? createPushNotificationSender(pushLogger, this.pushTokenStore);
        this.agentManager.setAgentAttentionCallback((params) => {
            void this.broadcastAgentAttention(params).catch((err) => {
                this.logger.warn({ err, agentId: params.agentId }, "Failed to broadcast agent attention");
            });
        });
        this.wss = this.createWebSocketServer(server, wsConfig, auth);
        this.startRuntimeMetricsInterval();
        this.logger.info("WebSocket server initialized on /ws");
    }
    assignOptionalServices(params) {
        this.speech = params.speech ?? null;
        this.terminalManager = params.terminalManager ?? null;
        this.dictation = params.dictation ?? null;
        this.onLifecycleIntent = params.onLifecycleIntent ?? null;
        this.serviceProxy = params.serviceProxy ?? null;
        this.scriptRuntimeStore = params.scriptRuntimeStore ?? null;
        this.onBranchChanged = params.onBranchChanged ?? null;
        this.getDaemonTcpPort = params.getDaemonTcpPort ?? null;
        this.getDaemonTcpHost = params.getDaemonTcpHost ?? null;
        this.serviceProxyPublicBaseUrl = params.serviceProxyPublicBaseUrl ?? null;
        this.resolveScriptHealth = params.resolveScriptHealth ?? null;
    }
    createWebSocketServer(server, wsConfig, auth) {
        const { allowedOrigins, hostnames } = wsConfig;
        const password = auth?.password;
        const wss = new WebSocketServer({
            server,
            path: "/ws",
            handleProtocols: (protocols) => selectWebSocketProtocol(protocols, password),
            verifyClient: ({ req }, callback) => {
                this.verifyWsUpgrade(req, allowedOrigins, hostnames, callback);
            },
        });
        wss.on("connection", (ws, request) => {
            void this.attachAuthenticatedSocket(ws, request, password);
        });
        return wss;
    }
    startRuntimeMetricsInterval() {
        const runtimeMetricsInterval = setInterval(() => {
            this.flushRuntimeMetrics();
        }, WS_RUNTIME_METRICS_FLUSH_MS);
        this.runtimeMetricsInterval = runtimeMetricsInterval;
        runtimeMetricsInterval.unref?.();
    }
    verifyWsUpgrade(req, allowedOrigins, hostnames, callback) {
        const requestMetadata = extractSocketRequestMetadata(req);
        const origin = requestMetadata.origin;
        const requestHost = requestMetadata.host ?? null;
        if (requestHost && !isHostnameAllowed(requestHost, hostnames)) {
            this.incrementRuntimeCounter("hostRejected");
            this.logger.warn({ ...requestMetadata, host: requestHost }, "Rejected connection from disallowed host");
            callback(false, 403, "Host not allowed");
            return;
        }
        const sameOrigin = !!origin &&
            !!requestHost &&
            (origin === `http://${requestHost}` || origin === `https://${requestHost}`);
        if (!origin || allowedOrigins.has("*") || allowedOrigins.has(origin) || sameOrigin) {
            callback(true);
        }
        else {
            this.incrementRuntimeCounter("originRejected");
            this.logger.warn({ ...requestMetadata, origin }, "Rejected connection from origin");
            callback(false, 403, "Origin not allowed");
        }
    }
    async attachAuthenticatedSocket(ws, request, password) {
        if (password) {
            const requestMetadata = extractSocketRequestMetadata(request);
            const protocol = extractWsBearerProtocol(request.headers["sec-websocket-protocol"]);
            const token = extractWsBearerToken(protocol);
            const isAuthorized = isBearerTokenValid({ password, token });
            if (!isAuthorized) {
                const reason = token === null ? "Password required" : "Incorrect password";
                this.logger.warn({ ...requestMetadata, hasToken: token !== null }, "Rejected WebSocket connection with invalid daemon password");
                ws.close(WS_CLOSE_DAEMON_AUTH_FAILED, reason);
                return;
            }
        }
        await this.attachSocket(ws, request);
    }
    broadcast(message) {
        const payload = JSON.stringify(message);
        for (const ws of this.sessions.keys()) {
            // WebSocket.OPEN = 1
            if (ws.readyState === 1) {
                ws.send(payload);
                this.runtimeMetrics.recordOutboundMessage(message, ws.bufferedAmount);
            }
        }
    }
    listActiveSessions() {
        return Array.from(new Set([...this.sessions.values(), ...this.externalSessionsByKey.values()].map((connection) => connection.session)));
    }
    publishSpeechReadiness(readiness) {
        this.updateServerCapabilities(buildServerCapabilities({ readiness }));
    }
    updateServerCapabilities(capabilities) {
        const next = capabilities ?? undefined;
        if (areServerCapabilitiesEqual(this.serverCapabilities, next)) {
            return;
        }
        this.serverCapabilities = next;
        this.broadcastCapabilitiesUpdate();
    }
    async attachExternalSocket(ws, metadata) {
        if (metadata?.transport === "relay") {
            this.incrementRuntimeCounter("relayExternalSocketAttached");
        }
        await this.attachSocket(ws, undefined, metadata);
    }
    async close() {
        this.unsubscribeSpeechReadiness?.();
        this.unsubscribeSpeechReadiness = null;
        this.unsubscribeDaemonConfigChange?.();
        this.unsubscribeDaemonConfigChange = null;
        if (this.runtimeMetricsInterval) {
            clearInterval(this.runtimeMetricsInterval);
            this.runtimeMetricsInterval = null;
        }
        this.flushRuntimeMetrics({ final: true });
        const uniqueConnections = new Set([
            ...this.sessions.values(),
            ...this.externalSessionsByKey.values(),
        ]);
        const pendingSockets = new Set(this.pendingConnections.keys());
        for (const pending of this.pendingConnections.values()) {
            if (pending.helloTimeout) {
                clearTimeout(pending.helloTimeout);
                pending.helloTimeout = null;
            }
        }
        const cleanupPromises = [];
        for (const connection of uniqueConnections) {
            if (connection.externalDisconnectCleanupTimeout) {
                clearTimeout(connection.externalDisconnectCleanupTimeout);
                connection.externalDisconnectCleanupTimeout = null;
            }
            cleanupPromises.push(connection.session.cleanup());
            for (const ws of connection.sockets) {
                cleanupPromises.push(new Promise((resolve) => {
                    // WebSocket.CLOSED = 3
                    if (ws.readyState === 3) {
                        resolve();
                        return;
                    }
                    ws.once("close", () => resolve());
                    ws.close();
                }));
            }
        }
        for (const ws of pendingSockets) {
            cleanupPromises.push(new Promise((resolve) => {
                if (ws.readyState === 3) {
                    resolve();
                    return;
                }
                ws.once("close", () => resolve());
                ws.close();
            }));
        }
        await Promise.all(cleanupPromises);
        this.providerSnapshotManager.destroy();
        this.checkoutDiffManager.dispose();
        this.workspaceGitService.dispose();
        this.pendingConnections.clear();
        this.sessions.clear();
        this.externalSessionsByKey.clear();
        this.wss.close();
    }
    sendToClient(ws, message) {
        // WebSocket.OPEN = 1. The check is a fast path; the socket can still
        // transition to closed between here and ws.send(), so guard the send too —
        // a synchronous throw here would propagate as an uncaughtException.
        if (ws.readyState !== 1) {
            return;
        }
        try {
            ws.send(JSON.stringify(message));
            this.runtimeMetrics.recordOutboundMessage(message, ws.bufferedAmount);
        }
        catch (err) {
            this.logger.warn({ err }, "ws_send_failed");
        }
    }
    sendBinaryToClient(ws, frame) {
        if (ws.readyState !== 1) {
            return;
        }
        try {
            ws.send(frame);
            this.runtimeMetrics.recordOutboundBinaryFrame(ws.bufferedAmount);
        }
        catch (err) {
            this.logger.warn({ err }, "ws_send_binary_failed");
        }
    }
    sendToConnection(connection, message) {
        for (const ws of connection.sockets) {
            this.sendToClient(ws, message);
        }
    }
    sendBinaryToConnection(connection, frame) {
        for (const ws of connection.sockets) {
            this.sendBinaryToClient(ws, frame);
        }
    }
    async attachSocket(ws, request, metadata) {
        const requestMetadata = extractSocketRequestMetadata(request);
        const connectionLoggerFields = {
            transport: metadata?.transport === "relay" ? "relay" : "direct",
        };
        if (requestMetadata.host) {
            connectionLoggerFields.host = requestMetadata.host;
        }
        if (requestMetadata.origin) {
            connectionLoggerFields.origin = requestMetadata.origin;
        }
        if (requestMetadata.userAgent) {
            connectionLoggerFields.userAgent = requestMetadata.userAgent;
        }
        if (requestMetadata.remoteAddress) {
            connectionLoggerFields.remoteAddress = requestMetadata.remoteAddress;
        }
        const connectionLogger = this.logger.child(connectionLoggerFields);
        const pending = {
            connectionLogger,
            helloTimeout: null,
        };
        const timeout = setTimeout(() => {
            if (this.pendingConnections.get(ws) !== pending) {
                return;
            }
            pending.helloTimeout = null;
            this.pendingConnections.delete(ws);
            pending.connectionLogger.warn({ timeoutMs: HELLO_TIMEOUT_MS }, "Closing connection due to missing hello");
            try {
                ws.close(WS_CLOSE_HELLO_TIMEOUT, "Hello timeout");
            }
            catch {
                // ignore close errors
            }
        }, HELLO_TIMEOUT_MS);
        pending.helloTimeout = timeout;
        timeout.unref?.();
        this.pendingConnections.set(ws, pending);
        this.incrementRuntimeCounter("connectedAwaitingHello");
        this.bindSocketHandlers(ws);
        pending.connectionLogger.trace({
            totalPendingConnections: this.pendingConnections.size,
        }, "Client connected; awaiting hello");
    }
    createSessionConnection(params) {
        const { ws, clientId, appVersion, clientCapabilities, connectionLogger } = params;
        let connection = null;
        const session = new Session({
            clientId,
            appVersion,
            clientCapabilities,
            onMessage: (msg) => {
                if (!connection) {
                    return;
                }
                this.sendToConnection(connection, wrapSessionMessage(msg));
            },
            onBinaryMessage: (frame) => {
                if (!connection) {
                    return;
                }
                this.sendBinaryToConnection(connection, frame);
            },
            onLifecycleIntent: (intent) => {
                this.onLifecycleIntent?.(intent);
            },
            logger: connectionLogger.child({ module: "session" }),
            downloadTokenStore: this.downloadTokenStore,
            pushTokenStore: this.pushTokenStore,
            paseoHome: this.paseoHome,
            worktreesRoot: this.worktreesRoot,
            agentManager: this.agentManager,
            agentStorage: this.agentStorage,
            projectRegistry: this.projectRegistry,
            workspaceRegistry: this.workspaceRegistry,
            chatService: this.chatService,
            loopService: this.loopService,
            scheduleService: this.scheduleService,
            checkoutDiffManager: this.checkoutDiffManager,
            github: this.github,
            workspaceGitService: this.workspaceGitService,
            daemonConfigStore: this.daemonConfigStore,
            mcpBaseUrl: this.mcpBaseUrl,
            stt: () => this.speech?.resolveStt() ?? null,
            sttLanguage: this.speech?.resolveSttLanguage() ?? "en",
            tts: () => this.speech?.resolveTts() ?? null,
            terminalManager: this.terminalManager,
            providerSnapshotManager: this.providerSnapshotManager,
            serviceProxy: this.serviceProxy ?? undefined,
            scriptRuntimeStore: this.scriptRuntimeStore ?? undefined,
            workspaceSetupSnapshots: this.workspaceSetupSnapshots,
            onBranchChanged: this.onBranchChanged ?? undefined,
            getDaemonTcpPort: this.getDaemonTcpPort ?? undefined,
            getDaemonTcpHost: this.getDaemonTcpHost ?? undefined,
            serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
            resolveScriptHealth: this.resolveScriptHealth ?? undefined,
            voice: {
                turnDetection: () => this.speech?.resolveTurnDetection() ?? null,
            },
            voiceBridge: {
                registerVoiceSpeakHandler: (agentId, handler) => {
                    this.voiceSpeakHandlers.set(agentId, handler);
                },
                unregisterVoiceSpeakHandler: (agentId) => {
                    this.voiceSpeakHandlers.delete(agentId);
                },
                registerVoiceCallerContext: (agentId, context) => {
                    this.voiceCallerContexts.set(agentId, context);
                },
                unregisterVoiceCallerContext: (agentId) => {
                    this.voiceCallerContexts.delete(agentId);
                },
            },
            dictation: this.dictation || this.speech
                ? {
                    finalTimeoutMs: this.dictation?.finalTimeoutMs,
                    stt: () => this.speech?.resolveDictationStt() ?? null,
                    sttLanguage: this.speech?.resolveDictationSttLanguage() ?? "en",
                    getSpeechReadiness: () => this.speech.getReadiness(),
                }
                : undefined,
            serverId: this.serverId,
            daemonVersion: this.daemonVersion,
            daemonRuntimeConfig: this.daemonRuntimeConfig,
        });
        connection = {
            session,
            clientId,
            appVersion,
            clientCapabilities,
            connectionLogger,
            sockets: new Set([ws]),
            externalDisconnectCleanupTimeout: null,
        };
        return connection;
    }
    clearPendingConnection(ws) {
        const pending = this.pendingConnections.get(ws);
        if (!pending) {
            return null;
        }
        if (pending.helloTimeout) {
            clearTimeout(pending.helloTimeout);
            pending.helloTimeout = null;
        }
        this.pendingConnections.delete(ws);
        return pending;
    }
    handleHello(params) {
        const { ws, message, pending } = params;
        if (message.protocolVersion !== WS_PROTOCOL_VERSION) {
            this.clearPendingConnection(ws);
            pending.connectionLogger.warn({
                receivedProtocolVersion: message.protocolVersion,
                expectedProtocolVersion: WS_PROTOCOL_VERSION,
            }, "Rejected hello due to protocol version mismatch");
            try {
                ws.close(WS_CLOSE_INCOMPATIBLE_PROTOCOL, "Incompatible protocol version");
            }
            catch {
                // ignore close errors
            }
            return;
        }
        const clientId = message.clientId.trim();
        if (clientId.length === 0) {
            this.clearPendingConnection(ws);
            pending.connectionLogger.warn("Rejected hello with empty clientId");
            try {
                ws.close(WS_CLOSE_INVALID_HELLO, "Invalid hello");
            }
            catch {
                // ignore close errors
            }
            return;
        }
        this.clearPendingConnection(ws);
        const existing = this.externalSessionsByKey.get(clientId);
        if (existing) {
            this.incrementRuntimeCounter("helloResumed");
            if (existing.externalDisconnectCleanupTimeout) {
                clearTimeout(existing.externalDisconnectCleanupTimeout);
                existing.externalDisconnectCleanupTimeout = null;
            }
            const newAppVersion = message.appVersion ?? null;
            if (newAppVersion && newAppVersion !== existing.appVersion) {
                existing.appVersion = newAppVersion;
                existing.session.updateAppVersion(newAppVersion);
            }
            const newClientCapabilities = message.capabilities ?? null;
            if (JSON.stringify(existing.clientCapabilities ?? null) !==
                JSON.stringify(newClientCapabilities ?? null)) {
                existing.clientCapabilities = newClientCapabilities;
                existing.session.updateClientCapabilities(newClientCapabilities);
            }
            existing.sockets.add(ws);
            this.sessions.set(ws, existing);
            this.sendToClient(ws, this.createServerInfoMessage());
            existing.connectionLogger.trace({
                clientId,
                resumed: true,
                totalSessions: this.sessions.size,
            }, "Client connected via hello");
            return;
        }
        const connectionLogger = pending.connectionLogger.child({ clientId });
        this.incrementRuntimeCounter("helloNew");
        const connection = this.createSessionConnection({
            ws,
            clientId,
            appVersion: message.appVersion ?? null,
            clientCapabilities: message.capabilities ?? null,
            connectionLogger,
        });
        this.sessions.set(ws, connection);
        this.externalSessionsByKey.set(clientId, connection);
        this.sendToClient(ws, this.createServerInfoMessage());
        connection.connectionLogger.trace({
            clientId,
            resumed: false,
            totalSessions: this.sessions.size,
        }, "Client connected via hello");
    }
    buildServerInfoStatusPayload() {
        return {
            status: "server_info",
            serverId: this.serverId,
            hostname: getHostname(),
            version: this.daemonVersion,
            ...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}),
            features: {
                // COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow.
                providersSnapshot: true,
                // COMPAT(checkoutGithubSetAutoMerge): added in v0.1.75, remove gate after 2026-11-13.
                checkoutGithubSetAutoMerge: true,
                // COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18.
                daemonStatusRpc: true,
                // COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
                "terminal-restore-modes": true,
                // COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X.
                rewind: true,
                // COMPAT(checkoutRefresh): added in v0.1.86, remove gate after 2026-11-29.
                checkoutRefresh: true,
                // COMPAT(checkoutStageUnstage): added in v0.1.91, remove gate after 2026-12-06.
                checkoutStageUnstage: true,
            },
        };
    }
    createServerInfoMessage() {
        return {
            type: "session",
            message: {
                type: "status",
                payload: this.buildServerInfoStatusPayload(),
            },
        };
    }
    createDaemonConfigChangedMessage(config) {
        return wrapSessionMessage({
            type: "status",
            payload: {
                status: "daemon_config_changed",
                config,
            },
        });
    }
    broadcastCapabilitiesUpdate() {
        this.broadcast(this.createServerInfoMessage());
    }
    broadcastDaemonConfigChanged(config) {
        this.broadcast(this.createDaemonConfigChangedMessage(config));
    }
    bindSocketHandlers(ws) {
        ws.on("message", (...args) => {
            const data = args[0];
            void this.handleRawMessage(ws, data);
        });
        ws.on("close", async (...args) => {
            const code = args[0];
            const reason = args[1];
            await this.detachSocket(ws, {
                code: typeof code === "number" ? code : undefined,
                reason,
            });
        });
        ws.on("error", async (...args) => {
            const error = args[0];
            const err = error instanceof Error ? error : new Error(String(error));
            const active = this.sessions.get(ws);
            const pending = this.pendingConnections.get(ws);
            const log = active?.connectionLogger ?? pending?.connectionLogger ?? this.logger;
            log.error({ err }, "Client error");
            await this.detachSocket(ws, { error: err });
        });
    }
    resolveVoiceSpeakHandler(callerAgentId) {
        return this.voiceSpeakHandlers.get(callerAgentId) ?? null;
    }
    resolveVoiceCallerContext(callerAgentId) {
        return this.voiceCallerContexts.get(callerAgentId) ?? null;
    }
    async detachSocket(ws, details) {
        const pending = this.clearPendingConnection(ws);
        if (pending) {
            this.incrementRuntimeCounter("pendingDisconnected");
            pending.connectionLogger.trace({
                code: details.code,
                reason: stringifyCloseReason(details.reason),
            }, "Pending client disconnected");
            return;
        }
        const connection = this.sessions.get(ws);
        if (!connection) {
            return;
        }
        this.sessions.delete(ws);
        connection.sockets.delete(ws);
        if (connection.sockets.size === 0) {
            this.incrementRuntimeCounter("sessionDisconnectedWaitingReconnect");
            if (connection.externalDisconnectCleanupTimeout) {
                clearTimeout(connection.externalDisconnectCleanupTimeout);
            }
            const timeout = setTimeout(() => {
                if (connection.externalDisconnectCleanupTimeout !== timeout) {
                    return;
                }
                connection.externalDisconnectCleanupTimeout = null;
                void this.cleanupConnection(connection, "Client disconnected (grace timeout)");
            }, EXTERNAL_SESSION_DISCONNECT_GRACE_MS);
            connection.externalDisconnectCleanupTimeout = timeout;
            connection.connectionLogger.trace({
                clientId: connection.clientId,
                code: details.code,
                reason: stringifyCloseReason(details.reason),
                reconnectGraceMs: EXTERNAL_SESSION_DISCONNECT_GRACE_MS,
            }, "Client disconnected; waiting for reconnect");
            return;
        }
        if (connection.sockets.size > 0) {
            this.incrementRuntimeCounter("sessionSocketDisconnectedAttached");
            connection.connectionLogger.trace({
                clientId: connection.clientId,
                remainingSockets: connection.sockets.size,
                code: details.code,
                reason: stringifyCloseReason(details.reason),
            }, "Client socket disconnected; session remains attached");
            return;
        }
        await this.cleanupConnection(connection, "Client disconnected");
    }
    async cleanupConnection(connection, logMessage) {
        this.incrementRuntimeCounter("sessionCleanup");
        if (connection.externalDisconnectCleanupTimeout) {
            clearTimeout(connection.externalDisconnectCleanupTimeout);
            connection.externalDisconnectCleanupTimeout = null;
        }
        for (const socket of connection.sockets) {
            this.sessions.delete(socket);
        }
        connection.sockets.clear();
        const existing = this.externalSessionsByKey.get(connection.clientId);
        if (existing === connection) {
            this.externalSessionsByKey.delete(connection.clientId);
        }
        connection.connectionLogger.trace({ clientId: connection.clientId, totalSessions: this.sessions.size }, logMessage);
        await connection.session.cleanup();
    }
    handleInvalidInboundMessage(args) {
        const { ws, parsed, parsedMessage, pendingConnection, activeConnection, log } = args;
        this.incrementRuntimeCounter("validationFailed");
        if (pendingConnection) {
            pendingConnection.connectionLogger.warn({ error: parsedMessage.error.message }, "Rejected pending message before hello");
            this.clearPendingConnection(ws);
            try {
                ws.close(WS_CLOSE_INVALID_HELLO, "Invalid hello");
            }
            catch {
                // ignore close errors
            }
            return;
        }
        const requestInfo = extractRequestInfoFromUnknownWsInbound(parsed);
        const isUnknownSchema = requestInfo?.requestId != null &&
            typeof parsed === "object" &&
            parsed != null &&
            "type" in parsed &&
            parsed.type === "session";
        log.warn({
            clientId: activeConnection?.clientId,
            requestId: requestInfo?.requestId,
            requestType: requestInfo?.requestType,
            error: parsedMessage.error.message,
        }, "WS inbound message validation failed");
        if (requestInfo) {
            this.sendToClient(ws, wrapSessionMessage({
                type: "rpc_error",
                payload: {
                    requestId: requestInfo.requestId,
                    requestType: requestInfo.requestType,
                    error: isUnknownSchema
                        ? `Unknown request, try upgrading the daemon (currently v${this.daemonVersion})`
                        : "Invalid message",
                    code: isUnknownSchema ? "unknown_schema" : "invalid_message",
                },
            }));
            return;
        }
        const errorMessage = `Invalid message: ${parsedMessage.error.message}`;
        this.sendToClient(ws, wrapSessionMessage({
            type: "status",
            payload: {
                status: "error",
                message: errorMessage,
            },
        }));
    }
    maybeHandleBinaryFrame(params) {
        const { ws, buffer, activeConnection, log } = params;
        const asBytes = asUint8Array(buffer);
        if (!asBytes) {
            return false;
        }
        const frame = decodeTerminalStreamFrame(asBytes);
        if (!frame) {
            return false;
        }
        if (!activeConnection) {
            this.incrementRuntimeCounter("binaryBeforeHelloRejected");
            log.warn("Rejected binary frame before hello");
            this.clearPendingConnection(ws);
            try {
                ws.close(WS_CLOSE_INVALID_HELLO, "Session message before hello");
            }
            catch {
                // ignore close errors
            }
            return true;
        }
        activeConnection.session.handleBinaryFrame(frame);
        return true;
    }
    handlePendingConnectionMessage(params) {
        const { ws, message, pendingConnection } = params;
        if (message.type === "hello") {
            this.handleHello({
                ws,
                message,
                pending: pendingConnection,
            });
            return;
        }
        pendingConnection.connectionLogger.warn({
            messageType: message.type,
        }, "Rejected pending message before hello");
        this.incrementRuntimeCounter("pendingMessageRejectedBeforeHello");
        this.clearPendingConnection(ws);
        try {
            ws.close(WS_CLOSE_INVALID_HELLO, "Session message before hello");
        }
        catch {
            // ignore close errors
        }
    }
    async handleRawMessage(ws, data) {
        const activeConnection = this.sessions.get(ws);
        const pendingConnection = this.pendingConnections.get(ws);
        const log = activeConnection?.connectionLogger ?? pendingConnection?.connectionLogger ?? this.logger;
        try {
            const buffer = bufferFromWsData(data);
            const binaryHandled = this.maybeHandleBinaryFrame({
                ws,
                buffer,
                activeConnection,
                log,
            });
            if (binaryHandled) {
                return;
            }
            const parsed = JSON.parse(buffer.toString());
            const parsedMessage = WSInboundMessageSchema.safeParse(parsed);
            if (!parsedMessage.success) {
                this.handleInvalidInboundMessage({
                    ws,
                    parsed,
                    parsedMessage,
                    pendingConnection,
                    activeConnection,
                    log,
                });
                return;
            }
            const message = parsedMessage.data;
            this.recordInboundMessageType(message.type);
            if (message.type === "ping") {
                this.sendToClient(ws, { type: "pong" });
                return;
            }
            if (message.type === "recording_state") {
                return;
            }
            if (pendingConnection) {
                this.handlePendingConnectionMessage({
                    ws,
                    message,
                    pendingConnection,
                });
                return;
            }
            if (!activeConnection) {
                this.incrementRuntimeCounter("missingConnectionForMessage");
                this.logger.error("No connection found for websocket");
                return;
            }
            if (message.type === "hello") {
                this.incrementRuntimeCounter("unexpectedHelloOnActiveConnection");
                activeConnection.connectionLogger.warn("Received hello on active connection");
                try {
                    ws.close(WS_CLOSE_INVALID_HELLO, "Unexpected hello");
                }
                catch {
                    // ignore close errors
                }
                return;
            }
            if (message.type === "session") {
                await this.dispatchSessionMessage(activeConnection, message);
            }
        }
        catch (error) {
            this.handleRawMessageError({ ws, data, error, log });
        }
    }
    async dispatchSessionMessage(activeConnection, message) {
        this.recordInboundSessionRequestType(message.message.type);
        const startMs = performance.now();
        await activeConnection.session.handleMessage(message.message);
        const durationMs = performance.now() - startMs;
        this.recordRequestLatency(message.message.type, durationMs);
        if (durationMs >= SLOW_REQUEST_THRESHOLD_MS) {
            activeConnection.connectionLogger.warn({
                requestType: message.message.type,
                durationMs: Math.round(durationMs),
                inflightRequests: activeConnection.session.getRuntimeMetrics().inflightRequests,
            }, "ws_slow_request");
        }
    }
    handleRawMessageError(params) {
        const { ws, data, error, log } = params;
        const err = error instanceof Error ? error : new Error(String(error));
        const { rawPayload, parsedPayload } = this.decodeRawMessagePayloadForError(data);
        const trimmedRawPayload = typeof rawPayload === "string" && rawPayload.length > 2000
            ? `${rawPayload.slice(0, 2000)}... (truncated)`
            : rawPayload;
        log.error({
            err,
            rawPayload: trimmedRawPayload,
            parsedPayload,
        }, "Failed to parse/handle message");
        if (this.pendingConnections.has(ws)) {
            this.clearPendingConnection(ws);
            try {
                ws.close(WS_CLOSE_INVALID_HELLO, "Invalid hello");
            }
            catch {
                // ignore close errors
            }
            return;
        }
        const requestInfo = extractRequestInfoFromUnknownWsInbound(parsedPayload);
        if (requestInfo) {
            this.sendToClient(ws, wrapSessionMessage({
                type: "rpc_error",
                payload: {
                    requestId: requestInfo.requestId,
                    requestType: requestInfo.requestType,
                    error: "Invalid message",
                    code: "invalid_message",
                },
            }));
            return;
        }
        this.sendToClient(ws, wrapSessionMessage({
            type: "status",
            payload: {
                status: "error",
                message: `Invalid message: ${err.message}`,
            },
        }));
    }
    decodeRawMessagePayloadForError(data) {
        let rawPayload = null;
        let parsedPayload = null;
        try {
            const buffer = bufferFromWsData(data);
            rawPayload = buffer.toString();
            parsedPayload = JSON.parse(rawPayload);
        }
        catch (payloadError) {
            rawPayload = rawPayload ?? "<unreadable>";
            parsedPayload = parsedPayload ?? rawPayload;
            const payloadErr = payloadError instanceof Error ? payloadError : new Error(String(payloadError));
            this.logger.error({ err: payloadErr }, "Failed to decode raw payload");
        }
        return { rawPayload, parsedPayload };
    }
    incrementRuntimeCounter(counter) {
        this.runtimeMetrics.incrementCounter(counter);
    }
    recordInboundMessageType(type) {
        this.runtimeMetrics.recordInboundMessage(type);
    }
    recordInboundSessionRequestType(type) {
        this.runtimeMetrics.recordInboundSessionRequest(type);
    }
    recordRequestLatency(type, durationMs) {
        this.runtimeMetrics.recordRequestLatency(type, durationMs);
    }
    collectSessionRuntimeMetrics() {
        const uniqueConnections = new Set(this.externalSessionsByKey.values());
        let terminalDirectorySubscriptionCount = 0;
        let terminalSubscriptionCount = 0;
        let inflightRequests = 0;
        let peakInflightRequests = 0;
        for (const connection of uniqueConnections) {
            const sessionMetrics = connection.session.getRuntimeMetrics();
            terminalDirectorySubscriptionCount += sessionMetrics.terminalDirectorySubscriptionCount;
            terminalSubscriptionCount += sessionMetrics.terminalSubscriptionCount;
            inflightRequests += sessionMetrics.inflightRequests;
            peakInflightRequests = Math.max(peakInflightRequests, sessionMetrics.peakInflightRequests);
            connection.session.resetPeakInflight();
        }
        return {
            ...this.checkoutDiffManager.getMetrics(),
            terminalDirectorySubscriptionCount,
            terminalSubscriptionCount,
            inflightRequests,
            peakInflightRequests,
        };
    }
    flushRuntimeMetrics(options) {
        const runtimeMetrics = this.runtimeMetrics.snapshotAndReset();
        const activeConnections = new Set(this.sessions.values()).size;
        const activeSockets = this.sessions.size;
        const pendingConnections = this.pendingConnections.size;
        const reconnectGraceSessions = [...this.externalSessionsByKey.values()].filter((connection) => connection.sockets.size === 0 && connection.externalDisconnectCleanupTimeout !== null).length;
        const sessionMetrics = this.collectSessionRuntimeMetrics();
        const agentSnapshot = this.agentManager.getMetricsSnapshot();
        this.logger.info({
            windowMs: runtimeMetrics.windowMs,
            final: Boolean(options?.final),
            sessions: {
                activeConnections,
                externalSessionKeys: this.externalSessionsByKey.size,
                reconnectGraceSessions,
            },
            sockets: {
                activeSockets,
                pendingConnections,
            },
            counters: runtimeMetrics.counters,
            inboundMessageTypesTop: runtimeMetrics.inboundMessageTypesTop,
            inboundSessionRequestTypesTop: runtimeMetrics.inboundSessionRequestTypesTop,
            outboundMessageTypesTop: runtimeMetrics.outboundMessageTypesTop,
            outboundSessionMessageTypesTop: runtimeMetrics.outboundSessionMessageTypesTop,
            outboundAgentStreamTypesTop: runtimeMetrics.outboundAgentStreamTypesTop,
            outboundAgentStreamAgentsTop: runtimeMetrics.outboundAgentStreamAgentsTop,
            outboundBinaryFrameTypesTop: runtimeMetrics.outboundBinaryFrameTypesTop,
            bufferedAmount: runtimeMetrics.bufferedAmount,
            runtime: sessionMetrics,
            latency: runtimeMetrics.latency,
            agents: agentSnapshot,
        }, "ws_runtime_metrics");
    }
    getClientActivityState(session) {
        const activity = session.getClientActivity();
        if (!activity) {
            return {
                appVisible: false,
                focusedAgentId: null,
                lastActivityAtMs: null,
            };
        }
        return {
            appVisible: activity.appVisible,
            focusedAgentId: activity.focusedAgentId,
            lastActivityAtMs: activity.lastActivityAt.getTime(),
        };
    }
    async broadcastAgentAttention(params) {
        const clientEntries = [];
        for (const [ws, connection] of this.sessions) {
            clientEntries.push({
                ws,
                state: this.getClientActivityState(connection.session),
            });
        }
        const allStates = clientEntries.map((e) => e.state);
        const nowMs = Date.now();
        const agent = this.agentManager.getAgent(params.agentId);
        const assistantMessage = await this.agentManager.getLastAssistantMessage(params.agentId);
        const notification = buildAgentAttentionNotificationPayload({
            reason: params.reason,
            serverId: this.serverId,
            agentId: params.agentId,
            assistantMessage,
            permissionRequest: agent ? findLatestPermissionRequest(agent.pendingPermissions) : null,
        });
        const plan = computeNotificationPlan({
            allStates,
            agentId: params.agentId,
            reason: params.reason,
            nowMs,
        });
        if (plan.shouldPush) {
            void this.pushNotificationSender.send(notification).catch((err) => {
                this.logger.warn({ err, agentId: params.agentId }, "Failed to send push notification");
            });
        }
        for (const [clientIndex, { ws }] of clientEntries.entries()) {
            const shouldNotify = clientIndex === plan.inAppRecipientIndex;
            const timestamp = new Date().toISOString();
            const message = wrapSessionMessage({
                type: "agent_stream",
                payload: {
                    agentId: params.agentId,
                    event: {
                        type: "attention_required",
                        provider: params.provider,
                        reason: params.reason,
                        timestamp,
                        shouldNotify,
                        notification,
                    },
                    timestamp,
                },
            });
            this.sendToClient(ws, message);
        }
    }
}
function extractSocketRequestMetadata(request) {
    if (!request || typeof request !== "object") {
        return {};
    }
    const record = request;
    const host = typeof record.headers?.host === "string" ? record.headers.host : undefined;
    const origin = typeof record.headers?.origin === "string" ? record.headers.origin : undefined;
    const userAgent = typeof record.headers?.["user-agent"] === "string" ? record.headers["user-agent"] : undefined;
    const remoteAddress = typeof record.socket?.remoteAddress === "string" ? record.socket.remoteAddress : undefined;
    return {
        ...(host ? { host } : {}),
        ...(origin ? { origin } : {}),
        ...(userAgent ? { userAgent } : {}),
        ...(remoteAddress ? { remoteAddress } : {}),
    };
}
function selectWebSocketProtocol(protocols, password) {
    if (!password) {
        return protocols.values().next().value ?? false;
    }
    for (const protocol of protocols) {
        const token = extractWsBearerToken(protocol);
        if (token !== null) {
            return protocol;
        }
    }
    return false;
}
function stringifyCloseReason(reason) {
    if (typeof reason === "string") {
        return reason.length > 0 ? reason : null;
    }
    if (Buffer.isBuffer(reason)) {
        const text = reason.toString();
        return text.length > 0 ? text : null;
    }
    if (reason == null) {
        return null;
    }
    const text = String(reason);
    return text.length > 0 ? text : null;
}
function extractRequestInfoFromUnknownWsInbound(payload) {
    if (!payload || typeof payload !== "object") {
        return null;
    }
    const record = payload;
    // Session-wrapped messages
    if (record.type === "session" && record.message && typeof record.message === "object") {
        const msg = record.message;
        if (typeof msg.requestId === "string") {
            return {
                requestId: msg.requestId,
                ...(typeof msg.type === "string" ? { requestType: msg.type } : {}),
            };
        }
    }
    // Non-session messages (future-proof)
    if (typeof record.requestId === "string") {
        return {
            requestId: record.requestId,
            ...(typeof record.type === "string" ? { requestType: record.type } : {}),
        };
    }
    return null;
}
//# sourceMappingURL=websocket-server.js.map