import Foundation

/// Client → Host messages (SYNC_PROTOCOL §5.1). The phone subscribes, approves, and — at
/// `control` scope — drives the same actions the Mac can (start chats, manage to-dos, manage
/// and integrate sessions).
public enum ClientMsg: Sendable, Equatable {
    case hello(Hello)
    case listSessions
    case listDashboard                        // request Home/Projects/To-Dos state
    case subscribe(Subscribe)
    case unsubscribe(SessionID)
    case approvalRespond(ApprovalID, Decision)
    case sendInput(SessionID, AgentInput)     // scope ≥ approve
    case interrupt(SessionID)                 // scope ≥ control
    case ping
    // Control scope:
    case startChat(StartChatRequest)
    case captureTodo(CaptureTodoRequest)
    case dispatchTodo(TodoID, ProjectID)
    case setTodoStatus(TodoID, TodoStatus)
    case deleteTodo(TodoID)
    case renameSession(SessionID, String)
    case setFavorite(SessionID, Bool)
    case setArchived(SessionID, Bool)
    /// Hand-mark a chat "Done" — flip its last turn's disposition to `.completed` so a chat stuck
    /// reading "Awaiting Input" drops out of "needs you" (the wire counterpart of the Mac sidebar's
    /// "Mark Done"). A no-op on the host unless the chat is idle at `.awaitingInput`, the only state
    /// the disposition refines. `.control` scope. Send only when the host advertised
    /// `WireCapabilities.canMarkSessionDone`; an older host throws on the unknown tag.
    case markSessionDone(SessionID)
    case deleteSession(SessionID)
    case integrate(SessionID, IntegrationMode)
    case discard(SessionID)
    // Control scope: change a session's model / reasoning / automation mid-stream (the same
    // affordances the Mac header exposes). `nil` model/effort resets to the host/app default.
    case setSessionModel(SessionID, String?)
    case setSessionEffort(SessionID, String?)
    case setSessionAuto(SessionID, Bool)
    case setSessionAutoShip(SessionID, Bool)
    case setSessionShipBranch(SessionID, String?)
    /// Cancel one queued (not-yet-sent) follow-up by id — matches the Mac composer's per-message
    /// ✕. The `UUID` is the `QueuedMessage.id` from the session summary.
    case cancelQueuedMessage(SessionID, UUID)
    /// Resolve a live "host command looks hung" alert from a remote surface (the Mac's
    /// `AppStore.resolveProcessStall`, lifted to the wire): `kill == true` tears down that
    /// command's whole process tree, `false` dismisses the alert and lets it run on. `stallID` is
    /// the `ProcessStallNote.id` from the alert note. `.control` scope. Send only when the host
    /// advertised `WireCapabilities.canResolveProcessStall`; an older host throws on the unknown tag.
    case resolveProcessStall(SessionID, stallID: String, kill: Bool)
    /// Ask for the session's full worktree diff (`HostMsg.sessionDiff`) — the Mac Diff tab's
    /// patch, on demand. Read-only (scope ≥ view). Send only when
    /// `WireCapabilities.canFetchDiff`; an older host treats it as an undecodable frame.
    case fetchDiff(SessionID)
    /// Ask the host for its mesh peer list (`HostMsg.peerList`) — the other devices it knows,
    /// for the mesh device list and the transfer destination picker (mesh P4). Read-only. Send
    /// only when `WireCapabilities.canListPeers`; an older host rejects the unknown tag.
    case listPeers
    /// Refresh the addresses the host has on file for *this* device (mesh P4) — a mac peer
    /// whose LAN endpoint or tailnet IP changed mid-connection. The host updates the sender's
    /// own record only. Send only when `WireCapabilities.canUpdateAddresses`; an older host
    /// rejects the unknown tag.
    case addressUpdate(PeerAddresses)
    /// Push this device's mesh roster (live members + revocation tombstones) to the host it's
    /// talking to (mesh "join"). The host merges it, so a device that scanned a *different* member
    /// still learns everyone. Send only when the host advertised `WireCapabilities.canSyncRoster`;
    /// an older host rejects the unknown tag.
    case meshRoster(MeshRosterPush)

    // Session transfer (mesh P5) — the source acts as a client of the destination. All gated
    // on `WireCapabilities.canReceiveSessionTransfer` + `.control` scope + a mac peer.
    /// Open a transfer with the manifest; the destination replies `transferAccept`/`transferReject`.
    case transferOffer(TransferOffer)
    /// One ordered slice of a transfer item; the destination replies `transferChunkAck`.
    case transferChunk(TransferChunk)
    /// Phase-2 commit (idempotent): the source has durably tombstoned; activate the staged session.
    case transferCommit(String)
    /// The source is abandoning the transfer (user cancelled / error); discard any staged data.
    case transferCancel(String)
    /// Ask the *owner* of a session to move it to a third device — the "Transfer…" menu on a chat
    /// this device doesn't own (mesh P5 follow-up). The owner runs its own `moveSession`, so every
    /// transfer still originates at the machine holding the worktree; this only names the
    /// destination (`deviceID`, which may be the requester itself — "bring it here"). `.control`
    /// scope + a session-owning peer. Send only when the owner advertised
    /// `WireCapabilities.canTransferOnRequest`; an older host throws on the unknown tag.
    case requestSessionTransfer(SessionID, String)

    /// Ask a peer for a session's full transcript after a seq cursor (full-transcript mesh sync).
    /// The requester acts as a client of the holder; the holder answers with `transcriptChunk`
    /// batches then `transcriptFetchComplete`, or `transcriptUnavailable`. Read-only (scope ≥
    /// view). Send only when the holder advertised `WireCapabilities.canSyncTranscripts`; an
    /// older peer throws on the unknown tag.
    case fetchTranscript(TranscriptFetch)

    // Carbon shard replication (docs/CARBON_SHARDING.md §8.1) — a mirror acts as a client of a
    // holder. All four gated on `WireCapabilities.canServeCarbon`; an older peer throws on the
    // unknown tag. Read-only (scope ≥ view): blobs are ciphertext, verifiable without the key.
    /// Ask a peer for its Carbon head state (`nil` project = all). Answered by `carbonHeads`.
    case fetchCarbonHeads(CarbonHeadsFetch)
    /// Fetch manifest ciphertexts by id (≤ 64). Each id answers as `carbonManifestData` or
    /// `carbonManifestUnavailable`.
    case fetchCarbonManifests(CarbonBlobsFetch)
    /// Fetch shard ciphertexts by id (≤ 256). Served under the §8.3 ack window: at most
    /// `CarbonFormat.shardAckWindow` un-acked `carbonShardData` frames in flight.
    case fetchCarbonShards(CarbonBlobsFetch)
    /// Window ack for received `carbonShardData` frames — the server sends more as these arrive.
    case carbonShardAck(CarbonShardAck)

    // Carbon lock verbs (CARBON_SHARDING §17.5) — a remote session's host acts as a client of
    // the domain's lock authority. Both gated on `WireCapabilities.canServeCarbon`; an older
    // peer throws on the unknown tag. Mutating (scope ≥ control): they arbitrate edits.
    /// Acquire `paths` in a lock domain for a remote session. Answered by `carbonLockDecision`
    /// (an interim `queued` may precede the final `granted`/`cancelled`).
    case carbonLockAcquire(CarbonLockRequest)
    /// Release a remote session's locks (`paths == nil` = all). No reply; the ledger carries
    /// the released state to every member.
    case carbonLockRelease(CarbonLockRequest)

