import Foundation
import Network
import SwiftUI
import NucleicProtocol
import NucleicTailnet
#if canImport(UIKit)
import UIKit
#endif

/// On the phone there's no app-side `SessionSummary` view-model to collide with, so the wire
/// type *is* the model. Alias it under the host's name so the shared vocabulary reads the same.
typealias WireSessionSummary = NucleicProtocol.SessionSummary

/// The phone's single source of UI state — a pure projection of the host (UX_IOS §1.2). Owns
/// the `SyncClient`, reflects connectivity truth, and exposes the session list + the open
/// session's transcript/approvals. No canonical state is invented on-device.
@MainActor
final class RemoteStore: ObservableObject {
    enum Connectivity: Equatable {
        case unpaired
        case connecting
        case reconnecting
        case connected(SyncTransportHint)
        case hostOffline
        case failed(String)

        var label: String {
            switch self {
            case .unpaired: "Not paired"
            case .connecting: "Connecting…"
            case .reconnecting: "Reconnecting…"
            case .connected(let transport): "Connected · \(transport.label)"
            case .hostOffline: "Mac offline"
            case .failed(let m): m
            }
        }
        var isLive: Bool {
            if case .connected = self { return true }
            return false
        }
    }

    @Published private(set) var connectivity: Connectivity = .unpaired
    /// A representative host name for the aggregate — the open session's Mac when a transcript is
    /// open, otherwise a live host. Sessions/projects from *every* connected Mac are merged into the
    /// flat state above (mesh P3), so no single "active host" is chosen; per-row provenance comes
    /// from `hostName(forSession:)` instead.
    @Published private(set) var hostName: String = ""
    @Published private(set) var sessions: [WireSessionSummary] = []
    /// The last session list seen from a live host, persisted to disk (`SessionCache`) and reloaded
    /// at launch. It backs `sessions` whenever nothing is connected, so the list shows a read-only
    /// history while offline instead of an empty "waiting to connect" screen.
    private var cachedSummaries: [WireSessionSummary] = SessionCache.loadSummaries()
    /// Debounced disk-write tasks for the offline cache (coalesce a burst of updates into one write).
    private var summaryPersistTask: Task<Void, Never>?
    private var transcriptPersistTask: Task<Void, Never>?
    @Published private(set) var capabilities = WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])
    @Published private(set) var grantedScope: DeviceScope = .approve
    /// The host's model/effort catalog (SYNC §5.2), driving the composer + session-header pickers.
    /// `.empty` until `Welcome` arrives; the pickers fall back to the built-in effort list.
    @Published private(set) var modelCatalog: WireModelCatalog = .empty

    /// Home / Projects / To-Dos state — the dashboard projection.
    @Published private(set) var dashboard = DashboardSnapshot.empty

    /// The host's mesh peers (mesh P4), from `HostMsg.peerList`. Populated only when the host
    /// advertises `capabilities.canListPeers` and the client asks; drives the future mesh device
    /// list and session-transfer destination picker.
    @Published private(set) var meshPeers: [PeerSummary] = []

    // Open session projection.
    @Published private(set) var openSessionID: SessionID?
    /// The paired Mac that owns the open session (mesh P3). The open transcript's intents route to
    /// it, and the host-specific projected values (capabilities/scope/catalog/connectivity) follow
    /// it while a session is open. `nil` when nothing is open.
    private var openSessionHostID: String?
    @Published private(set) var openEvents: [AgentEvent] = []
    @Published private(set) var openApprovals: [ApprovalRequest] = []

    /// The open session is blocked on an approval whose details haven't arrived yet — the state
    /// right after opening from a Live Activity or notification while the channel is still
    /// (re)connecting. The summary says `.awaitingApproval`, but `openApprovals` (which is filled
    /// from the live snapshot) is empty because there's no live channel yet. Drives a lightweight
    /// "loading approval" placeholder so a tap from the lock screen lands on the request-in-progress
    /// instead of a blank transcript; it flips off the instant the real card arrives (sub-second,
    /// thanks to the fast foreground reconnect). Never shown once live — then the snapshot is truth.
    var openApprovalLoading: Bool {
        guard !connectivity.isLive, openApprovals.isEmpty, let id = openSessionID,
              let summary = sessions.first(where: { $0.sessionID == id }) else { return false }
        return summary.status == .awaitingApproval || summary.pendingApprovalCount > 0
    }
    /// Whether the compact (iPhone) Sessions tab currently has a session detail pushed. Distinct
    /// from `openSessionID`, which is the *data* subscription and is only torn down on the detail's
    /// `onDisappear` — and SwiftUI fires that at the *end* of the pop transition, so keying the
    /// floating tab bar's visibility off it left the bar sliding back up 1–2s after a back-swipe.
    /// `SessionsView` sets this straight from its navigation path, which flips the instant the pop
    /// begins, so the bar reflows immediately while the transcript state survives the animation.
    @Published var compactDetailPresented = false
    /// The open session's full diff (the Mac Diff tab's patch), fetched on demand when the
    /// user opens the Diff tab and the host advertises `canFetchDiff`. Nil until it arrives.
    @Published private(set) var openDiff: WireSessionDiff?
    @Published private(set) var diffLoading = false

    /// "Add a device to this mesh" (Settings): the phone asks a connected Mac to mint a join code
    /// and shows it as a QR / copyable link. The phone can't mint one itself (it runs no listener),
    /// so this is always relayed through a live, capable host.
    enum AddDeviceState: Equatable {
        case idle
        case requesting          // waiting on the host's `pairingCode` reply
        case ready(String)       // the `nucleic://pair?d=…` join code to display
        case unavailable         // no live host could mint one right now
    }
    @Published private(set) var addDevice: AddDeviceState = .idle
    /// The host we asked to mint the current code, so a dismiss can tell the *same* Mac to close
    /// its pairing window.
    private var pairingMintHostID: String?

    /// A Mac trying to join via a code this phone shared, awaiting the user's allow/deny — the Mac
    /// forwarded its pairing confirm here (`HostMsg.macPairRequested`) so it can be approved from
    /// the phone. The "add a device" sheet renders an allow/deny dialog for it.
    @Published private(set) var pendingMacPairRequest: WireMacPairRequest?
    /// The host that forwarded the pending confirm, so the answer routes back to it.
    private var pendingMacPairHostID: String?

    /// A transient host-reported error (the mobile echo of the Mac's last-error bubble):
    /// shown as a red bubble at the bottom of the screen, auto-dismissed after a few seconds.
    struct LastError: Equatable, Identifiable {
        let id = UUID()
        let message: String
        let sessionID: SessionID?
    }
    @Published var lastError: LastError?
    private var errorDismissTask: Task<Void, Never>?

    /// When each session was last opened on this device, for the green "finished while you
    /// weren't looking" wash on the session list (the Mac's unseen-completion marker; the wire
    /// doesn't carry the host's flag, so the phone tracks its own view locally).
    @Published private(set) var lastOpenedAt: [SessionID: Date] = RemoteStore.loadLastOpened()

    /// A navigation request from outside the view hierarchy (notification tap → this session).
    /// `RootView` switches to the Sessions tab; `SessionsView` pushes it and clears.
    @Published var pendingRoute: SessionID?

    /// A request to surface the sessions list itself, no specific session — the Live Activity's
    /// "work running, nothing waiting" tap. Compact jumps to the Sessions tab; the iPad split
    /// reveals its sidebar (which always lists sessions). One-shot: the shell clears it.
    @Published var pendingSessionsList = false

    /// Whether the app is foreground-active (scene phase), mirrored here so the store can
    /// decide which transitions deserve a notification.
    private(set) var isActive = true

    func setScenePhaseActive(_ active: Bool) { isActive = active }

    /// Route to a session from a notification tap (deep link).
    func route(to sessionID: SessionID) { pendingRoute = sessionID }

    /// Ask the shell to show the sessions list (no specific session opened).
    func showSessionsList() { pendingSessionsList = true }

    /// Follow a `nucleic://` deep link — the Live Activity tap. A session waiting on the user
    /// opens straight into that session; the plain sessions link just surfaces the list.
    func handleDeepLink(_ url: URL) {
        switch NucleicDeepLink.route(for: url) {
        case .session(let id): route(to: SessionID(rawValue: id))
        case .sessionsList: showSessionsList()
        case .none: break
        }
    }

    /// An Allow/Deny straight from a notification action (UX_IOS §5.1). Requires a live
    /// channel; if the socket dropped while backgrounded, reconnect and send once ready —
    /// but only briefly (a stale queued approval must never fire minutes later; see
    /// UX_IOS §11.5, and the host dedupes/`alreadyResolved`s a lost race anyway).
    func respondFromNotification(_ id: ApprovalID, allow: Bool) {
        let decision: Decision = allow ? .allow(updatedInput: nil) : .deny(reason: nil)
        // The approval may belong to any connected Mac (mesh P3) and the notification carries no
        // hostID — broadcast to every live connection; the owning host resolves it, the rest see an
        // unknown/`alreadyResolved` approval and no-op.
        let live = connections.values.filter { $0.connectivity.isLive }
        if !live.isEmpty {
            for conn in live { conn.send(.approvalRespond(id, decision)) }
        } else {
            pendingNotificationDecision = (id, decision, Date())
            reconnect()
        }
    }

    /// At most one decision waits for reconnect, and it expires after 30s.
    private var pendingNotificationDecision: (ApprovalID, Decision, Date)?

    private func flushPendingNotificationDecision() {
        guard let (id, decision, at) = pendingNotificationDecision else { return }
        let live = connections.values.filter { $0.connectivity.isLive }
        guard !live.isEmpty else { return }   // wait until something's connected
        pendingNotificationDecision = nil
        guard Date().timeIntervalSince(at) < 30 else { return }  // stale — require the app
        for conn in live { conn.send(.approvalRespond(id, decision)) }
    }

    private static let lastOpenedKey = "nucleic.lastOpenedAt"
    private static func loadLastOpened() -> [SessionID: Date] {
        guard let raw = UserDefaults.standard.dictionary(forKey: lastOpenedKey) else { return [:] }
        return raw.reduce(into: [:]) { result, entry in
            if let date = entry.value as? Date { result[SessionID(rawValue: entry.key)] = date }
        }
    }
    private func persistLastOpened() {
        let raw = lastOpenedAt.reduce(into: [String: Date]()) { $0[$1.key.rawValue] = $1.value }
        UserDefaults.standard.set(raw, forKey: Self.lastOpenedKey)
    }

    /// Whether `summary` completed its work after the user last looked at it on this device.
    func unseenCompletion(_ summary: WireSessionSummary) -> Bool {
        let done = summary.status == .finished
            || (summary.status == .awaitingInput && summary.disposition == .completed)
        guard done, summary.sessionID != openSessionID else { return false }
        guard let opened = lastOpenedAt[summary.sessionID] else { return true }
        return summary.updatedAt > opened
    }

    /// Non-archived sessions (archived chats are hidden, matching the Mac sidebar).
    var liveSessions: [WireSessionSummary] { sessions.filter { !$0.archived } }

    /// Sessions needing a human (drives the app-icon badge + NEEDS YOU section). Uses turn
    /// disposition so a finished-the-work session doesn't count, and excludes archived.
    var needsYouCount: Int {
        liveSessions.filter { $0.status.needsYou($0.disposition) }.count
    }

    var canControl: Bool { grantedScope >= .control }

    /// The process-wide store. The SwiftUI `App` binds its `@StateObject` to this instance so the
    /// UI and any headless entry point share one store — in particular the background push handler
    /// (`PushAppDelegate`), which on a silent launch has no mounted scene (`onAppear` never fires)
    /// and so must reach the store directly to bring it online and adopt a push-started Live Activity.
    static let shared = RemoteStore()

    private let identity = IdentityStore.loadOrCreateIdentity()
    let discovery = LANDiscovery()
    /// Proactive network-path awareness (shared across every host connection): flips transports the
    /// instant Wi-Fi drops or returns, instead of waiting for a dead LAN socket to time out.
    let pathMonitor = NetworkPathMonitor()
    /// One live connection per paired Mac (mesh P3 multiplexer). All connected at once, and the flat
    /// `sessions`/`dashboard` above are the *merged* projection across every one of them — the phone
    /// shows all your Macs together, with no active-host selector. Intents route to the Mac that owns
    /// the target session/project (see `send`). Empty in demo mode (demo seeds state directly).
    private var connections: [String: HostConnection] = [:]

    /// The connection whose projection drives the host-specific flat values (capabilities, scope,
    /// catalog, connectivity, host name): the open session's Mac while a transcript is open, else a
    /// representative live host (one with a populated catalog, falling back to any live/any host).
    private var contextConnection: HostConnection? {
        if let id = openSessionHostID, let conn = connections[id] { return conn }
        return connections.values.first { $0.connectivity.isLive && !$0.modelCatalog.groups.isEmpty }
            ?? connections.values.first { $0.connectivity.isLive }
            ?? connections.values.first
    }

    /// A live connection to fall back on for host-agnostic intents (e.g. a project-less to-do
    /// capture, which has no owning Mac).
    private var firstLiveConnection: HostConnection? {
        connections.values.first { $0.connectivity.isLive } ?? connections.values.first
    }

    // MARK: Intent routing (mesh P3) — resolve the Mac that owns a session / project / to-do.

    private func connection(owningSession id: SessionID) -> HostConnection? {
        connections.values.first { $0.sessions.contains { $0.sessionID == id } }
    }
    private func connection(owningProject id: ProjectID) -> HostConnection? {
        connections.values.first { $0.dashboard.projects.contains { $0.id == id } }
    }
    private func connection(owningTodo id: TodoID) -> HostConnection? {
        connections.values.first { $0.dashboard.todos.contains { $0.id == id } }
    }
    /// Send to every live connection — for host-agnostic pulls (list sessions/dashboard) and as the
    /// safety fallback for an approval whose owning Mac isn't known (a notification-driven decision).
    private func broadcastLive(_ msg: ClientMsg) {
        for conn in connections.values where conn.connectivity.isLive { conn.send(msg) }
    }

    /// How many Macs are currently connected — the Sessions list shows a per-row host tag only when
    /// more than one, so a single-Mac view stays clean.
    var connectedHostCount: Int {
        if demoMode { return 1 }
        return connections.values.filter { $0.connectivity.isLive }.count
    }

    /// The name of the Mac that owns a session, for the per-row host tag. `nil` in demo (no
    /// connections) or when the session isn't found on any connected Mac.
    func hostName(forSession id: SessionID) -> String? {
        connection(owningSession: id)?.hostName
    }
    /// The phone's embedded Tailscale node state, for Settings (nil = not running). Shared across all
    /// connections (one embedded node, many dials).
    @Published private(set) var tailnetStatus: String?
    /// The Tailscale interactive-login URL while the node waits for a browser login (a
    /// first tailnet start with no auth key). Auto-opened; Settings shows a re-open button.
    @Published private(set) var tailnetLoginURL: URL?

    /// Offline demo mode: seeds mock state and simulates the agent locally so every surface
    /// renders — and the core loops (send, approve, start chat, to-dos) actually respond —
    /// without a paired Mac. Reachable in two ways: the `NUCLEIC_DEMO=1` env var (dev /
    /// screenshots, forced on at launch) and a persisted in-app toggle
    /// (`enterDemo()` / `exitDemo()`) so an App Store reviewer with no Mac can exercise the app
    /// (App Review Guideline 2.1). `@Published` so the UI can show a DEMO indicator and the
    /// "Leave demo" affordance.
    private static let demoModeKey = "nucleic.demoMode"
    @Published private(set) var demoMode = ProcessInfo.processInfo.environment["NUCLEIC_DEMO"] == "1"
        || UserDefaults.standard.bool(forKey: RemoteStore.demoModeKey)
    /// In-flight simulated-run tasks (canned streaming), cancelled on `exitDemo()`.
    private var demoTasks: [Task<Void, Never>] = []

    var isPaired: Bool { demoMode || IdentityStore.loadPairedHost() != nil }
    var deviceFingerprint: String { identity.fingerprint }

    // MARK: - Lifecycle

    func onAppear() {
        setupLiveActivityBridge()
        if demoMode { seedDemo(); return }
        startNetworkingIfNeeded()
        if isPaired {
            // Show the saved chat history immediately, before any host connects.
            if sessions.isEmpty { sessions = cachedSummaries }
            reconnect()
        }
    }

    /// Whether `startNetworkingIfNeeded` has already wired up path monitoring + LAN discovery.
    private var networkingStarted = false

    /// Start the shared network-path monitor and LAN discovery once. Factored out of `onAppear` so
    /// the background push handler — which runs when `onAppear` never fired (silent launch, no scene)
    /// — can bring the same machinery up before it dials.
    private func startNetworkingIfNeeded() {
        guard !networkingStarted else { return }
        networkingStarted = true
        // Re-plan every connection the moment the network path changes (left Wi-Fi, joined a
        // network, cellular⇄Wi-Fi) — the proactive half of "immediate switchover".
        pathMonitor.onChange = { [weak self] in self?.handlePathChange() }
        pathMonitor.start()
        discovery.start()
    }

    /// A network-path change: tell each host connection to re-plan its transport now.
    private func handlePathChange() {
        guard !demoMode else { return }
        for conn in connections.values { conn.networkPathChanged() }
    }

    /// Returning to the foreground: the network may have changed while the app was suspended (and
    /// path/keepalive callbacks were frozen). Re-read the path, re-dial anything not live, then
    /// actively revalidate the connections that still *look* live — a socket the OS killed while we
    /// were suspended wakes marked `.connected`, and trusting that would stall the reconnect until
    /// the 55s keepalive deadline. `revalidate` pings each such link and re-dials the instant it
    /// fails to answer, so the transport is correct (and fast) by the time the UI is on screen.
    func onForeground() {
        endBackgroundHold()
        guard !demoMode, isPaired else { return }
        // Foreground again: the app self-creates its own Live Activity now, so tell the hosts to stop
        // push-to-starting it (UX_IOS §5.3). Sent to already-live hosts; ones still reconnecting get
        // it via `didUpdate` once live.
        setForegroundState(true)
        pathMonitor.refresh()
        // Ensure a connection exists for every paired host (and drop unpaired); this re-dials the
        // ones already known to be offline.
        reconnect()
        // The zombies from suspension still read `.connected` — probe and re-dial the dead ones.
        for conn in connections.values where conn.connectivity.isLive { conn.revalidate() }
    }

    #if canImport(UIKit)
    /// Held while backgrounded so iOS keeps the process (and thus the live sockets + 20s keepalive)
    /// running for its short grant, instead of suspending us the moment we background.
    private var backgroundTask: UIBackgroundTaskIdentifier = .invalid
    #endif

    /// Entering the background: ask iOS to keep us running briefly so a quick app-switch (glance at
    /// another app, tap a Live Activity, answer a banner) doesn't tear the connection down and force
    /// a cold reconnect on return. The grant is short (~30s) and best-effort; once it lapses the OS
    /// suspends us and the socket dies, which the next `onForeground` revalidation reconnects fast.
    func onBackground() {
        guard !demoMode else { return }
        // Heading to the background: the app can't reliably start a Live Activity anymore, so tell the
        // hosts to push-to-start the glance themselves when work begins (UX_IOS §5.3). Sent now while
        // the socket-hold below still keeps the connection live for a beat.
        setForegroundState(false)
        #if canImport(UIKit)
        endBackgroundHold()
        backgroundTask = UIApplication.shared.beginBackgroundTask(withName: "nucleic.sync.hold") {
            [weak self] in self?.endBackgroundHold()
        }
        #endif
    }

    private func endBackgroundHold() {
        #if canImport(UIKit)
        guard backgroundTask != .invalid else { return }
        UIApplication.shared.endBackgroundTask(backgroundTask)
        backgroundTask = .invalid
        #endif
    }

    /// Handle the host's silent "adopt" wake (UX_IOS §5.3): a session started while the app was
    /// closed, the host push-*started* the Live Activity, and now the phone must come online long
    /// enough to harvest that activity's per-activity update token and register it — so live updates
    /// (and a clean end) resume without the user ever opening the app. Called from `PushAppDelegate`
    /// on a `content-available` background push, which may relaunch the app with no scene, so this
    /// does the setup `onAppear` normally would. Holds a background-task assertion until the token is
    /// registered with a host or the window closes, then calls `completion`. Best-effort: iOS may
    /// deny background runtime (budget, force-quit), in which case the glance keeps its start-time
    /// state until the app next runs.
    func handleLiveActivityAdoptWake(completion: @escaping () -> Void) {
        guard !demoMode, isPaired else { completion(); return }
        // A silent adopt wake means we're background (possibly cold-launched with no scene, where
        // `isForeground`'s default would otherwise read stale-true) — record that so the reconnect
        // below reports background and the host keeps owning the glance via push-to-start (§5.3).
        setForegroundState(false)
        setupLiveActivityBridge()   // starts the push-to-start + adoption observers if not already
        startNetworkingIfNeeded()
        #if canImport(UIKit)
        var bgTask: UIBackgroundTaskIdentifier = .invalid
        var finished = false
        let finish = {
            guard !finished else { return }
            finished = true
            if bgTask != .invalid {
                UIApplication.shared.endBackgroundTask(bgTask)
                bgTask = .invalid
            }
            completion()
        }
        bgTask = UIApplication.shared.beginBackgroundTask(withName: "nucleic.liveactivity.adopt") {
            finish()   // the OS reclaimed the grant before we finished — report what we have
        }
        #else
        let finish = completion
        #endif
        pathMonitor.refresh()
        reconnect()
        // Poll until the harvested update token has reached a host (`liveActivitySentTo` fills in via
        // the adopt → onPushToken → syncLiveActivityRegistration chain), or we run out of runtime.
        Task { @MainActor [weak self] in
            for _ in 0..<50 {   // ~25s at a 0.5s cadence, inside iOS's ~30s background grant
                guard let self, self.liveActivitySentTo.isEmpty else { break }
                try? await Task.sleep(for: .milliseconds(500))
            }
            finish()
        }
    }

    private func seedDemo() {
        connectivity = .connected(.lan)
        hostName = "Andrew's Mac"
        grantedScope = .control
        capabilities = WireCapabilities(
            canModifyToolInput: true, allowAlwaysScopes: [.session, .toolName], canFetchDiff: true)
        modelCatalog = WireModelCatalog(
            groups: [
                [WireModelCatalog.Model(sku: "claude-opus-4-8[1m]", displayName: "Opus 4.8", backend: .claudeCode,
                                        contextBadge: "1M", contextWindow: 1_000_000,
                                        efforts: ["low", "medium", "high", "xhigh", "max"], effortNoun: "Effort"),
                 WireModelCatalog.Model(sku: "claude-sonnet-4-6", displayName: "Sonnet 4.6", backend: .claudeCode,
                                        contextBadge: nil, contextWindow: 200_000,
                                        efforts: ["low", "medium", "high", "xhigh", "max"], effortNoun: "Effort")],
                [WireModelCatalog.Model(sku: "gpt-5.5", displayName: "GPT-5.5", backend: .codex,
                                        contextBadge: nil, contextWindow: 350_000,
                                        efforts: ["low", "medium", "high", "xhigh"], effortNoun: "Reasoning")],
                [WireModelCatalog.Model(sku: "grok-build", displayName: "Grok Build", backend: .grok,
                                        contextBadge: nil, contextWindow: 256_000,
                                        efforts: ["auto"], effortNoun: "Reasoning")],
            ],
            effortDisplayNames: ["auto": "Auto", "orchestra": "Orchestra"],
            orchestraSentinel: "orchestra", orchestraRequiresControlNote: "Requires Nucleic Control",
            fallbackModel: "claude-opus-4-8[1m]", fallbackEffort: "high")
        let p1 = ProjectID(rawValue: "p1"), p2 = ProjectID(rawValue: "p2")
        func sum(_ id: String, _ project: ProjectID, _ name: String, _ title: String,
                 _ status: SessionStatus, _ disp: TurnDisposition? = nil, approvals: Int = 0,
                 fav: Bool = false, arch: Bool = false, add: Int = 0, rem: Int = 0) -> WireSessionSummary {
            WireSessionSummary(
                sessionID: SessionID(rawValue: id), projectID: project.rawValue, projectName: name,
                backend: .claudeCode, status: status, disposition: disp, title: title,
                branch: "nucleic/\(id)", lastSeq: 10,
                diffStat: add + rem > 0 ? DiffStat(filesChanged: 2, added: add, removed: rem) : nil,
                pendingApprovalCount: approvals, favorite: fav, archived: arch,
                updatedAt: Date())
        }
        let cal = Calendar.current
        let today = cal.startOfDay(for: Date())
        let activity = (0..<40).map { i -> ActivityDay in
            let count = (i * 7) % 6
            // Sample tokens roughly track messages so the preview grid shades by usage.
            return ActivityDay(day: cal.date(byAdding: .day, value: -i, to: today)!,
                               count: count, tokens: count * 8_500 + (i * 137) % 4_000)
        }
        // Host 1 — "Andrew's Mac".
        let host1Sessions = [
            sum("a1", p1, "nucleic", "auth-refactor", .awaitingApproval, approvals: 1, add: 312, rem: 40),
            sum("a2", p1, "nucleic", "flaky-tests", .running, add: 88, rem: 12),
            sum("a3", p2, "website", "graphql-migration", .awaitingInput, .awaitingInput, fav: true),
            sum("a4", p2, "website", "docs-pass", .awaitingInput, .completed, add: 20, rem: 4),
            sum("a5", p1, "nucleic", "old-experiment", .finished, arch: true),
        ]
        let host1Dashboard = DashboardSnapshot(
            counts: DashboardCounts(
                projects: 2, chats: 5, activeChats: 2, messages: 142, activeDays: 9, tokens: 1_284_000),
            activity: activity,
            projects: [
                WireProject(id: p1, name: "nucleic", defaultBranch: "main", sessionCount: 3, activeCount: 2),
                WireProject(id: p2, name: "website", defaultBranch: "main", sessionCount: 2, activeCount: 1),
            ],
            todos: [
                WireTodo(id: TodoID(rawValue: "t1"), text: "Add dark mode to settings", summary: "Dark mode in settings",
                         projectID: p2, projectName: "website", status: .open, dispatchedSessionID: nil,
                         triage: "high", updatedAt: Date()),
                WireTodo(id: TodoID(rawValue: "t2"), text: "Investigate the memory leak in the sync server", summary: "Sync server memory leak",
                         projectID: p1, projectName: "nucleic", status: .open, dispatchedSessionID: nil,
                         triage: "critical", updatedAt: Date()),
                WireTodo(id: TodoID(rawValue: "t3"), text: "Write release notes", summary: nil,
                         projectID: nil, projectName: nil, status: .open, dispatchedSessionID: nil,
                         triage: "low", updatedAt: Date()),
            ],
            usage: WireSubscriptionUsage(
                fiveHour: WireUsageWindow(utilization: 42, resetsAt: Date().addingTimeInterval(3 * 3600)),
                sevenDay: WireUsageWindow(utilization: 78, resetsAt: Date().addingTimeInterval(2.4 * 86_400))),
            statusFeeds: [
                WireStatusFeed(provider: "claude", providerName: "Claude", incidents: []),
                WireStatusFeed(provider: "openai", providerName: "OpenAI", incidents: [
                    WireStatusIncident(
                        id: "i1", title: "Elevated errors on Codex", url: URL(string: "https://status.openai.com"),
                        updatedAt: Date(), state: "Monitoring", isResolved: false, components: ["Codex"]),
                ]),
                WireStatusFeed(provider: "xai", providerName: "xAI", incidents: []),
            ])
        // Host 2 — "Studio Mac" (mesh P3: a second paired Mac, for the host switcher).
        let p3 = ProjectID(rawValue: "p3")
        let host2Sessions = [
            sum("b1", p3, "renderer", "shadow-mapping", .running, add: 140, rem: 22),
            sum("b2", p3, "renderer", "gpu-profiling", .awaitingApproval, approvals: 1),
            sum("b3", p1, "nucleic", "cloud-runtime", .awaitingInput, .completed, add: 60, rem: 8),
        ]
        let host2Dashboard = DashboardSnapshot(
            counts: DashboardCounts(
                projects: 1, chats: 3, activeChats: 2, messages: 61, activeDays: 5, tokens: 540_000),
            activity: activity,
            projects: [WireProject(id: p3, name: "renderer", defaultBranch: "main", sessionCount: 2, activeCount: 2)],
            todos: [
                WireTodo(id: TodoID(rawValue: "t4"), text: "Bake the light probes overnight", summary: "Bake light probes",
                         projectID: p3, projectName: "renderer", status: .open, dispatchedSessionID: nil,
                         triage: "medium", updatedAt: Date()),
            ],
            usage: WireSubscriptionUsage(
                fiveHour: WireUsageWindow(utilization: 18, resetsAt: Date().addingTimeInterval(2 * 3600)),
                sevenDay: WireUsageWindow(utilization: 40, resetsAt: nil)),
            statusFeeds: [])
        // Mesh P3: seed BOTH demo Macs' worlds merged into the flat aggregate, exactly as the app
        // presents real paired Macs — every host's sessions/projects/to-dos shown together, no
        // switcher. `demoHandle` then mutates this aggregate directly for the interactive demo.
        sessions = host1Sessions + host2Sessions
        dashboard = DashboardSnapshot.merged([host1Dashboard, host2Dashboard])
        LiveActivityManager.shared.sync(hostName: hostName, sessions: liveSessions)
        NotificationRouter.shared.updateBadge(needsYouCount)
    }

    /// Offline diff fixture (NUCLEIC_DEMO) so the full-patch Diff tab renders without a host.
    private func demoDiff(_ sessionID: SessionID) -> WireSessionDiff {
        WireSessionDiff(
            sessionID: sessionID,
            stat: DiffStat(filesChanged: 2, added: 312, removed: 40),
            files: [
                WireFileDiff(path: "auth/middleware.ts", oldPath: nil, status: "modified", added: 290, removed: 38),
                WireFileDiff(path: "auth/session.ts", oldPath: nil, status: "added", added: 22, removed: 2),
            ],
            patch: """
            diff --git a/auth/middleware.ts b/auth/middleware.ts
            --- a/auth/middleware.ts
            +++ b/auth/middleware.ts
            @@ -10,7 +10,9 @@ export function requireSession(req: Request) {
            -  const token = req.headers.get("x-auth")
            +  const header = req.headers.get("authorization") ?? ""
            +  const token = header.replace(/^Bearer /, "")
            +  if (!token) throw new AuthError("missing bearer token")
               return verify(token)
             }
            diff --git a/auth/session.ts b/auth/session.ts
            new file mode 100644
            --- /dev/null
            +++ b/auth/session.ts
            @@ -0,0 +1,6 @@
            +export interface Session {
            +  userId: string
            +  issuedAt: number
            +}
            +
            +export const SESSION_TTL = 3600
            """)
    }

    // MARK: - Connections (mesh P3 multiplexer)

    /// Pair a newly-scanned Mac, make it the active host, and start its connection — while any
    /// existing connections keep running.
    func pair(with payload: PairingPayload) {
        let id = payload.hostStaticKey.fingerprintHex
        connections[id]?.teardown()
        let conn = makeConnection(hostID: id, hostName: payload.hostName)
        connections[id] = conn
        rebuildAggregate()
        conn.pair(with: payload)
    }

    /// Get or build the connection for a paired Mac.
    private func connection(for host: PairedHost) -> HostConnection {
        if let existing = connections[host.fingerprint] { return existing }
        let conn = makeConnection(hostID: host.fingerprint, hostName: host.hostName)
        connections[host.fingerprint] = conn
        return conn
    }

    private func makeConnection(hostID: String, hostName: String) -> HostConnection {
        HostConnection(
            hostID: hostID, hostName: hostName, identity: identity, discovery: discovery,
            pathMonitor: pathMonitor, callbacks: callbacks(for: hostID))
    }

    /// The callbacks one `HostConnection` uses to drive shared/aggregate state. `hostID` is captured
    /// so the open-transcript forwarding fires only for the Mac that owns the open session, while the
    /// merged list/dashboard, badges, and notifications are rebuilt across every host on any change.
    private func callbacks(for hostID: String) -> HostConnection.Callbacks {
        var cb = HostConnection.Callbacks()
        cb.didUpdate = { [weak self] in
            guard let self else { return }
            // Complete a session opened before its owning Mac was live — a Live Activity /
            // notification cold-launch deep link opens the detail while the app is still dialing, so
            // `open()` couldn't bind it to a connection that didn't exist yet. Runs before the merge
            // so the aggregate picks up the freshly-bound host's connectivity/scope this pass.
            self.bindOpenSessionIfNeeded()
            // Any host's change re-merges the aggregate the flat state binds to (mesh P3).
            self.rebuildAggregate()
            self.refreshAggregate()
            self.flushPendingNotificationDecision()
            // A host that just connected (or reconnected) needs the current Live Activity token
            // so it can push while the phone is away — and the push-to-start token so it can create
            // the glance cold when work starts before the app is opened.
            self.syncLiveActivityRegistration()
            self.syncPushToStartRegistration()
            // …and the current foreground state, so it push-to-starts the glance itself when the app
            // is backgrounded rather than waiting on a self-create that can't happen (UX_IOS §5.3).
            self.syncForegroundState()
        }
        cb.openSnapshot = { [weak self] snap in
            guard let self, hostID == self.openSessionHostID, snap.summary.sessionID == self.openSessionID else { return }
            // Merge, don't replace: a fresh open merges into an empty transcript (the tail window),
            // while a reconnect's warm-resubscribe delta appends to the history already on screen
            // instead of truncating it to the host's 200-event tail. Drain the streaming buffer
            // first so the seq-dedup merge sees the full stream (a buffered event the snapshot
            // also carries would otherwise be re-appended as a duplicate after the merge).
            self.drainPendingOpenEvents()
            self.openEvents = Self.mergedEvents(self.openEvents, snap.recentEvents)
            self.openApprovals = snap.pendingApprovals
            self.persistOpenTranscript()
        }
        cb.openEvents = { [weak self] batch in
            guard let self, hostID == self.openSessionHostID, batch.sessionID == self.openSessionID else { return }
            self.enqueueOpenEvents(batch.events)
        }
        cb.openBackfill = { [weak self] batch in
            guard let self, hostID == self.openSessionHostID, batch.sessionID == self.openSessionID else { return }
            // Full-history backfill precedes what's on screen — merge by seq so it slots in above the
            // tail rather than appending out of order. Drain first (same reason as openSnapshot).
            self.drainPendingOpenEvents()
            self.openEvents = Self.mergedEvents(self.openEvents, batch.events)
            self.persistOpenTranscript()
        }
        cb.transcriptPrefetched = { [weak self] sessionID, events in
            guard let self else { return }
            self.prefetchInFlight = nil
            if !events.isEmpty {
                // Merge into whatever the cache already holds (the fetch pulled only the gap);
                // `saveEvents` re-caps to the tail window on write.
                Task {
                    let cached = await SessionCache.loadEvents(sessionID)
                    await SessionCache.saveEvents(Self.mergedEvents(cached, events), for: sessionID)
                }
            }
            self.pumpTranscriptPrefetch()
        }
        cb.openDiff = { [weak self] diff in
            guard let self, hostID == self.openSessionHostID, diff.sessionID == self.openSessionID else { return }
            self.openDiff = diff
            self.diffLoading = false
        }
        cb.approvalRequested = { [weak self] req, title in
            guard let self else { return }
            if hostID == self.openSessionHostID, req.sessionID == self.openSessionID,
               !self.openApprovals.contains(where: { $0.id == req.id }) {
                self.openApprovals.append(req)
            }
            // Prefer the Live Activity: when backgrounded with a glance on screen, the glance itself
            // carries the approval (its inline Allow/Deny) and alerts (sound/haptic) via the local
            // update path — so don't also post a banner. In-app the glance isn't the surface (it
            // doesn't show over the app that controls it), so the banner is; and if there's no glance
            // at all (Live Activities off) the banner is the only surface, so keep it as the fallback.
            // willPresent still suppresses the banner when the user is already on this session.
            if self.isForeground || !LiveActivityManager.shared.hasLiveActivity {
                NotificationRouter.shared.postApproval(req, sessionTitle: title)
            }
        }
        cb.approvalResolved = { [weak self] resolved in
            guard let self else { return }
            self.openApprovals.removeAll { $0.id == resolved.id }
            NotificationRouter.shared.withdrawApproval(resolved.id)
        }
        cb.sessionBecameWaiting = { [weak self] summary in
            guard let self, !self.isActive else { return }
            // Backgrounded: prefer the glance — it shows the waiting session and alerts through the
            // local update (sound/haptic). Only fall back to a banner when there's no glance at all
            // (Live Activities off), so a needs-input arrival is never silent.
            if !LiveActivityManager.shared.hasLiveActivity {
                NotificationRouter.shared.postSessionUpdate(summary)
            }
        }
        cb.wireError = { [weak self] error in
            self?.showError(error.message, sessionID: error.sessionID)
        }
        cb.didPair = { [weak self] host in
            guard let self else { return }
            IdentityStore.savePairedHost(host)
            self.rebuildAggregate()
        }
        cb.meshRosterChanged = { [weak self] in
            // Mesh "join": a Mac was learned or revoked via gossip. Reconnect to every paired Mac
            // (connecting the newcomer) and drop any that left — without switching the active host.
            self?.reconnect()
        }
        cb.tailnetStatus = { [weak self] status, loginURL in
            self?.tailnetStatus = status
            self?.tailnetLoginURL = loginURL
        }
        cb.pairingCodeReceived = { [weak self] qr in
            guard let self else { return }
            // Only apply while a request is outstanding — ignore a late reply after the user
            // dismissed the sheet (we already told the host to cancel).
            guard case .requesting = self.addDevice else { return }
            self.addDevice = qr.map(AddDeviceState.ready) ?? .unavailable
        }
        cb.macPairRequested = { [weak self] req in
            guard let self else { return }
            self.pendingMacPairRequest = req
            self.pendingMacPairHostID = hostID
        }
        cb.macPairResolved = { [weak self] deviceID in
            guard let self, self.pendingMacPairRequest?.deviceID == deviceID else { return }
            self.pendingMacPairRequest = nil
            self.pendingMacPairHostID = nil
        }
        return cb
    }

    /// Re-merge every connected Mac's projection into the flat @Published state the UI binds to
    /// (mesh P3): all hosts' sessions and dashboards shown together, with the host-specific values
    /// (host name, capabilities, catalog, connectivity, scope) following the open session's Mac —
    /// or a representative one. No-op in demo, which seeds the aggregate directly.
    private func rebuildAggregate() {
        guard !demoMode else { return }
        // Every assignment below goes through `setIfChanged`: `didUpdate` fires on *every* wire
        // frame from *any* host (session churn, dashboard refresh, diff-stat ticks), and a plain
        // `@Published` assignment fires `objectWillChange` even when the value is identical —
        // re-evaluating every view observing the store for nothing. Equality checks over these
        // small aggregates are far cheaper than a whole-tree SwiftUI invalidation.
        let live = aggregatedSessions()
        if !live.isEmpty {
            if sessions != live {
                sessions = live
                cachedSummaries = live
                persistSummaries(live)
                // The list moved — new or advanced sessions may need their transcript cache
                // warmed so a first open starts at the end like a revisit does.
                scheduleTranscriptPrefetch()
            }
        } else if connections.values.contains(where: { $0.connectivity.isLive }) {
            // Connected, but the host genuinely has no sessions — reflect that honestly.
            setIfChanged(\.sessions, [])
        } else {
            // Offline: keep showing the saved history rather than blanking the list.
            setIfChanged(\.sessions, cachedSummaries)
        }
        setIfChanged(\.dashboard, DashboardSnapshot.merged(connections.values.map(\.dashboard)))
        setIfChanged(\.meshPeers, connections.values.flatMap(\.meshPeers))

        let ctx = contextConnection
        setIfChanged(\.hostName, ctx?.hostName ?? "")
        setIfChanged(\.capabilities, ctx?.capabilities
            ?? WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: []))
        setIfChanged(\.modelCatalog, ctx?.modelCatalog ?? .empty)

        if let id = openSessionHostID, let conn = connections[id] {
            // While a transcript is open, the composer/controls act on *that* Mac — its connectivity
            // gates send and its scope drives the control affordances.
            setIfChanged(\.connectivity, conn.connectivity)
            setIfChanged(\.grantedScope, conn.grantedScope)
        } else {
            setIfChanged(\.connectivity, aggregateConnectivity())
            // Optimistic new-chat gating: enabled if *any* Mac grants control (the owning Mac still
            // enforces scope when the intent lands there).
            setIfChanged(\.grantedScope, connections.values
                .filter { $0.connectivity.isLive }.map(\.grantedScope).max() ?? .approve)
        }
    }

    /// Assign a `@Published` property only when the value actually differs, so a no-op rebuild
    /// doesn't fire `objectWillChange` (and with it a whole-tree view re-evaluation).
    private func setIfChanged<T: Equatable>(_ keyPath: ReferenceWritableKeyPath<RemoteStore, T>, _ value: T) {
        if self[keyPath: keyPath] != value { self[keyPath: keyPath] = value }
    }

    /// Merge transcript events by `seq` (monotonic, globally unique within a session), keeping the
    /// union sorted. Lets a reconnect's snapshot fold its events into the transcript already on
    /// screen without duplicating what's shown or dropping history outside the host's tail window.
    /// A fresh open merges into `[]`, so it's just the tail — the same result as a plain replace.
    private static func mergedEvents(_ existing: [AgentEvent], _ incoming: [AgentEvent]) -> [AgentEvent] {
        guard !existing.isEmpty else { return incoming }
        guard !incoming.isEmpty else { return existing }
        var bySeq: [UInt64: AgentEvent] = [:]
        bySeq.reserveCapacity(existing.count + incoming.count)
        for e in existing { bySeq[e.seq] = e }
        for e in incoming { bySeq[e.seq] = e }
        return bySeq.values.sorted { $0.seq < $1.seq }
    }

    // MARK: - Offline cache (SessionCache)

    /// Debounced write of the live session list to disk, so a read-only history survives a
    /// disconnect / relaunch.
    private func persistSummaries(_ list: [WireSessionSummary]) {
        summaryPersistTask?.cancel()
        summaryPersistTask = Task { [list] in
            try? await Task.sleep(nanoseconds: 2_000_000_000)
            guard !Task.isCancelled else { return }
            await SessionCache.saveSummaries(list)
        }
    }

    /// Seed the open transcript from disk so a cached session's history shows instantly — even fully
    /// offline. A live snapshot/backfill merges on top by seq (dedup), so this never double-counts.
    private func loadCachedTranscript(_ sessionID: SessionID) {
        Task { [weak self] in
            let cached = await SessionCache.loadEvents(sessionID)
            guard let self, self.openSessionID == sessionID, !cached.isEmpty else { return }
            // Drain any live deltas first so the seq-dedup merge sees the complete stream.
            self.drainPendingOpenEvents()
            self.openEvents = Self.mergedEvents(cached, self.openEvents)
        }
    }

    // MARK: - Background transcript prefetch

    /// Warm the offline transcript cache for recent sessions *before* they're ever opened. A
    /// fresh open otherwise starts from an empty transcript and waits on the host's snapshot, so
    /// the view renders at the top and visibly drops to the end as history lands; a session
    /// opened before seeds instantly from `SessionCache` and opens at its end. Prefetching runs
    /// the same `fetchTranscript` flow the open path uses — one session at a time, newest first,
    /// pulling only what the cache is missing — so every recent session opens like a warm one.
    private var prefetchQueue: [SessionID] = []
    private var prefetchInFlight: SessionID?
    /// The summary `lastSeq` each session was last prefetched (or attempted) at, so a session is
    /// re-queued only after it has moved on — not on every aggregate rebuild.
    private var prefetchedSeq: [SessionID: UInt64] = [:]

    /// Rebuild the prefetch queue from the freshest summaries. Called when the session list
    /// actually changes (connect, session churn); cheap when nothing needs pulling.
    private func scheduleTranscriptPrefetch() {
        guard !demoMode else { return }
        prefetchQueue = sessions
            .filter { !$0.archived && $0.sessionID != openSessionID && $0.sessionID != prefetchInFlight }
            .sorted { $0.updatedAt > $1.updatedAt }
            .prefix(8)
            .filter { $0.lastSeq > (prefetchedSeq[$0.sessionID] ?? 0) }
            .map(\.sessionID)
        pumpTranscriptPrefetch()
    }

    /// Start the next queued prefetch if none is in flight. Serial on purpose — background work
    /// must trickle behind the live stream, not contend with it.
    private func pumpTranscriptPrefetch() {
        guard prefetchInFlight == nil, !prefetchQueue.isEmpty else { return }
        let id = prefetchQueue.removeFirst()
        guard let summary = sessions.first(where: { $0.sessionID == id }),
              let conn = connection(owningSession: id), conn.canPrefetchTranscript
        else {
            // Gone / connection busy or incapable — skip; a later list update re-queues it.
            pumpTranscriptPrefetch()
            return
        }
        prefetchInFlight = id
        // Mark the attempt at this watermark now, so an empty/unavailable result doesn't
        // re-queue in a loop; the session re-qualifies once its lastSeq advances.
        prefetchedSeq[id] = summary.lastSeq
        Task { [weak self] in
            // Pull only the gap beyond what's cached; a cold session is bounded to the same
            // tail window the cache would keep anyway.
            let cachedLast = await SessionCache.loadEvents(id).last?.seq ?? 0
            guard let self else { return }
            if cachedLast >= summary.lastSeq {
                self.prefetchInFlight = nil  // cache already current
                self.pumpTranscriptPrefetch()
                return
            }
            let coldFloor = summary.lastSeq > UInt64(SessionCache.eventLimit)
                ? summary.lastSeq - UInt64(SessionCache.eventLimit) : 0
            let afterSeq = cachedLast > 0 ? cachedLast : coldFloor
            if !conn.prefetchTranscript(id, afterSeq: afterSeq) {
                self.prefetchInFlight = nil  // couldn't start (connection changed) — move on
                self.pumpTranscriptPrefetch()
            }
        }
    }

    // MARK: - Streaming delta coalescing

    /// Streaming transcript deltas arrive one wire frame at a time — often dozens per second
    /// while the agent talks — and every `openEvents` mutation fires `objectWillChange`, which
    /// re-evaluates *every* view observing the store (the Sessions/Home tabs stay mounted behind
    /// the pushed session detail, so they pay this too). Buffer incoming deltas and publish at
    /// most one append per `openEventsFlushInterval`: the first delta after a quiet gap applies
    /// immediately (the leading edge — first-token latency stays imperceptible), followers ride
    /// the next scheduled flush. ~10 UI updates/sec still reads as live streaming; the view tree
    /// stops being invalidated per wire frame. Merge/close/flush paths drain the buffer first, so
    /// nothing downstream ever sees a partial stream.
    private var pendingOpenEvents: [AgentEvent] = []
    private var openEventsFlushTask: Task<Void, Never>?
    private var lastOpenEventsFlushAt = Date.distantPast
    private static let openEventsFlushInterval: TimeInterval = 0.1

    private func enqueueOpenEvents(_ events: [AgentEvent]) {
        pendingOpenEvents.append(contentsOf: events)
        guard openEventsFlushTask == nil else { return }  // a trailing flush is already scheduled
        let elapsed = Date().timeIntervalSince(lastOpenEventsFlushAt)
        if elapsed >= Self.openEventsFlushInterval {
            drainPendingOpenEvents()
        } else {
            let delay = Self.openEventsFlushInterval - elapsed
            openEventsFlushTask = Task { [weak self] in
                try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
                guard let self, !Task.isCancelled else { return }
                self.openEventsFlushTask = nil
                self.drainPendingOpenEvents()
            }
        }
    }

    /// Publish the buffered deltas (and schedule persistence). Called on the flush cadence, and
    /// eagerly by anything that merges, persists, or clears `openEvents`, so those paths always
    /// operate on the complete stream.
    private func drainPendingOpenEvents() {
        openEventsFlushTask?.cancel()
        openEventsFlushTask = nil
        guard !pendingOpenEvents.isEmpty else { return }
        lastOpenEventsFlushAt = Date()
        openEvents.append(contentsOf: pendingOpenEvents)
        pendingOpenEvents.removeAll(keepingCapacity: true)
        persistOpenTranscript()
    }

    /// Drop buffered deltas without publishing — for session switch/close/unpair, where the
    /// buffer belongs to a transcript that is being cleared (a stale session's tail must never
    /// leak into the next session's freshly-opened transcript).
    private func discardPendingOpenEvents() {
        openEventsFlushTask?.cancel()
        openEventsFlushTask = nil
        pendingOpenEvents.removeAll(keepingCapacity: true)
    }

    /// Debounced write of the open transcript, called after each batch of events lands.
    private func persistOpenTranscript() {
        guard !demoMode, let id = openSessionID, !openEvents.isEmpty else { return }
        let events = openEvents
        transcriptPersistTask?.cancel()
        transcriptPersistTask = Task { [events, id] in
            try? await Task.sleep(nanoseconds: 1_500_000_000)
            guard !Task.isCancelled else { return }
            await SessionCache.saveEvents(events, for: id)
        }
    }

    /// Flush the open transcript to disk immediately (on close), cancelling any pending debounce.
    private func flushOpenTranscript(_ id: SessionID, _ events: [AgentEvent]) {
        transcriptPersistTask?.cancel()
        guard !demoMode, !events.isEmpty else { return }
        Task { await SessionCache.saveEvents(events, for: id) }
    }

    /// The flat sessions list: every connected Mac's sessions, deduped by id (ids are globally
    /// unique). Views handle sorting/grouping.
    private func aggregatedSessions() -> [WireSessionSummary] {
        var seen = Set<SessionID>()
        var result: [WireSessionSummary] = []
        for conn in connections.values {
            for summary in conn.sessions where seen.insert(summary.sessionID).inserted {
                result.append(summary)
            }
        }
        return result
    }

    /// One connectivity value for the whole app when no transcript is open (the chip + global
    /// gating): connected if any Mac is live, else the most-informative in-progress/offline state.
    private func aggregateConnectivity() -> Connectivity {
        let all = connections.values.map(\.connectivity)
        guard !all.isEmpty else { return .unpaired }
        if let live = all.first(where: { $0.isLive }) { return live }
        if all.contains(where: { if case .connecting = $0 { return true } else { return false } }) { return .connecting }
        if all.contains(where: { if case .reconnecting = $0 { return true } else { return false } }) { return .reconnecting }
        if all.contains(.hostOffline) { return .hostOffline }
        return all.first ?? .unpaired
    }

    /// Refresh cross-host aggregates: the app-icon badge (needs-you across *all* Macs) + the Live
    /// Activity summary. `sessions`/`liveSessions` are already the merged aggregate.
    private func refreshAggregate() {
        NotificationRouter.shared.updateBadge(needsYouCount)
        // When no session is blocked on an approval anymore, recall the "waiting for your approval"
        // wake tickle a prior push may have left on the lock screen. Complements the relay's silent
        // clear (which covers phones that were away): this catches the phone that saw the tickle and
        // then connected, so the resolution arrives as a broadcast rather than a push. Idempotent —
        // a no-op when the tickle isn't there. Per-approval locals are withdrawn as each resolves.
        let approvalsPending = liveSessions.contains {
            $0.pendingApprovalCount > 0 || $0.status == .awaitingApproval
        }
        if !approvalsPending {
            NotificationRouter.shared.withdrawApprovalAttention()
        }
        LiveActivityManager.shared.sync(hostName: hostName, sessions: liveSessions)
    }

    /// Tear down every live connection (leaving the paired registry intact) — for entering demo.
    private func teardownAll() {
        for conn in connections.values { conn.teardown() }
        connections.removeAll()
    }

    /// Connect to every paired Mac at once (mesh P3 multiplexer): each `HostConnection` runs its own
    /// IK reconnect (LAN→tailnet, with backoff), so all your Macs are live simultaneously and the
    /// switcher flips between them instantly. Idempotent — an already-live connection is left alone;
    /// an offline one (re)dials. Connections for since-unpaired Macs are dropped. The launch +
    /// "Reconnect" path.
    func reconnect() {
        let hosts = IdentityStore.pairedHosts()
        guard !hosts.isEmpty else { connectivity = .unpaired; return }
        let paired = Set(hosts.map(\.fingerprint))
        for (id, conn) in connections where !paired.contains(id) { conn.teardown(); connections[id] = nil }
        for host in hosts {
            let conn = connection(for: host)
            if !conn.connectivity.isLive { conn.reconnect(to: host) }
        }
        rebuildAggregate()
        refreshAggregate()
    }


    /// Unpair this device entirely — drop every paired Mac and its connection (the "Unpair this
    /// device" button). Nothing left to show, so the app returns to the pairing intro.
    func unpair() {
        for (id, conn) in connections { conn.teardown(); connections[id] = nil }
        for host in IdentityStore.pairedHosts() { IdentityStore.removePairedHost(id: host.fingerprint) }
        IdentityStore.clearPairedHost()
        finishUnpairIfEmpty()
    }

    /// Forget one paired Mac (the Settings ▸ Mesh per-row remove): drop just its connection +
    /// registry record. Other Macs keep running and stay in the merged view.
    func unpair(_ hostID: String) {
        connections[hostID]?.teardown()
        connections[hostID] = nil
        IdentityStore.removePairedHost(id: hostID)
        if IdentityStore.pairedHosts().isEmpty {
            finishUnpairIfEmpty()
        } else {
            rebuildAggregate()
            refreshAggregate()
        }
    }

    /// Wind the app back to the unpaired state once no Macs remain: clear the open transcript + flat
    /// aggregate and spin the embedded Tailscale node down.
    private func finishUnpairIfEmpty() {
        openSessionID = nil
        compactDetailPresented = false
        openSessionHostID = nil
        discardPendingOpenEvents()
        openEvents = []; openApprovals = []; openDiff = nil; diffLoading = false
        connectivity = .unpaired
        // No Mac left whose history to hold — drop the offline cache too.
        cachedSummaries = []
        SessionCache.clear()
        sessions = []
        dashboard = .empty
        // Nothing left to dial — spin the embedded Tailscale node down if it was running.
        Task { await TailnetNode.shared.stop() }
        tailnetStatus = nil
        LiveActivityManager.shared.end()
        NotificationRouter.shared.updateBadge(0)
    }

    /// Enter the in-app demo (the "Explore a demo" button): drop any live connection, persist the
    /// flag so it survives relaunch (a reviewer may relaunch), and seed the mock world. `isPaired`
    /// then returns true, so `RootView` shows the full TabView.
    func enterDemo() {
        teardownAll()
        demoMode = true
        UserDefaults.standard.set(true, forKey: Self.demoModeKey)
        seedDemo()
    }

    /// Leave the demo (Settings ▸ Leave demo): cancel any simulated runs, clear the mock world,
    /// and return to the real state — reconnect if a Mac is actually paired, else the pairing intro.
    func exitDemo() {
        demoMode = false
        UserDefaults.standard.set(false, forKey: Self.demoModeKey)
        demoTasks.forEach { $0.cancel() }
        demoTasks.removeAll()
        openSessionID = nil
        compactDetailPresented = false
        openSessionHostID = nil
        discardPendingOpenEvents()
        openEvents = []
        openApprovals = []
        openDiff = nil
        diffLoading = false
        sessions = []
        dashboard = .empty
        LiveActivityManager.shared.end()
        NotificationRouter.shared.updateBadge(0)
        if IdentityStore.loadPairedHost() != nil {
            reconnect()
        } else {
            connectivity = .unpaired
        }
    }

    // MARK: - Intents (UX_IOS §9)

    func open(_ sessionID: SessionID) {
        openSessionID = sessionID
        // Hide the compact shell's floating tab bar the instant a session view opens — from *any*
        // entry point (Sessions, Home, Projects, a notification tap), since every one funnels
        // through here. Tied to the session view opening rather than the Sessions list being
        // navigated away from, so the bar slides out even when the chat wasn't launched from that
        // list. `SessionsView` still clears it early on back-swipe for a responsive re-show.
        compactDetailPresented = true
        // Record which Mac owns this session (mesh P3) so its connection forwards the transcript and
        // the host-specific projected values follow it.
        openSessionHostID = connection(owningSession: sessionID)?.hostID
        discardPendingOpenEvents()
        openEvents = []
        openApprovals = []
        openDiff = nil
        diffLoading = false
        markOpened(sessionID)
        if demoMode { seedDemoTranscript(sessionID); return }
        // Seed from the on-device cache so the transcript shows instantly — including fully offline,
        // where the subscribe below is a no-op. Live events merge on top by seq (dedup).
        loadCachedTranscript(sessionID)
        // Tell the owning connection so it forwards the snapshot/events (and dedupes them), re-derive
        // the context host (capabilities/scope/catalog/connectivity now follow it), then subscribe.
        connection(owningSession: sessionID)?.openSessionID = sessionID
        rebuildAggregate()
        send(.subscribe(Subscribe(sessionID: sessionID, sinceSeq: nil, verbosity: .full)))
        // Pull the full history too — the subscribe above only returns a 200-event tail, so without
        // this the transcript would start at the connection point with no events from before it.
        connection(owningSession: sessionID)?.fetchFullTranscript(sessionID)
    }

    /// Bind the open session to its owning Mac once that Mac is live — the deferred half of `open()`
    /// for a session opened before any connection existed (a Live Activity / notification cold-launch
    /// deep link). Because `open()` ran while the app was still dialing, `connection(owningSession:)`
    /// found nothing: the host binding stayed nil, no `subscribe` went out, and the transcript
    /// callbacks — all gated on `openSessionHostID` — dropped everything the host later sent, leaving
    /// the detail stuck on "Disconnected" with an empty transcript and a dead composer. Once a host
    /// connects and lists its sessions, `didUpdate` calls this: it finds the owner and finishes the
    /// subscription so the transcript loads and connectivity/scope follow the bound host. Idempotent
    /// and cheap — a no-op on the common path where `open()` already bound a live connection.
    private func bindOpenSessionIfNeeded() {
        guard !demoMode, let sessionID = openSessionID,
              let conn = connection(owningSession: sessionID), conn.connectivity.isLive else { return }
        // Already bound to this live host with its subscription in place — nothing to redo.
        guard openSessionHostID != conn.hostID || conn.openSessionID != sessionID else { return }
        openSessionHostID = conn.hostID
        conn.openSessionID = sessionID
        conn.send(.subscribe(Subscribe(sessionID: sessionID, sinceSeq: nil, verbosity: .full)))
        conn.fetchFullTranscript(sessionID)
    }

    /// Ask the host for the open session's full patch (Diff tab). No-op when the host
    /// doesn't advertise the capability — the view falls back to the diffstat summary.
    func fetchDiff(_ sessionID: SessionID) {
        if demoMode { openDiff = demoDiff(sessionID); return }
        guard capabilities.canFetchDiff else { return }
        diffLoading = openDiff == nil
        send(.fetchDiff(sessionID))
    }

    // MARK: - Add a device to this mesh (relayed pairing-code mint)

    /// Whether the "add a device" button should appear: a live Mac that advertises
    /// `canMintPairingCode` is reachable to mint a join code (or we're in demo).
    var canAddDeviceToMesh: Bool {
        demoMode || pairingMintConnection != nil
    }

    /// The connection we route a mint request to: prefer the context host if it can mint, else any
    /// live host that can. A code minted by *any* mesh member joins the whole group (mesh "join").
    private var pairingMintConnection: HostConnection? {
        if let ctx = contextConnection, ctx.connectivity.isLive, ctx.capabilities.canMintPairingCode {
            return ctx
        }
        return connections.values.first { $0.connectivity.isLive && $0.capabilities.canMintPairingCode }
    }

    /// Ask a connected Mac to open a pairing window and hand back its join code. The reply arrives
    /// asynchronously as `AddDeviceState.ready` (or `.unavailable`) via the host connection.
    func requestPairingCode() {
        if demoMode { addDevice = .ready(Self.demoPairingCode); return }
        guard let conn = pairingMintConnection else { addDevice = .unavailable; return }
        pairingMintHostID = conn.hostID
        addDevice = .requesting
        conn.send(.requestPairingCode)
    }

    /// The user dismissed the "add a device" sheet — tell the minting Mac to close its pairing
    /// window (retire the one-time secret) and reset to idle. Any Mac-join awaiting approval is
    /// abandoned here too; the host fails it closed when its pairing window shuts.
    func cancelPairingCode() {
        if !demoMode, let id = pairingMintHostID { connections[id]?.send(.cancelPairingCode) }
        pairingMintHostID = nil
        pendingMacPairRequest = nil
        pendingMacPairHostID = nil
        addDevice = .idle
    }

    /// Approve or deny a Mac joining via a code this phone shared (the forwarded confirm). Routes
    /// the answer to the Mac that forwarded it and clears the prompt optimistically.
    func respondMacPair(_ approve: Bool) {
        guard let req = pendingMacPairRequest else { return }
        if let id = pendingMacPairHostID { connections[id]?.send(.respondMacPair(req.deviceID, approve)) }
        pendingMacPairRequest = nil
        pendingMacPairHostID = nil
    }

    /// A stand-in join code for the offline demo so the QR/copy sheet renders without a Mac.
    private static let demoPairingCode = "nucleic://pair?d=demo"

    /// Record that the user looked at this session now (clears its unseen-completion wash).
    func markOpened(_ sessionID: SessionID) {
        lastOpenedAt[sessionID] = Date()
        persistLastOpened()
    }

    /// Offline transcript fixture (NUCLEIC_DEMO) so the richer transcript surfaces — grouped
    /// tools, Orchestra card, usage/cost, file changes, run outcome — render without a host.
    private func seedDemoTranscript(_ sessionID: SessionID) {
        func event(_ seq: UInt64, _ kind: AgentEvent.Kind) -> AgentEvent {
            AgentEvent(sessionID: sessionID, seq: seq, at: Date(), backend: .claudeCode,
                       nativeType: nil, kind: kind)
        }
        // A realistic `git commit` (heredoc message) so the transcript's structured commit card
        // is exercisable offline: expand the Bash call to see the subject + Markdown body.
        let demoCommitCommand = "git commit -F - <<'EOF'\nfix: harden auth middleware\n\nRequire a Bearer token and reject a missing or blank one.\n\n- extract `requireSession`\n- add a `Bearer` prefix check\nEOF"
        // A multi-step, destructive shell pipeline so the transcript's step list (with the delete
        // flagged in red) is exercisable offline: expand the Bash call to see the breakdown.
        let demoCleanupCommand = "cd ~/code/nucleic && rm -rf .worktrees/auth-old && git worktree prune && git branch -D nucleic/auth-old"
        openEvents = [
            event(1, .sessionStarted(SessionStarted(
                backendSessionID: "demo", model: "claude-opus-4-8[1m]", cwd: "~/code/nucleic", toolNames: []))),
            event(2, .userText(TextChunk(messageID: "u1", text: "Refactor the auth middleware and run the tests.", isPartial: false))),
            event(3, .assistantText(TextChunk(messageID: "a1", text: "I'll update the auth middleware, then run the suite.\n\n**Plan:**\n- extract `requireSession`\n- add a `Bearer` check", isPartial: false))),
            event(4, .toolCallStarted(ToolCall(toolCallID: "t1", name: "Edit", input: ["file_path": "auth/middleware.ts"]))),
            event(5, .toolCallCompleted(ToolCall(toolCallID: "t1", name: "Edit", input: ["file_path": "auth/middleware.ts"]))),
            event(6, .fileChange(FileChange(path: "auth/middleware.ts", kind: .update, toolCallID: "t1"))),
            event(7, .toolResult(ToolResult(toolCallID: "t1", content: "Applied 2 edits to auth/middleware.ts", isError: false))),
            event(8, .toolCallStarted(ToolCall(toolCallID: "t2", name: "Bash", input: ["command": "npm test"]))),
            event(9, .toolCallCompleted(ToolCall(toolCallID: "t2", name: "Bash", input: ["command": "npm test"]))),
            event(10, .toolResult(ToolResult(toolCallID: "t2", content: "42 passing\n0 failing", isError: false))),
            event(11, .toolCallStarted(ToolCall(toolCallID: "t3", name: "Task", input: ["description": "Audit other call sites", "prompt": "Find every caller of the old auth API."]))),
            event(12, .toolCallCompleted(ToolCall(toolCallID: "t3", name: "Task", input: ["description": "Audit other call sites"]))),
            event(13, .toolResult(ToolResult(toolCallID: "t3", content: "Checked 7 files; 1 stale caller updated.", isError: false))),
            event(14, .toolCallStarted(ToolCall(toolCallID: "t4", name: "Bash", input: ["command": .string(demoCommitCommand)]))),
            event(15, .toolCallCompleted(ToolCall(toolCallID: "t4", name: "Bash", input: ["command": .string(demoCommitCommand)]))),
            event(16, .toolResult(ToolResult(toolCallID: "t4", content: "[nucleic/auth-refactor 1a2b3c4] fix: harden auth middleware\n 2 files changed, 312 insertions(+), 40 deletions(-)", isError: false))),
            event(17, .toolCallStarted(ToolCall(toolCallID: "t5", name: "Bash", input: ["command": .string(demoCleanupCommand)]))),
            event(18, .toolCallCompleted(ToolCall(toolCallID: "t5", name: "Bash", input: ["command": .string(demoCleanupCommand)]))),
            event(19, .toolResult(ToolResult(toolCallID: "t5", content: "Removed 1 worktree; deleted branch nucleic/auth-old.", isError: false))),
            event(20, .usage(Usage(inputTokens: 84_300, outputTokens: 2_140, costUSD: 0.0421, contextInputTokens: 84_300))),
            event(21, .runFinished(RunFinished(outcome: .completed, finalText: "Done."))),
        ]
        // If this session is blocked on a human, surface a real approval card so the
        // Allow/Deny loop is exercisable in the demo (the seeded `a1` session).
        if sessions.first(where: { $0.sessionID == sessionID })?.status == .awaitingApproval {
            openApprovals = [ApprovalRequest(
                id: Self.demoApprovalID(for: sessionID),
                sessionID: sessionID, toolCallID: "t-appr", toolName: "Bash",
                input: ["command": "npm run deploy"], title: "Run npm run deploy",
                risk: .execute, createdAt: Date())]
        }
    }

    /// Deterministic approval id for a demo session's seeded approval, so re-opening the same
    /// session doesn't stack duplicate cards.
    private static func demoApprovalID(for sessionID: SessionID) -> ApprovalID {
        ApprovalID(rawValue: "demo-appr-\(sessionID.rawValue)")
    }

    /// Close a session's live subscription. `id` names *which* session is closing — the detail
    /// view passes its own. On iPad's split view, switching session A→B can mount B (which calls
    /// `open(B)`, setting `openSessionID = B`) *before* A's detail disappears; so we always
    /// unsubscribe the named session but only tear down the shared open-state when it still
    /// belongs to that session — otherwise we'd wipe B's freshly-loaded transcript. Called with
    /// no argument it closes whatever is currently open (the iPhone push/pop path, unchanged).
    func closeOpen(_ id: SessionID? = nil) {
        guard let target = id ?? openSessionID else { return }
        send(.unsubscribe(target))
        markOpened(target)  // everything up to now has been seen
        guard openSessionID == target else { return }
        connection(owningSession: target)?.openSessionID = nil
        openSessionID = nil
        // Session view closed — restore the compact tab bar (the counterpart to `open`'s hide).
        // On the Sessions tab the back-swipe has usually already cleared this early for a snappy
        // re-show; this is the catch-all for the other entry points and programmatic closes.
        compactDetailPresented = false
        openSessionHostID = nil
        // Persist the final transcript before clearing it, so it's warm for the next open / offline.
        // Buffered streaming deltas are part of that transcript — publish them first.
        drainPendingOpenEvents()
        flushOpenTranscript(target, openEvents)
        openEvents = []
        openApprovals = []
        openDiff = nil
        diffLoading = false
        // Context reverts to the aggregate now that no transcript is open.
        if !demoMode { rebuildAggregate() }
    }

    func respond(_ approval: ApprovalRequest, _ decision: Decision) {
        // Route to the Mac that owns the approval's session (the approval message carries no
        // sessionID of its own). Demo resolves locally.
        if demoMode {
            demoHandle(.approvalRespond(approval.id, decision))
        } else {
            connection(owningSession: approval.sessionID)?.send(.approvalRespond(approval.id, decision))
        }
        openApprovals.removeAll { $0.id == approval.id }  // optimistic dismiss; host confirms
    }

    func sendInput(_ text: String, to sessionID: SessionID) {
        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else { return }
        send(.sendInput(sessionID, AgentInput(text: trimmed)))
    }

    func refreshSessions() { send(.listSessions); send(.listDashboard) }

    // MARK: - Control intents (control scope; the same actions the Mac can take)

    func startChat(in projectID: ProjectID, message: String, model: String? = nil,
                   effort: String? = nil, baseBranch: String? = nil,
                   useWorktree: Bool = true, auto: Bool? = nil) {
        let text = message.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !text.isEmpty else { return }
        send(.startChat(StartChatRequest(
            projectID: projectID, message: text, model: model, effort: effort,
            baseBranch: baseBranch, useWorktree: useWorktree, auto: auto)))
    }

    func captureTodo(_ text: String, projectID: ProjectID?) {
        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else { return }
        send(.captureTodo(CaptureTodoRequest(text: trimmed, projectID: projectID)))
    }

    func dispatchTodo(_ id: TodoID, in projectID: ProjectID) { send(.dispatchTodo(id, projectID)) }
    func completeTodo(_ id: TodoID) { send(.setTodoStatus(id, .done)) }
    func deleteTodo(_ id: TodoID) { send(.deleteTodo(id)) }

    func renameSession(_ id: SessionID, to title: String) {
        let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else { return }
        send(.renameSession(id, trimmed))
    }
    func setFavorite(_ id: SessionID, _ favorite: Bool) { send(.setFavorite(id, favorite)) }
    func setArchived(_ id: SessionID, _ archived: Bool) { send(.setArchived(id, archived)) }
    func deleteSession(_ id: SessionID) {
        send(.deleteSession(id))
        if id == openSessionID { closeOpen() }
    }
    func integrate(_ id: SessionID, _ mode: IntegrationMode) { send(.integrate(id, mode)) }
    /// Throw away the session's branch/worktree without landing it (the Mac's Discard…).
    func discard(_ id: SessionID) { send(.discard(id)) }
    func interrupt(_ id: SessionID) { send(.interrupt(id)) }
    /// Cancel one queued (not-yet-sent) follow-up by id — the phone's per-message ✕.
    func cancelQueuedMessage(_ id: SessionID, _ messageID: UUID) { send(.cancelQueuedMessage(id, messageID)) }

    // Mid-session model / reasoning / automation — the same affordances the Mac header exposes.
    // `nil` model/effort resets the session to the host/app default.
    func setSessionModel(_ id: SessionID, _ model: String?) { send(.setSessionModel(id, model)) }
    func setSessionEffort(_ id: SessionID, _ effort: String?) { send(.setSessionEffort(id, effort)) }
    func setSessionAuto(_ id: SessionID, _ auto: Bool) { send(.setSessionAuto(id, auto)) }
    func setSessionAutoShip(_ id: SessionID, _ autoShip: Bool) { send(.setSessionAutoShip(id, autoShip)) }
    func setSessionShipBranch(_ id: SessionID, _ branch: String?) { send(.setSessionShipBranch(id, branch)) }

    // MARK: - App Intents support

    /// Bring networking online and dial the paired host(s) for an App Intent that runs in a *cold*
    /// background process (Siri / Shortcuts / a widget or Live Activity button), where the SwiftUI
    /// scene never mounts and `onAppear` never fires. Mirrors the push handler's silent-launch
    /// bootstrap (`startNetworkingIfNeeded` + `reconnect`). Idempotent; a no-op in demo mode.
    func bootstrapForIntent() {
        guard !demoMode else { return }
        startNetworkingIfNeeded()
        if isPaired {
            // Show the persisted session list immediately so a cold intent query (Siri/Spotlight)
            // has data to answer with before any host connects — same seed as `onAppear`.
            if sessions.isEmpty { sessions = cachedSummaries }
            reconnect()
        }
    }

    /// Whether any paired Mac currently has a live channel.
    var hasLiveConnection: Bool { connections.values.contains { $0.connectivity.isLive } }

    /// Await a live connection to any paired Mac, up to `timeout` seconds — an intent must act over a
    /// *live* channel or fail clean (UX_IOS §6, §3.1). Brings networking up first if it's cold, then
    /// polls until a link comes up or the deadline passes. Returns whether a link is live.
    func awaitLiveConnection(timeout: TimeInterval = 6) async -> Bool {
        if demoMode || hasLiveConnection { return true }
        bootstrapForIntent()
        let deadline = Date().addingTimeInterval(timeout)
        while Date() < deadline {
            try? await Task.sleep(for: .milliseconds(200))
            if hasLiveConnection { return true }
        }
        return hasLiveConnection
    }

    /// Resolve an approval by id from an App Intent with a full `Decision`. Like
    /// `respondFromNotification`, it prefers the owning Mac, else broadcasts to every live Mac (the
    /// owner resolves; the rest see an unknown / `alreadyResolved` id and no-op), and queues briefly
    /// on a dropped link so a decision made just as the socket blips still lands. `sessionID` (when
    /// the surface knows it) routes directly. Returns whether it went out over a live channel now.
    @discardableResult
    func respondToApproval(id: ApprovalID, sessionID: SessionID?, decision: Decision) -> Bool {
        if demoMode { demoHandle(.approvalRespond(id, decision)); return true }
        if let sessionID, let conn = connection(owningSession: sessionID), conn.connectivity.isLive {
            conn.send(.approvalRespond(id, decision))
            openApprovals.removeAll { $0.id == id }
            return true
        }
        let live = connections.values.filter { $0.connectivity.isLive }
        if !live.isEmpty {
            for conn in live { conn.send(.approvalRespond(id, decision)) }
            openApprovals.removeAll { $0.id == id }
            return true
        }
        pendingNotificationDecision = (id, decision, Date())
        reconnect()
        return false
    }

    // MARK: - Plumbing

    /// Route an intent to the Mac that owns its target (mesh P3). Sessions/projects/to-dos are shown
    /// merged across every connected Mac, so an intent goes to the connection that owns the session,
    /// project, or to-do it names — not to a single "active" host. Host-agnostic pulls (list
    /// sessions/dashboard) broadcast to every live Mac; a project-less to-do capture (no owner) goes
    /// to the first live Mac. Demo has no connections and mutates the seeded aggregate directly.
    private func send(_ msg: ClientMsg) {
        if demoMode { demoHandle(msg); return }
        switch msg {
        // Session-owning intents → the Mac that has this session.
        case .subscribe(let s):
            connection(owningSession: s.sessionID)?.send(msg)
        case .unsubscribe(let id), .interrupt(let id), .deleteSession(let id), .discard(let id),
             .integrate(let id, _), .renameSession(let id, _), .setFavorite(let id, _),
             .setArchived(let id, _), .setSessionModel(let id, _), .setSessionEffort(let id, _),
             .setSessionAuto(let id, _), .setSessionAutoShip(let id, _), .setSessionShipBranch(let id, _),
             .sendInput(let id, _), .cancelQueuedMessage(let id, _), .fetchDiff(let id):
            connection(owningSession: id)?.send(msg)

        // Project-owning intents → the Mac that has this project.
        case .startChat(let req):
            connection(owningProject: req.projectID)?.send(msg)
        case .dispatchTodo(_, let projectID):
            connection(owningProject: projectID)?.send(msg)

        // To-do-owning intents → the Mac that has this to-do.
        case .setTodoStatus(let id, _), .deleteTodo(let id):
            connection(owningTodo: id)?.send(msg)

        // A capture with a project goes to that Mac; a project-less one has no owner → first live.
        case .captureTodo(let req):
            if let pid = req.projectID, let conn = connection(owningProject: pid) { conn.send(msg) }
            else { firstLiveConnection?.send(msg) }

        // Approvals carry no sessionID — `respond(_:_:)` routes by the approval's session directly;
        // a stray one here (or a notification-driven decision) broadcasts and the owner resolves it.
        case .approvalRespond:
            broadcastLive(msg)

        // Host-agnostic pulls → every live Mac.
        case .listSessions, .listDashboard:
            broadcastLive(msg)

        // Never originated from here (connection-internal, or handled by dedicated loops).
        // `requestPairingCode`/`cancelPairingCode` are sent straight to the chosen host by
        // `requestPairingCode()`/`cancelPairingCode()`, not through this owner-routing switch.
        case .hello, .ping, .listPeers, .addressUpdate, .meshRoster,
             .registerLiveActivity, .endLiveActivity, .registerPushToStartToken, .setForeground,
             .transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript,
             .requestPairingCode, .cancelPairingCode, .respondMacPair:
            break
        }
    }

    // MARK: - Live Activity push registration (UX_IOS §5.3)

    /// The current Live Activity's APNS update token + id, shipped to every capable Mac so it can
    /// refresh the lock-screen glance over APNs while the phone is backgrounded and its socket is
    /// suspended. Sent to *all* connected hosts (not just the active one) — while the phone is
    /// away, whichever Mac has work to report should be able to push.
    private var liveActivityReg: (token: String, activityID: String)?
    /// Host ids that already have the current token (re-sent to a host that (re)connects, and
    /// re-sent to everyone when the token rotates).
    private var liveActivitySentTo: Set<String> = []
    /// This device's push-to-start token (iOS 17.2+), shipped to every capable Mac so it can create
    /// the Live Activity over APNs when work starts before the app is opened. Device-scoped, so —
    /// unlike `liveActivityReg` — it persists across activities and isn't cleared when one ends.
    private var pushToStartToken: String?
    /// Host ids that already have the current push-to-start token (mirrors `liveActivitySentTo`).
    private var pushToStartSentTo: Set<String> = []
    /// Whether the app is currently foreground. Reported to capable Macs (`ClientMsg.setForeground`)
    /// so they know whether to defer to the phone's own Live Activity creation (foreground) or
    /// push-to-start the glance themselves (backgrounded — the phone can't reliably start one). Starts
    /// `true`: `onAppear` runs in the foreground; the background adopt-wake flips it first (§5.3).
    /// Mirrored to `LiveActivityManager` so it knows whether a "needs you" arrival should alert
    /// through the glance (backgrounded) or stay silent while a banner announces it (foreground).
    private var isForeground = true {
        didSet { LiveActivityManager.shared.foreground = isForeground }
    }
    /// Host ids already told the *current* `isForeground` value — cleared whenever it flips so the
    /// next sync re-notifies every host (mirrors `pushToStartSentTo`).
    private var foregroundSentTo: Set<String> = []

    /// Guards `setupLiveActivityBridge` — it's called from both `onAppear` and the background adopt
    /// wake, and installing the callbacks / observers once is enough.
    private var liveActivityBridgeSetup = false

    private func setupLiveActivityBridge() {
        guard !liveActivityBridgeSetup else { return }
        liveActivityBridgeSetup = true
        LiveActivityManager.shared.onPushToken = { [weak self] token, activityID in
            guard let self, self.liveActivityReg?.token != token else { return }
            self.liveActivityReg = (token, activityID)
            self.liveActivitySentTo.removeAll()  // a fresh token must reach every host again
            self.syncLiveActivityRegistration()
        }
        LiveActivityManager.shared.onPushToStartToken = { [weak self] token in
            guard let self, self.pushToStartToken != token else { return }
            self.pushToStartToken = token
            self.pushToStartSentTo.removeAll()  // a fresh token must reach every host again
            self.syncPushToStartRegistration()
        }
        // Start observing the push-to-start token (and adopting any push-started activity) now — it's
        // device-scoped and must be captured even before any Activity exists.
        LiveActivityManager.shared.beginPushToStartObservation()
        LiveActivityManager.shared.onActivityEnded = { [weak self] activityID in
            guard let self else { return }
            self.liveActivityReg = nil
            self.liveActivitySentTo.removeAll()
            for conn in self.connections.values where conn.capabilities.canPushLiveActivity {
                conn.send(.endLiveActivity(activityID))
            }
        }
    }

    /// Send the current Live Activity token to every live, capable host that hasn't got it yet.
    /// Called when the token changes and on every connection update, so a host that just connected
    /// — or reconnected while the phone was away — learns the token it needs to push.
    private func syncLiveActivityRegistration() {
        guard let reg = liveActivityReg else { return }
        // A host that dropped should re-register when it returns.
        liveActivitySentTo = liveActivitySentTo.filter { connections[$0]?.connectivity.isLive == true }
        for (id, conn) in connections {
            guard conn.connectivity.isLive, conn.capabilities.canPushLiveActivity,
                  !liveActivitySentTo.contains(id) else { continue }
            conn.send(.registerLiveActivity(reg.token, reg.activityID))
            liveActivitySentTo.insert(id)
        }
    }

    /// Send the current push-to-start token to every live, capable host that hasn't got it yet — the
    /// mirror of `syncLiveActivityRegistration` for the device-scoped token that lets a host create
    /// the glance over APNs when work starts before the app is opened (iOS 17.2+).
    private func syncPushToStartRegistration() {
        guard let token = pushToStartToken else { return }
        // A host that dropped should re-register when it returns.
        pushToStartSentTo = pushToStartSentTo.filter { connections[$0]?.connectivity.isLive == true }
        for (id, conn) in connections {
            // Gate on the dedicated push-to-start bit, not `canPushLiveActivity`: an older host can
            // advertise the latter yet throw on the unknown `registerPushToStartToken` tag.
            guard conn.connectivity.isLive, conn.capabilities.canPushToStartLiveActivity,
                  !pushToStartSentTo.contains(id) else { continue }
            conn.send(.registerPushToStartToken(token))
            pushToStartSentTo.insert(id)
        }
    }

    /// Tell every capable, live host the current foreground state so it can pick the Live Activity
    /// path: defer to the phone's own creation while foreground, or push-to-start the glance while
    /// backgrounded (UX_IOS §5.3). De-duped per host against the value already sent; the set is
    /// cleared by `setForegroundState` when the value flips, and a host that dropped re-learns it when
    /// it returns (via the `didUpdate` re-sync). Gated on the dedicated capability bit — an older host
    /// (even one advertising push-to-start) throws on the unknown `setForeground` tag.
    private func syncForegroundState() {
        foregroundSentTo = foregroundSentTo.filter { connections[$0]?.connectivity.isLive == true }
        for (id, conn) in connections {
            guard conn.connectivity.isLive, conn.capabilities.canReceiveForegroundState,
                  !foregroundSentTo.contains(id) else { continue }
            conn.send(.setForeground(isForeground))
            foregroundSentTo.insert(id)
        }
    }

    /// Record a foreground/background transition and push it to the hosts. No-op when unchanged so an
    /// `inactive`↔`active` flutter (or a repeat call) doesn't re-notify; on a real flip it clears the
    /// per-host dedup so `syncForegroundState` re-sends to everyone.
    private func setForegroundState(_ foreground: Bool) {
        guard isForeground != foreground else { return }
        isForeground = foreground
        foregroundSentTo.removeAll()
        syncForegroundState()
    }

    // MARK: - Demo simulator (offline, interactive)
    //
    // In demo mode there's no host, so writes can't go over the wire. Instead they mutate the
    // already-`@Published` projection directly and (for the agent loop) stream a short canned run,
    // so a reviewer can send messages, approve actions, start chats, and manage to-dos and see the
    // UI respond — the read-only fixtures (`seedDemo`/`seedDemoTranscript`/`demoDiff`) already
    // cover the passive surfaces.

    private func demoHandle(_ msg: ClientMsg) {
        switch msg {
        case .sendInput(let id, let input):
            demoRun(id, userText: input.plainText ?? "")
        case .startChat(let req):
            demoStartChat(req)
        case .approvalRespond(let id, let decision):
            demoResolveApproval(id, decision)
        case .captureTodo(let req):
            demoCaptureTodo(req)
        case .setTodoStatus(let id, let status):
            demoSetTodoStatus(id, status)
        case .deleteTodo(let id):
            demoMutateTodos { $0.filter { $0.id != id } }
        case .dispatchTodo(let id, _):
            demoSetTodoStatus(id, .dispatched)
        case .renameSession(let id, let title):
            demoUpdateSession(id) { $0.demoCopy(title: title) }
        case .setFavorite(let id, let favorite):
            demoUpdateSession(id) { $0.demoCopy(favorite: favorite) }
        case .setArchived(let id, let archived):
            demoUpdateSession(id) { $0.demoCopy(archived: archived) }
        case .deleteSession(let id):
            sessions.removeAll { $0.sessionID == id }
            NotificationRouter.shared.updateBadge(needsYouCount)
            LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions)
        case .discard(let id):
            sessions.removeAll { $0.sessionID == id }
            NotificationRouter.shared.updateBadge(needsYouCount)
        case .interrupt(let id):
            demoUpdateSession(id) { $0.demoCopy(status: .interrupted, disposition: .some(nil)) }
        case .integrate(let id, _):
            demoAppend(id, .note(NoteEvent(text: "nvrsion: Landed to trunk.", icon: "arrow.triangle.branch")))
            demoUpdateSession(id) { $0.demoCopy(status: .finished, disposition: .some(.completed)) }
        case .setSessionModel(let id, let model):
            demoUpdateSession(id) { $0.demoCopy(model: .some(model)) }
        case .setSessionEffort(let id, let effort):
            demoUpdateSession(id) { $0.demoCopy(effort: .some(effort)) }
        case .setSessionAuto(let id, let auto):
            demoUpdateSession(id) { $0.demoCopy(auto: auto) }
        case .setSessionAutoShip(let id, let autoShip):
            demoUpdateSession(id) { $0.demoCopy(autoShip: autoShip) }
        case .setSessionShipBranch(let id, let branch):
            demoUpdateSession(id) { $0.demoCopy(shipBranch: .some(branch)) }
        case .hello, .listSessions, .listDashboard, .subscribe, .unsubscribe,
             .ping, .cancelQueuedMessage, .fetchDiff, .fetchTranscript, .listPeers,
             .addressUpdate, .meshRoster,
             // Live Activity push registration is a real-connection concern (there's no host to
             // push in demo), so it's inert here.
             .registerLiveActivity, .endLiveActivity, .registerPushToStartToken, .setForeground,
             // Session transfer (mesh P5) is a Mac↔Mac flow — the phone never originates these,
             // and demo has no peer Macs, so they're inert here.
             .transferOffer, .transferChunk, .transferCommit, .transferCancel,
             // "Add a device" mint is handled directly against a live host, not via demoHandle;
             // the demo path short-circuits in `requestPairingCode()` with a stand-in code.
             .requestPairingCode, .cancelPairingCode, .respondMacPair:
            break  // passive / already handled by the seeded fixtures (demo has no mesh peers)
        }
    }

    /// Append a transcript event to the open session (no-op if it isn't the one on screen).
    private func demoAppend(_ sessionID: SessionID, _ kind: AgentEvent.Kind) {
        guard sessionID == openSessionID else { return }
        let seq = (openEvents.map(\.seq).max() ?? 0) + 1
        let backend = sessions.first { $0.sessionID == sessionID }?.backend ?? .claudeCode
        openEvents.append(AgentEvent(
            sessionID: sessionID, seq: seq, at: Date(), backend: backend, nativeType: nil, kind: kind))
    }

    /// Replace a session summary in the list, keeping the badge / Live Activity in sync.
    private func demoUpdateSession(_ id: SessionID, _ transform: (WireSessionSummary) -> WireSessionSummary) {
        guard let i = sessions.firstIndex(where: { $0.sessionID == id }) else { return }
        sessions[i] = transform(sessions[i])
        NotificationRouter.shared.updateBadge(needsYouCount)
        LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions)
    }

    /// Stream a short, believable canned turn for a session: assistant prose, a tool call +
    /// result, usage, and a finish — flipping the summary running → awaiting-input. Each step
    /// re-checks demo mode and cancellation so `exitDemo()` stops it cleanly. Runs entirely on the
    /// main actor (the store is `@MainActor`, and a `Task` in an isolated method inherits it).
    private func demoRun(_ sessionID: SessionID, userText: String?) {
        if let userText, !userText.isEmpty {
            demoAppend(sessionID, .userText(TextChunk(messageID: UUID().uuidString, text: userText, isPartial: false)))
        }
        demoUpdateSession(sessionID) { $0.demoCopy(status: .running, disposition: .some(nil)) }
        let toolID = UUID().uuidString
        let task = Task { [weak self] in
            guard let self else { return }
            @MainActor func pause(_ ms: Int) async -> Bool {
                try? await Task.sleep(for: .milliseconds(ms))
                if Task.isCancelled { return false }
                return self.demoMode
            }
            guard await pause(600) else { return }
            self.demoAppend(sessionID, .assistantText(TextChunk(
                messageID: UUID().uuidString, text: "On it — let me take a look.", isPartial: false)))
            guard await pause(700) else { return }
            self.demoAppend(sessionID, .toolCallStarted(ToolCall(
                toolCallID: toolID, name: "Bash", input: ["command": "npm test"])))
            guard await pause(900) else { return }
            self.demoAppend(sessionID, .toolResult(ToolResult(
                toolCallID: toolID, content: "42 passing\n0 failing", isError: false)))
            guard await pause(700) else { return }
            self.demoAppend(sessionID, .assistantText(TextChunk(
                messageID: UUID().uuidString,
                text: "All green — tests pass and the change is in place. Anything else?",
                isPartial: false)))
            self.demoAppend(sessionID, .usage(Usage(
                inputTokens: 12_400, outputTokens: 640, costUSD: 0.0088, contextInputTokens: 12_400)))
            self.demoAppend(sessionID, .runFinished(RunFinished(outcome: .completed, finalText: "Done.")))
            self.demoUpdateSession(sessionID) {
                $0.demoCopy(status: .awaitingInput, disposition: .some(.completed))
            }
        }
        demoTasks.append(task)
    }

    private func demoStartChat(_ req: StartChatRequest) {
        let project = dashboard.projects.first { $0.id == req.projectID }
        let id = SessionID(rawValue: "demo-\(UUID().uuidString.prefix(8))")
        let title = String(req.message.prefix(48))
        let summary = WireSessionSummary(
            sessionID: id, projectID: req.projectID.rawValue,
            projectName: project?.name ?? "project", backend: BackendID.forModel(req.model) ?? .claudeCode,
            status: .running, disposition: nil, title: title.isEmpty ? "New chat" : title,
            branch: "nucleic/\(id.rawValue)", lastSeq: 0, diffStat: nil,
            pendingApprovalCount: 0, favorite: false, archived: false,
            model: req.model, effort: req.effort, auto: req.auto ?? false,
            updatedAt: Date())
        sessions.insert(summary, at: 0)
        LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions)
        demoRun(id, userText: req.message)
    }

    private func demoResolveApproval(_ id: ApprovalID, _ decision: Decision) {
        openApprovals.removeAll { $0.id == id }
        NotificationRouter.shared.withdrawApproval(id)
        guard let sessionID = openSessionID else { return }
        switch decision {
        case .deny, .cancelRun:
            demoAppend(sessionID, .note(NoteEvent(text: "You denied the command.", icon: "hand.raised")))
            demoUpdateSession(sessionID) {
                $0.demoCopy(status: .awaitingInput, disposition: .some(.awaitingInput), pendingApprovalCount: 0)
            }
        case .allow, .allowAlways:
            demoAppend(sessionID, .toolResult(ToolResult(
                toolCallID: "t-appr", content: "Deployed successfully.", isError: false)))
            demoUpdateSession(sessionID) { $0.demoCopy(pendingApprovalCount: 0) }
            demoRun(sessionID, userText: nil)  // wrap up the turn after the approved tool runs
        }
    }

    private func demoCaptureTodo(_ req: CaptureTodoRequest) {
        let project = req.projectID.flatMap { pid in dashboard.projects.first { $0.id == pid } }
        let todo = WireTodo(
            id: .generate(), text: req.text, summary: nil, projectID: req.projectID,
            projectName: project?.name, status: .open, dispatchedSessionID: nil,
            triage: nil, updatedAt: Date())
        demoMutateTodos { [todo] + $0 }
    }

    private func demoSetTodoStatus(_ id: TodoID, _ status: TodoStatus) {
        demoMutateTodos { todos in
            todos.map { todo in
                todo.id == id
                    ? WireTodo(id: todo.id, text: todo.text, summary: todo.summary,
                               projectID: todo.projectID, projectName: todo.projectName,
                               status: status, dispatchedSessionID: todo.dispatchedSessionID,
                               triage: todo.triage, updatedAt: Date())
                    : todo
            }
        }
    }

    /// Rebuild the dashboard with a transformed to-do list (its fields are `let`).
    private func demoMutateTodos(_ transform: ([WireTodo]) -> [WireTodo]) {
        dashboard = DashboardSnapshot(
            counts: dashboard.counts, activity: dashboard.activity, projects: dashboard.projects,
            todos: transform(dashboard.todos), usage: dashboard.usage, statusFeeds: dashboard.statusFeeds)
    }


    /// Turn a raw `NWError` string into a short, fixable hint. The common onboarding failures are
    /// a denied Local Network permission (EPERM / -65555) and the Mac not listening (refused).
    static func friendlyTransportError(_ error: String) -> String {
        let lower = error.lowercased()
        if lower.contains("denied") || lower.contains("not permitted") || lower.contains("65555") {
            return "Can't reach the local network. In Settings ▸ Nucleic, allow Local Network access, then try again."
        }
        if lower.contains("refused") {
            return "Your Mac refused the connection. Check that remote access is still on in Nucleic ▸ Settings."
        }
        return "Couldn't connect to your Mac — make sure it's on the same Wi‑Fi and remote access is on."
    }

    /// Show a transient error bubble, replacing any current one; auto-dismisses after 6s
    /// (matching the Mac's last-error overlay cadence).
    private func showError(_ message: String, sessionID: SessionID?) {
        let error = LastError(message: message, sessionID: sessionID)
        lastError = error
        errorDismissTask?.cancel()
        errorDismissTask = Task { [weak self] in
            try? await Task.sleep(for: .seconds(6))
            guard let self, self.lastError == error else { return }
            self.lastError = nil
        }
    }

    func dismissError() {
        errorDismissTask?.cancel()
        lastError = nil
    }

}

