chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:10:34 +08:00
commit a789495a98
1551 changed files with 718128 additions and 0 deletions
+209
View File
@@ -0,0 +1,209 @@
import Foundation
/// Connection lifecycle reported to the UI.
public enum ConnectionPhase: Equatable, Sendable {
case disconnected
case connecting
case connected
/// Waiting before the next reconnect attempt.
case reconnecting(attempt: Int)
/// Gave up or was told to stop; `reason` is user-displayable.
case failed(reason: String)
}
/// Everything the UI observes from a connection: lifecycle changes plus
/// decoded server events.
public enum ConnectionOutput: Equatable, Sendable {
case phase(ConnectionPhase)
case event(ServerEvent)
}
/// Actor owning one WebSocket connection to a jcode server.
///
/// Responsibilities:
/// - connect, authenticate, subscribe
/// - decode incoming NDJSON frames into `ServerEvent`s
/// - automatic reconnect with capped exponential backoff
/// - request-ID assignment
///
/// It deliberately knows nothing about app state; consumers feed the output
/// stream into `SessionReducer`.
public actor Connection {
public struct Configuration: Sendable {
public var gateway: Gateway
public var authToken: String
/// Maximum reconnect attempts before reporting `.failed`. Nil retries forever.
public var maxReconnectAttempts: Int?
/// Base backoff delay in seconds, doubled per attempt and capped at 30s.
public var baseBackoffSeconds: Double
public init(
gateway: Gateway,
authToken: String,
maxReconnectAttempts: Int? = nil,
baseBackoffSeconds: Double = 1.0
) {
self.gateway = gateway
self.authToken = authToken
self.maxReconnectAttempts = maxReconnectAttempts
self.baseBackoffSeconds = baseBackoffSeconds
}
}
private let configuration: Configuration
private let makeTransport: @Sendable () -> any WebSocketTransport
private var transport: (any WebSocketTransport)?
private var nextRequestID: UInt64 = 1
private var runTask: Task<Void, Never>?
private var continuation: AsyncStream<ConnectionOutput>.Continuation?
private var targetSessionID: String?
private var stopped = false
/// Set when the server announced a reload; the next reconnect attempt
/// skips backoff because the drop is expected and the server returns fast.
private var expectServerReload = false
/// Set when the server asked this client to close; reconnecting would
/// fight the server, so the loop ends with a failed phase instead.
private var closeRequestedReason: String?
public init(
configuration: Configuration,
makeTransport: @escaping @Sendable () -> any WebSocketTransport = {
URLSessionWebSocketTransport()
}
) {
self.configuration = configuration
self.makeTransport = makeTransport
}
/// Starts the connection loop. The returned stream yields phase changes
/// and decoded events until `stop()` is called or the stream is cancelled.
public func start(resumeSessionID: String? = nil) -> AsyncStream<ConnectionOutput> {
targetSessionID = resumeSessionID
stopped = false
expectServerReload = false
closeRequestedReason = nil
let (stream, continuation) = AsyncStream.makeStream(of: ConnectionOutput.self)
self.continuation = continuation
runTask = Task { await runLoop() }
continuation.onTermination = { _ in
Task { [weak self] in await self?.stop() }
}
return stream
}
public func stop() async {
guard !stopped else { return }
stopped = true
runTask?.cancel()
runTask = nil
if let transport {
await transport.close()
}
transport = nil
continuation?.yield(.phase(.disconnected))
continuation?.finish()
continuation = nil
}
/// Sends a request, assigning it a fresh ID. Returns the assigned ID.
@discardableResult
public func send(_ build: @Sendable (UInt64) -> Request) async throws -> UInt64 {
guard let transport else { throw TransportError.notConnected }
let id = nextRequestID
nextRequestID += 1
let request = build(id)
try await transport.send(text: request.encodedLine())
return id
}
// MARK: - Internals
private func runLoop() async {
var attempt = 0
while !Task.isCancelled && !stopped {
yield(.phase(attempt == 0 ? .connecting : .reconnecting(attempt: attempt)))
let transport = makeTransport()
do {
try await transport.connect(
url: configuration.gateway.webSocketURL,
authToken: configuration.authToken
)
self.transport = transport
yield(.phase(.connected))
attempt = 0
try await subscribeAndSync()
try await receiveLoop(transport: transport)
// Clean close: fall through to reconnect.
} catch {
if Task.isCancelled || stopped { break }
if case TransportError.unauthorized = error {
// Retrying with the same token can never succeed.
self.transport = nil
yield(
.phase(
.failed(
reason:
"This device is no longer paired with the server. Re-pair to reconnect."
)))
return
}
}
self.transport = nil
if Task.isCancelled || stopped { break }
if let reason = closeRequestedReason {
await transport.close()
yield(.phase(.failed(reason: reason)))
return
}
attempt += 1
if let max = configuration.maxReconnectAttempts, attempt > max {
yield(.phase(.failed(reason: "Could not reach server after \(max) attempts")))
return
}
if expectServerReload {
// The server told us it is restarting: reconnect eagerly with a
// short fixed delay instead of exponential backoff.
expectServerReload = false
try? await Task.sleep(nanoseconds: UInt64(configuration.baseBackoffSeconds * 500_000_000))
continue
}
let delay = min(
configuration.baseBackoffSeconds * pow(2.0, Double(attempt - 1)), 30.0)
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
}
}
private func subscribeAndSync() async throws {
let sessionID = targetSessionID
try await send { .subscribe(id: $0, targetSessionID: sessionID) }
try await send { .getHistory(id: $0) }
}
private func receiveLoop(transport: any WebSocketTransport) async throws {
while !Task.isCancelled && !stopped {
guard let text = try await transport.receiveText() else { return }
// A frame may contain multiple newline-delimited events.
for line in text.split(separator: "\n", omittingEmptySubsequences: true) {
if let event = try? ServerEvent.decode(line: String(line)) {
switch event {
case .sessionID(let sessionID):
targetSessionID = sessionID
case .reloading:
expectServerReload = true
case .sessionCloseRequested(let reason):
closeRequestedReason =
reason.isEmpty ? "Server closed this session" : reason
default:
break
}
yield(.event(event))
if closeRequestedReason != nil { return }
}
}
}
}
private func yield(_ output: ConnectionOutput) {
continuation?.yield(output)
}
}
+151
View File
@@ -0,0 +1,151 @@
import Foundation
/// A saved server credential.
public struct ServerCredential: Codable, Equatable, Sendable, Identifiable {
public var id: String { "\(host):\(port)" }
public var host: String
public var port: UInt16
public var token: String
public var serverName: String
public var serverVersion: String
public var pairedAt: Date
public init(
host: String,
port: UInt16,
token: String,
serverName: String,
serverVersion: String,
pairedAt: Date = Date()
) {
self.host = host
self.port = port
self.token = token
self.serverName = serverName
self.serverVersion = serverVersion
self.pairedAt = pairedAt
}
public var gateway: Gateway {
Gateway(host: host, port: port)
}
}
/// Storage for paired-server credentials.
public protocol CredentialStore: Sendable {
func loadAll() -> [ServerCredential]
func save(_ credential: ServerCredential)
func remove(id: String)
}
/// Keychain-backed store used on device. All credentials live under one
/// keychain item as a JSON array; the auth token is the only secret and the
/// set is expected to stay tiny.
///
/// Falls back to a JSON file in Application Support when the keychain is
/// unavailable (e.g. unsigned simulator builds returning
/// `errSecMissingEntitlement`). Real signed builds use the keychain.
public struct KeychainCredentialStore: CredentialStore {
private static let service = "com.jcode.mobile.servers"
private static let account = "paired-servers"
public init() {}
public func loadAll() -> [ServerCredential] {
var query = baseQuery()
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess, let data = result as? Data {
return (try? JSONDecoder().decode([ServerCredential].self, from: data)) ?? []
}
if status != errSecItemNotFound {
NSLog("JCodeKit: keychain read failed (%d), trying file fallback", status)
}
if let data = try? Data(contentsOf: Self.fallbackURL) {
return (try? JSONDecoder().decode([ServerCredential].self, from: data)) ?? []
}
return []
}
public func save(_ credential: ServerCredential) {
var all = loadAll().filter { $0.id != credential.id }
all.append(credential)
persist(all)
}
public func remove(id: String) {
persist(loadAll().filter { $0.id != id })
}
private func persist(_ credentials: [ServerCredential]) {
guard let data = try? JSONEncoder().encode(credentials) else { return }
var query = baseQuery()
let attributes: [String: Any] = [kSecValueData as String: data]
var status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
if status == errSecItemNotFound {
query[kSecValueData as String] = data
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
status = SecItemAdd(query as CFDictionary, nil)
}
if status != errSecSuccess {
NSLog("JCodeKit: keychain write failed (%d), using file fallback", status)
persistToFile(data)
}
}
private func persistToFile(_ data: Data) {
let url = Self.fallbackURL
do {
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try data.write(to: url, options: [.atomic, .completeFileProtection])
} catch {
NSLog("JCodeKit: file fallback write failed: %@", error.localizedDescription)
}
}
private static var fallbackURL: URL {
let base =
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)
.first ?? FileManager.default.temporaryDirectory
return base.appendingPathComponent("jcode-servers.json")
}
private func baseQuery() -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: Self.service,
kSecAttrAccount as String: Self.account,
]
}
}
/// In-memory store for tests and previews.
public final class InMemoryCredentialStore: CredentialStore, @unchecked Sendable {
private let lock = NSLock()
private var credentials: [ServerCredential] = []
public init() {}
public func loadAll() -> [ServerCredential] {
lock.withLock { credentials }
}
public func save(_ credential: ServerCredential) {
lock.withLock {
credentials.removeAll { $0.id == credential.id }
credentials.append(credential)
}
}
public func remove(id: String) {
lock.withLock {
credentials.removeAll { $0.id == id }
}
}
}
+75
View File
@@ -0,0 +1,75 @@
import Foundation
/// Describes a jcode server gateway endpoint.
///
/// The server exposes:
/// - `GET http://host:port/health` reachability + version probe
/// - `POST http://host:port/pair` pairing-code -> auth-token exchange
/// - `ws://host:port/ws` newline-delimited JSON protocol over WebSocket
public struct Gateway: Hashable, Sendable {
public static let defaultPort: UInt16 = 7643
public var host: String
public var port: UInt16
public init(host: String, port: UInt16 = Gateway.defaultPort) {
self.host = host
self.port = port
}
public var healthURL: URL {
url(scheme: "http", path: "/health")
}
public var pairURL: URL {
url(scheme: "http", path: "/pair")
}
public var webSocketURL: URL {
url(scheme: "ws", path: "/ws")
}
private func url(scheme: String, path: String) -> URL {
var components = URLComponents()
components.scheme = scheme
components.host = host
components.port = Int(port)
components.path = path
guard let url = components.url else {
// Host strings from pairing are validated before reaching here;
// a failure indicates programmer error.
preconditionFailure("invalid gateway endpoint: \(scheme)://\(host):\(port)\(path)")
}
return url
}
}
/// Parses `jcode://pair?host=H&port=P&code=C` URIs from QR codes.
public enum PairURI {
public struct Payload: Equatable, Sendable {
public var gateway: Gateway
public var code: String
public init(gateway: Gateway, code: String) {
self.gateway = gateway
self.code = code
}
}
public static func parse(_ string: String) -> Payload? {
guard let components = URLComponents(string: string),
components.scheme == "jcode",
components.host == "pair" || components.path == "pair"
|| components.host == nil && components.path == "/pair"
else { return nil }
let items = components.queryItems ?? []
func value(_ name: String) -> String? {
items.first(where: { $0.name == name })?.value
}
guard let host = value("host"), !host.isEmpty,
let code = value("code"), !code.isEmpty
else { return nil }
let port = value("port").flatMap(UInt16.init) ?? Gateway.defaultPort
return Payload(gateway: Gateway(host: host, port: port), code: code)
}
}
+77
View File
@@ -0,0 +1,77 @@
import Foundation
/// Exchanges a pairing code for a long-lived auth token via `POST /pair`.
public struct PairingClient: Sendable {
public struct Response: Equatable, Sendable {
public var token: String
public var serverName: String
public var serverVersion: String
public init(token: String, serverName: String, serverVersion: String) {
self.token = token
self.serverName = serverName
self.serverVersion = serverVersion
}
}
public enum PairingError: Error, Equatable {
case invalidCode(String)
case serverError(statusCode: Int, message: String)
case invalidResponse
}
private let session: URLSession
public init(session: URLSession = .shared) {
self.session = session
}
public func pair(
gateway: Gateway,
code: String,
deviceID: String,
deviceName: String
) async throws -> Response {
var request = URLRequest(url: gateway.pairURL)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.timeoutInterval = 15
let body: [String: String] = [
"code": code,
"device_id": deviceID,
"device_name": deviceName,
]
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw PairingError.invalidResponse
}
let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] ?? [:]
guard http.statusCode == 200 else {
let message = object["error"] as? String ?? "HTTP \(http.statusCode)"
if http.statusCode == 401 {
throw PairingError.invalidCode(message)
}
throw PairingError.serverError(statusCode: http.statusCode, message: message)
}
guard let token = object["token"] as? String, !token.isEmpty else {
throw PairingError.invalidResponse
}
return Response(
token: token,
serverName: object["server_name"] as? String ?? "jcode",
serverVersion: object["server_version"] as? String ?? "unknown"
)
}
/// Probes `GET /health`. Returns true when the gateway is reachable.
public func checkHealth(gateway: Gateway) async -> Bool {
var request = URLRequest(url: gateway.healthURL)
request.timeoutInterval = 5
guard let (_, response) = try? await session.data(for: request),
let http = response as? HTTPURLResponse
else { return false }
return http.statusCode == 200
}
}
+528
View File
@@ -0,0 +1,528 @@
import Foundation
/// One entry in the rendered transcript.
public struct TranscriptEntry: Equatable, Sendable, Identifiable {
public enum Role: String, Sendable {
case user
case assistant
case system
}
public struct ToolCall: Equatable, Sendable, Identifiable {
public enum Status: Equatable, Sendable {
case streamingInput
case running
case succeeded
case failed(String)
}
public var id: String
public var name: String
public var input: String
public var output: String
public var status: Status
public init(
id: String, name: String, input: String = "", output: String = "",
status: Status = .streamingInput
) {
self.id = id
self.name = name
self.input = input
self.output = output
self.status = status
}
}
public var id: UUID
public var role: Role
public var text: String
public var reasoning: String
public var toolCalls: [ToolCall]
/// True while this entry is still receiving streamed content.
public var isStreaming: Bool
/// True while this entry is a soft-interrupt waiting for the server to
/// inject it at a safe point. Cleared by `soft_interrupt_injected`.
public var isQueued: Bool
public init(
id: UUID = UUID(),
role: Role,
text: String,
reasoning: String = "",
toolCalls: [ToolCall] = [],
isStreaming: Bool = false,
isQueued: Bool = false
) {
self.id = id
self.role = role
self.text = text
self.reasoning = reasoning
self.toolCalls = toolCalls
self.isStreaming = isStreaming
self.isQueued = isQueued
}
}
/// A transient, user-dismissible notice surfaced to the UI.
///
/// Covers out-of-band server signals that must never be silently dropped:
/// push notifications, interrupts, and context compaction events.
public struct Notice: Equatable, Sendable, Identifiable {
public enum Kind: Equatable, Sendable {
case info
case notification
case compaction
}
public var id: UUID
public var kind: Kind
public var message: String
public init(id: UUID = UUID(), kind: Kind = .info, message: String) {
self.id = id
self.kind = kind
self.message = message
}
}
/// Full client-side session state derived from server events.
public struct SessionState: Equatable, Sendable {
public var phase: ConnectionPhase
public var transcript: [TranscriptEntry]
public var sessionID: String?
public var sessionTitle: String?
public var allSessions: [String]
/// Human titles for sessions in `allSessions`, keyed by session ID.
/// Populated from `session_renamed` broadcasts and history payloads.
public var sessionTitles: [String: String]
public var isProcessing: Bool
public var isReasoning: Bool
public var modelName: String?
public var providerName: String?
public var availableModels: [String]
public var reasoningEffort: String?
public var serverVersion: String?
public var tokenInput: UInt64
public var tokenOutput: UInt64
public var statusDetail: String?
/// Live turn-level provider phase (e.g. "authenticating", "waiting").
public var serverPhase: String?
public var errorBanner: String?
public var notices: [Notice]
/// Soft-interrupt messages queued mid-run, in send order, until the
/// server confirms injection.
public var pendingInterrupts: [String]
public var hasPendingInterrupts: Bool { !pendingInterrupts.isEmpty }
/// Human title for a session in the list, falling back to nil when unknown.
public func title(forSession sessionID: String) -> String? {
sessionTitles[sessionID]
}
public init() {
phase = .disconnected
transcript = []
sessionID = nil
sessionTitle = nil
allSessions = []
sessionTitles = [:]
isProcessing = false
isReasoning = false
modelName = nil
providerName = nil
availableModels = []
reasoningEffort = nil
serverVersion = nil
tokenInput = 0
tokenOutput = 0
statusDetail = nil
serverPhase = nil
errorBanner = nil
notices = []
pendingInterrupts = []
}
}
/// Local intents that mutate state without a server round-trip.
public enum LocalIntent: Equatable, Sendable {
/// User submitted a message; append it optimistically.
case userSentMessage(String)
/// User queued a soft-interrupt message mid-run.
case userQueuedInterrupt(String)
/// User cancelled all queued soft-interrupts before they injected; the
/// optimistic bubbles are removed because they will never reach the agent.
case cancelledQueuedInterrupts
/// Dismiss the current error banner.
case dismissError
/// Dismiss a transient notice by id.
case dismissNotice(UUID)
/// User cleared the conversation; wipe the transcript optimistically while
/// keeping the connection and session metadata.
case clearedConversation
/// Reset everything (switching servers/sessions).
case reset
}
/// Pure state machine turning connection output into session state.
///
/// All streaming, tool lifecycle, and history-sync behavior lives here so it
/// can be exhaustively unit tested on macOS without UI or network.
public enum SessionReducer {
public static func reduce(_ state: SessionState, _ output: ConnectionOutput) -> SessionState {
switch output {
case .phase(let phase):
return reducePhase(state, phase)
case .event(let event):
return reduceEvent(state, event)
}
}
public static func reduce(_ state: SessionState, intent: LocalIntent) -> SessionState {
var state = state
switch intent {
case .userSentMessage(let text):
state.transcript.append(TranscriptEntry(role: .user, text: text))
state.isProcessing = true
state.errorBanner = nil
case .userQueuedInterrupt(let text):
state.transcript.append(TranscriptEntry(role: .user, text: text, isQueued: true))
state.pendingInterrupts.append(text)
case .cancelledQueuedInterrupts:
state.transcript.removeAll { $0.isQueued }
state.pendingInterrupts = []
case .dismissError:
state.errorBanner = nil
case .dismissNotice(let id):
state.notices.removeAll { $0.id == id }
case .clearedConversation:
state.transcript = []
state.isProcessing = false
state.isReasoning = false
state.errorBanner = nil
case .reset:
state = SessionState()
}
return state
}
// MARK: - Phase
private static func reducePhase(_ state: SessionState, _ phase: ConnectionPhase)
-> SessionState
{
var state = state
state.phase = phase
switch phase {
case .connected:
state.errorBanner = nil
case .failed(let reason):
state.errorBanner = reason
state.isProcessing = false
state.isReasoning = false
case .disconnected, .reconnecting:
state.isProcessing = false
state.isReasoning = false
state.serverPhase = nil
finishStreaming(&state)
case .connecting:
break
}
return state
}
// MARK: - Events
private static func reduceEvent(_ state: SessionState, _ event: ServerEvent) -> SessionState {
var state = state
switch event {
case .textDelta(let text):
withStreamingAssistant(&state) { $0.text += text }
case .reasoningDelta(let text):
state.isReasoning = true
withStreamingAssistant(&state) { $0.reasoning += text }
case .reasoningDone:
state.isReasoning = false
case .textReplace(let text):
withStreamingAssistant(&state) { $0.text = text }
case .toolStart(let id, let name):
withStreamingAssistant(&state) { entry in
entry.toolCalls.append(.init(id: id, name: name))
}
case .toolInput(let delta):
withStreamingAssistant(&state) { entry in
if !entry.toolCalls.isEmpty {
entry.toolCalls[entry.toolCalls.count - 1].input += delta
}
}
case .toolExec(let id, let name):
withStreamingAssistant(&state) { entry in
if let index = entry.toolCalls.firstIndex(where: { $0.id == id }) {
entry.toolCalls[index].status = .running
} else {
entry.toolCalls.append(.init(id: id, name: name, status: .running))
}
}
case .toolDone(let id, let name, let output, let error):
withStreamingAssistant(&state) { entry in
if let index = entry.toolCalls.firstIndex(where: { $0.id == id }) {
entry.toolCalls[index].output = output
entry.toolCalls[index].status = error.map { .failed($0) } ?? .succeeded
} else {
entry.toolCalls.append(
.init(
id: id, name: name, output: output,
status: error.map { .failed($0) } ?? .succeeded
))
}
}
case .messageEnd:
finishStreaming(&state)
case .connectionPhase(let phase):
state.serverPhase = phase.isEmpty ? nil : phase
case .softInterruptInjected(let content, let displayRole, _, _):
resolvePendingInterrupt(&state, content: content, displayRole: displayRole)
case .retryRollback(let attempt, let max):
// The provider is replaying the response from the top: discard all
// partial output from the current attempt so it does not duplicate.
if let last = state.transcript.indices.last, state.transcript[last].isStreaming {
state.transcript.removeLast()
}
state.isReasoning = false
state.statusDetail = "Retrying (\(attempt)/\(max))"
case .done:
state.isProcessing = false
state.isReasoning = false
state.serverPhase = nil
finishStreaming(&state)
drainPendingInterrupts(&state)
case .interrupted:
state.isProcessing = false
state.isReasoning = false
state.serverPhase = nil
finishStreaming(&state)
drainPendingInterrupts(&state)
state.notices.append(Notice(message: "Interrupted"))
case .error(_, let message, let retryAfterSecs):
state.isProcessing = false
state.isReasoning = false
state.serverPhase = nil
finishStreaming(&state)
if let retry = retryAfterSecs {
state.errorBanner = "\(message) (retry in \(retry)s)"
} else {
state.errorBanner = message
}
case .tokenUsage(let input, let output):
state.tokenInput = input
state.tokenOutput = output
case .statusDetail(let detail):
state.statusDetail = detail
case .state(_, let sessionID, _, let isProcessing):
state.sessionID = sessionID
state.isProcessing = isProcessing
case .sessionID(let sessionID):
state.sessionID = sessionID
case .sessionRenamed(let sessionID, let displayTitle):
state.sessionTitles[sessionID] = displayTitle
if state.sessionID == nil || state.sessionID == sessionID {
state.sessionTitle = displayTitle
}
case .history(let payload):
state = applyHistory(state, payload)
case .modelChanged(_, let model, let error):
if let error {
state.errorBanner = error
} else {
state.modelName = model
}
case .reasoningEffortChanged(_, let effort, let error):
if let error {
state.errorBanner = error
} else {
state.reasoningEffort = effort
}
case .compactResult(_, let message, let success):
if success {
state.notices.append(Notice(kind: .compaction, message: message))
} else {
state.errorBanner = message
}
case .availableModelsUpdated(let models, let providerModel):
state.availableModels = models
if let providerModel {
state.modelName = providerModel
}
case .compaction(let trigger, let tokensSaved):
if let saved = tokensSaved, trigger != "background" {
state.notices.append(
Notice(kind: .compaction, message: "Context compacted (\(saved) tokens saved)"))
}
case .notification(let fromName, let message):
let prefix = fromName.map { "\($0): " } ?? ""
state.notices.append(Notice(kind: .notification, message: prefix + message))
case .reloading:
state.notices.append(Notice(message: "Server is updating, reconnecting shortly"))
case .sessionCloseRequested(let reason):
state.isProcessing = false
state.isReasoning = false
finishStreaming(&state)
state.errorBanner = reason.isEmpty ? "Server closed this session" : reason
case .ack, .pong, .unknown:
break
}
return state
}
private static func applyHistory(
_ state: SessionState, _ payload: ServerEvent.HistoryPayload
) -> SessionState {
var state = state
state.sessionID = payload.sessionID
state.providerName = payload.providerName ?? state.providerName
state.modelName = payload.providerModel ?? state.modelName
if !payload.availableModels.isEmpty {
state.availableModels = payload.availableModels
}
if !payload.allSessions.isEmpty {
state.allSessions = payload.allSessions
}
state.serverVersion = payload.serverVersion ?? state.serverVersion
state.sessionTitle = payload.displayTitle ?? state.sessionTitle
state.reasoningEffort = payload.reasoningEffort ?? state.reasoningEffort
if let title = payload.displayTitle {
state.sessionTitles[payload.sessionID] = title
}
// History replaces the transcript wholesale, so optimistic queued
// bubbles are rebuilt from the server's authoritative view.
state.pendingInterrupts = []
if let totals = payload.totalTokens {
state.tokenInput = totals.input
state.tokenOutput = totals.output
}
// History replaces the transcript wholesale: it is the server's
// authoritative view, used on connect and reconnect.
state.transcript = payload.messages.compactMap { message in
let role: TranscriptEntry.Role
switch message.role {
case "user": role = .user
case "assistant": role = .assistant
case "system": role = .system
default: return nil
}
var toolCalls: [TranscriptEntry.ToolCall] = []
if let data = message.toolData {
toolCalls.append(
.init(
id: data.id,
name: data.name,
input: data.input,
output: data.output ?? "",
status: data.error.map { .failed($0) }
?? (data.output != nil ? .succeeded : .running)
))
} else {
toolCalls = message.toolCalls.map { name in
.init(id: name, name: name, status: .succeeded)
}
}
// Skip empty assistant placeholders.
if message.content.isEmpty && toolCalls.isEmpty {
return nil
}
return TranscriptEntry(role: role, text: message.content, toolCalls: toolCalls)
}
return state
}
// MARK: - Helpers
/// Marks the matching queued soft-interrupt as delivered. If the injection
/// came from another client (no optimistic bubble), appends the content so
/// the transcript still reflects what the agent saw.
private static func resolvePendingInterrupt(
_ state: inout SessionState, content: String, displayRole: String?
) {
if let index = state.pendingInterrupts.firstIndex(of: content) {
state.pendingInterrupts.remove(at: index)
}
if let index = state.transcript.firstIndex(where: { $0.isQueued && $0.text == content })
?? state.transcript.firstIndex(where: { $0.isQueued })
{
state.transcript[index].isQueued = false
} else {
let role: TranscriptEntry.Role = displayRole == "system" ? .system : .user
state.transcript.append(TranscriptEntry(role: role, text: content))
}
}
/// The turn is over: anything still marked queued has either been consumed
/// server-side or is moot, so stop showing it as pending.
private static func drainPendingInterrupts(_ state: inout SessionState) {
state.pendingInterrupts = []
for index in state.transcript.indices where state.transcript[index].isQueued {
state.transcript[index].isQueued = false
}
}
/// Mutates the trailing streaming assistant entry, creating it if needed.
private static func withStreamingAssistant(
_ state: inout SessionState, _ mutate: (inout TranscriptEntry) -> Void
) {
if let last = state.transcript.indices.last,
state.transcript[last].role == .assistant,
state.transcript[last].isStreaming
{
mutate(&state.transcript[last])
} else {
var entry = TranscriptEntry(role: .assistant, text: "", isStreaming: true)
mutate(&entry)
state.transcript.append(entry)
}
}
private static func finishStreaming(_ state: inout SessionState) {
if let last = state.transcript.indices.last, state.transcript[last].isStreaming {
state.transcript[last].isStreaming = false
// Drop fully-empty assistant stubs (e.g. tool-only turns that
// were replaced or cancelled before any text arrived).
if state.transcript[last].text.isEmpty
&& state.transcript[last].toolCalls.isEmpty
&& state.transcript[last].reasoning.isEmpty
{
state.transcript.removeLast()
}
}
}
}
+98
View File
@@ -0,0 +1,98 @@
import Foundation
/// Abstraction over a WebSocket so `Connection` is testable with a fake.
public protocol WebSocketTransport: Sendable {
/// Opens the socket. Throws if the connection or auth fails.
func connect(url: URL, authToken: String) async throws
/// Sends one text frame.
func send(text: String) async throws
/// Receives the next text frame. Returns nil when the socket closes.
func receiveText() async throws -> String?
/// Closes the socket.
func close() async
}
/// URLSession-backed transport used in production.
///
/// Auth is sent via `Authorization: Bearer <token>` on the upgrade request,
/// matching the gateway's preferred auth source.
public actor URLSessionWebSocketTransport: WebSocketTransport {
private var task: URLSessionWebSocketTask?
public init() {}
public func connect(url: URL, authToken: String) async throws {
var request = URLRequest(url: url)
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
request.timeoutInterval = 10
let task = URLSession.shared.webSocketTask(with: request)
task.resume()
self.task = task
// Force the handshake to complete (and surface auth failures) by
// sending a WebSocket-level ping before declaring success.
do {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
task.sendPing { error in
if let error {
cont.resume(throwing: error)
} else {
cont.resume()
}
}
}
} catch {
// The gateway rejects unknown/revoked tokens at the upgrade with
// 401. Surface that distinctly so the connection loop can stop
// retrying and prompt a re-pair instead of backing off forever.
if let http = task.response as? HTTPURLResponse, http.statusCode == 401 {
throw TransportError.unauthorized
}
throw error
}
}
public func send(text: String) async throws {
guard let task else { throw TransportError.notConnected }
try await task.send(.string(text))
}
public func receiveText() async throws -> String? {
guard let task else { throw TransportError.notConnected }
while true {
let message: URLSessionWebSocketTask.Message
do {
message = try await task.receive()
} catch {
// Treat close as end-of-stream rather than error when the
// task reports a normal closure.
if task.closeCode != .invalid {
return nil
}
throw error
}
switch message {
case .string(let text):
return text
case .data(let data):
if let text = String(data: data, encoding: .utf8) {
return text
}
@unknown default:
continue
}
}
}
public func close() async {
task?.cancel(with: .normalClosure, reason: nil)
task = nil
}
}
public enum TransportError: Error, Equatable {
case notConnected
/// The server rejected the WebSocket upgrade as unauthorized (401):
/// the pairing token is unknown or was revoked. Reconnecting with the
/// same token cannot succeed; the device must re-pair.
case unauthorized
}
+435
View File
@@ -0,0 +1,435 @@
import Foundation
/// Client request to a jcode server.
///
/// Wire format mirrors `crates/jcode-protocol/src/wire.rs` (`#[serde(tag = "type")]`,
/// snake_case tags). Only the requests the iOS app uses are modeled; the server
/// ignores fields it does not expect.
public enum Request: Equatable, Sendable {
case subscribe(id: UInt64, targetSessionID: String?)
case message(id: UInt64, content: String)
case cancel(id: UInt64)
case softInterrupt(id: UInt64, content: String, urgent: Bool)
case cancelSoftInterrupts(id: UInt64)
case ping(id: UInt64)
case getHistory(id: UInt64)
case resumeSession(id: UInt64, sessionID: String)
case setModel(id: UInt64, model: String)
case setReasoningEffort(id: UInt64, effort: String)
case compact(id: UInt64)
case renameSession(id: UInt64, title: String?)
case clear(id: UInt64)
public var id: UInt64 {
switch self {
case let .subscribe(id, _), let .message(id, _), let .cancel(id),
let .softInterrupt(id, _, _), let .cancelSoftInterrupts(id),
let .ping(id), let .getHistory(id), let .resumeSession(id, _),
let .setModel(id, _), let .setReasoningEffort(id, _), let .compact(id),
let .renameSession(id, _), let .clear(id):
return id
}
}
/// Encodes the request as a single JSON line (no trailing newline).
public func encodedLine() throws -> String {
var object: [String: Any] = ["id": id]
switch self {
case let .subscribe(_, targetSessionID):
object["type"] = "subscribe"
if let targetSessionID {
object["target_session_id"] = targetSessionID
}
case let .message(_, content):
object["type"] = "message"
object["content"] = content
case .cancel:
object["type"] = "cancel"
case let .softInterrupt(_, content, urgent):
object["type"] = "soft_interrupt"
object["content"] = content
object["urgent"] = urgent
case .cancelSoftInterrupts:
object["type"] = "cancel_soft_interrupts"
case .ping:
object["type"] = "ping"
case .getHistory:
object["type"] = "get_history"
case let .resumeSession(_, sessionID):
object["type"] = "resume_session"
object["session_id"] = sessionID
case let .setModel(_, model):
object["type"] = "set_model"
object["model"] = model
case let .setReasoningEffort(_, effort):
object["type"] = "set_reasoning_effort"
object["effort"] = effort
case .compact:
object["type"] = "compact"
case let .renameSession(_, title):
object["type"] = "rename_session"
if let title {
object["title"] = title
}
case .clear:
object["type"] = "clear"
}
let data = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])
guard let line = String(data: data, encoding: .utf8) else {
throw WireError.encodingFailed
}
return line
}
}
/// A tool call as represented in history payloads.
public struct ToolCallRecord: Equatable, Sendable {
public var id: String
public var name: String
public var input: String
public var output: String?
public var error: String?
public init(id: String, name: String, input: String, output: String?, error: String?) {
self.id = id
self.name = name
self.input = input
self.output = output
self.error = error
}
}
/// A message in conversation history (response to `get_history`).
public struct HistoryMessage: Equatable, Sendable {
public var role: String
public var content: String
public var toolCalls: [String]
public var toolData: ToolCallRecord?
public init(
role: String, content: String, toolCalls: [String] = [], toolData: ToolCallRecord? = nil
) {
self.role = role
self.content = content
self.toolCalls = toolCalls
self.toolData = toolData
}
}
/// Server event from a jcode server. Unknown event types decode as `.unknown`
/// so newer servers never break older apps.
public enum ServerEvent: Equatable, Sendable {
case ack(id: UInt64)
case textDelta(text: String)
case reasoningDelta(text: String)
case reasoningDone(durationSecs: Double?)
case textReplace(text: String)
case toolStart(id: String, name: String)
case toolInput(delta: String)
case toolExec(id: String, name: String)
case toolDone(id: String, name: String, output: String, error: String?)
case tokenUsage(input: UInt64, output: UInt64)
case statusDetail(detail: String)
case connectionPhase(phase: String)
case softInterruptInjected(content: String, displayRole: String?, point: String, toolsSkipped: Int?)
case retryRollback(attempt: Int, max: Int)
case messageEnd
case interrupted
case done(id: UInt64)
case error(id: UInt64, message: String, retryAfterSecs: UInt64?)
case pong(id: UInt64)
case state(id: UInt64, sessionID: String, messageCount: Int, isProcessing: Bool)
case sessionID(sessionID: String)
case sessionRenamed(sessionID: String, displayTitle: String)
case history(HistoryPayload)
case modelChanged(id: UInt64, model: String, error: String?)
case reasoningEffortChanged(id: UInt64, effort: String?, error: String?)
case compactResult(id: UInt64, message: String, success: Bool)
case availableModelsUpdated(models: [String], providerModel: String?)
case compaction(trigger: String, tokensSaved: UInt64?)
case notification(fromName: String?, message: String)
case reloading(newSocket: String?)
case sessionCloseRequested(reason: String)
case unknown(type: String)
public struct HistoryPayload: Equatable, Sendable {
public var id: UInt64
public var sessionID: String
public var messages: [HistoryMessage]
public var providerName: String?
public var providerModel: String?
public var availableModels: [String]
public var totalTokens: TokenTotals?
public var allSessions: [String]
public var serverVersion: String?
public var displayTitle: String?
public var reasoningEffort: String?
public struct TokenTotals: Equatable, Sendable {
public var input: UInt64
public var output: UInt64
public init(input: UInt64, output: UInt64) {
self.input = input
self.output = output
}
}
public init(
id: UInt64,
sessionID: String,
messages: [HistoryMessage],
providerName: String? = nil,
providerModel: String? = nil,
availableModels: [String] = [],
totalTokens: TokenTotals? = nil,
allSessions: [String] = [],
serverVersion: String? = nil,
displayTitle: String? = nil,
reasoningEffort: String? = nil
) {
self.id = id
self.sessionID = sessionID
self.messages = messages
self.providerName = providerName
self.providerModel = providerModel
self.availableModels = availableModels
self.totalTokens = totalTokens
self.allSessions = allSessions
self.serverVersion = serverVersion
self.displayTitle = displayTitle
self.reasoningEffort = reasoningEffort
}
}
/// Decodes one newline-delimited JSON event line.
public static func decode(line: String) throws -> ServerEvent {
guard let data = line.data(using: .utf8),
let parsed = try? JSONSerialization.jsonObject(with: data),
let object = parsed as? [String: Any]
else {
throw WireError.invalidJSON(line: line)
}
guard let type = object["type"] as? String else {
throw WireError.missingType(line: line)
}
let json = JSONObject(object)
switch type {
case "ack":
return .ack(id: json.uint64("id"))
case "text_delta":
return .textDelta(text: json.string("text"))
case "reasoning_delta":
return .reasoningDelta(text: json.string("text"))
case "reasoning_done":
return .reasoningDone(durationSecs: json.optionalDouble("duration_secs"))
case "text_replace":
return .textReplace(text: json.string("text"))
case "tool_start":
return .toolStart(id: json.string("id"), name: json.string("name"))
case "tool_input":
return .toolInput(delta: json.string("delta"))
case "tool_exec":
return .toolExec(id: json.string("id"), name: json.string("name"))
case "tool_done":
return .toolDone(
id: json.string("id"),
name: json.string("name"),
output: json.string("output"),
error: json.optionalString("error")
)
case "tokens":
return .tokenUsage(input: json.uint64("input"), output: json.uint64("output"))
case "status_detail":
return .statusDetail(detail: json.string("detail"))
case "connection_phase":
return .connectionPhase(phase: json.string("phase"))
case "soft_interrupt_injected":
return .softInterruptInjected(
content: json.string("content"),
displayRole: json.optionalString("display_role"),
point: json.string("point"),
toolsSkipped: json.optionalInt("tools_skipped")
)
case "retry_rollback":
return .retryRollback(attempt: json.int("attempt"), max: json.int("max"))
case "message_end":
return .messageEnd
case "interrupted":
return .interrupted
case "done":
return .done(id: json.uint64("id"))
case "error":
return .error(
id: json.uint64("id"),
message: json.string("message"),
retryAfterSecs: json.optionalUInt64("retry_after_secs")
)
case "pong":
return .pong(id: json.uint64("id"))
case "state":
return .state(
id: json.uint64("id"),
sessionID: json.string("session_id"),
messageCount: json.int("message_count"),
isProcessing: json.bool("is_processing")
)
case "session":
return .sessionID(sessionID: json.string("session_id"))
case "session_renamed":
return .sessionRenamed(
sessionID: json.string("session_id"),
displayTitle: json.string("display_title")
)
case "history":
return .history(decodeHistory(json))
case "model_changed":
return .modelChanged(
id: json.uint64("id"),
model: json.string("model"),
error: json.optionalString("error")
)
case "reasoning_effort_changed":
return .reasoningEffortChanged(
id: json.uint64("id"),
effort: json.optionalString("effort"),
error: json.optionalString("error")
)
case "compact_result":
return .compactResult(
id: json.uint64("id"),
message: json.string("message"),
success: json.bool("success")
)
case "available_models_updated":
return .availableModelsUpdated(
models: json.stringArray("available_models"),
providerModel: json.optionalString("provider_model")
)
case "compaction":
return .compaction(
trigger: json.string("trigger"),
tokensSaved: json.optionalUInt64("tokens_saved")
)
case "notification":
return .notification(
fromName: json.optionalString("from_name"),
message: json.string("message")
)
case "reloading":
return .reloading(newSocket: json.optionalString("new_socket"))
case "session_close_requested":
return .sessionCloseRequested(reason: json.string("reason"))
default:
return .unknown(type: type)
}
}
private static func decodeHistory(_ json: JSONObject) -> HistoryPayload {
let messages = json.objectArray("messages").map { msg -> HistoryMessage in
var toolData: ToolCallRecord?
if let td = msg.optionalObject("tool_data") {
toolData = ToolCallRecord(
id: td.string("id"),
name: td.string("name"),
input: td.string("input"),
output: td.optionalString("output"),
error: td.optionalString("error")
)
}
return HistoryMessage(
role: msg.string("role"),
content: msg.string("content"),
toolCalls: msg.stringArray("tool_calls"),
toolData: toolData
)
}
var totals: HistoryPayload.TokenTotals?
if let pair = json.raw["total_tokens"] as? [Any], pair.count == 2,
let input = JSONObject.coerceUInt64(pair[0]),
let output = JSONObject.coerceUInt64(pair[1])
{
totals = HistoryPayload.TokenTotals(input: input, output: output)
}
return HistoryPayload(
id: json.uint64("id"),
sessionID: json.string("session_id"),
messages: messages,
providerName: json.optionalString("provider_name"),
providerModel: json.optionalString("provider_model"),
availableModels: json.stringArray("available_models"),
totalTokens: totals,
allSessions: json.stringArray("all_sessions"),
serverVersion: json.optionalString("server_version"),
displayTitle: json.optionalString("display_title"),
reasoningEffort: json.optionalString("reasoning_effort")
)
}
}
public enum WireError: Error, Equatable {
case encodingFailed
case invalidJSON(line: String)
case missingType(line: String)
}
/// Lenient JSON accessor. The wire protocol omits absent optionals and the
/// app must never crash on a server that is newer or older than itself.
struct JSONObject {
let raw: [String: Any]
init(_ raw: [String: Any]) {
self.raw = raw
}
func string(_ key: String) -> String {
raw[key] as? String ?? ""
}
func optionalString(_ key: String) -> String? {
raw[key] as? String
}
func bool(_ key: String) -> Bool {
raw[key] as? Bool ?? false
}
func int(_ key: String) -> Int {
(raw[key] as? NSNumber)?.intValue ?? 0
}
func optionalInt(_ key: String) -> Int? {
(raw[key] as? NSNumber)?.intValue
}
func uint64(_ key: String) -> UInt64 {
Self.coerceUInt64(raw[key] ?? 0) ?? 0
}
func optionalUInt64(_ key: String) -> UInt64? {
raw[key].flatMap(Self.coerceUInt64)
}
func optionalDouble(_ key: String) -> Double? {
(raw[key] as? NSNumber)?.doubleValue
}
func stringArray(_ key: String) -> [String] {
raw[key] as? [String] ?? []
}
func objectArray(_ key: String) -> [JSONObject] {
(raw[key] as? [[String: Any]])?.map(JSONObject.init) ?? []
}
func optionalObject(_ key: String) -> JSONObject? {
(raw[key] as? [String: Any]).map(JSONObject.init)
}
static func coerceUInt64(_ value: Any) -> UInt64? {
if let number = value as? NSNumber {
return number.uint64Value
}
return nil
}
}