    /// Register (or refresh) this device's Live Activity push token so the host can keep the
    /// lock-screen glance fresh over APNs while the phone is backgrounded and its socket is
    /// suspended (UX_IOS §5.3). Carries the per-activity update token (hex) and the activity's id.
    /// Read-only (scope ≥ view — it only enables the host to push the same aggregate the phone
    /// already shows). Send only when the host advertised `WireCapabilities.canPushLiveActivity`;
    /// an older host throws on the unknown tag.
    case registerLiveActivity(String, String)
    /// The phone's Live Activity ended (dismissed / all idle) — stop pushing to it. Carries the
    /// activity id. Same gating as `registerLiveActivity`.
    case endLiveActivity(String)
    /// Register (or refresh) this device's **push-to-start** token (iOS 17.2+) — the device-scoped
    /// token that lets the host *create* the Live Activity over APNs when a session starts while the
    /// app is closed/backgrounded, without the user first having to open it (UX_IOS §5.3). Distinct
    /// from `registerLiveActivity`'s per-activity update token: this one exists before any Activity
    /// does and survives app termination. Read-only (scope ≥ view) and same gating as
    /// `registerLiveActivity` — send only when the host advertised `WireCapabilities.canPushLiveActivity`.
    case registerPushToStartToken(String)
    /// Report whether this device's app is currently **foreground** (`true`) or backgrounded
    /// (`false`). The host uses it to pick the Live Activity path (UX_IOS §5.3): a foreground phone
    /// creates the glance itself over the socket, so the host defers; a backgrounded phone can't
    /// reliably start one — even while its socket lingers — so the host push-to-starts it instead.
    /// Sent on connect and on every foreground/background transition. Read-only (scope ≥ view). Send
    /// only when the host advertised `WireCapabilities.canReceiveForegroundState`; an older host
    /// throws on the unknown tag.
    case setForeground(Bool)

    /// Ask a connected Mac to open a pairing window and hand back its join code so the phone can
    /// display it — the "add a device to this mesh" button. The host answers with
    /// `HostMsg.pairingCode`. `.control` scope; send only when the host advertised
    /// `WireCapabilities.canMintPairingCode` (an older host throws on the unknown tag).
    case requestPairingCode
    /// Close the pairing window opened by `requestPairingCode` (the phone dismissed the sheet) so
    /// the one-time secret doesn't stay armed. Same gating as `requestPairingCode`.
    case cancelPairingCode
    /// Approve or deny a Mac trying to join, forwarded here as `HostMsg.macPairRequested` — so the
    /// phone that shared the join code can allow the join without walking to the Mac. `deviceID`
    /// identifies the pending request; first responder wins (the accepting Mac can still answer on
    /// its own screen). `.control` scope; send only when the host advertised
    /// `WireCapabilities.canMintPairingCode`.
    case respondMacPair(String, Bool)

    /// The answer to a delegated intelligence request (`HostMsg.intelligenceRequest`, runner §5) —
    /// an AFM-capable device ran one `IntelligenceProviding` call locally and returns the outputs
    /// (or an error, so the host falls straight back to heuristics). Keyed by the request id.
    /// Read-only in effect (it only feeds soft-AI summaries/classification). Send only when the
    /// host advertised `WireCapabilities.canDelegateIntelligence`; an older host throws on the
    /// unknown tag.
    case intelligenceResult(WireIntelligenceResult)
    /// Push this device's credential manifest — descriptors + refresh leases, never secret bytes
    /// (runner §6). The host learns what it can ask for (`HostMsg.credentialNeeded`) and who
    /// refreshes each kind. Scope ≥ approve. Send only when the host advertised
    /// `WireCapabilities.canReceiveSealedCredentials`; an older host throws on the unknown tag.
    case credentialManifest(CredentialManifest)
    /// Deliver credential records sealed to the host's sealing key (runner §6) — answering a
    /// `credentialNeeded`, or proactively after this device refreshed a token. The host can only
    /// open boxes sealed to *its* key; everything in between carries ciphertext. Scope ≥ approve.
    /// Same gating as `credentialManifest`.
    case credentialProvision(SealedCredentialEnvelope)
    /// Delete credential kinds mesh-wide (the tombstone counterpart of `credentialProvision`):
    /// the host clears its own copy, records each tombstone, fans it out to its other clients
    /// (`HostMsg.credentialRevoked`) and re-gossips it peer-ward — `deletedAt`-monotonic, so a
    /// stale holder can never resurrect what the user removed. `.control` scope (deleting the
    /// mesh-wide credential is the same trust tier as rotating it via `agentLoginBegin`). Send
    /// only when the host advertised `WireCapabilities.canRevokeCredentials`; an older host
    /// throws on the unknown tag.
    case credentialRevoke([CredentialTombstone])
    /// Clone a git repository on the host and register it as a project (CLOUD_RUNTIME §4.3) —
    /// how a device seeds work onto a fresh runner, whose store starts empty. `.control` scope.
    /// Send only when the host advertised `WireCapabilities.canCreateProjects`; an older host
    /// throws on the unknown tag. Failure comes back as `HostMsg.error`; success surfaces via
    /// the dashboard broadcast carrying the new project.
    case createProject(WireCreateProjectRequest)

    /// Subscribe to the host's cast feed (mesh casting, `CastMessages.swift`): live `casts`
    /// fanout plus a `castCatchUp` pass over every (origin, channel) the host holds at this
    /// device's scope, resumed from the carried cursors. Read-only (scope ≥ view, per-channel
    /// minimums on top). Send only when the host advertised `WireCapabilities.canCast`; an
    /// older host throws on the unknown tag.
    case castSubscribe(CastSubscribe)

    /// Report this device's live composer state for a session (mesh composer streaming) so the
    /// host can republish it on the `composer.typing` cast channel — a client can't cast, so the
    /// session's host is its relay. Throttled sender-side (`ComposerTypingState
    /// .minPublishInterval`); `active: false` ends the typing (send / cleared / idle). Scope ≥
    /// approve (the same trust that lets the device send input lets it stream a draft). Send
    /// only when the host advertised `WireCapabilities.canStreamComposer`; an older host throws
    /// on the unknown tag.
    case composerTyping(WireComposerTyping)

    /// Ask the host to change the account-level synced settings (`SyncedSettings`) — e.g. toggle
    /// whether Live-Activity approvals resolve over the Nucleic Edge. The host is the source of
    /// truth: it applies, persists, enforces (a change here can force the relay method on), and
    /// re-broadcasts the result as `HostMsg.settings`. Approve scope (these settings govern how
    /// approvals behave, matching `approvalRespond`). Send only when the host advertised
    /// `WireCapabilities.canSyncSettings`; an older host throws on the unknown tag.
    case updateSettings(SyncedSettings)

    /// Start a direct-path upgrade of this relay session (Covalence direct, SYNC_PROTOCOL §3.3):
    /// the client's punch candidates, NAT class, probe token, and QUIC cert pin — exchanged
    /// inside Noise so the relay never sees an address. The host answers `directAnswer` (and
    /// both sides start punching) or `directDecline`. Read-only in effect (it changes the byte
    /// path, not any state). Send only when the host advertised
    /// `WireCapabilities.canDirectConnect` AND the session rides the relay; an older host
    /// throws on the unknown tag.
    case directOffer(DirectPunchOffer)
    /// The client proved a round trip to this host candidate: stop punching, rebind the punch
    /// socket as the QUIC listener, answer `directGo`. Same gating as `directOffer`.
    case directSelect(DirectSelect)

    // Remote agent sign-in (docs/REMOTE_AGENT_LOGIN.md). All three gated on
    // `WireCapabilities.canBrokerAgentLogin` + `.control` scope — starting a sign-in rotates a
    // mesh-wide credential, the same trust tier as the other control verbs.
    /// Ask this host to start one agent sign-in (Claude/Codex OAuth, brokered host-side). The
    /// host answers `agentLoginChallenge` (open this URL, capture the redirect) and eventually
    /// `agentLoginResult`, both correlated by the carried `requestID`.
    case agentLoginBegin(WireAgentLoginBegin)
    /// The captured redirect for an in-flight attempt — the authorization code (+ state) from
    /// the device's loopback listener or the user's paste. The host verifies state and runs the
    /// token exchange; the PKCE verifier never leaves it.
    case agentLoginCallback(WireAgentLoginCallback)
    /// The device abandoned the attempt (sheet dismissed / timeout) — the host discards the
    /// PKCE state and answers with a failed `agentLoginResult`. Carries the `requestID`.
    case agentLoginCancel(String)
}

