// Codable mirror of gateway/src/contract.ts. docs/api.md describes the same JSON for humans. // Change both sides together. // // String enums from the gateway (phase, host status, approval decision) are modeled as // RawRepresentable structs rather than Swift enums, so a value the watch does not know yet still // decodes instead of failing the whole feed. import Foundation struct Phase: RawRepresentable, Codable, Hashable, Sendable { let rawValue: String init(rawValue: String) { self.rawValue = rawValue } static let starting = Phase(rawValue: "starting") static let running = Phase(rawValue: "running") static let waitingForApproval = Phase(rawValue: "waiting_for_approval") static let waitingForInput = Phase(rawValue: "waiting_for_input") static let completed = Phase(rawValue: "completed") static let failed = Phase(rawValue: "failed") var needsYou: Bool { self == .waitingForApproval || self == .waitingForInput } var isWorking: Bool { self == .starting || self == .running } } struct HostStatus: RawRepresentable, Codable, Hashable, Sendable { let rawValue: String init(rawValue: String) { self.rawValue = rawValue } static let connecting = HostStatus(rawValue: "connecting") static let online = HostStatus(rawValue: "online") static let offline = HostStatus(rawValue: "offline") static let authInvalid = HostStatus(rawValue: "auth_invalid") static let incompatible = HostStatus(rawValue: "incompatible") /// A short label for a host that is not online, such as "offline" or "needs a new token". var problem: String? { switch self { case .online: nil case .connecting: "connecting" case .authInvalid: "needs a new token" case .incompatible: "incompatible" default: "offline" } } } struct ApprovalDecision: RawRepresentable, Codable, Hashable, Sendable { let rawValue: String init(rawValue: String) { self.rawValue = rawValue } static let accept = ApprovalDecision(rawValue: "accept") static let acceptForSession = ApprovalDecision(rawValue: "acceptForSession") static let acceptAlways = ApprovalDecision(rawValue: "acceptAlways") static let decline = ApprovalDecision(rawValue: "decline") static let cancel = ApprovalDecision(rawValue: "cancel") } struct HostSummary: Codable, Hashable, Sendable, Identifiable { var id: String var label: String var status: HostStatus var error: String? } /// Something a thread is waiting on: an approval, or questions for the user. struct PendingRequest: Codable, Hashable, Sendable, Identifiable { struct Option: Codable, Hashable, Sendable { var decision: ApprovalDecision var label: String } struct Question: Codable, Hashable, Sendable, Identifiable { struct Choice: Codable, Hashable, Sendable { var label: String var value: String } var id: String var header: String var question: String var options: [Choice] var allowCustomAnswer: Bool } var id: String /// `command`, `file-change`, `permission`, `user_input`, ... var kind: String var prompt: String? var reason: String? var options: [Option] var questions: [Question] var isQuestion: Bool { !questions.isEmpty } /// The Allow and Deny pair shown on a feed card, when the server offers both. var quickOptions: (allow: Option, deny: Option)? { guard let allow = options.first(where: { $0.decision == .accept }), let deny = options.first(where: { $0.decision == .decline }) else { return nil } return (allow, deny) } /// One line saying what the agent wants, for a feed card. var summary: String { if let question = questions.first { return question.question } if let prompt, !prompt.isEmpty { return prompt } if let reason, !reason.isEmpty { return reason } return switch kind { case "command": "Run a command" case "file-change", "file_change": "Change files" default: "Approve a request" } } } struct FeedItem: Codable, Hashable, Sendable, Identifiable { var hostId: String var hostLabel: String var threadId: String var projectId: String var projectTitle: String var title: String var phase: Phase var updatedAt: Date var lastMessage: String? var request: PendingRequest? var id: String { "\(hostId)/\(threadId)" } var route: Route { .thread(hostId: hostId, threadId: threadId) } } struct Feed: Codable, Hashable, Sendable { var needsYou: [FeedItem] var working: [FeedItem] var recent: [FeedItem] var hosts: [HostSummary] } struct Message: Codable, Hashable, Sendable, Identifiable { struct Role: RawRepresentable, Codable, Hashable, Sendable { let rawValue: String init(rawValue: String) { self.rawValue = rawValue } static let user = Role(rawValue: "user") static let assistant = Role(rawValue: "assistant") static let system = Role(rawValue: "system") } var id: String var role: Role var text: String var createdAt: Date } struct ThreadDetail: Codable, Hashable, Sendable { var item: FeedItem /// Oldest first, at most 20. var messages: [Message] var canStop: Bool } struct ProjectsResponse: Codable, Hashable, Sendable { struct Host: Codable, Hashable, Sendable, Identifiable { var id: String var label: String var status: HostStatus var error: String? var projects: [Project] /// Projects a new thread can start in. A project without a default model is not offered. var launchable: [Project] { projects.filter { $0.model != nil } } } struct Project: Codable, Hashable, Sendable, Identifiable { var id: String var title: String var model: String? } var hosts: [Host] } struct PairRequest: Codable, Sendable { var code: String var deviceName: String } struct PairResponse: Codable, Sendable { var deviceId: String var deviceToken: String } struct PushRegistration: Codable, Sendable { enum Environment: String, Codable, Sendable { case sandbox, production } var apnsToken: String var apnsEnvironment: Environment } struct SendMessageRequest: Codable, Sendable { var text: String } enum RespondRequest: Encodable, Sendable { case decision(requestId: String, decision: ApprovalDecision) case answers(requestId: String, answers: [String: String]) private enum CodingKeys: String, CodingKey { case requestId, decision, answers } func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch self { case let .decision(requestId, decision): try container.encode(requestId, forKey: .requestId) try container.encode(decision, forKey: .decision) case let .answers(requestId, answers): try container.encode(requestId, forKey: .requestId) try container.encode(answers, forKey: .answers) } } } struct NewThreadRequest: Codable, Sendable { var hostId: String var projectId: String var text: String } struct NewThreadResponse: Codable, Sendable { var hostId: String var threadId: String } struct ErrorResponse: Codable, Sendable { var error: String } /// Notification categories the watch registers actions for. enum PushCategory: String, CaseIterable, Sendable { case approval, finished, failed, input, host } enum JSON { static func decoder() -> JSONDecoder { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .custom { decoder in let container = try decoder.singleValueContainer() let string = try container.decode(String.self) guard let date = parseDate(string) else { throw DecodingError.dataCorruptedError( in: container, debugDescription: "Not an ISO 8601 date: \(string)") } return date } return decoder } static func encoder() -> JSONEncoder { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .custom { date, encoder in var container = encoder.singleValueContainer() try container.encode(Date.ISO8601FormatStyle(includingFractionalSeconds: true).format(date)) } return encoder } /// Parses ISO 8601 timestamps with or without fractional seconds. static func parseDate(_ string: String) -> Date? { let withFraction = Date.ISO8601FormatStyle(includingFractionalSeconds: true) if let date = try? withFraction.parse(string) { return date } return try? Date.ISO8601FormatStyle().parse(string) } }