extension Data {
    /// Same fingerprint scheme as `DeviceIdentity.fingerprint` (first 8 bytes of SHA-256).
    var fingerprintHex: String {
        DeviceIdentity.fingerprint(ofStaticKey: self)
    }
}

extension DashboardSnapshot {
    /// Merge every connected Mac's dashboard into one aggregate projection (mesh P3), so Home,
    /// Projects, and To-Dos show all your Macs together: projects and to-dos concatenated, activity
    /// summed per day, counts summed (active-days recomputed from the merged activity), subscription
    /// usage taken from the first Mac that reports it (a user's Macs share one account, so their
    /// windows match — summing would double-count), and provider status feeds unioned by provider
    /// (provider status is global, not per-Mac).
    static func merged(_ snaps: [DashboardSnapshot]) -> DashboardSnapshot {
        guard snaps.count != 1 else { return snaps[0] }
        guard !snaps.isEmpty else { return .empty }

        let projects = snaps.flatMap(\.projects)
        let todos = snaps.flatMap(\.todos)

        // Activity summed per start-of-day.
        var byDay: [Date: (count: Int, tokens: Int)] = [:]
        for snap in snaps {
            for day in snap.activity {
                let key = Calendar.current.startOfDay(for: day.day)
                var entry = byDay[key] ?? (0, 0)
                entry.count += day.count
                entry.tokens += day.tokens
                byDay[key] = entry
            }
        }
        let activity = byDay
            .map { ActivityDay(day: $0.key, count: $0.value.count, tokens: $0.value.tokens) }
            .sorted { $0.day < $1.day }

        let counts = DashboardCounts(
            projects: projects.count,
            chats: snaps.reduce(0) { $0 + $1.counts.chats },
            activeChats: snaps.reduce(0) { $0 + $1.counts.activeChats },
            messages: snaps.reduce(0) { $0 + $1.counts.messages },
            activeDays: activity.filter { $0.count > 0 || $0.tokens > 0 }.count,
            tokens: snaps.reduce(0) { $0 + $1.counts.tokens })

        let usage = snaps.compactMap(\.usage).first

        var seenProviders = Set<String>()
        var statusFeeds: [WireStatusFeed] = []
        for snap in snaps {
            for feed in snap.statusFeeds where seenProviders.insert(feed.provider).inserted {
                statusFeeds.append(feed)
            }
        }

        return DashboardSnapshot(
            counts: counts, activity: activity, projects: projects,
            todos: todos, usage: usage, statusFeeds: statusFeeds)
    }
}