/// Host → Client messages (SYNC_PROTOCOL §5.2). Forward-compatible: unknown tags decode to
/// `.unknown` rather than throwing, so a newer host can add cases without breaking older
/// clients (SYNC §9).
public enum HostMsg: Sendable, Equatable {
    case welcome(Welcome)
    case sessionList([SessionSummary])
    case snapshot(SessionSnapshot)
    case events(EventBatch)
    case approvalRequested(ApprovalRequest)
    case approvalResolved(ApprovalResolved)
    case sessionUpdated(SessionSummary)
    case dashboard(DashboardSnapshot)
    /// The full worktree diff a `fetchDiff` asked for.
    case sessionDiff(WireSessionDiff)
    /// The host's mesh peer list, answering `ClientMsg.listPeers` (mesh P4).
    case peerList([PeerSummary])
    /// The host's mesh roster (live members + revocation tombstones), pushed to a client that
    /// advertised `WireClientCapabilities.canSyncRoster` (mesh "join"). The client merges it —
    /// auto-pinning mac members it didn't know and dropping tombstoned ones — so joining via any
    /// member converges the whole group. Safe as a new tag (`.unknown` fallback), but the client
    /// must also have a `SyncClient.Event.meshRoster` case + `messageLoop` branch to act on it.
    case meshRoster(MeshRosterPush)

    // Session transfer replies (mesh P5), dest→source. Safe as new tags (`.unknown` fallback),
    // but each ALSO needs a `SyncClient.Event` case + `messageLoop` branch, or the source —
    // which receives these as a client — silently drops them.
    case transferAccept(TransferAccept)
    case transferReject(TransferReject)
    /// The destination staged the session non-runnable and verified every item hash.
    case transferReady(String)
    /// The destination activated the staged session (answers `transferCommit`, idempotent).
    case transferCommitted(String)
    /// Flow-control ack for one persisted chunk.
    case transferChunkAck(TransferChunkAck)

    // Full-transcript mesh sync replies (holder→requester). Safe as new tags (`.unknown`
    // fallback), but each ALSO needs a `SyncClient.Event` case + `messageLoop` branch, or a
    // requester — which receives these as a client — silently drops them.
    /// One ordered batch of the requested transcript events.
    case transcriptChunk(TranscriptChunk)
    /// Terminal success: every batch was sent (carries the tip seq and, on a full fetch, header).
    case transcriptFetchComplete(TranscriptFetchComplete)
    /// Terminal failure: this holder can't serve the fetch (e.g. `notHeld` while probing peers).
    case transcriptUnavailable(TranscriptUnavailable)

    // Carbon shard replication replies + push (holder→mirror, docs/CARBON_SHARDING.md §8.1).
    // Safe as new tags (`.unknown` fallback), but each ALSO needs a `SyncClient.Event` case +
    // `messageLoop` branch, or a mirror — which receives these as a client — silently drops them.
    /// Full head state answering `fetchCarbonHeads` (idempotent).
    case carbonHeads(CarbonHeadsReply)
    /// Owner push on every new manifest (§8.2 step 1) — mirrors pull-fetch what they lack.
    case carbonHeadAdvanced(CarbonHead)
    /// One manifest ciphertext answering `fetchCarbonManifests`.
    case carbonManifestData(CarbonBlobData)
    case carbonManifestUnavailable(CarbonBlobUnavailable)
    /// One shard ciphertext answering `fetchCarbonShards` (§8.3 window applies).
    case carbonShardData(CarbonBlobData)
    case carbonShardUnavailable(CarbonBlobUnavailable)
    /// Lock-arbitration outcome for a `carbonLockAcquire` (CARBON_SHARDING §17.5). Safe as a
    /// new tag (`.unknown` fallback) but needs its `SyncClient.Event` case + branch.
    case carbonLockDecision(CarbonLockDecisionMsg)
    /// Authority push on every lock-ledger append (§17.5) — members chain-sync the `lock`
    /// stream exactly like any other Carbon head and re-fold.
    case carbonLockLedgerAdvanced(CarbonHead)

    /// The current collapsed Bash summary lines for a subscribed session (mesh session sync),
    /// pushed to peers viewing it as the owner (re)computes them. Safe as a new tag (`.unknown`
    /// fallback), but a client that acts on it ALSO needs a `SyncClient.Event.toolSummaries`
    /// case + `messageLoop` branch, or it silently drops them.
    case toolSummaries(ToolSummaryBatch)

    /// A subscribed session's transcript was truncated in place — the owner reverted (undid) the
    /// chat to just before a prior message. A viewer drops every event with `seq > throughSeq`, so
    /// its transcript shrinks to match instead of waiting for a manual refetch. Safe as a new tag
    /// (`.unknown` fallback), but a client that acts on it ALSO needs a
    /// `SyncClient.Event.transcriptReverted` case + `messageLoop` branch, or it silently drops it.
    case transcriptReverted(TranscriptReverted)

    /// This device's relay credential (mesh P2), pushed after every hello when the host has
    /// the Covalence method enabled. Devices persist it and use the relay as a
    /// dial fallback; devices paired before the relay adopt it from this push alone.
    case relayMembership(WireRelayMembership)

    /// A pairing/join code a client asked this host to mint (`ClientMsg.requestPairingCode`) — the
    /// `nucleic://pair?d=…` string the phone renders as a QR and offers to copy. `nil` when the
    /// host has meshing on but couldn't mint one right now (e.g. the sync server isn't running).
    /// Safe as a new tag (`.unknown` fallback), but a client that acts on it ALSO needs a
    /// `SyncClient.Event.pairingCode` case + `messageLoop` branch.
    case pairingCode(String?)

    /// A Mac is trying to join the group and needs the user's allow/deny (mesh P4) — forwarded to
    /// control-scope clients so the phone that shared the join code can approve it, in parallel
    /// with the accepting Mac's own dialog (first responder wins). Safe as a new tag (`.unknown`
    /// fallback), but a client that acts on it ALSO needs a `SyncClient.Event.macPairRequested`
    /// case + `messageLoop` branch.
    case macPairRequested(WireMacPairRequest)
    /// The pending Mac-pair (identified by `deviceID`) was answered or withdrawn — `approved`
    /// carries the outcome. Clients dismiss the forwarded prompt by id. Same forward-compat notes
    /// as `macPairRequested`.
    case macPairResolved(String, Bool)

    /// One delegated intelligence call (runner §5), pushed only to clients that advertised
    /// `WireClientCapabilities.canProvideIntelligence`. The device runs it on its local AFM
    /// queue and answers `ClientMsg.intelligenceResult` with the same id. Safe as a new tag
    /// (`.unknown` fallback), but an executor ALSO needs a `SyncClient.Event.intelligenceRequest`
    /// case + `messageLoop` branch, or it silently drops the work.
    case intelligenceRequest(WireIntelligenceRequest)
    /// The host lacks (or lost) credentials it needs to run agents (runner §6): which kinds, and
    /// the X25519 key to seal them to. A holding device answers `ClientMsg.credentialProvision`.
    /// Safe as a new tag (`.unknown` fallback) with the same `SyncClient.Event` caveat.
    case credentialNeeded(WireCredentialNeed)
    /// The host rotated a credential itself (it holds the refresh lease) and mirrors the new
    /// revision back, sealed to this device's own sealing key from its last manifest (runner §6) —
    /// the cloud analogue of `syncClaudeLoginBack`. Same forward-compat notes as
    /// `credentialNeeded`.
    case credentialUpdate(SealedCredentialEnvelope)
    /// Credential kinds were deleted mesh-wide (key deletion from any device): pushed post-hello
    /// (the host's whole tombstone table, so a late joiner converges) and fanned out when a
    /// deletion lands. Receivers clear their local copies (`deletedAt`-monotonic — a strictly
    /// newer login still wins) and record the tombstones so their own manifests stop offering
    /// the kind. Safe as a new tag (`.unknown` fallback) with the usual `SyncClient.Event` caveat.
    case credentialRevoked([CredentialTombstone])

