Files
nucleic/Sources/NucleicProtocol/Sync/WireMessages.swift
T

842 lines
47 KiB
Swift

import Foundation
/// The Mac ↔ iPhone application message set (SYNC_PROTOCOL §5). A projection of the
/// backend `AgentEvent` stream + approval round-trip: the host is the single authority,
/// the phone subscribes and answers. Identifiers (`SessionID`, `ApprovalID`, `Decision`,
/// `AgentEvent`) are the exact backend types — no parallel model.
///
/// Transport-agnostic: identical over LAN (v1) and the future Cloudflare relay; only the
/// bytes-moving layer swaps. Encoded as CBOR inside an encrypted `Frame`.
public enum SyncProtocol {
/// Bumped on any breaking change to the message set; `hello`/`welcome` exchange it.
public static let version = 1
}
/// What a paired device may do (SYNC §1.5). v1 iPhone is granted `approve`. Ordered so the
/// host can check "scope ≥ approve".
public enum DeviceScope: String, Sendable, Codable, Comparable {
case view // read sessions + transcripts + diffs
case approve // + answer approvals, send follow-up input
case control // + start/merge/discard/interrupt (later)
private var rank: Int {
switch self {
case .view: 0
case .approve: 1
case .control: 2
}
}
public static func < (lhs: DeviceScope, rhs: DeviceScope) -> Bool { lhs.rank < rhs.rank }
}
/// Per-subscriber firehose level (SYNC §5.5). The host coalesces before encryption, per
/// client, so each gets what it asked for without affecting others or the transcript.
public enum Verbosity: String, Sendable, Codable {
case statusOnly // only sessionUpdated / approvalRequested / runFinished
case coalesced // partial deltas buffered + flushed on block boundary (default on cell/relay)
case full // every AgentEvent including partial deltas (good on LAN)
}
// MARK: - Handshake payloads
public struct Hello: Sendable, Codable, Equatable {
public let protocolVersion: Int
public let deviceID: String
public let deviceLabel: String
public let scopeClaim: DeviceScope
/// APNs token for background wake (SYNC §6); nil until push ships (M5).
public let pushToken: String?
/// The client's APNs topic — its own bundle id (`Bundle.main.bundleIdentifier`). The relay
/// needs this to address pushes: per-channel builds have suffixed bundle ids (…`.beta` /
/// …`.canary`), and APNs rejects any push whose `apns-topic` isn't the target app's bundle id
/// with `BadTopic`. The host forwards it to the relay so wake + Live Activity pushes carry the
/// right topic for *this* phone's build (not a single hardcoded one). Optional on the wire — a
/// remote that predates it decodes as `nil` and the relay falls back to its configured topic.
public let pushTopic: String?
/// The build channel this remote was cut from. The host rejects a `hello` whose channel
/// is incompatible with its own (`ReleaseChannel.isCompatible`). Optional on the wire so a
/// host talking to a remote that predates the field decodes it as `nil` and skips the gate.
public let channel: ReleaseChannel?
/// What kind of device this is (mesh P4): `SyncTransportHint`-style raw string ("mac" /
/// "iphone" / "cloud"). Optional on the wire — absent means a pre-mesh iPhone.
public let deviceKind: String?
/// What the connecting client advertises about itself (mesh feature level + host/agent
/// capabilities). Optional; absent means pre-mesh (mesh level 0).
public let clientCaps: WireClientCapabilities?
/// Where this client can itself be dialed (mesh P4) — meaningful for a mac/cloud peer
/// that also hosts. The host persists these so a session transfer can dial back later.
/// Optional; a phone (or a pre-mesh client) omits it.
public let addresses: PeerAddresses?
public init(
protocolVersion: Int = SyncProtocol.version,
deviceID: String, deviceLabel: String,
scopeClaim: DeviceScope = .approve, pushToken: String? = nil,
pushTopic: String? = nil,
channel: ReleaseChannel? = nil,
deviceKind: String? = nil, clientCaps: WireClientCapabilities? = nil,
addresses: PeerAddresses? = nil
) {
self.protocolVersion = protocolVersion
self.deviceID = deviceID
self.deviceLabel = deviceLabel
self.scopeClaim = scopeClaim
self.pushToken = pushToken
self.pushTopic = pushTopic
self.channel = channel
self.deviceKind = deviceKind
self.clientCaps = clientCaps
self.addresses = addresses
}
private enum CodingKeys: String, CodingKey {
case protocolVersion, deviceID, deviceLabel, scopeClaim, pushToken, pushTopic, channel
case deviceKind, clientCaps, addresses
}
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.protocolVersion = try c.decode(Int.self, forKey: .protocolVersion)
self.deviceID = try c.decode(String.self, forKey: .deviceID)
self.deviceLabel = try c.decode(String.self, forKey: .deviceLabel)
self.scopeClaim = try c.decode(DeviceScope.self, forKey: .scopeClaim)
self.pushToken = try c.decodeIfPresent(String.self, forKey: .pushToken)
self.pushTopic = try c.decodeIfPresent(String.self, forKey: .pushTopic)
// Tolerate a remote that predates the channel gate.
self.channel = try c.decodeIfPresent(ReleaseChannel.self, forKey: .channel)
// Mesh P4 — absent on a pre-mesh iPhone.
self.deviceKind = try c.decodeIfPresent(String.self, forKey: .deviceKind)
self.clientCaps = try c.decodeIfPresent(WireClientCapabilities.self, forKey: .clientCaps)
self.addresses = try c.decodeIfPresent(PeerAddresses.self, forKey: .addresses)
}
/// The device kind, defaulting to iPhone for pre-mesh clients.
public var kind: PeerKind { deviceKind.map(PeerKind.init(rawValue:)) ?? .iphone }
}
public struct HostInfo: Sendable, Codable, Equatable {
public let hostID: String
public let hostName: String
public init(hostID: String, hostName: String) {
self.hostID = hostID
self.hostName = hostName
}
}
/// The capability bits the phone UI reads to mirror the Mac's affordances (e.g. show
/// "modify-and-allow" only when the backend supports it — UX_IOS §5.2).
public struct WireCapabilities: Sendable, Codable, Equatable {
public let canModifyToolInput: Bool
public let allowAlwaysScopes: [AlwaysScope]
/// Whether the host answers `ClientMsg.fetchDiff` with a full patch. The phone only
/// offers the full-diff view when set; an older host that omits the key decodes as
/// `false` and the phone keeps its summary fallback.
public let canFetchDiff: Bool
/// Whether the host answers `ClientMsg.listPeers` with its mesh peer list (mesh P4). A
/// client must not send `listPeers` unless this is set — an older host would reject the
/// unknown tag. Omitted ⇒ `false`.
public let canListPeers: Bool
/// Whether the host accepts `ClientMsg.addressUpdate` (mesh P4) — a client refreshing the
/// addresses the host has on file for it mid-connection. Same gating contract as
/// `canListPeers`. Omitted ⇒ `false`.
public let canUpdateAddresses: Bool
/// Whether the host accepts an inbound session transfer (mesh P5) — the `transferOffer`/
/// `transferChunk`/`transferCommit` verbs. A source Mac must not send them unless this is
/// set; an older host would reject the unknown tag. Omitted ⇒ `false`.
public let canReceiveSessionTransfer: Bool
/// Whether the host speaks mesh roster gossip (mesh "join") — it answers with and accepts
/// `meshRoster` pushes so scanning any one member joins the scanner to the whole group. A
/// client must not send `ClientMsg.meshRoster` unless this is set (same gating contract as
/// `canListPeers`); an older host falls back to the display-only `listPeers` path. Omitted ⇒
/// `false`.
public let canSyncRoster: Bool
/// Whether this peer answers `ClientMsg.fetchTranscript` with a session's full transcript —
/// from a live controller OR an on-disk transcript it holds (owned or mirrored). A requester
/// must not send `fetchTranscript` unless this is set; an older peer throws on the unknown
/// tag (same gating contract as `canReceiveSessionTransfer`). Omitted ⇒ `false`.
public let canSyncTranscripts: Bool
/// Whether the host pushes Live Activity updates over APNs to keep the phone's lock-screen
/// glance fresh while it's backgrounded (UX_IOS §5.3). The phone must not send
/// `ClientMsg.registerLiveActivity` unless this is set; an older host throws on the unknown
/// tag (same gating contract as `canSyncRoster`). Omitted ⇒ `false`.
public let canPushLiveActivity: Bool
/// Whether the host can **push-to-start** a Live Activity (iOS 17.2+) — create the lock-screen
/// glance over APNs when work starts while the app is closed, so it appears without the user
/// opening the app first (UX_IOS §5.3). A *separate* bit from `canPushLiveActivity` on purpose:
/// a host that predates push-to-start can advertise `canPushLiveActivity` but would throw on the
/// unknown `registerPushToStartToken` tag, so the phone must gate that message on *this* bit, not
/// on the older one. Omitted ⇒ `false`.
public let canPushToStartLiveActivity: Bool
/// Whether the host will mint a pairing code on demand for a client to relay — the phone's
/// "add a device to this mesh" button asks a connected Mac to open a pairing window and
/// returns its QR/join code (`ClientMsg.requestPairingCode` → `HostMsg.pairingCode`). A
/// client must not send `requestPairingCode`/`cancelPairingCode` unless this is set; an older
/// host throws on the unknown tag (same gating contract as `canPushLiveActivity`). Omitted ⇒
/// `false`.
public let canMintPairingCode: Bool
/// Whether the host understands `ClientMsg.setForeground` — the phone reporting whether its app is
/// foreground (creating its own Live Activity over the socket) or backgrounded (unable to, so the
/// host should push-to-start the glance instead — UX_IOS §5.3). A *separate* bit from
/// `canPushToStartLiveActivity` on purpose: an already-deployed host advertises push-to-start yet
/// predates this tag and would throw on it, so the phone must gate `setForeground` on *this* bit,
/// not the older one. Omitted ⇒ `false`.
public let canReceiveForegroundState: Bool
/// Whether this host delegates intelligence work (docs/COVALENCE_RUNNER.md §5) — it may push
/// `HostMsg.intelligenceRequest` to clients advertising
/// `WireClientCapabilities.canProvideIntelligence`, and it understands the
/// `ClientMsg.intelligenceResult` answer. A client must not send `intelligenceResult` unless
/// this is set — an older host throws on the unknown tag. Omitted ⇒ `false`.
public let canDelegateIntelligence: Bool
/// Whether this host accepts the credential-mesh verbs (docs/COVALENCE_RUNNER.md §6) —
/// `ClientMsg.credentialManifest` (descriptors + refresh leases, no secrets) and
/// `ClientMsg.credentialProvision` (records sealed to the host's sealing key, answering
/// `HostMsg.credentialNeeded`). A client must not send either unless this is set; an older
/// host throws on the unknown tag. Omitted ⇒ `false`.
public let canReceiveSealedCredentials: Bool
/// Whether this host accepts `ClientMsg.createProject` — cloning a git URL and registering
/// it as a project (CLOUD_RUNTIME §4.3), the verb that seeds work onto a fresh runner. A
/// client must not send it unless this is set; an older host throws on the unknown tag
/// (same gating contract as `canReceiveSealedCredentials`). Omitted ⇒ `false`.
public let canCreateProjects: Bool
/// Whether this host serves mesh casts (`CastMessages.swift`) — it accepts
/// `ClientMsg.castSubscribe` and pushes `HostMsg.casts`/`castCatchUp` to subscribers. A
/// client must not send `castSubscribe` unless this is set; an older host throws on the
/// unknown tag (same gating contract as `canSyncTranscripts`). Omitted ⇒ `false`.
public let canCast: Bool
/// Whether this host relays live composer typing — it accepts `ClientMsg.composerTyping`
/// and republishes it on the `composer.typing` cast channel (mesh composer streaming). A
/// client must not send `composerTyping` unless this is set; an older host throws on the
/// unknown tag (same gating contract as `canCast`). Omitted ⇒ `false`.
public let canStreamComposer: Bool
/// Whether this host acknowledges a `startChat` that carries a `requestID` with
/// `HostMsg.chatStarted` (idempotent per id) — the mesh-dispatch contract. A dispatcher only
/// treats hosts advertising this as Mesh candidates; an older host stays manually
/// targetable but is never auto-routed to. Omitted ⇒ `false`.
public let canAcknowledgeDispatch: Bool
/// Whether this host syncs account-level settings (`SyncedSettings`) — it stamps the current
/// value into `Welcome.settings` and understands `ClientMsg.updateSettings`, broadcasting the
/// result as `HostMsg.settings`. A client must not send `updateSettings` unless this is set;
/// an older host throws on the unknown tag (same gating contract as `canAcknowledgeDispatch`).
/// Omitted ⇒ `false`.
public let canSyncSettings: Bool
/// Whether this host upgrades relay sessions to a hole-punched direct path (Covalence
/// direct, SYNC_PROTOCOL §3.3) — it accepts `ClientMsg.directOffer`/`directSelect` and
/// answers `HostMsg.directAnswer`/`directGo`/`directDecline`. A client must not send either
/// verb unless this is set; an older host throws on the unknown tag (same gating contract as
/// `canSyncSettings`). Omitted ⇒ `false`.
public let canDirectConnect: Bool
/// Whether this host brokers remote agent sign-in (docs/REMOTE_AGENT_LOGIN.md) — it accepts
/// `ClientMsg.agentLoginBegin`/`agentLoginCallback`/`agentLoginCancel` and answers with
/// `HostMsg.agentLoginChallenge`/`agentLoginResult` (+ the `agentAuthStatus` push). A client
/// must not send any of the three unless this is set; an older host throws on the unknown
/// tag (same gating contract as `canDirectConnect`). *Which providers* it can broker rides
/// per-provider in `WireProviderAuthStatus.canBrokerLogin`, not here. Omitted ⇒ `false`.
public let canBrokerAgentLogin: Bool
/// Whether this host accepts `ClientMsg.credentialRevoke` — a device deleting a credential
/// kind mesh-wide (the tombstone counterpart of `credentialProvision`). The host clears its
/// local copy, records the tombstone, and fans it out (`HostMsg.credentialRevoked` + peer
/// re-gossip). A client must not send it unless this is set; an older host throws on the
/// unknown tag (same gating contract as `canBrokerAgentLogin`). Omitted ⇒ `false`.
public let canRevokeCredentials: Bool
/// Whether this host accepts `ClientMsg.resolveProcessStall` — a remote surface resolving a
/// live "host command looks hung" alert (Kill tears down the process tree, Keep-waiting
/// dismisses it), the wire counterpart of the Mac's in-transcript Kill/Keep-waiting buttons. A
/// client must not send it unless this is set; an older host throws on the unknown tag (same
/// gating contract as `canRevokeCredentials`). Omitted ⇒ `false`.
public let canResolveProcessStall: Bool
/// Whether this host honors `ClientMsg.requestSessionTransfer` — moving one of *its* sessions
/// to a third device on request, so "Transfer…" works from a Mac that doesn't own the chat
/// (including "bring it here"). A client must not send it unless this is set; an older host
/// throws on the unknown tag (same gating contract as `canResolveProcessStall`). Omitted ⇒
/// `false`.
public let canTransferOnRequest: Bool
/// Whether this peer serves Carbon shard replication (docs/CARBON_SHARDING.md §8.1) — it
/// answers `fetchCarbonHeads`/`fetchCarbonManifests`/`fetchCarbonShards` (+ the ack-window
/// verbs). A mirror must not send any of the four unless this is set; an older peer throws
/// on the unknown tag (same gating contract as `canTransferOnRequest`). Omitted ⇒ `false`.
public let canServeCarbon: Bool
/// Whether this host accepts `ClientMsg.markSessionDone` — a remote surface hand-marking a chat
/// "Done" (its last turn's disposition flips to `.completed`), the wire counterpart of the Mac
/// sidebar's "Mark Done". A client must not send it unless this is set; an older host throws on
/// the unknown tag (same gating contract as `canServeCarbon`). Omitted ⇒ `false`.
public let canMarkSessionDone: Bool
public init(
canModifyToolInput: Bool, allowAlwaysScopes: [AlwaysScope],
canFetchDiff: Bool = false, canListPeers: Bool = false,
canUpdateAddresses: Bool = false, canReceiveSessionTransfer: Bool = false,
canSyncRoster: Bool = false, canSyncTranscripts: Bool = false,
canPushLiveActivity: Bool = false, canPushToStartLiveActivity: Bool = false,
canMintPairingCode: Bool = false, canReceiveForegroundState: Bool = false,
canDelegateIntelligence: Bool = false, canReceiveSealedCredentials: Bool = false,
canCreateProjects: Bool = false, canCast: Bool = false,
canStreamComposer: Bool = false,
canAcknowledgeDispatch: Bool = false,
canSyncSettings: Bool = false,
canDirectConnect: Bool = false,
canBrokerAgentLogin: Bool = false,
canRevokeCredentials: Bool = false,
canResolveProcessStall: Bool = false,
canTransferOnRequest: Bool = false,
canServeCarbon: Bool = false,
canMarkSessionDone: Bool = false
) {
self.canModifyToolInput = canModifyToolInput
self.allowAlwaysScopes = allowAlwaysScopes
self.canFetchDiff = canFetchDiff
self.canListPeers = canListPeers
self.canUpdateAddresses = canUpdateAddresses
self.canReceiveSessionTransfer = canReceiveSessionTransfer
self.canSyncRoster = canSyncRoster
self.canSyncTranscripts = canSyncTranscripts
self.canPushLiveActivity = canPushLiveActivity
self.canPushToStartLiveActivity = canPushToStartLiveActivity
self.canMintPairingCode = canMintPairingCode
self.canReceiveForegroundState = canReceiveForegroundState
self.canDelegateIntelligence = canDelegateIntelligence
self.canReceiveSealedCredentials = canReceiveSealedCredentials
self.canCreateProjects = canCreateProjects
self.canCast = canCast
self.canStreamComposer = canStreamComposer
self.canAcknowledgeDispatch = canAcknowledgeDispatch
self.canSyncSettings = canSyncSettings
self.canDirectConnect = canDirectConnect
self.canBrokerAgentLogin = canBrokerAgentLogin
self.canRevokeCredentials = canRevokeCredentials
self.canResolveProcessStall = canResolveProcessStall
self.canTransferOnRequest = canTransferOnRequest
self.canServeCarbon = canServeCarbon
self.canMarkSessionDone = canMarkSessionDone
}
private enum CodingKeys: String, CodingKey {
case canModifyToolInput, allowAlwaysScopes, canFetchDiff, canListPeers
case canUpdateAddresses, canReceiveSessionTransfer, canSyncRoster, canSyncTranscripts
case canPushLiveActivity, canPushToStartLiveActivity, canMintPairingCode
case canReceiveForegroundState
case canDelegateIntelligence, canReceiveSealedCredentials
case canCreateProjects
case canCast, canStreamComposer, canAcknowledgeDispatch, canSyncSettings
case canDirectConnect
case canBrokerAgentLogin
case canRevokeCredentials
case canResolveProcessStall
case canTransferOnRequest
case canServeCarbon
case canMarkSessionDone
}
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.canModifyToolInput = try c.decode(Bool.self, forKey: .canModifyToolInput)
self.allowAlwaysScopes = try c.decode([AlwaysScope].self, forKey: .allowAlwaysScopes)
// Tolerate a host that predates on-demand diff fetch / peer listing / address updates /
// session transfer / roster gossip / Live Activity push.
self.canFetchDiff = try c.decodeIfPresent(Bool.self, forKey: .canFetchDiff) ?? false
self.canListPeers = try c.decodeIfPresent(Bool.self, forKey: .canListPeers) ?? false
self.canUpdateAddresses = try c.decodeIfPresent(Bool.self, forKey: .canUpdateAddresses) ?? false
self.canReceiveSessionTransfer =
try c.decodeIfPresent(Bool.self, forKey: .canReceiveSessionTransfer) ?? false
self.canSyncRoster = try c.decodeIfPresent(Bool.self, forKey: .canSyncRoster) ?? false
self.canSyncTranscripts =
try c.decodeIfPresent(Bool.self, forKey: .canSyncTranscripts) ?? false
self.canPushLiveActivity =
try c.decodeIfPresent(Bool.self, forKey: .canPushLiveActivity) ?? false
self.canPushToStartLiveActivity =
try c.decodeIfPresent(Bool.self, forKey: .canPushToStartLiveActivity) ?? false
self.canMintPairingCode =
try c.decodeIfPresent(Bool.self, forKey: .canMintPairingCode) ?? false
self.canReceiveForegroundState =
try c.decodeIfPresent(Bool.self, forKey: .canReceiveForegroundState) ?? false
self.canDelegateIntelligence =
try c.decodeIfPresent(Bool.self, forKey: .canDelegateIntelligence) ?? false
self.canReceiveSealedCredentials =
try c.decodeIfPresent(Bool.self, forKey: .canReceiveSealedCredentials) ?? false
self.canCreateProjects =
try c.decodeIfPresent(Bool.self, forKey: .canCreateProjects) ?? false
self.canCast = try c.decodeIfPresent(Bool.self, forKey: .canCast) ?? false
self.canStreamComposer =
try c.decodeIfPresent(Bool.self, forKey: .canStreamComposer) ?? false
self.canAcknowledgeDispatch =
try c.decodeIfPresent(Bool.self, forKey: .canAcknowledgeDispatch) ?? false
self.canSyncSettings =
try c.decodeIfPresent(Bool.self, forKey: .canSyncSettings) ?? false
self.canDirectConnect =
try c.decodeIfPresent(Bool.self, forKey: .canDirectConnect) ?? false
self.canBrokerAgentLogin =
try c.decodeIfPresent(Bool.self, forKey: .canBrokerAgentLogin) ?? false
self.canRevokeCredentials =
try c.decodeIfPresent(Bool.self, forKey: .canRevokeCredentials) ?? false
self.canResolveProcessStall =
try c.decodeIfPresent(Bool.self, forKey: .canResolveProcessStall) ?? false
self.canTransferOnRequest =
try c.decodeIfPresent(Bool.self, forKey: .canTransferOnRequest) ?? false
self.canServeCarbon =
try c.decodeIfPresent(Bool.self, forKey: .canServeCarbon) ?? false
self.canMarkSessionDone =
try c.decodeIfPresent(Bool.self, forKey: .canMarkSessionDone) ?? false
}
}
/// A Mac mid-pairing awaiting the user's allow/deny (mesh P4), forwarded to a control-scope client
/// so the pairing can be approved from the phone that relayed the join code — not only on the
/// accepting Mac's own screen (`HostMsg.macPairRequested`). `deviceID` keys the response
/// (`ClientMsg.respondMacPair`) and the withdrawal (`HostMsg.macPairResolved`).
public struct WireMacPairRequest: Sendable, Codable, Equatable {
public let deviceID: String
public let label: String
public init(deviceID: String, label: String) {
self.deviceID = deviceID
self.label = label
}
}
public struct Welcome: Sendable, Codable, Equatable {
public let protocolVersion: Int
public let grantedScope: DeviceScope
public let host: HostInfo
public let capabilities: WireCapabilities
/// The model/effort catalog the phone's pickers render (SYNC §5.2). Optional on the wire so
/// a phone talking to a host that predates the field decodes it as an empty catalog and falls
/// back to its built-in effort list.
public let modelCatalog: WireModelCatalog
/// The host's build channel, so the remote can confirm compatibility from its side too
/// (`ReleaseChannel.isCompatible`). Optional on the wire so a remote talking to a host that
/// predates the field decodes it as `nil` and skips the gate.
public let channel: ReleaseChannel?
/// Where this host can currently be dialed (mesh P4). Clients persist these per host so
/// reconnect hints stay fresh without a new pairing QR. Optional; a pre-mesh host omits it.
public let addresses: PeerAddresses?
/// What kind of host this is (`PeerKind` raw value: "mac"/"cloud"/…), so a device pairing
/// directly with it pins the right kind rather than assuming a Mac (docs/COVALENCE_RUNNER.md
/// §3 — a runner is a `.cloud` host). Optional/raw-string: a host that predates the field
/// decodes as `nil` and the peer keeps its `.mac` default; an unknown future kind round-trips
/// as its raw value.
public let hostKind: String?
/// The account-level synced settings the host currently holds (`SyncedSettings`), stamped in
/// so a device that connects mid-session starts from the host's truth without waiting for a
/// `HostMsg.settings` broadcast. Optional/decode-if-present: a host that predates the field
/// (or one that doesn't advertise `canSyncSettings`) omits it and the device keeps
/// `SyncedSettings.default`.
public let settings: SyncedSettings?
public init(
protocolVersion: Int = SyncProtocol.version,
grantedScope: DeviceScope, host: HostInfo, capabilities: WireCapabilities,
modelCatalog: WireModelCatalog = .empty, channel: ReleaseChannel? = nil,
addresses: PeerAddresses? = nil, hostKind: String? = nil,
settings: SyncedSettings? = nil
) {
self.protocolVersion = protocolVersion
self.grantedScope = grantedScope
self.host = host
self.capabilities = capabilities
self.modelCatalog = modelCatalog
self.channel = channel
self.addresses = addresses
self.hostKind = hostKind
self.settings = settings
}
private enum CodingKeys: String, CodingKey {
case protocolVersion, grantedScope, host, capabilities, modelCatalog, channel, addresses
case hostKind, settings
}
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.protocolVersion = try c.decode(Int.self, forKey: .protocolVersion)
self.grantedScope = try c.decode(DeviceScope.self, forKey: .grantedScope)
self.host = try c.decode(HostInfo.self, forKey: .host)
self.capabilities = try c.decode(WireCapabilities.self, forKey: .capabilities)
self.modelCatalog = try c.decodeIfPresent(WireModelCatalog.self, forKey: .modelCatalog) ?? .empty
self.channel = try c.decodeIfPresent(ReleaseChannel.self, forKey: .channel)
self.addresses = try c.decodeIfPresent(PeerAddresses.self, forKey: .addresses)
self.hostKind = try c.decodeIfPresent(String.self, forKey: .hostKind)
self.settings = try c.decodeIfPresent(SyncedSettings.self, forKey: .settings)
}
/// The host's kind, defaulting to `.mac` for a host that predates the field.
public var kind: PeerKind { hostKind.map(PeerKind.init(rawValue:)) ?? .mac }
}
/// The relay credential the host issues a device (mesh P2): the roomID it belongs to and a
/// long-lived membership token it later trades for connection tokens (`RelayAPI`). Delivered
/// two ways — inside the pairing QR (so pairing itself can ride the relay) and pushed as
/// `HostMsg.relayMembership` after every hello, which is what lets a device paired *before*
/// the relay existed adopt it, and refreshes the ~90-day token long before it lapses.
/// Content stays out of reach either way: the token only authorizes room admission; every
/// frame is Noise-E2EE.
public struct WireRelayMembership: Sendable, Codable, Equatable {
/// The host's relay room — the full SHA-256 hex of its static key (= `HostID`).
public let roomID: String
/// The long-lived membership token this device presents to `POST /v1/relay/connect`.
public let token: String
/// Override of the built-in relay base URL (dev/self-host); nil = production relay.
public let url: String?
/// Unix seconds when `token` expires. Informational — the host re-issues on every connect.
public let exp: Double?
public init(roomID: String, token: String, url: String? = nil, exp: Double? = nil) {
self.roomID = roomID
self.token = token
self.url = url
self.exp = exp
}
}
public struct Subscribe: Sendable, Codable, Equatable {
public let sessionID: SessionID
/// nil → host sends a snapshot + tail, not full history (SYNC §5.3).
public let sinceSeq: UInt64?
public let verbosity: Verbosity
public init(sessionID: SessionID, sinceSeq: UInt64?, verbosity: Verbosity) {
self.sessionID = sessionID
self.sinceSeq = sinceSeq
self.verbosity = verbosity
}
}
// MARK: - Session projections
/// Where a session went when it was moved to another Mac (mesh P5). Present only on a
/// tombstoned summary (`archived == true`); `nil` on a live session. Lets any viewer render
/// "Moved to <deviceName>" instead of the session silently vanishing from the list.
public struct MovedDestination: Sendable, Codable, Equatable {
/// The destination Mac's `HostID` (full SHA-256 hex of its static key).
public let deviceID: String
/// The destination Mac's friendly name, resolved from the paired-device store when the
/// summary is built (falls back to a generic label when unknown), so a phone that can't
/// resolve the id itself still renders a name.
public let deviceName: String
public init(deviceID: String, deviceName: String) {
self.deviceID = deviceID
self.deviceName = deviceName
}
}
/// Where a session came from when it was transferred here (mesh P5) — the mirror of
/// `MovedDestination`. Present on a session that arrived from another Mac; `nil` for one created
/// here. Lets a viewer render a subtle "Arrived from <deviceName>" note.
public struct ArrivedFrom: Sendable, Codable, Equatable {
/// The source Mac's `HostID` (full SHA-256 hex of its static key).
public let deviceID: String
/// The source Mac's friendly name, resolved from the paired-device store when the summary is
/// built (falls back to a generic label when unknown).
public let deviceName: String
public init(deviceID: String, deviceName: String) {
self.deviceID = deviceID
self.deviceName = deviceName
}
}
public struct SessionSummary: Sendable, Codable, Equatable {
public let sessionID: SessionID
public let projectID: String
public let projectName: String
public let backend: BackendID
public let status: SessionStatus
/// Refines `.awaitingInput` into "done" vs "needs you" (matches the Mac sidebar).
public let disposition: TurnDisposition?
public let title: String
public let branch: String
public let lastSeq: UInt64
/// Monotonic count of transcript reverts (undo) this session has performed. A revert resets
/// the seq counter, so `(sessionID, seq)` only identifies content *within* one epoch — a
/// receiver holding events under a different epoch than the owner advertises must drop and
/// refetch them (its copy may hold pre-revert content at reused seqs). 0 = never reverted;
/// `nil` = a host that predates the field (no comparison possible — legacy behavior).
public let revertEpoch: UInt64?
public let diffStat: DiffStat?
public let pendingApprovalCount: Int
/// When the active pending block is an `AskUserQuestion`, the number of questions it
/// asks; `nil` when the block is a plain approval or there's none. Lets a remote name
/// that block "Question(s) Asked" instead of the generic "Awaiting approval", matching
/// the host sidebar. (Optional so summaries from a host that predates the field decode
/// as "not a question".)
public let pendingQuestionCount: Int?
/// The id of this session's single most-urgent pending approval (its oldest), so a remote surface
/// that resolves an approval without opening the transcript — the aggregate Live Activity's inline
/// Allow/Deny — knows which one to act on. Just the id, never the tool input: the glance stays
/// content-free. `nil` when nothing's pending (or a host that predates the field).
public let firstApprovalID: ApprovalID?
/// Whether that approval is high-risk (destructive / network / host-exec). A surface hides inline
/// Allow for a high-risk request and offers only Deny / open-the-app (§3.3). `nil` when there's
/// none, or unknown (a host that predates the field) — treated as "no inline Allow".
public let firstApprovalIsHighRisk: Bool?
public let favorite: Bool
public let archived: Bool
/// A follow-up the user submitted while the agent was mid-turn, held to send the moment
/// the current turn finishes. `nil` = nothing queued. (Optional so older hosts/clients
/// that omit the key decode it as "no queued message".) A newline-joined summary of
/// `queuedMessages`, kept for backward compatibility.
public let queuedMessage: String?
/// The queued follow-ups as discrete items (each with its text and attachments), so the
/// phone can show and cancel each one individually — matching the Mac composer. Empty when
/// nothing is queued. (Tolerates summaries from a host that predates the field.)
public let queuedMessages: [QueuedMessage]
/// The session's current model SKU / effort level — what the phone's header pickers show as
/// selected and mutate via `setSessionModel` / `setSessionEffort`. `nil` = host/app default.
public let model: String?
public let effort: String?
/// Auto-approval and autoship state (mirrors the Mac header toggles). Default `false` so
/// summaries from a host that predates these fields decode cleanly.
public let auto: Bool
public let autoShip: Bool
/// Per-session autoship destination override; `nil` inherits the project default.
public let shipBranch: String?
/// The latest turn's context-window occupancy (input tokens of the final model call), so the
/// phone can show a context-usage badge without scanning the transcript. `nil` if unknown.
public let contextInputTokens: Int?
public let updatedAt: Date
/// When the user last submitted a message in this session. Drives the sidebar's recency
/// ordering so rows settle on the last *turn* rather than jumping on every bit of agent
/// activity (`updatedAt` bumps on output/diff refresh/autoship notes). `nil` = no user
/// message yet, or a summary from a host that predates the field — the sidebar then falls
/// back to recency of any activity, matching the local comparator.
public let lastUserMessageAt: Date?
/// Set once this session was moved to another Mac (mesh P5) — the summary is then a
/// tombstone (`archived == true`) and this names where it went. `nil` for a live session.
/// (Optional so summaries from a host that predates the field decode as "not moved".)
public let movedTo: MovedDestination?
/// Set on a session that arrived here from another Mac (mesh P5) — names where it came from,
/// for a subtle "Arrived from <Mac>" note. `nil` for a session created here. (Optional so
/// summaries from a host that predates the field decode as "not transferred in".)
public let arrivedFrom: ArrivedFrom?
public init(
sessionID: SessionID, projectID: String, projectName: String, backend: BackendID,
status: SessionStatus, disposition: TurnDisposition? = nil, title: String, branch: String,
lastSeq: UInt64, revertEpoch: UInt64? = nil, diffStat: DiffStat?,
pendingApprovalCount: Int = 0,
pendingQuestionCount: Int? = nil,
firstApprovalID: ApprovalID? = nil, firstApprovalIsHighRisk: Bool? = nil,
favorite: Bool = false, archived: Bool = false, queuedMessage: String? = nil,
queuedMessages: [QueuedMessage] = [],
model: String? = nil, effort: String? = nil, auto: Bool = false, autoShip: Bool = false,
shipBranch: String? = nil, contextInputTokens: Int? = nil,
updatedAt: Date, lastUserMessageAt: Date? = nil,
movedTo: MovedDestination? = nil, arrivedFrom: ArrivedFrom? = nil
) {
self.sessionID = sessionID
self.projectID = projectID
self.projectName = projectName
self.backend = backend
self.status = status
self.disposition = disposition
self.title = title
self.branch = branch
self.lastSeq = lastSeq
self.revertEpoch = revertEpoch
self.diffStat = diffStat
self.pendingApprovalCount = pendingApprovalCount
self.pendingQuestionCount = pendingQuestionCount
self.firstApprovalID = firstApprovalID
self.firstApprovalIsHighRisk = firstApprovalIsHighRisk
self.favorite = favorite
self.archived = archived
self.queuedMessage = queuedMessage
self.queuedMessages = queuedMessages
self.model = model
self.effort = effort
self.auto = auto
self.autoShip = autoShip
self.shipBranch = shipBranch
self.contextInputTokens = contextInputTokens
self.updatedAt = updatedAt
self.lastUserMessageAt = lastUserMessageAt
self.movedTo = movedTo
self.arrivedFrom = arrivedFrom
}
private enum CodingKeys: String, CodingKey {
case sessionID, projectID, projectName, backend, status, disposition, title, branch
case lastSeq, revertEpoch, diffStat, pendingApprovalCount, pendingQuestionCount
case firstApprovalID, firstApprovalIsHighRisk, favorite, archived, queuedMessage
case queuedMessages
case model, effort, auto, autoShip, shipBranch, contextInputTokens, updatedAt
case lastUserMessageAt
case movedTo, arrivedFrom
}
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.sessionID = try c.decode(SessionID.self, forKey: .sessionID)
self.projectID = try c.decode(String.self, forKey: .projectID)
self.projectName = try c.decode(String.self, forKey: .projectName)
self.backend = try c.decode(BackendID.self, forKey: .backend)
self.status = try c.decode(SessionStatus.self, forKey: .status)
self.disposition = try c.decodeIfPresent(TurnDisposition.self, forKey: .disposition)
self.title = try c.decode(String.self, forKey: .title)
self.branch = try c.decode(String.self, forKey: .branch)
self.lastSeq = try c.decode(UInt64.self, forKey: .lastSeq)
self.revertEpoch = try c.decodeIfPresent(UInt64.self, forKey: .revertEpoch)
self.diffStat = try c.decodeIfPresent(DiffStat.self, forKey: .diffStat)
self.pendingApprovalCount = try c.decodeIfPresent(Int.self, forKey: .pendingApprovalCount) ?? 0
self.pendingQuestionCount = try c.decodeIfPresent(Int.self, forKey: .pendingQuestionCount)
self.firstApprovalID = try c.decodeIfPresent(ApprovalID.self, forKey: .firstApprovalID)
self.firstApprovalIsHighRisk = try c.decodeIfPresent(Bool.self, forKey: .firstApprovalIsHighRisk)
self.favorite = try c.decodeIfPresent(Bool.self, forKey: .favorite) ?? false
self.archived = try c.decodeIfPresent(Bool.self, forKey: .archived) ?? false
self.queuedMessage = try c.decodeIfPresent(String.self, forKey: .queuedMessage)
self.queuedMessages = try c.decodeIfPresent([QueuedMessage].self, forKey: .queuedMessages) ?? []
// New fields — tolerate summaries from a host that predates them.
self.model = try c.decodeIfPresent(String.self, forKey: .model)
self.effort = try c.decodeIfPresent(String.self, forKey: .effort)
self.auto = try c.decodeIfPresent(Bool.self, forKey: .auto) ?? false
self.autoShip = try c.decodeIfPresent(Bool.self, forKey: .autoShip) ?? false
self.shipBranch = try c.decodeIfPresent(String.self, forKey: .shipBranch)
self.contextInputTokens = try c.decodeIfPresent(Int.self, forKey: .contextInputTokens)
self.updatedAt = try c.decode(Date.self, forKey: .updatedAt)
self.lastUserMessageAt = try c.decodeIfPresent(Date.self, forKey: .lastUserMessageAt)
self.movedTo = try c.decodeIfPresent(MovedDestination.self, forKey: .movedTo)
self.arrivedFrom = try c.decodeIfPresent(ArrivedFrom.self, forKey: .arrivedFrom)
}
}
extension SessionSummary {
/// Whether this session's turn *ended* — a completed/finished chat with nothing required from
/// the user. Mirrors the Mac sidebar's "Done" (`AppStore.SessionSummary.isCompleted`): a run
/// that finished normally, or an interactive turn classified `.completed`. A session asking a
/// question, blocked on an approval, or not yet classified is not done.
public var isTurnDone: Bool {
switch status {
case .finished: return true
case .awaitingInput: return disposition == .completed
default: return false
}
}
/// Whether the session is Done *and* quiet — nothing left for it to do: its turn ended and no
/// queued follow-up is waiting to dispatch when it next runs. A cloud runner sums the
/// complement of this across its sessions into the heartbeat's `activeSessions`, so the pool
/// can sleep a runner once every session it holds is quiescent (docs/COVALENCE_RUNNER.md §7).
/// A moved-away tombstone (`archived`) is inert and counts as quiescent.
public var isQuiescent: Bool {
if archived { return true }
return isTurnDone && queuedMessages.isEmpty
}
}
/// How the phone asks the host to land a session's branch (control scope). Mirrors the Mac's
/// integrate strategies.
public enum IntegrationMode: String, Sendable, Codable {
case merge, squash, rebase
}
public struct SessionSnapshot: Sendable, Codable, Equatable {
public let summary: SessionSummary
/// Tail window, oldest→newest (not the whole transcript — SYNC §5.3).
public let recentEvents: [AgentEvent]
public let pendingApprovals: [ApprovalRequest]
/// Client continues (`subscribe sinceSeq`) from here.
public let cursor: UInt64
/// The session's current collapsed Bash summary lines (mesh session sync), so a peer
/// subscribing mid-session seeds every already-computed card without waiting for the next
/// live `HostMsg.toolSummaries`. Non-empty only when this snapshot's owner has the session
/// open (that's the only session it summarizes); empty otherwise. Wire-optional, defaulted
/// `[]`, so an older owner that omits it still decodes.
public let toolSummaries: [ToolSummaryLine]
public init(
summary: SessionSummary, recentEvents: [AgentEvent],
pendingApprovals: [ApprovalRequest], cursor: UInt64,
toolSummaries: [ToolSummaryLine] = []
) {
self.summary = summary
self.recentEvents = recentEvents
self.pendingApprovals = pendingApprovals
self.cursor = cursor
self.toolSummaries = toolSummaries
}
private enum CodingKeys: String, CodingKey {
case summary, recentEvents, pendingApprovals, cursor, toolSummaries
}
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.summary = try c.decode(SessionSummary.self, forKey: .summary)
self.recentEvents = try c.decode([AgentEvent].self, forKey: .recentEvents)
self.pendingApprovals = try c.decode([ApprovalRequest].self, forKey: .pendingApprovals)
self.cursor = try c.decode(UInt64.self, forKey: .cursor)
self.toolSummaries =
try c.decodeIfPresent([ToolSummaryLine].self, forKey: .toolSummaries) ?? []
}
public func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(summary, forKey: .summary)
try c.encode(recentEvents, forKey: .recentEvents)
try c.encode(pendingApprovals, forKey: .pendingApprovals)
try c.encode(cursor, forKey: .cursor)
// Omit an empty set so the common no-summaries snapshot stays byte-for-byte as before.
if !toolSummaries.isEmpty { try c.encode(toolSummaries, forKey: .toolSummaries) }
}
}
/// An ordered batch of transcript events for one session (`HostMsg.events`). Carries its
/// seq range implicitly via the events; the client dedupes on `(sessionID, seq)`.
public struct EventBatch: Sendable, Codable, Equatable {
public let sessionID: SessionID
public let events: [AgentEvent]
public init(sessionID: SessionID, events: [AgentEvent]) {
self.sessionID = sessionID
self.events = events
}
}
/// A transcript was truncated in place — the user reverted (undid) the chat to just before a
/// prior message, dropping that message and everything after it. `throughSeq` is the transcript's
/// new tip: a receiver drops every event with `seq > throughSeq` from the session (0 ⇒ the whole
/// transcript was cleared). Unlike `EventBatch`, which only ever *grows* a mirrored transcript,
/// this is the one wire message that *shrinks* it — so a revert converges across the mesh the way
/// a new message does, instead of leaving receivers on a stale transcript until a manual refetch.
public struct TranscriptReverted: Sendable, Codable, Equatable {
public let sessionID: SessionID
public let throughSeq: UInt64
/// The session's revert epoch *after* this revert (see `SessionSummary.revertEpoch`). A
/// receiver that truncates to `throughSeq` records this as its held epoch, so the next
/// reconcile pass doesn't mistake its (now converged) copy for one that missed the revert.
/// `nil` from a host that predates the field.
public let revertEpoch: UInt64?
public init(sessionID: SessionID, throughSeq: UInt64, revertEpoch: UInt64? = nil) {
self.sessionID = sessionID
self.throughSeq = throughSeq
self.revertEpoch = revertEpoch
}
}
// MARK: - Errors (SYNC §8)
public struct WireError: Sendable, Codable, Equatable, Error {
public enum Code: String, Sendable, Codable {
case unauthorized // bad/revoked device or insufficient scope
case unknownSession
case alreadyResolved // lost the approval race
case unsupported // e.g. interrupt on a non-control scope
case backpressure // host overloaded; client should back off
case protocolVersion // version mismatch; client must upgrade
case channelMismatch // remote/host built from incompatible release channels
case malformed // undecodable frame
}
public let code: Code
public let message: String
public let sessionID: SessionID?
public init(code: Code, message: String, sessionID: SessionID? = nil) {
self.code = code
self.message = message
self.sessionID = sessionID
}
}