885 lines
50 KiB
Swift
885 lines
50 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
|
|
/// Whether this host routes Intelligence for a remote surface — it projects
|
|
/// `Welcome.intelligence` / `HostMsg.intelligenceCatalog`, honors `StartChatRequest.intelligence`,
|
|
/// and accepts `ClientMsg.setSessionIntelligence`. A client must not send
|
|
/// `setSessionIntelligence` unless this is set; an older host throws on the unknown tag (same
|
|
/// gating contract as `canMarkSessionDone`). Note `StartChatRequest.intelligence` needs no
|
|
/// gate of its own — it's an additive field on an existing verb, and an older host simply
|
|
/// ignores it and starts the chat on its defaults. Omitted ⇒ `false`.
|
|
public let canRouteIntelligence: 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,
|
|
canRouteIntelligence: 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
|
|
self.canRouteIntelligence = canRouteIntelligence
|
|
}
|
|
|
|
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
|
|
case canRouteIntelligence
|
|
}
|
|
|
|
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
|
|
self.canRouteIntelligence =
|
|
try c.decodeIfPresent(Bool.self, forKey: .canRouteIntelligence) ?? 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 Intelligence rail's ladder and the host's currently-resolved routes (SYNC §5.2). The
|
|
/// phone renders its rail from this and previews a route without a round trip; it is
|
|
/// re-pushed as `HostMsg.intelligenceCatalog` whenever the host's provider/quota posture
|
|
/// moves. Optional on the wire: a host that predates the field (or one with routing switched
|
|
/// off) decodes as `.empty` and the phone falls back to its manual model/effort menus.
|
|
public let intelligence: WireIntelligenceCatalog
|
|
/// 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,
|
|
intelligence: WireIntelligenceCatalog = .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.intelligence = intelligence
|
|
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, intelligence
|
|
}
|
|
|
|
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.intelligence =
|
|
try c.decodeIfPresent(WireIntelligenceCatalog.self, forKey: .intelligence) ?? .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?
|
|
/// The Intelligence rail's stop this chat was last routed at (`IntelligenceLevel.rawValue`),
|
|
/// and the purpose it was routed *for* — the two halves of the router's decision that
|
|
/// `model`/`effort` are merely the *result* of.
|
|
///
|
|
/// Both are load-bearing for a remote rail, and their absence was a real bug: without the
|
|
/// level, a remote had to guess its rail position by matching the pair back through the route
|
|
/// table (many stops share a model, so it guessed wrong); without the purpose, it re-classified
|
|
/// the composer draft to predict a route, while the host routes on what the *chat* is for — so
|
|
/// the two devices confidently named different models for the same stop. `nil` for a chat that
|
|
/// was never routed (started before routing, or with a model pinned by hand), where the rail
|
|
/// falls back to the nearest match.
|
|
public let routedLevel: Int?
|
|
public let routedPurpose: 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,
|
|
routedLevel: Int? = nil, routedPurpose: 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.routedLevel = routedLevel
|
|
self.routedPurpose = routedPurpose
|
|
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 routedLevel, routedPurpose
|
|
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.routedLevel = try c.decodeIfPresent(Int.self, forKey: .routedLevel)
|
|
self.routedPurpose = try c.decodeIfPresent(String.self, forKey: .routedPurpose)
|
|
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
|
|
}
|
|
}
|