    /// The outcome of this connection's `ClientMsg.createProject` (CLOUD_RUNTIME §4.3), keyed by
    /// the request's `requestID` — success (project id + name) or a human-readable failure. Sent
    /// only to the requester; everyone else sees the project via the dashboard broadcast. Safe as
    /// a new tag (`.unknown` fallback), but a client that acts on it ALSO needs a
    /// `SyncClient.Event.projectCreated` case + `messageLoop` branch.
    case projectCreated(WireProjectCreated)

    /// The owner's runner-pool credential riding the mesh (COVALENCE_RUNNER §0.2 item 4),
    /// pushed post-hello to control-scope peers the way `relayMembership` is — so every trusted
    /// device manages the SAME pool instead of PoP-enrolling its own (which rotates the secret).
    /// Receivers merge on `updatedAt` (newest wins). Safe as a new tag (`.unknown` fallback),
    /// with the usual `SyncClient.Event` caveat.
    case runnerPoolCredential(WireRunnerPoolCredential)

    /// The definitive outcome of a mesh-dispatched `startChat` (correlated by `requestID`) —
    /// sent only to the requester, and idempotent per id (a retried request acks the same
    /// session). Safe as a new tag (`.unknown` fallback), but a dispatcher ALSO needs the
    /// `SyncClient.Event.chatStarted` case + `messageLoop` branch.
    case chatStarted(WireChatStarted)

    /// A live batch of casts (mesh casting) — pushed to subscribed clients that advertised
    /// `WireClientCapabilities.canCast`. Safe as a new tag (`.unknown` fallback), but a client
    /// that acts on it ALSO needs the `SyncClient.Event.casts` case + `messageLoop` branch.
    case casts(CastBatch)
    /// Catch-up for one (origin, channel) of the subscriber's cast feed — ordered batches, then
    /// `complete: true` with the holder's tip. Same forward-compat notes as `casts`.
    case castCatchUp(CastCatchUp)

    /// The account-level synced settings changed (`SyncedSettings`) — broadcast to every connected
    /// device whenever the host applies an `updateSettings` or changes the value locally, so the
    /// mesh converges on one truth. The connect-time value also rides in `Welcome.settings`. Sent
    /// only to clients whose host advertised `WireCapabilities.canSyncSettings`. Safe as a new tag
    /// (`.unknown` fallback), but a client that acts on it ALSO needs a `SyncClient.Event.settings`
    /// case + `messageLoop` branch.
    case settings(SyncedSettings)

    /// The host's half of a direct-path upgrade (Covalence direct, SYNC_PROTOCOL §3.3),
    /// answering `ClientMsg.directOffer` — its own candidates, NAT class, probe token, and
    /// QUIC cert pin. Both sides punch on receipt. Safe as a new tag (`.unknown` fallback),
    /// but an upgrading client ALSO needs the `SyncClient.Event.directAnswer` case +
    /// `messageLoop` branch, or the upgrade silently never starts.
    case directAnswer(DirectPunchOffer)
    /// The host's QUIC listener is up on the selected candidate — dial now. Same
    /// forward-compat notes as `directAnswer`.
    case directGo
    /// The host won't upgrade this session (setting off, punch pointless, attempt already
    /// in flight, …) — the client stays on relay and backs off. Same forward-compat notes.
    case directDecline(String)

    // Remote agent sign-in (docs/REMOTE_AGENT_LOGIN.md). Safe as new tags (`.unknown`
    // fallback), but each ALSO needs a `SyncClient.Event` case + `messageLoop` branch, or the
    // initiating device silently drops them.
    /// The consent challenge for an attempt this connection began (`agentLoginBegin`): open the
    /// authorize URL in a browser and capture the redirect as described. Sent only to the
    /// initiator.
    case agentLoginChallenge(WireAgentLoginChallenge)
    /// The attempt's definitive outcome, keyed by `requestID` — sent only to the initiator;
    /// everyone else converges via the `agentAuthStatus` push that follows a success.
    case agentLoginResult(WireAgentLoginResult)
    /// The host's per-provider install/auth state (and which providers it can broker a sign-in
    /// for), pushed post-hello and on every change — the wire projection of Settings ▸ Agents.
    case agentAuthStatus([WireProviderAuthStatus])

    case error(WireError)
    case pong
    /// An unrecognized future message tag (forward-compat); the client ignores it.
    case unknown(String)
}

// MARK: - Codable (tagged: {"t": <tag>, ...payload})

private enum EnvelopeKey: String, CodingKey {
    case t, sessionID, approvalID, decision, input, tag
    case request, todoID, projectID, todoStatus, title, flag, mode
    // Remote resolution of a "host command looks hung" alert (the Kill / Keep-waiting buttons).
    case stallID, kill
    case model, effort, branch, messageID, addresses
    // Session transfer (mesh P5).
    case transferID, offer, chunk, accept, reject, ack
    // Full-transcript mesh sync.
    case fetch, transcriptChunk, transcriptComplete, transcriptUnavailable
    // Carbon shard replication (docs/CARBON_SHARDING.md §8.1).
    case carbonFetch, carbonHeads, carbonHead, carbonBlob, carbonUnavailable, carbonAck
    // Carbon lock verbs (CARBON_SHARDING §17.5).
    case carbonLock, carbonLockDecision
    // Collapsed tool-call summaries (mesh session sync).
    case toolSummaries
    // Transcript revert/undo fan-out (a truncate-to-tip across the mesh).
    case reverted
    // Relay membership push (mesh P2).
    case membership
    // Mesh roster gossip (mesh "join").
    case roster
    // Live Activity push registration (UX_IOS §5.3).
    case pushToken, activityID, pushToStartToken
    // Remote pairing-code mint ("add a device to this mesh").
    case qr
    // Forwarded Mac-pair approval (approve a Mac join from the phone).
    case macPairRequest, deviceID, approved
    // Covalence runner: intelligence delegation + credential mesh (docs/COVALENCE_RUNNER.md).
    case intelligence, intelligenceResult, credentialNeed, credentials, manifest
    // Mesh-wide credential deletion (key deletion from any device).
    case tombstones
    // Remote project creation (CLOUD_RUNTIME §4.3).
    case projectCreated
    // Runner-pool credential on the mesh (COVALENCE_RUNNER §0.2 item 4).
    case poolCredential
    // Mesh casting (CastMessages.swift).
    case castSub, castBatch, castCatchUp
    // Live composer streaming (the "composer.typing" channel).
    case composerTyping
    // Mesh dispatch.
    case chatStarted
    // Account-level synced settings (SyncedSettings).
    case settings
    // Direct-path upgrade (Covalence direct, SYNC_PROTOCOL §3.3).
    case direct, reason
    // Remote agent sign-in (docs/REMOTE_AGENT_LOGIN.md).
    case agentLogin, requestID, authStatus
}

