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 } /// The live transport when connected — for the optimistic overlay's "Connected · …" label. var transport: SyncTransportHint? { if case .connected(let t) = self { return t } return nil } } @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? private var transcriptPersistTask: Task? @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? /// "Add a project" (Projects tab): the phone asks a connected host advertising /// `canCreateProjects` to clone a git URL and register it (CLOUD_RUNTIME §4.3) — how a /// fresh Antimatter runner gets its first project. The outcome arrives asynchronously as /// `HostMsg.projectCreated`, correlated by the request id below; the project row itself /// rides the dashboard push. enum AddProjectState: Equatable { case idle case creating // waiting on the host's `projectCreated` reply case created(String) // success — the new project's display name case failed(String) // the host's human-readable failure (clone error, bad URL, …) } @Published private(set) var addProject: AddProjectState = .idle /// The in-flight request's id — replies are matched on it, so a late/stale `projectCreated` /// (the user dismissed the sheet and started another) can't settle the wrong request. private var createProjectRequestID: 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? /// 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)) } } // MARK: - Optimistic connect window // // iOS suspends the app on background and silently kills its sockets; the phone can also wake with // a socket the OS already tore down but that still reads `.connected`. Either way the link takes a // beat to re-handshake (fast — see `HostConnection.revalidate` — but not instant). Flashing // "Disconnected" and graying every action for that second reads as jank. So for a few seconds // after launch/foreground the store *pretends* it's still connected (see `rebuildAggregate`): the // chip stays green and the composer/controls stay live, presenting the last known-good state. // Actions the user takes meanwhile are held in `pendingActions` and replayed the instant a real // link comes up — or discarded if the window lapses without one (UX_IOS §6, §11.5). /// How long to keep presenting the last-live state after a launch/foreground before revealing the /// truth. The fast foreground reconnect is typically sub-second, so this is a safe ceiling. private static let optimisticWindow: TimeInterval = 5 /// Deadline of the current pretend-connected window; nil when not pretending. private var optimisticUntil: Date? private var optimisticExpiryTask: Task? /// User intents taken during the optimistic window while no link was live yet — replayed on /// connect, discarded on expiry. Each carries the time it was queued as a staleness guard. private var pendingActions: [(msg: ClientMsg, at: Date)] = [] /// The last known-good live projection, kept so the optimistic overlay (and a cold launch, where /// no connection has re-formed yet) can present "connected" before the socket actually /// re-handshakes. Only transport + granted scope are needed: capabilities/catalog are already /// retained on the `HostConnection` across a drop, and it's connectivity + scope that gate the UI. private struct LastLiveSnapshot: Codable, Equatable { var transport: SyncTransportHint var grantedScope: DeviceScope } private var lastLive: LastLiveSnapshot? = RemoteStore.loadLastLive() private static let lastLiveKey = "nucleic.lastLiveProjection" private static func loadLastLive() -> LastLiveSnapshot? { guard let data = UserDefaults.standard.data(forKey: lastLiveKey) else { return nil } return try? JSONDecoder().decode(LastLiveSnapshot.self, from: data) } private static func saveLastLive(_ snap: LastLiveSnapshot) { guard let data = try? JSONEncoder().encode(snap) else { return } UserDefaults.standard.set(data, forKey: lastLiveKey) } /// Whether we're currently inside the pretend-connected window. private var isOptimisticActive: Bool { guard let until = optimisticUntil else { return false } return Date() < until } /// Snapshot the current known-good live projection so the overlay can present it later. Cheap: a /// no-op unless transport or scope actually changed, and persisted so even a cold launch paints /// connected before the first handshake. private func captureLastLive(transport: SyncTransportHint, scope: DeviceScope) { let snap = LastLiveSnapshot(transport: transport, grantedScope: scope) guard snap != lastLive else { return } lastLive = snap Self.saveLastLive(snap) } /// Enter the pretend-connected window (launch / foreground). No-op in demo or when unpaired — /// there's nothing to pretend a connection to. Safe to call when already live: the overlay only /// engages if the link reads not-live within the window (the woken-zombie-socket case), and the /// window simply expires harmlessly if the connection stays up. private func beginOptimisticWindow() { guard !demoMode, isPaired else { return } optimisticUntil = Date().addingTimeInterval(Self.optimisticWindow) optimisticExpiryTask?.cancel() optimisticExpiryTask = Task { [weak self] in try? await Task.sleep(for: .seconds(Self.optimisticWindow)) guard let self, !Task.isCancelled else { return } self.expireOptimisticWindow() } } /// The window lapsed. If a link came up we send whatever was queued; if not, we throw the queued /// actions away (never fire them late — UX_IOS §11.5) and re-render the true, honest state so the /// chip and composer stop pretending. private func expireOptimisticWindow() { optimisticUntil = nil optimisticExpiryTask = nil if hasLiveConnection { flushPendingActions() } else { pendingActions.removeAll() } guard !demoMode else { return } rebuildAggregate() refreshAggregate() } /// Replay everything queued during the optimistic window now that a link is live, oldest first. /// Called from `didUpdate` on every connectivity change and on window expiry. A stale entry (older /// than the window plus slack, e.g. the link flapped up-and-down) is dropped rather than fired late. private func flushPendingActions() { guard hasLiveConnection, !pendingActions.isEmpty else { return } let queued = pendingActions pendingActions.removeAll() for (msg, at) in queued where Date().timeIntervalSince(at) < Self.optimisticWindow + 5 { send(msg) } } /// Whether an intent should be held during the optimistic window (a genuine user write/control /// action worth replaying) versus dropped (host-agnostic pulls and connection-internal traffic, /// which the reconnect re-issues on its own — subscribe/list/fetch are re-sent on `.ready`). private func isQueueableIntent(_ msg: ClientMsg) -> Bool { switch msg { case .sendInput, .startChat, .captureTodo, .dispatchTodo, .setTodoStatus, .deleteTodo, .renameSession, .setFavorite, .setArchived, .deleteSession, .discard, .integrate, .interrupt, .cancelQueuedMessage, .setSessionModel, .setSessionEffort, .setSessionAuto, .setSessionAutoShip, .setSessionShipBranch, .approvalRespond: return true default: return false } } 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] = [] 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 } // Assume connected while the first handshake completes, so launch doesn't open on a // grayed-out "Disconnected" shell for the second it takes to come up. beginOptimisticWindow() 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 } // Pretend we're still connected for a few seconds while the (possibly OS-killed) sockets // re-handshake — the reconnect below is fast, and flashing "Disconnected" in the meantime // just feels janky. Set before `reconnect()` so the connecting/reconnecting states it // produces are overlaid with the last-live projection (see `rebuildAggregate`). beginOptimisticWindow() // 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() } } /// Handle the host's silent "reconnect" nudge (SYNC_PROTOCOL §3.2): the Mac noticed our LAN link /// drop and is asking us to re-dial over a non-local transport before the connection silently /// goes dark. 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. Re-running /// `reconnect()` walks each host's LAN→tailnet→relay candidate loop — LAN just failed, so it /// lands on tailnet or relay from wherever the phone now is. Holds a background-task assertion /// until a host is live again or the window closes. Best-effort: iOS may deny background runtime /// (budget, force-quit), in which case the reconnect simply happens on the next foreground. func handleReconnectWake(completion: @escaping () -> Void) { guard !demoMode, isPaired else { completion(); return } // A silent wake means we're background (possibly cold-launched with no scene, where // `isForeground`'s default would read stale-true); record that so the reconnect reports // background and the host keeps owning the Live Activity via push-to-start (UX_IOS §5.3). setForegroundState(false) 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.sync.reconnect") { finish() // the OS reclaimed the grant before we finished — report what we have } #else let finish = completion #endif pathMonitor.refresh() reconnect() // Poll until at least one host is live again (the dial loop found a working path), or we run // out of background 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.connections.values.contains(where: { $0.connectivity.isLive }) 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 link just came up — replay any intents the user took while we were optimistically // pretending to be connected. self.flushPendingActions() // 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 } cb.projectCreated = { [weak self] outcome in guard let self else { return } // Only the reply to the request in flight settles the sheet — a stale one (the user // dismissed and retried, or another device's create) is dropped. guard let pending = self.createProjectRequestID, outcome.requestID == pending else { return } self.createProjectRequestID = nil if let error = outcome.error { self.addProject = .failed(error) } else { self.addProject = .created(outcome.name ?? "Project") } } 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) var targetConnectivity: Connectivity var targetScope: DeviceScope 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. targetConnectivity = conn.connectivity targetScope = conn.grantedScope } else { targetConnectivity = aggregateConnectivity() // Optimistic new-chat gating: enabled if *any* Mac grants control (the owning Mac still // enforces scope when the intent lands there). targetScope = connections.values .filter { $0.connectivity.isLive }.map(\.grantedScope).max() ?? .approve } if targetConnectivity.isLive { // Truly live — remember this projection so the optimistic overlay can present it across // the next foreground/relaunch. captureLastLive(transport: targetConnectivity.transport ?? .lan, scope: targetScope) } else if isOptimisticActive, let last = lastLive { // Right after launch/foreground the link is re-handshaking (or a suspended socket woke up // looking dead). Rather than flash "Disconnected" for the beat it takes to reconnect, keep // presenting the last known-good live state — the app assumes it's connected. Actions // taken now are queued in `send` and replayed once a link is up (or discarded when the // window lapses without one). targetConnectivity = .connected(last.transport) targetScope = last.grantedScope } setIfChanged(\.connectivity, targetConnectivity) setIfChanged(\.grantedScope, targetScope) } /// 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(_ keyPath: ReferenceWritableKeyPath, _ 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? 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() 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) /// Ownership token for the open-session state: `open()` bumps and returns it, and the detail /// view hands its own back to `closeOpen`. Two detail views for the *same* session can be /// mounted at once — a Live Activity deep link onto an already-open chat pushes a duplicate, /// and a tab switch can remount the open session in another tab's stack. `closeOpen`'s /// session-id guard can't tell those copies apart, so without the token the covered copy's /// teardown would wipe the state the copy on screen is using (blank transcript, dead /// subscription, tab bar restored over the chat bar). private var openGeneration = 0 @discardableResult func open(_ sessionID: SessionID) -> Int { openGeneration += 1 // 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 markOpened(sessionID) // A second mount of the already-open session (see `openGeneration`): the transcript, // approvals, and wire subscription are live and already this session's — keep them, // don't wipe and reload. The new mount just takes ownership via the fresh generation. guard openSessionID != sessionID else { return openGeneration } openSessionID = sessionID // 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 if demoMode { seedDemoTranscript(sessionID); return openGeneration } // 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) return openGeneration } /// 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" // MARK: - Add a project (CLOUD_RUNTIME §4.3) /// Hosts that can register a project right now (live + advertising `canCreateProjects`) — /// the Add Project sheet's destination picker. Name-sorted for a stable picker. var projectCreationHosts: [(hostID: String, name: String)] { connections.values .filter { $0.connectivity.isLive && $0.capabilities.canCreateProjects } .map { ($0.hostID, $0.hostName) } .sorted { $0.name < $1.name } } /// Whether the Add Project affordance should appear: a capable host is reachable (or demo). var canCreateProject: Bool { demoMode || !projectCreationHosts.isEmpty } /// Ask `hostID` (or the first capable host) to clone `gitURL` and register it as a project. /// The outcome arrives asynchronously as `.created`/`.failed` via the host connection; the /// new project row follows on the dashboard push. func createProject(gitURL: String, name: String?, branch: String?, onHost hostID: String?) { if demoMode { addProject = .created(name?.isEmpty == false ? name! : "Project") return } let conn = hostID.flatMap { connections[$0] } ?? connections.values.first { $0.connectivity.isLive && $0.capabilities.canCreateProjects } guard let conn, conn.connectivity.isLive, conn.capabilities.canCreateProjects else { addProject = .failed("No connected host can add projects right now.") return } let requestID = UUID().uuidString createProjectRequestID = requestID addProject = .creating conn.send(.createProject(WireCreateProjectRequest( gitURL: gitURL, branch: branch?.isEmpty == false ? branch : nil, name: name?.isEmpty == false ? name : nil, requestID: requestID))) } /// The Add Project sheet closed — drop any in-flight correlation (a late reply is ignored) /// and reset the state for the next open. func resetAddProject() { createProjectRequestID = nil addProject = .idle } /// 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). /// /// `token` is the value the closing view got from its `open()` call. A stale token means /// another detail has since taken ownership — possibly of the *same* session (a Live Activity /// deep link onto the already-open chat mounts a duplicate detail): then even the unsubscribe /// must be skipped, because the wire subscription now belongs to the copy still on screen. func closeOpen(_ id: SessionID? = nil, token: Int? = nil) { guard let target = id ?? openSessionID else { return } if let token, token != openGeneration { // Stale mount unmounting after a newer `open()`. Same session: everything belongs to // the current owner — full no-op. Different session (the iPad A→B switch): it still // unsubscribes itself, exactly as before. guard target != openSessionID else { return } send(.unsubscribe(target)) markOpened(target) 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 { // Through `send` so an Allow/Deny tapped during the optimistic window is queued and // replayed on connect (a live host resolves it; others no-op on the unknown id), rather // than dropped because the owning Mac's socket is mid-reconnect. send(.approvalRespond(approval.id, decision)) } openApprovals.removeAll { $0.id == approval.id } // optimistic dismiss; host confirms } func sendInput(_ text: String, attachments: [WireAttachment] = [], to sessionID: SessionID) { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) // An attachment-only follow-up (files, no typed text) is a valid turn — the host folds the // file references in as the message body. guard !trimmed.isEmpty || !attachments.isEmpty else { return } send(.sendInput(sessionID, AgentInput(text: trimmed, attachments: attachments))) } 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, attachments: [WireAttachment] = []) { let text = message.trimmingCharacters(in: .whitespacesAndNewlines) // An attachment-only opening message is allowed — the host materializes the files into the // new working tree and uses their references as the first prompt. guard !text.isEmpty || !attachments.isEmpty else { return } send(.startChat(StartChatRequest( projectID: projectID, message: text, model: model, effort: effort, baseBranch: baseBranch, useWorktree: useWorktree, auto: auto, attachments: attachments))) } 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 } // Optimistic window (post launch/foreground): if no Mac is live yet but we're presenting a // pretend-connected UI, hold the user's intent and replay it the instant a link comes up // rather than dropping it silently. Discarded if the window lapses without a connection. if !hasLiveConnection, isOptimisticActive, isQueueableIntent(msg) { pendingActions.append((msg, Date())) 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. // The runner verbs (intelligence results, credential mesh — ANTIMATTER_RUNNER §5–6) will // ride their own executor/vault loops when the phone side lands; nothing routes them here. // `createProject` (CLOUD_RUNTIME §4.3) will go straight to a user-chosen host when the // phone grows that UI — a brand-new project has no owner to route by. case .hello, .ping, .listPeers, .addressUpdate, .meshRoster, .registerLiveActivity, .endLiveActivity, .registerPushToStartToken, .setForeground, .transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript, .requestPairingCode, .cancelPairingCode, .respondMacPair, .intelligenceResult, .credentialManifest, .credentialProvision, .createProject: 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 = [] /// 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 = [] /// 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 = [] /// 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, // Antimatter runner verbs (ANTIMATTER_RUNNER §5–6) — demo has no runner host. .intelligenceResult, .credentialManifest, .credentialProvision, // Remote project creation (CLOUD_RUNTIME §4.3) — demo has no host to clone on. .createProject: 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, codexUsage: dashboard.codexUsage, 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 let codexUsage = snaps.compactMap(\.codexUsage).first var seenProviders = Set() 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, codexUsage: codexUsage, 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) } }