ai-dispatch 10.38.0

Multi-AI CLI team orchestrator
// Minimal SSE reader over URLSession for /api/events.
// Exports: SSEClient.

import Foundation

struct SSEMessage: Sendable {
    let event: String
    let data: String
}

final class SSEClient: NSObject, URLSessionDataDelegate, @unchecked Sendable {
    private let onMessage: @Sendable (SSEMessage) -> Void
    private let onFailure: @Sendable (Error) -> Void
    private var session: URLSession?
    private var task: URLSessionDataTask?
    private var buffer = Data()
    private var currentEvent = "message"
    private var dataLines: [String] = []

    init(
        onMessage: @escaping @Sendable (SSEMessage) -> Void,
        onFailure: @escaping @Sendable (Error) -> Void
    ) {
        self.onMessage = onMessage
        self.onFailure = onFailure
    }

    func start(request: URLRequest) {
        stop()
        let config = URLSessionConfiguration.default
        config.timeoutIntervalForRequest = 0
        config.timeoutIntervalForResource = 0
        let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
        self.session = session
        let task = session.dataTask(with: request)
        self.task = task
        task.resume()
    }

    func stop() {
        task?.cancel()
        task = nil
        session?.invalidateAndCancel()
        session = nil
        buffer.removeAll()
        currentEvent = "message"
        dataLines.removeAll()
    }

    func urlSession(
        _ session: URLSession,
        dataTask: URLSessionDataTask,
        didReceive data: Data
    ) {
        buffer.append(data)
        while let range = buffer.range(of: Data("\n".utf8)) {
            let lineData = buffer.subdata(in: buffer.startIndex..<range.lowerBound)
            buffer.removeSubrange(buffer.startIndex..<range.upperBound)
            handleLine(String(data: lineData, encoding: .utf8) ?? "")
        }
    }

    func urlSession(
        _ session: URLSession,
        task: URLSessionTask,
        didCompleteWithError error: Error?
    ) {
        if let error {
            onFailure(error)
            return
        }
        if let response = task.response as? HTTPURLResponse, response.statusCode >= 400 {
            onFailure(SSEError.httpStatus(response.statusCode))
        }
    }

    private func handleLine(_ raw: String) {
        let line = raw.hasSuffix("\r") ? String(raw.dropLast()) : raw
        if line.isEmpty {
            flushEvent()
            return
        }
        if line.hasPrefix(":") { return }
        if line.hasPrefix("event:") {
            currentEvent = String(line.dropFirst(6)).trimmingCharacters(in: .whitespaces)
            return
        }
        if line.hasPrefix("data:") {
            dataLines.append(String(line.dropFirst(5)).trimmingCharacters(in: .whitespaces))
        }
    }

    private func flushEvent() {
        guard !dataLines.isEmpty else {
            currentEvent = "message"
            return
        }
        let message = SSEMessage(event: currentEvent, data: dataLines.joined(separator: "\n"))
        currentEvent = "message"
        dataLines.removeAll()
        onMessage(message)
    }
}

enum SSEError: Error {
    case httpStatus(Int)
}