extension ClientMsg: Codable {
    public init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: EnvelopeKey.self)
        switch try c.decode(String.self, forKey: .t) {
        case "hello": self = .hello(try Hello(from: decoder))
        case "listSessions": self = .listSessions
        case "subscribe": self = .subscribe(try Subscribe(from: decoder))
        case "unsubscribe": self = .unsubscribe(try c.decode(SessionID.self, forKey: .sessionID))
        case "approvalRespond":
            self = .approvalRespond(
                try c.decode(ApprovalID.self, forKey: .approvalID),
                try c.decode(Decision.self, forKey: .decision))
        case "sendInput":
            self = .sendInput(
                try c.decode(SessionID.self, forKey: .sessionID),
                try c.decode(AgentInput.self, forKey: .input))
        case "interrupt": self = .interrupt(try c.decode(SessionID.self, forKey: .sessionID))
        case "ping": self = .ping
        case "listDashboard": self = .listDashboard
        case "startChat": self = .startChat(try c.decode(StartChatRequest.self, forKey: .request))
        case "captureTodo": self = .captureTodo(try c.decode(CaptureTodoRequest.self, forKey: .request))
        case "dispatchTodo":
            self = .dispatchTodo(
                try c.decode(TodoID.self, forKey: .todoID),
                try c.decode(ProjectID.self, forKey: .projectID))
        case "setTodoStatus":
            self = .setTodoStatus(
                try c.decode(TodoID.self, forKey: .todoID),
                try c.decode(TodoStatus.self, forKey: .todoStatus))
        case "deleteTodo": self = .deleteTodo(try c.decode(TodoID.self, forKey: .todoID))
        case "renameSession":
            self = .renameSession(
                try c.decode(SessionID.self, forKey: .sessionID),
                try c.decode(String.self, forKey: .title))
        case "setFavorite":
            self = .setFavorite(
                try c.decode(SessionID.self, forKey: .sessionID),
                try c.decode(Bool.self, forKey: .flag))
        case "setArchived":
            self = .setArchived(
                try c.decode(SessionID.self, forKey: .sessionID),
                try c.decode(Bool.self, forKey: .flag))
        case "markSessionDone":
            self = .markSessionDone(try c.decode(SessionID.self, forKey: .sessionID))
        case "deleteSession": self = .deleteSession(try c.decode(SessionID.self, forKey: .sessionID))
        case "integrate":
            self = .integrate(
                try c.decode(SessionID.self, forKey: .sessionID),
                try c.decode(IntegrationMode.self, forKey: .mode))
        case "discard": self = .discard(try c.decode(SessionID.self, forKey: .sessionID))
        case "setSessionModel":
            self = .setSessionModel(
                try c.decode(SessionID.self, forKey: .sessionID),
                try c.decodeIfPresent(String.self, forKey: .model))
        case "setSessionEffort":
            self = .setSessionEffort(
                try c.decode(SessionID.self, forKey: .sessionID),
                try c.decodeIfPresent(String.self, forKey: .effort))
        case "setSessionAuto":
            self = .setSessionAuto(
                try c.decode(SessionID.self, forKey: .sessionID),
                try c.decode(Bool.self, forKey: .flag))
        case "setSessionAutoShip":
            self = .setSessionAutoShip(
                try c.decode(SessionID.self, forKey: .sessionID),
                try c.decode(Bool.self, forKey: .flag))
        case "setSessionShipBranch":
            self = .setSessionShipBranch(
                try c.decode(SessionID.self, forKey: .sessionID),
                try c.decodeIfPresent(String.self, forKey: .branch))
        case "cancelQueuedMessage":
            self = .cancelQueuedMessage(
                try c.decode(SessionID.self, forKey: .sessionID),
                try c.decode(UUID.self, forKey: .messageID))
        case "resolveProcessStall":
            self = .resolveProcessStall(
                try c.decode(SessionID.self, forKey: .sessionID),
                stallID: try c.decode(String.self, forKey: .stallID),
                kill: try c.decode(Bool.self, forKey: .kill))
        case "fetchDiff":
            self = .fetchDiff(try c.decode(SessionID.self, forKey: .sessionID))
        case "listPeers": self = .listPeers
        case "addressUpdate":
            self = .addressUpdate(try c.decode(PeerAddresses.self, forKey: .addresses))
        case "meshRoster":
            self = .meshRoster(try c.decode(MeshRosterPush.self, forKey: .roster))
        case "transferOffer":
            self = .transferOffer(try c.decode(TransferOffer.self, forKey: .offer))
        case "transferChunk":
            self = .transferChunk(try c.decode(TransferChunk.self, forKey: .chunk))
        case "transferCommit":
            self = .transferCommit(try c.decode(String.self, forKey: .transferID))
        case "transferCancel":
            self = .transferCancel(try c.decode(String.self, forKey: .transferID))
        case "requestSessionTransfer":
            self = .requestSessionTransfer(
                try c.decode(SessionID.self, forKey: .sessionID),
                try c.decode(String.self, forKey: .deviceID))
        case "fetchTranscript":
            self = .fetchTranscript(try c.decode(TranscriptFetch.self, forKey: .fetch))
        case "fetchCarbonHeads":
            self = .fetchCarbonHeads(try c.decode(CarbonHeadsFetch.self, forKey: .carbonFetch))
        case "fetchCarbonManifests":
            self = .fetchCarbonManifests(try c.decode(CarbonBlobsFetch.self, forKey: .carbonFetch))
        case "fetchCarbonShards":
            self = .fetchCarbonShards(try c.decode(CarbonBlobsFetch.self, forKey: .carbonFetch))
        case "carbonShardAck":
            self = .carbonShardAck(try c.decode(CarbonShardAck.self, forKey: .carbonAck))
        case "carbonLockAcquire":
            self = .carbonLockAcquire(try c.decode(CarbonLockRequest.self, forKey: .carbonLock))
        case "carbonLockRelease":
            self = .carbonLockRelease(try c.decode(CarbonLockRequest.self, forKey: .carbonLock))
        case "registerLiveActivity":
            self = .registerLiveActivity(
                try c.decode(String.self, forKey: .pushToken),
                try c.decode(String.self, forKey: .activityID))
        case "endLiveActivity":
            self = .endLiveActivity(try c.decode(String.self, forKey: .activityID))
        case "registerPushToStartToken":
            self = .registerPushToStartToken(try c.decode(String.self, forKey: .pushToStartToken))
        case "setForeground": self = .setForeground(try c.decode(Bool.self, forKey: .flag))
        case "requestPairingCode": self = .requestPairingCode
        case "cancelPairingCode": self = .cancelPairingCode
        case "respondMacPair":
            self = .respondMacPair(
                try c.decode(String.self, forKey: .deviceID),
                try c.decode(Bool.self, forKey: .approved))
        case "intelligenceResult":
            self = .intelligenceResult(
                try c.decode(WireIntelligenceResult.self, forKey: .intelligenceResult))
        case "credentialManifest":
            self = .credentialManifest(try c.decode(CredentialManifest.self, forKey: .manifest))
        case "credentialProvision":
            self = .credentialProvision(
                try c.decode(SealedCredentialEnvelope.self, forKey: .credentials))
        case "credentialRevoke":
            self = .credentialRevoke(
                try c.decode([CredentialTombstone].self, forKey: .tombstones))
        case "createProject":
            self = .createProject(try c.decode(WireCreateProjectRequest.self, forKey: .request))
        case "castSubscribe":
            self = .castSubscribe(try c.decode(CastSubscribe.self, forKey: .castSub))
        case "composerTyping":
            self = .composerTyping(try c.decode(WireComposerTyping.self, forKey: .composerTyping))
        case "updateSettings":
            self = .updateSettings(try c.decode(SyncedSettings.self, forKey: .settings))
        case "directOffer":
            self = .directOffer(try c.decode(DirectPunchOffer.self, forKey: .direct))
        case "directSelect":
            self = .directSelect(try c.decode(DirectSelect.self, forKey: .direct))
        case "agentLoginBegin":
            self = .agentLoginBegin(try c.decode(WireAgentLoginBegin.self, forKey: .agentLogin))
        case "agentLoginCallback":
            self = .agentLoginCallback(
                try c.decode(WireAgentLoginCallback.self, forKey: .agentLogin))
        case "agentLoginCancel":
            self = .agentLoginCancel(try c.decode(String.self, forKey: .requestID))
        case let other:
            throw DecodingError.dataCorruptedError(
                forKey: .t, in: c, debugDescription: "Unknown ClientMsg \(other)")
        }
    }

    public func encode(to encoder: Encoder) throws {
        var c = encoder.container(keyedBy: EnvelopeKey.self)
        switch self {
        case .hello(let h):
            try c.encode("hello", forKey: .t); try h.encode(to: encoder)
        case .listSessions:
            try c.encode("listSessions", forKey: .t)
        case .subscribe(let s):
            try c.encode("subscribe", forKey: .t); try s.encode(to: encoder)
        case .unsubscribe(let id):
            try c.encode("unsubscribe", forKey: .t); try c.encode(id, forKey: .sessionID)
        case .approvalRespond(let id, let decision):
            try c.encode("approvalRespond", forKey: .t)
            try c.encode(id, forKey: .approvalID)
            try c.encode(decision, forKey: .decision)
        case .sendInput(let id, let input):
            try c.encode("sendInput", forKey: .t)
            try c.encode(id, forKey: .sessionID)
            try c.encode(input, forKey: .input)
        case .interrupt(let id):
            try c.encode("interrupt", forKey: .t); try c.encode(id, forKey: .sessionID)
        case .ping:
            try c.encode("ping", forKey: .t)
        case .listDashboard:
            try c.encode("listDashboard", forKey: .t)
        case .startChat(let r):
            try c.encode("startChat", forKey: .t); try c.encode(r, forKey: .request)
        case .captureTodo(let r):
            try c.encode("captureTodo", forKey: .t); try c.encode(r, forKey: .request)
        case .dispatchTodo(let todo, let project):
            try c.encode("dispatchTodo", forKey: .t)
            try c.encode(todo, forKey: .todoID)
            try c.encode(project, forKey: .projectID)
        case .setTodoStatus(let todo, let status):
            try c.encode("setTodoStatus", forKey: .t)
            try c.encode(todo, forKey: .todoID)
            try c.encode(status, forKey: .todoStatus)
        case .deleteTodo(let todo):
            try c.encode("deleteTodo", forKey: .t); try c.encode(todo, forKey: .todoID)
        case .renameSession(let id, let title):
            try c.encode("renameSession", forKey: .t)
            try c.encode(id, forKey: .sessionID)
            try c.encode(title, forKey: .title)
        case .setFavorite(let id, let flag):
            try c.encode("setFavorite", forKey: .t)
            try c.encode(id, forKey: .sessionID)
            try c.encode(flag, forKey: .flag)
        case .setArchived(let id, let flag):
            try c.encode("setArchived", forKey: .t)
            try c.encode(id, forKey: .sessionID)
            try c.encode(flag, forKey: .flag)
        case .markSessionDone(let id):
            try c.encode("markSessionDone", forKey: .t); try c.encode(id, forKey: .sessionID)
        case .deleteSession(let id):
            try c.encode("deleteSession", forKey: .t); try c.encode(id, forKey: .sessionID)
        case .integrate(let id, let mode):
            try c.encode("integrate", forKey: .t)
            try c.encode(id, forKey: .sessionID)
            try c.encode(mode, forKey: .mode)
        case .discard(let id):
            try c.encode("discard", forKey: .t); try c.encode(id, forKey: .sessionID)
        case .setSessionModel(let id, let model):
            try c.encode("setSessionModel", forKey: .t)
            try c.encode(id, forKey: .sessionID)
            try c.encodeIfPresent(model, forKey: .model)
        case .setSessionEffort(let id, let effort):
            try c.encode("setSessionEffort", forKey: .t)
            try c.encode(id, forKey: .sessionID)
            try c.encodeIfPresent(effort, forKey: .effort)
        case .setSessionAuto(let id, let flag):
            try c.encode("setSessionAuto", forKey: .t)
            try c.encode(id, forKey: .sessionID)
            try c.encode(flag, forKey: .flag)
        case .setSessionAutoShip(let id, let flag):
            try c.encode("setSessionAutoShip", forKey: .t)
            try c.encode(id, forKey: .sessionID)
            try c.encode(flag, forKey: .flag)
        case .setSessionShipBranch(let id, let branch):
            try c.encode("setSessionShipBranch", forKey: .t)
            try c.encode(id, forKey: .sessionID)
            try c.encodeIfPresent(branch, forKey: .branch)
        case .cancelQueuedMessage(let id, let messageID):
            try c.encode("cancelQueuedMessage", forKey: .t)
            try c.encode(id, forKey: .sessionID)
            try c.encode(messageID, forKey: .messageID)
        case .resolveProcessStall(let id, let stallID, let kill):
            try c.encode("resolveProcessStall", forKey: .t)
            try c.encode(id, forKey: .sessionID)
            try c.encode(stallID, forKey: .stallID)
            try c.encode(kill, forKey: .kill)
        case .fetchDiff(let id):
            try c.encode("fetchDiff", forKey: .t); try c.encode(id, forKey: .sessionID)
        case .listPeers:
            try c.encode("listPeers", forKey: .t)
        case .addressUpdate(let addresses):
            try c.encode("addressUpdate", forKey: .t)
            try c.encode(addresses, forKey: .addresses)
        case .meshRoster(let roster):
            try c.encode("meshRoster", forKey: .t)
            try c.encode(roster, forKey: .roster)
        case .transferOffer(let offer):
            try c.encode("transferOffer", forKey: .t); try c.encode(offer, forKey: .offer)
        case .transferChunk(let chunk):
            try c.encode("transferChunk", forKey: .t); try c.encode(chunk, forKey: .chunk)
        case .transferCommit(let id):
            try c.encode("transferCommit", forKey: .t); try c.encode(id, forKey: .transferID)
        case .transferCancel(let id):
            try c.encode("transferCancel", forKey: .t); try c.encode(id, forKey: .transferID)
        case .requestSessionTransfer(let id, let deviceID):
            try c.encode("requestSessionTransfer", forKey: .t)
            try c.encode(id, forKey: .sessionID)
            try c.encode(deviceID, forKey: .deviceID)
        case .fetchCarbonHeads(let f):
            try c.encode("fetchCarbonHeads", forKey: .t)
            try c.encode(f, forKey: .carbonFetch)
        case .fetchCarbonManifests(let f):
            try c.encode("fetchCarbonManifests", forKey: .t)
            try c.encode(f, forKey: .carbonFetch)
        case .fetchCarbonShards(let f):
            try c.encode("fetchCarbonShards", forKey: .t)
            try c.encode(f, forKey: .carbonFetch)
        case .carbonShardAck(let ack):
            try c.encode("carbonShardAck", forKey: .t)
            try c.encode(ack, forKey: .carbonAck)
        case .carbonLockAcquire(let req):
            try c.encode("carbonLockAcquire", forKey: .t)
            try c.encode(req, forKey: .carbonLock)
        case .carbonLockRelease(let req):
            try c.encode("carbonLockRelease", forKey: .t)
            try c.encode(req, forKey: .carbonLock)
        case .fetchTranscript(let f):
            try c.encode("fetchTranscript", forKey: .t); try c.encode(f, forKey: .fetch)
        case .registerLiveActivity(let token, let activityID):
            try c.encode("registerLiveActivity", forKey: .t)
            try c.encode(token, forKey: .pushToken)
            try c.encode(activityID, forKey: .activityID)
        case .endLiveActivity(let activityID):
            try c.encode("endLiveActivity", forKey: .t)
            try c.encode(activityID, forKey: .activityID)
        case .registerPushToStartToken(let token):
            try c.encode("registerPushToStartToken", forKey: .t)
            try c.encode(token, forKey: .pushToStartToken)
        case .setForeground(let foreground):
            try c.encode("setForeground", forKey: .t)
            try c.encode(foreground, forKey: .flag)
        case .requestPairingCode:
            try c.encode("requestPairingCode", forKey: .t)
        case .cancelPairingCode:
            try c.encode("cancelPairingCode", forKey: .t)
        case .respondMacPair(let deviceID, let approve):
            try c.encode("respondMacPair", forKey: .t)
            try c.encode(deviceID, forKey: .deviceID)
            try c.encode(approve, forKey: .approved)
        case .intelligenceResult(let result):
            try c.encode("intelligenceResult", forKey: .t)
            try c.encode(result, forKey: .intelligenceResult)
        case .credentialManifest(let manifest):
            try c.encode("credentialManifest", forKey: .t)
            try c.encode(manifest, forKey: .manifest)
        case .credentialProvision(let envelope):
            try c.encode("credentialProvision", forKey: .t)
            try c.encode(envelope, forKey: .credentials)
        case .credentialRevoke(let tombstones):
            try c.encode("credentialRevoke", forKey: .t)
            try c.encode(tombstones, forKey: .tombstones)
        case .createProject(let request):
            try c.encode("createProject", forKey: .t)
            try c.encode(request, forKey: .request)
        case .castSubscribe(let sub):
            try c.encode("castSubscribe", forKey: .t)
            try c.encode(sub, forKey: .castSub)
        case .composerTyping(let typing):
            try c.encode("composerTyping", forKey: .t)
            try c.encode(typing, forKey: .composerTyping)
        case .updateSettings(let settings):
            try c.encode("updateSettings", forKey: .t)
            try c.encode(settings, forKey: .settings)
        case .directOffer(let offer):
            try c.encode("directOffer", forKey: .t)
            try c.encode(offer, forKey: .direct)
        case .directSelect(let select):
            try c.encode("directSelect", forKey: .t)
            try c.encode(select, forKey: .direct)
        case .agentLoginBegin(let begin):
            try c.encode("agentLoginBegin", forKey: .t)
            try c.encode(begin, forKey: .agentLogin)
        case .agentLoginCallback(let callback):
            try c.encode("agentLoginCallback", forKey: .t)
            try c.encode(callback, forKey: .agentLogin)
        case .agentLoginCancel(let requestID):
            try c.encode("agentLoginCancel", forKey: .t)
            try c.encode(requestID, forKey: .requestID)
        }
    }
}