extension WireSessionSummary {
    /// Recency key for ordering session lists: the last *user* message, falling back to `updatedAt`
    /// for sessions with no user message yet (or summaries from a host that predates the field).
    /// Ordering on this keeps a chat's position steady until the user speaks again, rather than
    /// reshuffling the list on every bit of agent activity (which bumps `updatedAt` every few
    /// seconds). Mirrors the Mac sidebar's last-turn ordering.
    var lastTurnAt: Date { lastUserMessageAt ?? updatedAt }

    /// A copy with selected fields overridden — the demo simulator's only way to "mutate" a
    /// summary, whose stored properties are all `let`. Nullable fields use a double optional so a
    /// caller can distinguish "keep" (omit) from "set to nil" (`.some(nil)`). `updatedAt` bumps
    /// to now unless given. Only the fields the simulator touches are exposed.
    func demoCopy(
        status: SessionStatus? = nil,
        disposition: TurnDisposition?? = nil,
        title: String? = nil,
        favorite: Bool? = nil,
        archived: Bool? = nil,
        pendingApprovalCount: Int? = nil,
        diffStat: DiffStat?? = nil,
        model: String?? = nil,
        effort: String?? = nil,
        auto: Bool? = nil,
        autoShip: Bool? = nil,
        shipBranch: String?? = nil,
        updatedAt: Date? = nil
    ) -> WireSessionSummary {
        WireSessionSummary(
            sessionID: sessionID, projectID: projectID, projectName: projectName, backend: backend,
            status: status ?? self.status,
            disposition: disposition ?? self.disposition,
            title: title ?? self.title, branch: branch, lastSeq: lastSeq,
            diffStat: diffStat ?? self.diffStat,
            pendingApprovalCount: pendingApprovalCount ?? self.pendingApprovalCount,
            favorite: favorite ?? self.favorite,
            archived: archived ?? self.archived,
            queuedMessage: queuedMessage, queuedMessages: queuedMessages,
            model: model ?? self.model, effort: effort ?? self.effort,
            auto: auto ?? self.auto, autoShip: autoShip ?? self.autoShip,
            shipBranch: shipBranch ?? self.shipBranch,
            contextInputTokens: contextInputTokens,
            updatedAt: updatedAt ?? Date(),
            movedTo: movedTo, arrivedFrom: arrivedFrom)
    }
}