extension HostMsg: Codable {
    public init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: EnvelopeKey.self)
        let tag = try c.decode(String.self, forKey: .t)
        switch tag {
        case "welcome": self = .welcome(try Welcome(from: decoder))
        case "sessionList": self = .sessionList(try c.decode([SessionSummary].self, forKey: .input))
        case "snapshot": self = .snapshot(try SessionSnapshot(from: decoder))
        case "events": self = .events(try EventBatch(from: decoder))
        case "approvalRequested": self = .approvalRequested(try ApprovalRequest(from: decoder))
        case "approvalResolved": self = .approvalResolved(try ApprovalResolved(from: decoder))
        case "sessionUpdated": self = .sessionUpdated(try SessionSummary(from: decoder))
        case "dashboard": self = .dashboard(try DashboardSnapshot(from: decoder))
        case "sessionDiff": self = .sessionDiff(try WireSessionDiff(from: decoder))
        case "peerList": self = .peerList(try c.decode([PeerSummary].self, forKey: .input))
        case "meshRoster": self = .meshRoster(try c.decode(MeshRosterPush.self, forKey: .roster))
        case "transferAccept": self = .transferAccept(try c.decode(TransferAccept.self, forKey: .accept))
        case "transferReject": self = .transferReject(try c.decode(TransferReject.self, forKey: .reject))
        case "transferReady": self = .transferReady(try c.decode(String.self, forKey: .transferID))
        case "transferCommitted": self = .transferCommitted(try c.decode(String.self, forKey: .transferID))
        case "transferChunkAck": self = .transferChunkAck(try c.decode(TransferChunkAck.self, forKey: .ack))
        case "transcriptChunk":
            self = .transcriptChunk(try c.decode(TranscriptChunk.self, forKey: .transcriptChunk))
        case "transcriptComplete":
            self = .transcriptFetchComplete(
                try c.decode(TranscriptFetchComplete.self, forKey: .transcriptComplete))
        case "transcriptUnavailable":
            self = .transcriptUnavailable(
                try c.decode(TranscriptUnavailable.self, forKey: .transcriptUnavailable))
        case "carbonHeads":
            self = .carbonHeads(try c.decode(CarbonHeadsReply.self, forKey: .carbonHeads))
        case "carbonHeadAdvanced":
            self = .carbonHeadAdvanced(try c.decode(CarbonHead.self, forKey: .carbonHead))
        case "carbonManifestData":
            self = .carbonManifestData(try c.decode(CarbonBlobData.self, forKey: .carbonBlob))
        case "carbonManifestUnavailable":
            self = .carbonManifestUnavailable(
                try c.decode(CarbonBlobUnavailable.self, forKey: .carbonUnavailable))
        case "carbonShardData":
            self = .carbonShardData(try c.decode(CarbonBlobData.self, forKey: .carbonBlob))
        case "carbonShardUnavailable":
            self = .carbonShardUnavailable(
                try c.decode(CarbonBlobUnavailable.self, forKey: .carbonUnavailable))
        case "carbonLockDecision":
            self = .carbonLockDecision(
                try c.decode(CarbonLockDecisionMsg.self, forKey: .carbonLockDecision))
        case "carbonLockLedgerAdvanced":
            self = .carbonLockLedgerAdvanced(try c.decode(CarbonHead.self, forKey: .carbonHead))
        case "toolSummaries":
            self = .toolSummaries(try c.decode(ToolSummaryBatch.self, forKey: .toolSummaries))
        case "transcriptReverted":
            self = .transcriptReverted(try c.decode(TranscriptReverted.self, forKey: .reverted))
        case "relayMembership":
            self = .relayMembership(try c.decode(WireRelayMembership.self, forKey: .membership))
        case "pairingCode":
            self = .pairingCode(try c.decodeIfPresent(String.self, forKey: .qr))
        case "macPairRequested":
            self = .macPairRequested(try c.decode(WireMacPairRequest.self, forKey: .macPairRequest))
        case "macPairResolved":
            self = .macPairResolved(
                try c.decode(String.self, forKey: .deviceID),
                try c.decode(Bool.self, forKey: .approved))
        case "intelligenceRequest":
            self = .intelligenceRequest(
                try c.decode(WireIntelligenceRequest.self, forKey: .intelligence))
        case "credentialNeeded":
            self = .credentialNeeded(try c.decode(WireCredentialNeed.self, forKey: .credentialNeed))
        case "credentialUpdate":
            self = .credentialUpdate(
                try c.decode(SealedCredentialEnvelope.self, forKey: .credentials))
        case "credentialRevoked":
            self = .credentialRevoked(
                try c.decode([CredentialTombstone].self, forKey: .tombstones))
        case "projectCreated":
            self = .projectCreated(try c.decode(WireProjectCreated.self, forKey: .projectCreated))
        case "runnerPoolCredential":
            self = .runnerPoolCredential(
                try c.decode(WireRunnerPoolCredential.self, forKey: .poolCredential))
        case "chatStarted":
            self = .chatStarted(try c.decode(WireChatStarted.self, forKey: .chatStarted))
        case "casts":
            self = .casts(try c.decode(CastBatch.self, forKey: .castBatch))
        case "castCatchUp":
            self = .castCatchUp(try c.decode(CastCatchUp.self, forKey: .castCatchUp))
        case "settings":
            self = .settings(try c.decode(SyncedSettings.self, forKey: .settings))
        case "directAnswer":
            self = .directAnswer(try c.decode(DirectPunchOffer.self, forKey: .direct))
        case "directGo": self = .directGo
        case "directDecline":
            self = .directDecline(try c.decode(String.self, forKey: .reason))
        case "agentLoginChallenge":
            self = .agentLoginChallenge(
                try c.decode(WireAgentLoginChallenge.self, forKey: .agentLogin))
        case "agentLoginResult":
            self = .agentLoginResult(try c.decode(WireAgentLoginResult.self, forKey: .agentLogin))
        case "agentAuthStatus":
            self = .agentAuthStatus(try c.decode([WireProviderAuthStatus].self, forKey: .authStatus))
        case "error": self = .error(try WireError(from: decoder))
        case "pong": self = .pong
        default: self = .unknown(tag)  // forward-compatible
        }
    }

    public func encode(to encoder: Encoder) throws {
        var c = encoder.container(keyedBy: EnvelopeKey.self)
        switch self {
        case .welcome(let w):
            try c.encode("welcome", forKey: .t); try w.encode(to: encoder)
        case .sessionList(let list):
            try c.encode("sessionList", forKey: .t); try c.encode(list, forKey: .input)
        case .snapshot(let s):
            try c.encode("snapshot", forKey: .t); try s.encode(to: encoder)
        case .events(let b):
            try c.encode("events", forKey: .t); try b.encode(to: encoder)
        case .approvalRequested(let r):
            try c.encode("approvalRequested", forKey: .t); try r.encode(to: encoder)
        case .approvalResolved(let r):
            try c.encode("approvalResolved", forKey: .t); try r.encode(to: encoder)
        case .sessionUpdated(let s):
            try c.encode("sessionUpdated", forKey: .t); try s.encode(to: encoder)
        case .dashboard(let d):
            try c.encode("dashboard", forKey: .t); try d.encode(to: encoder)
        case .sessionDiff(let d):
            try c.encode("sessionDiff", forKey: .t); try d.encode(to: encoder)
        case .peerList(let peers):
            try c.encode("peerList", forKey: .t); try c.encode(peers, forKey: .input)
        case .meshRoster(let roster):
            try c.encode("meshRoster", forKey: .t); try c.encode(roster, forKey: .roster)
        case .transferAccept(let a):
            try c.encode("transferAccept", forKey: .t); try c.encode(a, forKey: .accept)
        case .transferReject(let r):
            try c.encode("transferReject", forKey: .t); try c.encode(r, forKey: .reject)
        case .transferReady(let id):
            try c.encode("transferReady", forKey: .t); try c.encode(id, forKey: .transferID)
        case .transferCommitted(let id):
            try c.encode("transferCommitted", forKey: .t); try c.encode(id, forKey: .transferID)
        case .transferChunkAck(let ack):
            try c.encode("transferChunkAck", forKey: .t); try c.encode(ack, forKey: .ack)
        case .carbonHeads(let reply):
            try c.encode("carbonHeads", forKey: .t)
            try c.encode(reply, forKey: .carbonHeads)
        case .carbonHeadAdvanced(let head):
            try c.encode("carbonHeadAdvanced", forKey: .t)
            try c.encode(head, forKey: .carbonHead)
        case .carbonManifestData(let data):
            try c.encode("carbonManifestData", forKey: .t)
            try c.encode(data, forKey: .carbonBlob)
        case .carbonManifestUnavailable(let un):
            try c.encode("carbonManifestUnavailable", forKey: .t)
            try c.encode(un, forKey: .carbonUnavailable)
        case .carbonShardData(let data):
            try c.encode("carbonShardData", forKey: .t)
            try c.encode(data, forKey: .carbonBlob)
        case .carbonShardUnavailable(let un):
            try c.encode("carbonShardUnavailable", forKey: .t)
            try c.encode(un, forKey: .carbonUnavailable)
        case .carbonLockDecision(let decision):
            try c.encode("carbonLockDecision", forKey: .t)
            try c.encode(decision, forKey: .carbonLockDecision)
        case .carbonLockLedgerAdvanced(let head):
            try c.encode("carbonLockLedgerAdvanced", forKey: .t)
            try c.encode(head, forKey: .carbonHead)
        case .transcriptChunk(let chunk):
            try c.encode("transcriptChunk", forKey: .t)
            try c.encode(chunk, forKey: .transcriptChunk)
        case .transcriptFetchComplete(let done):
            try c.encode("transcriptComplete", forKey: .t)
            try c.encode(done, forKey: .transcriptComplete)
        case .transcriptUnavailable(let un):
            try c.encode("transcriptUnavailable", forKey: .t)
            try c.encode(un, forKey: .transcriptUnavailable)
        case .toolSummaries(let batch):
            try c.encode("toolSummaries", forKey: .t)
            try c.encode(batch, forKey: .toolSummaries)
        case .transcriptReverted(let reverted):
            try c.encode("transcriptReverted", forKey: .t)
            try c.encode(reverted, forKey: .reverted)
        case .relayMembership(let m):
            try c.encode("relayMembership", forKey: .t); try c.encode(m, forKey: .membership)
        case .pairingCode(let qr):
            try c.encode("pairingCode", forKey: .t); try c.encodeIfPresent(qr, forKey: .qr)
        case .macPairRequested(let req):
            try c.encode("macPairRequested", forKey: .t); try c.encode(req, forKey: .macPairRequest)
        case .macPairResolved(let deviceID, let approved):
            try c.encode("macPairResolved", forKey: .t)
            try c.encode(deviceID, forKey: .deviceID)
            try c.encode(approved, forKey: .approved)
        case .intelligenceRequest(let request):
            try c.encode("intelligenceRequest", forKey: .t)
            try c.encode(request, forKey: .intelligence)
        case .credentialNeeded(let need):
            try c.encode("credentialNeeded", forKey: .t)
            try c.encode(need, forKey: .credentialNeed)
        case .credentialUpdate(let envelope):
            try c.encode("credentialUpdate", forKey: .t)
            try c.encode(envelope, forKey: .credentials)
        case .credentialRevoked(let tombstones):
            try c.encode("credentialRevoked", forKey: .t)
            try c.encode(tombstones, forKey: .tombstones)
        case .projectCreated(let outcome):
            try c.encode("projectCreated", forKey: .t)
            try c.encode(outcome, forKey: .projectCreated)
        case .runnerPoolCredential(let credential):
            try c.encode("runnerPoolCredential", forKey: .t)
            try c.encode(credential, forKey: .poolCredential)
        case .chatStarted(let outcome):
            try c.encode("chatStarted", forKey: .t)
            try c.encode(outcome, forKey: .chatStarted)
        case .casts(let batch):
            try c.encode("casts", forKey: .t)
            try c.encode(batch, forKey: .castBatch)
        case .castCatchUp(let catchUp):
            try c.encode("castCatchUp", forKey: .t)
            try c.encode(catchUp, forKey: .castCatchUp)
        case .settings(let settings):
            try c.encode("settings", forKey: .t)
            try c.encode(settings, forKey: .settings)
        case .directAnswer(let offer):
            try c.encode("directAnswer", forKey: .t)
            try c.encode(offer, forKey: .direct)
        case .directGo:
            try c.encode("directGo", forKey: .t)
        case .directDecline(let reason):
            try c.encode("directDecline", forKey: .t)
            try c.encode(reason, forKey: .reason)
        case .agentLoginChallenge(let challenge):
            try c.encode("agentLoginChallenge", forKey: .t)
            try c.encode(challenge, forKey: .agentLogin)
        case .agentLoginResult(let result):
            try c.encode("agentLoginResult", forKey: .t)
            try c.encode(result, forKey: .agentLogin)
        case .agentAuthStatus(let statuses):
            try c.encode("agentAuthStatus", forKey: .t)
            try c.encode(statuses, forKey: .authStatus)
        case .error(let e):
            try c.encode("error", forKey: .t); try e.encode(to: encoder)
        case .pong:
            try c.encode("pong", forKey: .t)
        case .unknown(let tag):
            try c.encode(tag, forKey: .t)
        }
    }
}
