Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Models/RemoteStore.swift
T
abkslmandNucleic 63c92ee093 Live Activity Launch Consistency
Nucleic-Session: 9D2EE9AB-2E44-485B-A196-54D250F9D89B
Co-authored-by: Nucleic <[email protected]>
2026-07-06 20:04:41 -07:00

1908 lines
104 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import Network
import SwiftUI
import NucleicProtocol
import NucleicTailnet
#if canImport(UIKit)
import UIKit
#endif
/// On the phone there's no app-side `SessionSummary` view-model to collide with, so the wire
/// type *is* the model. Alias it under the host's name so the shared vocabulary reads the same.
typealias WireSessionSummary = NucleicProtocol.SessionSummary
/// The phone's single source of UI state — a pure projection of the host (UX_IOS §1.2). Owns
/// the `SyncClient`, reflects connectivity truth, and exposes the session list + the open
/// session's transcript/approvals. No canonical state is invented on-device.
@MainActor
final class RemoteStore: ObservableObject {
enum Connectivity: Equatable {
case unpaired
case connecting
case reconnecting
case connected(SyncTransportHint)
case hostOffline
case failed(String)
var label: String {
switch self {
case .unpaired: "Not paired"
case .connecting: "Connecting…"
case .reconnecting: "Reconnecting…"
case .connected(let transport): "Connected · \(transport.label)"
case .hostOffline: "Mac offline"
case .failed(let m): m
}
}
var isLive: Bool {
if case .connected = self { return true }
return false
}
}
@Published private(set) var connectivity: Connectivity = .unpaired
/// A representative host name for the aggregate — the open session's Mac when a transcript is
/// open, otherwise a live host. Sessions/projects from *every* connected Mac are merged into the
/// flat state above (mesh P3), so no single "active host" is chosen; per-row provenance comes
/// from `hostName(forSession:)` instead.
@Published private(set) var hostName: String = ""
@Published private(set) var sessions: [WireSessionSummary] = []
/// The last session list seen from a live host, persisted to disk (`SessionCache`) and reloaded
/// at launch. It backs `sessions` whenever nothing is connected, so the list shows a read-only
/// history while offline instead of an empty "waiting to connect" screen.
private var cachedSummaries: [WireSessionSummary] = SessionCache.loadSummaries()
/// Debounced disk-write tasks for the offline cache (coalesce a burst of updates into one write).
private var summaryPersistTask: Task<Void, Never>?
private var transcriptPersistTask: Task<Void, Never>?
@Published private(set) var capabilities = WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])
@Published private(set) var grantedScope: DeviceScope = .approve
/// The host's model/effort catalog (SYNC §5.2), driving the composer + session-header pickers.
/// `.empty` until `Welcome` arrives; the pickers fall back to the built-in effort list.
@Published private(set) var modelCatalog: WireModelCatalog = .empty
/// Home / Projects / To-Dos state — the dashboard projection.
@Published private(set) var dashboard = DashboardSnapshot.empty
/// The host's mesh peers (mesh P4), from `HostMsg.peerList`. Populated only when the host
/// advertises `capabilities.canListPeers` and the client asks; drives the future mesh device
/// list and session-transfer destination picker.
@Published private(set) var meshPeers: [PeerSummary] = []
// Open session projection.
@Published private(set) var openSessionID: SessionID?
/// The paired Mac that owns the open session (mesh P3). The open transcript's intents route to
/// it, and the host-specific projected values (capabilities/scope/catalog/connectivity) follow
/// it while a session is open. `nil` when nothing is open.
private var openSessionHostID: String?
@Published private(set) var openEvents: [AgentEvent] = []
@Published private(set) var openApprovals: [ApprovalRequest] = []
/// The open session is blocked on an approval whose details haven't arrived yet — the state
/// right after opening from a Live Activity or notification while the channel is still
/// (re)connecting. The summary says `.awaitingApproval`, but `openApprovals` (which is filled
/// from the live snapshot) is empty because there's no live channel yet. Drives a lightweight
/// "loading approval" placeholder so a tap from the lock screen lands on the request-in-progress
/// instead of a blank transcript; it flips off the instant the real card arrives (sub-second,
/// thanks to the fast foreground reconnect). Never shown once live — then the snapshot is truth.
var openApprovalLoading: Bool {
guard !connectivity.isLive, openApprovals.isEmpty, let id = openSessionID,
let summary = sessions.first(where: { $0.sessionID == id }) else { return false }
return summary.status == .awaitingApproval || summary.pendingApprovalCount > 0
}
/// Whether the compact (iPhone) Sessions tab currently has a session detail pushed. Distinct
/// from `openSessionID`, which is the *data* subscription and is only torn down on the detail's
/// `onDisappear` — and SwiftUI fires that at the *end* of the pop transition, so keying the
/// floating tab bar's visibility off it left the bar sliding back up 1–2s after a back-swipe.
/// `SessionsView` sets this straight from its navigation path, which flips the instant the pop
/// begins, so the bar reflows immediately while the transcript state survives the animation.
@Published var compactDetailPresented = false
/// The open session's full diff (the Mac Diff tab's patch), fetched on demand when the
/// user opens the Diff tab and the host advertises `canFetchDiff`. Nil until it arrives.
@Published private(set) var openDiff: WireSessionDiff?
@Published private(set) var diffLoading = false
/// "Add a device to this mesh" (Settings): the phone asks a connected Mac to mint a join code
/// and shows it as a QR / copyable link. The phone can't mint one itself (it runs no listener),
/// so this is always relayed through a live, capable host.
enum AddDeviceState: Equatable {
case idle
case requesting // waiting on the host's `pairingCode` reply
case ready(String) // the `nucleic://pair?d=…` join code to display
case unavailable // no live host could mint one right now
}
@Published private(set) var addDevice: AddDeviceState = .idle
/// The host we asked to mint the current code, so a dismiss can tell the *same* Mac to close
/// its pairing window.
private var pairingMintHostID: String?
/// A Mac trying to join via a code this phone shared, awaiting the user's allow/deny — the Mac
/// forwarded its pairing confirm here (`HostMsg.macPairRequested`) so it can be approved from
/// the phone. The "add a device" sheet renders an allow/deny dialog for it.
@Published private(set) var pendingMacPairRequest: WireMacPairRequest?
/// The host that forwarded the pending confirm, so the answer routes back to it.
private var pendingMacPairHostID: String?
/// A transient host-reported error (the mobile echo of the Mac's last-error bubble):
/// shown as a red bubble at the bottom of the screen, auto-dismissed after a few seconds.
struct LastError: Equatable, Identifiable {
let id = UUID()
let message: String
let sessionID: SessionID?
}
@Published var lastError: LastError?
private var errorDismissTask: Task<Void, Never>?
/// When each session was last opened on this device, for the green "finished while you
/// weren't looking" wash on the session list (the Mac's unseen-completion marker; the wire
/// doesn't carry the host's flag, so the phone tracks its own view locally).
@Published private(set) var lastOpenedAt: [SessionID: Date] = RemoteStore.loadLastOpened()
/// A navigation request from outside the view hierarchy (notification tap → this session).
/// `RootView` switches to the Sessions tab; `SessionsView` pushes it and clears.
@Published var pendingRoute: SessionID?
/// A request to surface the sessions list itself, no specific session — the Live Activity's
/// "work running, nothing waiting" tap. Compact jumps to the Sessions tab; the iPad split
/// reveals its sidebar (which always lists sessions). One-shot: the shell clears it.
@Published var pendingSessionsList = false
/// Whether the app is foreground-active (scene phase), mirrored here so the store can
/// decide which transitions deserve a notification.
private(set) var isActive = true
func setScenePhaseActive(_ active: Bool) { isActive = active }
/// Route to a session from a notification tap (deep link).
func route(to sessionID: SessionID) { pendingRoute = sessionID }
/// Ask the shell to show the sessions list (no specific session opened).
func showSessionsList() { pendingSessionsList = true }
/// Follow a `nucleic://` deep link — the Live Activity tap. A session waiting on the user
/// opens straight into that session; the plain sessions link just surfaces the list.
func handleDeepLink(_ url: URL) {
switch NucleicDeepLink.route(for: url) {
case .session(let id): route(to: SessionID(rawValue: id))
case .sessionsList: showSessionsList()
case .none: break
}
}
/// An Allow/Deny straight from a notification action (UX_IOS §5.1). Requires a live
/// channel; if the socket dropped while backgrounded, reconnect and send once ready —
/// but only briefly (a stale queued approval must never fire minutes later; see
/// UX_IOS §11.5, and the host dedupes/`alreadyResolved`s a lost race anyway).
func respondFromNotification(_ id: ApprovalID, allow: Bool) {
let decision: Decision = allow ? .allow(updatedInput: nil) : .deny(reason: nil)
// The approval may belong to any connected Mac (mesh P3) and the notification carries no
// hostID — broadcast to every live connection; the owning host resolves it, the rest see an
// unknown/`alreadyResolved` approval and no-op.
let live = connections.values.filter { $0.connectivity.isLive }
if !live.isEmpty {
for conn in live { conn.send(.approvalRespond(id, decision)) }
} else {
pendingNotificationDecision = (id, decision, Date())
reconnect()
}
}
/// At most one decision waits for reconnect, and it expires after 30s.
private var pendingNotificationDecision: (ApprovalID, Decision, Date)?
private func flushPendingNotificationDecision() {
guard let (id, decision, at) = pendingNotificationDecision else { return }
let live = connections.values.filter { $0.connectivity.isLive }
guard !live.isEmpty else { return } // wait until something's connected
pendingNotificationDecision = nil
guard Date().timeIntervalSince(at) < 30 else { return } // stale — require the app
for conn in live { conn.send(.approvalRespond(id, decision)) }
}
private static let lastOpenedKey = "nucleic.lastOpenedAt"
private static func loadLastOpened() -> [SessionID: Date] {
guard let raw = UserDefaults.standard.dictionary(forKey: lastOpenedKey) else { return [:] }
return raw.reduce(into: [:]) { result, entry in
if let date = entry.value as? Date { result[SessionID(rawValue: entry.key)] = date }
}
}
private func persistLastOpened() {
let raw = lastOpenedAt.reduce(into: [String: Date]()) { $0[$1.key.rawValue] = $1.value }
UserDefaults.standard.set(raw, forKey: Self.lastOpenedKey)
}
/// Whether `summary` completed its work after the user last looked at it on this device.
func unseenCompletion(_ summary: WireSessionSummary) -> Bool {
let done = summary.status == .finished
|| (summary.status == .awaitingInput && summary.disposition == .completed)
guard done, summary.sessionID != openSessionID else { return false }
guard let opened = lastOpenedAt[summary.sessionID] else { return true }
return summary.updatedAt > opened
}
/// Non-archived sessions (archived chats are hidden, matching the Mac sidebar).
var liveSessions: [WireSessionSummary] { sessions.filter { !$0.archived } }
/// Sessions needing a human (drives the app-icon badge + NEEDS YOU section). Uses turn
/// disposition so a finished-the-work session doesn't count, and excludes archived.
var needsYouCount: Int {
liveSessions.filter { $0.status.needsYou($0.disposition) }.count
}
var canControl: Bool { grantedScope >= .control }
/// The process-wide store. The SwiftUI `App` binds its `@StateObject` to this instance so the
/// UI and any headless entry point share one store — in particular the background push handler
/// (`PushAppDelegate`), which on a silent launch has no mounted scene (`onAppear` never fires)
/// and so must reach the store directly to bring it online and adopt a push-started Live Activity.
static let shared = RemoteStore()
private let identity = IdentityStore.loadOrCreateIdentity()
let discovery = LANDiscovery()
/// Proactive network-path awareness (shared across every host connection): flips transports the
/// instant Wi-Fi drops or returns, instead of waiting for a dead LAN socket to time out.
let pathMonitor = NetworkPathMonitor()
/// One live connection per paired Mac (mesh P3 multiplexer). All connected at once, and the flat
/// `sessions`/`dashboard` above are the *merged* projection across every one of them — the phone
/// shows all your Macs together, with no active-host selector. Intents route to the Mac that owns
/// the target session/project (see `send`). Empty in demo mode (demo seeds state directly).
private var connections: [String: HostConnection] = [:]
/// The connection whose projection drives the host-specific flat values (capabilities, scope,
/// catalog, connectivity, host name): the open session's Mac while a transcript is open, else a
/// representative live host (one with a populated catalog, falling back to any live/any host).
private var contextConnection: HostConnection? {
if let id = openSessionHostID, let conn = connections[id] { return conn }
return connections.values.first { $0.connectivity.isLive && !$0.modelCatalog.groups.isEmpty }
?? connections.values.first { $0.connectivity.isLive }
?? connections.values.first
}
/// A live connection to fall back on for host-agnostic intents (e.g. a project-less to-do
/// capture, which has no owning Mac).
private var firstLiveConnection: HostConnection? {
connections.values.first { $0.connectivity.isLive } ?? connections.values.first
}
// MARK: Intent routing (mesh P3) — resolve the Mac that owns a session / project / to-do.
private func connection(owningSession id: SessionID) -> HostConnection? {
connections.values.first { $0.sessions.contains { $0.sessionID == id } }
}
private func connection(owningProject id: ProjectID) -> HostConnection? {
connections.values.first { $0.dashboard.projects.contains { $0.id == id } }
}
private func connection(owningTodo id: TodoID) -> HostConnection? {
connections.values.first { $0.dashboard.todos.contains { $0.id == id } }
}
/// Send to every live connection — for host-agnostic pulls (list sessions/dashboard) and as the
/// safety fallback for an approval whose owning Mac isn't known (a notification-driven decision).
private func broadcastLive(_ msg: ClientMsg) {
for conn in connections.values where conn.connectivity.isLive { conn.send(msg) }
}
/// How many Macs are currently connected — the Sessions list shows a per-row host tag only when
/// more than one, so a single-Mac view stays clean.
var connectedHostCount: Int {
if demoMode { return 1 }
return connections.values.filter { $0.connectivity.isLive }.count
}
/// The name of the Mac that owns a session, for the per-row host tag. `nil` in demo (no
/// connections) or when the session isn't found on any connected Mac.
func hostName(forSession id: SessionID) -> String? {
connection(owningSession: id)?.hostName
}
/// The phone's embedded Tailscale node state, for Settings (nil = not running). Shared across all
/// connections (one embedded node, many dials).
@Published private(set) var tailnetStatus: String?
/// The Tailscale interactive-login URL while the node waits for a browser login (a
/// first tailnet start with no auth key). Auto-opened; Settings shows a re-open button.
@Published private(set) var tailnetLoginURL: URL?
/// Offline demo mode: seeds mock state and simulates the agent locally so every surface
/// renders — and the core loops (send, approve, start chat, to-dos) actually respond —
/// without a paired Mac. Reachable in two ways: the `NUCLEIC_DEMO=1` env var (dev /
/// screenshots, forced on at launch) and a persisted in-app toggle
/// (`enterDemo()` / `exitDemo()`) so an App Store reviewer with no Mac can exercise the app
/// (App Review Guideline 2.1). `@Published` so the UI can show a DEMO indicator and the
/// "Leave demo" affordance.
private static let demoModeKey = "nucleic.demoMode"
@Published private(set) var demoMode = ProcessInfo.processInfo.environment["NUCLEIC_DEMO"] == "1"
|| UserDefaults.standard.bool(forKey: RemoteStore.demoModeKey)
/// In-flight simulated-run tasks (canned streaming), cancelled on `exitDemo()`.
private var demoTasks: [Task<Void, Never>] = []
var isPaired: Bool { demoMode || IdentityStore.loadPairedHost() != nil }
var deviceFingerprint: String { identity.fingerprint }
// MARK: - Lifecycle
func onAppear() {
setupLiveActivityBridge()
if demoMode { seedDemo(); return }
startNetworkingIfNeeded()
if isPaired {
// Show the saved chat history immediately, before any host connects.
if sessions.isEmpty { sessions = cachedSummaries }
reconnect()
}
}
/// Whether `startNetworkingIfNeeded` has already wired up path monitoring + LAN discovery.
private var networkingStarted = false
/// Start the shared network-path monitor and LAN discovery once. Factored out of `onAppear` so
/// the background push handler — which runs when `onAppear` never fired (silent launch, no scene)
/// — can bring the same machinery up before it dials.
private func startNetworkingIfNeeded() {
guard !networkingStarted else { return }
networkingStarted = true
// Re-plan every connection the moment the network path changes (left Wi-Fi, joined a
// network, cellular⇄Wi-Fi) — the proactive half of "immediate switchover".
pathMonitor.onChange = { [weak self] in self?.handlePathChange() }
pathMonitor.start()
discovery.start()
}
/// A network-path change: tell each host connection to re-plan its transport now.
private func handlePathChange() {
guard !demoMode else { return }
for conn in connections.values { conn.networkPathChanged() }
}
/// Returning to the foreground: the network may have changed while the app was suspended (and
/// path/keepalive callbacks were frozen). Re-read the path, re-dial anything not live, then
/// actively revalidate the connections that still *look* live — a socket the OS killed while we
/// were suspended wakes marked `.connected`, and trusting that would stall the reconnect until
/// the 55s keepalive deadline. `revalidate` pings each such link and re-dials the instant it
/// fails to answer, so the transport is correct (and fast) by the time the UI is on screen.
func onForeground() {
endBackgroundHold()
guard !demoMode, isPaired else { return }
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 }
#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 }
setupLiveActivityBridge() // starts the push-to-start + adoption observers if not already
startNetworkingIfNeeded()
#if canImport(UIKit)
var bgTask: UIBackgroundTaskIdentifier = .invalid
var finished = false
let finish = {
guard !finished else { return }
finished = true
if bgTask != .invalid {
UIApplication.shared.endBackgroundTask(bgTask)
bgTask = .invalid
}
completion()
}
bgTask = UIApplication.shared.beginBackgroundTask(withName: "nucleic.liveactivity.adopt") {
finish() // the OS reclaimed the grant before we finished — report what we have
}
#else
let finish = completion
#endif
pathMonitor.refresh()
reconnect()
// Poll until the harvested update token has reached a host (`liveActivitySentTo` fills in via
// the adopt → onPushToken → syncLiveActivityRegistration chain), or we run out of runtime.
Task { @MainActor [weak self] in
for _ in 0..<50 { // ~25s at a 0.5s cadence, inside iOS's ~30s background grant
guard let self, self.liveActivitySentTo.isEmpty else { break }
try? await Task.sleep(for: .milliseconds(500))
}
finish()
}
}
private func seedDemo() {
connectivity = .connected(.lan)
hostName = "Andrew's Mac"
grantedScope = .control
capabilities = WireCapabilities(
canModifyToolInput: true, allowAlwaysScopes: [.session, .toolName], canFetchDiff: true)
modelCatalog = WireModelCatalog(
groups: [
[WireModelCatalog.Model(sku: "claude-opus-4-8[1m]", displayName: "Opus 4.8", backend: .claudeCode,
contextBadge: "1M", contextWindow: 1_000_000,
efforts: ["low", "medium", "high", "xhigh", "max"], effortNoun: "Effort"),
WireModelCatalog.Model(sku: "claude-sonnet-4-6", displayName: "Sonnet 4.6", backend: .claudeCode,
contextBadge: nil, contextWindow: 200_000,
efforts: ["low", "medium", "high", "xhigh", "max"], effortNoun: "Effort")],
[WireModelCatalog.Model(sku: "gpt-5.5", displayName: "GPT-5.5", backend: .codex,
contextBadge: nil, contextWindow: 350_000,
efforts: ["low", "medium", "high", "xhigh"], effortNoun: "Reasoning")],
[WireModelCatalog.Model(sku: "grok-build", displayName: "Grok Build", backend: .grok,
contextBadge: nil, contextWindow: 256_000,
efforts: ["auto"], effortNoun: "Reasoning")],
],
effortDisplayNames: ["auto": "Auto", "orchestra": "Orchestra"],
orchestraSentinel: "orchestra", orchestraRequiresControlNote: "Requires Nucleic Control",
fallbackModel: "claude-opus-4-8[1m]", fallbackEffort: "high")
let p1 = ProjectID(rawValue: "p1"), p2 = ProjectID(rawValue: "p2")
func sum(_ id: String, _ project: ProjectID, _ name: String, _ title: String,
_ status: SessionStatus, _ disp: TurnDisposition? = nil, approvals: Int = 0,
fav: Bool = false, arch: Bool = false, add: Int = 0, rem: Int = 0) -> WireSessionSummary {
WireSessionSummary(
sessionID: SessionID(rawValue: id), projectID: project.rawValue, projectName: name,
backend: .claudeCode, status: status, disposition: disp, title: title,
branch: "nucleic/\(id)", lastSeq: 10,
diffStat: add + rem > 0 ? DiffStat(filesChanged: 2, added: add, removed: rem) : nil,
pendingApprovalCount: approvals, favorite: fav, archived: arch,
updatedAt: Date())
}
let cal = Calendar.current
let today = cal.startOfDay(for: Date())
let activity = (0..<40).map { i -> ActivityDay in
let count = (i * 7) % 6
// Sample tokens roughly track messages so the preview grid shades by usage.
return ActivityDay(day: cal.date(byAdding: .day, value: -i, to: today)!,
count: count, tokens: count * 8_500 + (i * 137) % 4_000)
}
// Host 1 — "Andrew's Mac".
let host1Sessions = [
sum("a1", p1, "nucleic", "auth-refactor", .awaitingApproval, approvals: 1, add: 312, rem: 40),
sum("a2", p1, "nucleic", "flaky-tests", .running, add: 88, rem: 12),
sum("a3", p2, "website", "graphql-migration", .awaitingInput, .awaitingInput, fav: true),
sum("a4", p2, "website", "docs-pass", .awaitingInput, .completed, add: 20, rem: 4),
sum("a5", p1, "nucleic", "old-experiment", .finished, arch: true),
]
let host1Dashboard = DashboardSnapshot(
counts: DashboardCounts(
projects: 2, chats: 5, activeChats: 2, messages: 142, activeDays: 9, tokens: 1_284_000),
activity: activity,
projects: [
WireProject(id: p1, name: "nucleic", defaultBranch: "main", sessionCount: 3, activeCount: 2),
WireProject(id: p2, name: "website", defaultBranch: "main", sessionCount: 2, activeCount: 1),
],
todos: [
WireTodo(id: TodoID(rawValue: "t1"), text: "Add dark mode to settings", summary: "Dark mode in settings",
projectID: p2, projectName: "website", status: .open, dispatchedSessionID: nil,
triage: "high", updatedAt: Date()),
WireTodo(id: TodoID(rawValue: "t2"), text: "Investigate the memory leak in the sync server", summary: "Sync server memory leak",
projectID: p1, projectName: "nucleic", status: .open, dispatchedSessionID: nil,
triage: "critical", updatedAt: Date()),
WireTodo(id: TodoID(rawValue: "t3"), text: "Write release notes", summary: nil,
projectID: nil, projectName: nil, status: .open, dispatchedSessionID: nil,
triage: "low", updatedAt: Date()),
],
usage: WireSubscriptionUsage(
fiveHour: WireUsageWindow(utilization: 42, resetsAt: Date().addingTimeInterval(3 * 3600)),
sevenDay: WireUsageWindow(utilization: 78, resetsAt: Date().addingTimeInterval(2.4 * 86_400))),
statusFeeds: [
WireStatusFeed(provider: "claude", providerName: "Claude", incidents: []),
WireStatusFeed(provider: "openai", providerName: "OpenAI", incidents: [
WireStatusIncident(
id: "i1", title: "Elevated errors on Codex", url: URL(string: "https://status.openai.com"),
updatedAt: Date(), state: "Monitoring", isResolved: false, components: ["Codex"]),
]),
WireStatusFeed(provider: "xai", providerName: "xAI", incidents: []),
])
// Host 2 — "Studio Mac" (mesh P3: a second paired Mac, for the host switcher).
let p3 = ProjectID(rawValue: "p3")
let host2Sessions = [
sum("b1", p3, "renderer", "shadow-mapping", .running, add: 140, rem: 22),
sum("b2", p3, "renderer", "gpu-profiling", .awaitingApproval, approvals: 1),
sum("b3", p1, "nucleic", "cloud-runtime", .awaitingInput, .completed, add: 60, rem: 8),
]
let host2Dashboard = DashboardSnapshot(
counts: DashboardCounts(
projects: 1, chats: 3, activeChats: 2, messages: 61, activeDays: 5, tokens: 540_000),
activity: activity,
projects: [WireProject(id: p3, name: "renderer", defaultBranch: "main", sessionCount: 2, activeCount: 2)],
todos: [
WireTodo(id: TodoID(rawValue: "t4"), text: "Bake the light probes overnight", summary: "Bake light probes",
projectID: p3, projectName: "renderer", status: .open, dispatchedSessionID: nil,
triage: "medium", updatedAt: Date()),
],
usage: WireSubscriptionUsage(
fiveHour: WireUsageWindow(utilization: 18, resetsAt: Date().addingTimeInterval(2 * 3600)),
sevenDay: WireUsageWindow(utilization: 40, resetsAt: nil)),
statusFeeds: [])
// Mesh P3: seed BOTH demo Macs' worlds merged into the flat aggregate, exactly as the app
// presents real paired Macs — every host's sessions/projects/to-dos shown together, no
// switcher. `demoHandle` then mutates this aggregate directly for the interactive demo.
sessions = host1Sessions + host2Sessions
dashboard = DashboardSnapshot.merged([host1Dashboard, host2Dashboard])
LiveActivityManager.shared.sync(hostName: hostName, sessions: liveSessions)
NotificationRouter.shared.updateBadge(needsYouCount)
}
/// Offline diff fixture (NUCLEIC_DEMO) so the full-patch Diff tab renders without a host.
private func demoDiff(_ sessionID: SessionID) -> WireSessionDiff {
WireSessionDiff(
sessionID: sessionID,
stat: DiffStat(filesChanged: 2, added: 312, removed: 40),
files: [
WireFileDiff(path: "auth/middleware.ts", oldPath: nil, status: "modified", added: 290, removed: 38),
WireFileDiff(path: "auth/session.ts", oldPath: nil, status: "added", added: 22, removed: 2),
],
patch: """
diff --git a/auth/middleware.ts b/auth/middleware.ts
--- a/auth/middleware.ts
+++ b/auth/middleware.ts
@@ -10,7 +10,9 @@ export function requireSession(req: Request) {
- const token = req.headers.get("x-auth")
+ const header = req.headers.get("authorization") ?? ""
+ const token = header.replace(/^Bearer /, "")
+ if (!token) throw new AuthError("missing bearer token")
return verify(token)
}
diff --git a/auth/session.ts b/auth/session.ts
new file mode 100644
--- /dev/null
+++ b/auth/session.ts
@@ -0,0 +1,6 @@
+export interface Session {
+ userId: string
+ issuedAt: number
+}
+
+export const SESSION_TTL = 3600
""")
}
// MARK: - Connections (mesh P3 multiplexer)
/// Pair a newly-scanned Mac, make it the active host, and start its connection — while any
/// existing connections keep running.
func pair(with payload: PairingPayload) {
let id = payload.hostStaticKey.fingerprintHex
connections[id]?.teardown()
let conn = makeConnection(hostID: id, hostName: payload.hostName)
connections[id] = conn
rebuildAggregate()
conn.pair(with: payload)
}
/// Get or build the connection for a paired Mac.
private func connection(for host: PairedHost) -> HostConnection {
if let existing = connections[host.fingerprint] { return existing }
let conn = makeConnection(hostID: host.fingerprint, hostName: host.hostName)
connections[host.fingerprint] = conn
return conn
}
private func makeConnection(hostID: String, hostName: String) -> HostConnection {
HostConnection(
hostID: hostID, hostName: hostName, identity: identity, discovery: discovery,
pathMonitor: pathMonitor, callbacks: callbacks(for: hostID))
}
/// The callbacks one `HostConnection` uses to drive shared/aggregate state. `hostID` is captured
/// so the open-transcript forwarding fires only for the Mac that owns the open session, while the
/// merged list/dashboard, badges, and notifications are rebuilt across every host on any change.
private func callbacks(for hostID: String) -> HostConnection.Callbacks {
var cb = HostConnection.Callbacks()
cb.didUpdate = { [weak self] in
guard let self else { return }
// Complete a session opened before its owning Mac was live — a Live Activity /
// notification cold-launch deep link opens the detail while the app is still dialing, so
// `open()` couldn't bind it to a connection that didn't exist yet. Runs before the merge
// so the aggregate picks up the freshly-bound host's connectivity/scope this pass.
self.bindOpenSessionIfNeeded()
// Any host's change re-merges the aggregate the flat state binds to (mesh P3).
self.rebuildAggregate()
self.refreshAggregate()
self.flushPendingNotificationDecision()
// A host that just connected (or reconnected) needs the current Live Activity token
// so it can push while the phone is away — and the push-to-start token so it can create
// the glance cold when work starts before the app is opened.
self.syncLiveActivityRegistration()
self.syncPushToStartRegistration()
}
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)
}
// Post for any host; the router suppresses the banner if the user is on this session.
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 }
NotificationRouter.shared.postSessionUpdate(summary)
}
cb.wireError = { [weak self] error in
self?.showError(error.message, sessionID: error.sessionID)
}
cb.didPair = { [weak self] host in
guard let self else { return }
IdentityStore.savePairedHost(host)
self.rebuildAggregate()
}
cb.meshRosterChanged = { [weak self] in
// Mesh "join": a Mac was learned or revoked via gossip. Reconnect to every paired Mac
// (connecting the newcomer) and drop any that left — without switching the active host.
self?.reconnect()
}
cb.tailnetStatus = { [weak self] status, loginURL in
self?.tailnetStatus = status
self?.tailnetLoginURL = loginURL
}
cb.pairingCodeReceived = { [weak self] qr in
guard let self else { return }
// Only apply while a request is outstanding — ignore a late reply after the user
// dismissed the sheet (we already told the host to cancel).
guard case .requesting = self.addDevice else { return }
self.addDevice = qr.map(AddDeviceState.ready) ?? .unavailable
}
cb.macPairRequested = { [weak self] req in
guard let self else { return }
self.pendingMacPairRequest = req
self.pendingMacPairHostID = hostID
}
cb.macPairResolved = { [weak self] deviceID in
guard let self, self.pendingMacPairRequest?.deviceID == deviceID else { return }
self.pendingMacPairRequest = nil
self.pendingMacPairHostID = nil
}
return cb
}
/// Re-merge every connected Mac's projection into the flat @Published state the UI binds to
/// (mesh P3): all hosts' sessions and dashboards shown together, with the host-specific values
/// (host name, capabilities, catalog, connectivity, scope) following the open session's Mac —
/// or a representative one. No-op in demo, which seeds the aggregate directly.
private func rebuildAggregate() {
guard !demoMode else { return }
// Every assignment below goes through `setIfChanged`: `didUpdate` fires on *every* wire
// frame from *any* host (session churn, dashboard refresh, diff-stat ticks), and a plain
// `@Published` assignment fires `objectWillChange` even when the value is identical —
// re-evaluating every view observing the store for nothing. Equality checks over these
// small aggregates are far cheaper than a whole-tree SwiftUI invalidation.
let live = aggregatedSessions()
if !live.isEmpty {
if sessions != live {
sessions = live
cachedSummaries = live
persistSummaries(live)
// The list moved — new or advanced sessions may need their transcript cache
// warmed so a first open starts at the end like a revisit does.
scheduleTranscriptPrefetch()
}
} else if connections.values.contains(where: { $0.connectivity.isLive }) {
// Connected, but the host genuinely has no sessions — reflect that honestly.
setIfChanged(\.sessions, [])
} else {
// Offline: keep showing the saved history rather than blanking the list.
setIfChanged(\.sessions, cachedSummaries)
}
setIfChanged(\.dashboard, DashboardSnapshot.merged(connections.values.map(\.dashboard)))
setIfChanged(\.meshPeers, connections.values.flatMap(\.meshPeers))
let ctx = contextConnection
setIfChanged(\.hostName, ctx?.hostName ?? "")
setIfChanged(\.capabilities, ctx?.capabilities
?? WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: []))
setIfChanged(\.modelCatalog, ctx?.modelCatalog ?? .empty)
if let id = openSessionHostID, let conn = connections[id] {
// While a transcript is open, the composer/controls act on *that* Mac — its connectivity
// gates send and its scope drives the control affordances.
setIfChanged(\.connectivity, conn.connectivity)
setIfChanged(\.grantedScope, conn.grantedScope)
} else {
setIfChanged(\.connectivity, aggregateConnectivity())
// Optimistic new-chat gating: enabled if *any* Mac grants control (the owning Mac still
// enforces scope when the intent lands there).
setIfChanged(\.grantedScope, connections.values
.filter { $0.connectivity.isLive }.map(\.grantedScope).max() ?? .approve)
}
}
/// Assign a `@Published` property only when the value actually differs, so a no-op rebuild
/// doesn't fire `objectWillChange` (and with it a whole-tree view re-evaluation).
private func setIfChanged<T: Equatable>(_ keyPath: ReferenceWritableKeyPath<RemoteStore, T>, _ value: T) {
if self[keyPath: keyPath] != value { self[keyPath: keyPath] = value }
}
/// Merge transcript events by `seq` (monotonic, globally unique within a session), keeping the
/// union sorted. Lets a reconnect's snapshot fold its events into the transcript already on
/// screen without duplicating what's shown or dropping history outside the host's tail window.
/// A fresh open merges into `[]`, so it's just the tail — the same result as a plain replace.
private static func mergedEvents(_ existing: [AgentEvent], _ incoming: [AgentEvent]) -> [AgentEvent] {
guard !existing.isEmpty else { return incoming }
guard !incoming.isEmpty else { return existing }
var bySeq: [UInt64: AgentEvent] = [:]
bySeq.reserveCapacity(existing.count + incoming.count)
for e in existing { bySeq[e.seq] = e }
for e in incoming { bySeq[e.seq] = e }
return bySeq.values.sorted { $0.seq < $1.seq }
}
// MARK: - Offline cache (SessionCache)
/// Debounced write of the live session list to disk, so a read-only history survives a
/// disconnect / relaunch.
private func persistSummaries(_ list: [WireSessionSummary]) {
summaryPersistTask?.cancel()
summaryPersistTask = Task { [list] in
try? await Task.sleep(nanoseconds: 2_000_000_000)
guard !Task.isCancelled else { return }
await SessionCache.saveSummaries(list)
}
}
/// Seed the open transcript from disk so a cached session's history shows instantly — even fully
/// offline. A live snapshot/backfill merges on top by seq (dedup), so this never double-counts.
private func loadCachedTranscript(_ sessionID: SessionID) {
Task { [weak self] in
let cached = await SessionCache.loadEvents(sessionID)
guard let self, self.openSessionID == sessionID, !cached.isEmpty else { return }
// Drain any live deltas first so the seq-dedup merge sees the complete stream.
self.drainPendingOpenEvents()
self.openEvents = Self.mergedEvents(cached, self.openEvents)
}
}
// MARK: - Background transcript prefetch
/// Warm the offline transcript cache for recent sessions *before* they're ever opened. A
/// fresh open otherwise starts from an empty transcript and waits on the host's snapshot, so
/// the view renders at the top and visibly drops to the end as history lands; a session
/// opened before seeds instantly from `SessionCache` and opens at its end. Prefetching runs
/// the same `fetchTranscript` flow the open path uses — one session at a time, newest first,
/// pulling only what the cache is missing — so every recent session opens like a warm one.
private var prefetchQueue: [SessionID] = []
private var prefetchInFlight: SessionID?
/// The summary `lastSeq` each session was last prefetched (or attempted) at, so a session is
/// re-queued only after it has moved on — not on every aggregate rebuild.
private var prefetchedSeq: [SessionID: UInt64] = [:]
/// Rebuild the prefetch queue from the freshest summaries. Called when the session list
/// actually changes (connect, session churn); cheap when nothing needs pulling.
private func scheduleTranscriptPrefetch() {
guard !demoMode else { return }
prefetchQueue = sessions
.filter { !$0.archived && $0.sessionID != openSessionID && $0.sessionID != prefetchInFlight }
.sorted { $0.updatedAt > $1.updatedAt }
.prefix(8)
.filter { $0.lastSeq > (prefetchedSeq[$0.sessionID] ?? 0) }
.map(\.sessionID)
pumpTranscriptPrefetch()
}
/// Start the next queued prefetch if none is in flight. Serial on purpose — background work
/// must trickle behind the live stream, not contend with it.
private func pumpTranscriptPrefetch() {
guard prefetchInFlight == nil, !prefetchQueue.isEmpty else { return }
let id = prefetchQueue.removeFirst()
guard let summary = sessions.first(where: { $0.sessionID == id }),
let conn = connection(owningSession: id), conn.canPrefetchTranscript
else {
// Gone / connection busy or incapable — skip; a later list update re-queues it.
pumpTranscriptPrefetch()
return
}
prefetchInFlight = id
// Mark the attempt at this watermark now, so an empty/unavailable result doesn't
// re-queue in a loop; the session re-qualifies once its lastSeq advances.
prefetchedSeq[id] = summary.lastSeq
Task { [weak self] in
// Pull only the gap beyond what's cached; a cold session is bounded to the same
// tail window the cache would keep anyway.
let cachedLast = await SessionCache.loadEvents(id).last?.seq ?? 0
guard let self else { return }
if cachedLast >= summary.lastSeq {
self.prefetchInFlight = nil // cache already current
self.pumpTranscriptPrefetch()
return
}
let coldFloor = summary.lastSeq > UInt64(SessionCache.eventLimit)
? summary.lastSeq - UInt64(SessionCache.eventLimit) : 0
let afterSeq = cachedLast > 0 ? cachedLast : coldFloor
if !conn.prefetchTranscript(id, afterSeq: afterSeq) {
self.prefetchInFlight = nil // couldn't start (connection changed) — move on
self.pumpTranscriptPrefetch()
}
}
}
// MARK: - Streaming delta coalescing
/// Streaming transcript deltas arrive one wire frame at a time — often dozens per second
/// while the agent talks — and every `openEvents` mutation fires `objectWillChange`, which
/// re-evaluates *every* view observing the store (the Sessions/Home tabs stay mounted behind
/// the pushed session detail, so they pay this too). Buffer incoming deltas and publish at
/// most one append per `openEventsFlushInterval`: the first delta after a quiet gap applies
/// immediately (the leading edge — first-token latency stays imperceptible), followers ride
/// the next scheduled flush. ~10 UI updates/sec still reads as live streaming; the view tree
/// stops being invalidated per wire frame. Merge/close/flush paths drain the buffer first, so
/// nothing downstream ever sees a partial stream.
private var pendingOpenEvents: [AgentEvent] = []
private var openEventsFlushTask: Task<Void, Never>?
private var lastOpenEventsFlushAt = Date.distantPast
private static let openEventsFlushInterval: TimeInterval = 0.1
private func enqueueOpenEvents(_ events: [AgentEvent]) {
pendingOpenEvents.append(contentsOf: events)
guard openEventsFlushTask == nil else { return } // a trailing flush is already scheduled
let elapsed = Date().timeIntervalSince(lastOpenEventsFlushAt)
if elapsed >= Self.openEventsFlushInterval {
drainPendingOpenEvents()
} else {
let delay = Self.openEventsFlushInterval - elapsed
openEventsFlushTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
guard let self, !Task.isCancelled else { return }
self.openEventsFlushTask = nil
self.drainPendingOpenEvents()
}
}
}
/// Publish the buffered deltas (and schedule persistence). Called on the flush cadence, and
/// eagerly by anything that merges, persists, or clears `openEvents`, so those paths always
/// operate on the complete stream.
private func drainPendingOpenEvents() {
openEventsFlushTask?.cancel()
openEventsFlushTask = nil
guard !pendingOpenEvents.isEmpty else { return }
lastOpenEventsFlushAt = Date()
openEvents.append(contentsOf: pendingOpenEvents)
pendingOpenEvents.removeAll(keepingCapacity: true)
persistOpenTranscript()
}
/// Drop buffered deltas without publishing — for session switch/close/unpair, where the
/// buffer belongs to a transcript that is being cleared (a stale session's tail must never
/// leak into the next session's freshly-opened transcript).
private func discardPendingOpenEvents() {
openEventsFlushTask?.cancel()
openEventsFlushTask = nil
pendingOpenEvents.removeAll(keepingCapacity: true)
}
/// Debounced write of the open transcript, called after each batch of events lands.
private func persistOpenTranscript() {
guard !demoMode, let id = openSessionID, !openEvents.isEmpty else { return }
let events = openEvents
transcriptPersistTask?.cancel()
transcriptPersistTask = Task { [events, id] in
try? await Task.sleep(nanoseconds: 1_500_000_000)
guard !Task.isCancelled else { return }
await SessionCache.saveEvents(events, for: id)
}
}
/// Flush the open transcript to disk immediately (on close), cancelling any pending debounce.
private func flushOpenTranscript(_ id: SessionID, _ events: [AgentEvent]) {
transcriptPersistTask?.cancel()
guard !demoMode, !events.isEmpty else { return }
Task { await SessionCache.saveEvents(events, for: id) }
}
/// The flat sessions list: every connected Mac's sessions, deduped by id (ids are globally
/// unique). Views handle sorting/grouping.
private func aggregatedSessions() -> [WireSessionSummary] {
var seen = Set<SessionID>()
var result: [WireSessionSummary] = []
for conn in connections.values {
for summary in conn.sessions where seen.insert(summary.sessionID).inserted {
result.append(summary)
}
}
return result
}
/// One connectivity value for the whole app when no transcript is open (the chip + global
/// gating): connected if any Mac is live, else the most-informative in-progress/offline state.
private func aggregateConnectivity() -> Connectivity {
let all = connections.values.map(\.connectivity)
guard !all.isEmpty else { return .unpaired }
if let live = all.first(where: { $0.isLive }) { return live }
if all.contains(where: { if case .connecting = $0 { return true } else { return false } }) { return .connecting }
if all.contains(where: { if case .reconnecting = $0 { return true } else { return false } }) { return .reconnecting }
if all.contains(.hostOffline) { return .hostOffline }
return all.first ?? .unpaired
}
/// Refresh cross-host aggregates: the app-icon badge (needs-you across *all* Macs) + the Live
/// Activity summary. `sessions`/`liveSessions` are already the merged aggregate.
private func refreshAggregate() {
NotificationRouter.shared.updateBadge(needsYouCount)
// When no session is blocked on an approval anymore, recall the "waiting for your approval"
// wake tickle a prior push may have left on the lock screen. Complements the relay's silent
// clear (which covers phones that were away): this catches the phone that saw the tickle and
// then connected, so the resolution arrives as a broadcast rather than a push. Idempotent —
// a no-op when the tickle isn't there. Per-approval locals are withdrawn as each resolves.
let approvalsPending = liveSessions.contains {
$0.pendingApprovalCount > 0 || $0.status == .awaitingApproval
}
if !approvalsPending {
NotificationRouter.shared.withdrawApprovalAttention()
}
LiveActivityManager.shared.sync(hostName: hostName, sessions: liveSessions)
}
/// Tear down every live connection (leaving the paired registry intact) — for entering demo.
private func teardownAll() {
for conn in connections.values { conn.teardown() }
connections.removeAll()
}
/// Connect to every paired Mac at once (mesh P3 multiplexer): each `HostConnection` runs its own
/// IK reconnect (LAN→tailnet, with backoff), so all your Macs are live simultaneously and the
/// switcher flips between them instantly. Idempotent — an already-live connection is left alone;
/// an offline one (re)dials. Connections for since-unpaired Macs are dropped. The launch +
/// "Reconnect" path.
func reconnect() {
let hosts = IdentityStore.pairedHosts()
guard !hosts.isEmpty else { connectivity = .unpaired; return }
let paired = Set(hosts.map(\.fingerprint))
for (id, conn) in connections where !paired.contains(id) { conn.teardown(); connections[id] = nil }
for host in hosts {
let conn = connection(for: host)
if !conn.connectivity.isLive { conn.reconnect(to: host) }
}
rebuildAggregate()
refreshAggregate()
}
/// Unpair this device entirely — drop every paired Mac and its connection (the "Unpair this
/// device" button). Nothing left to show, so the app returns to the pairing intro.
func unpair() {
for (id, conn) in connections { conn.teardown(); connections[id] = nil }
for host in IdentityStore.pairedHosts() { IdentityStore.removePairedHost(id: host.fingerprint) }
IdentityStore.clearPairedHost()
finishUnpairIfEmpty()
}
/// Forget one paired Mac (the Settings ▸ Mesh per-row remove): drop just its connection +
/// registry record. Other Macs keep running and stay in the merged view.
func unpair(_ hostID: String) {
connections[hostID]?.teardown()
connections[hostID] = nil
IdentityStore.removePairedHost(id: hostID)
if IdentityStore.pairedHosts().isEmpty {
finishUnpairIfEmpty()
} else {
rebuildAggregate()
refreshAggregate()
}
}
/// Wind the app back to the unpaired state once no Macs remain: clear the open transcript + flat
/// aggregate and spin the embedded Tailscale node down.
private func finishUnpairIfEmpty() {
openSessionID = nil
compactDetailPresented = false
openSessionHostID = nil
discardPendingOpenEvents()
openEvents = []; openApprovals = []; openDiff = nil; diffLoading = false
connectivity = .unpaired
// No Mac left whose history to hold — drop the offline cache too.
cachedSummaries = []
SessionCache.clear()
sessions = []
dashboard = .empty
// Nothing left to dial — spin the embedded Tailscale node down if it was running.
Task { await TailnetNode.shared.stop() }
tailnetStatus = nil
LiveActivityManager.shared.end()
NotificationRouter.shared.updateBadge(0)
}
/// Enter the in-app demo (the "Explore a demo" button): drop any live connection, persist the
/// flag so it survives relaunch (a reviewer may relaunch), and seed the mock world. `isPaired`
/// then returns true, so `RootView` shows the full TabView.
func enterDemo() {
teardownAll()
demoMode = true
UserDefaults.standard.set(true, forKey: Self.demoModeKey)
seedDemo()
}
/// Leave the demo (Settings ▸ Leave demo): cancel any simulated runs, clear the mock world,
/// and return to the real state — reconnect if a Mac is actually paired, else the pairing intro.
func exitDemo() {
demoMode = false
UserDefaults.standard.set(false, forKey: Self.demoModeKey)
demoTasks.forEach { $0.cancel() }
demoTasks.removeAll()
openSessionID = nil
compactDetailPresented = false
openSessionHostID = nil
discardPendingOpenEvents()
openEvents = []
openApprovals = []
openDiff = nil
diffLoading = false
sessions = []
dashboard = .empty
LiveActivityManager.shared.end()
NotificationRouter.shared.updateBadge(0)
if IdentityStore.loadPairedHost() != nil {
reconnect()
} else {
connectivity = .unpaired
}
}
// MARK: - Intents (UX_IOS §9)
func open(_ sessionID: SessionID) {
openSessionID = sessionID
// Hide the compact shell's floating tab bar the instant a session view opens — from *any*
// entry point (Sessions, Home, Projects, a notification tap), since every one funnels
// through here. Tied to the session view opening rather than the Sessions list being
// navigated away from, so the bar slides out even when the chat wasn't launched from that
// list. `SessionsView` still clears it early on back-swipe for a responsive re-show.
compactDetailPresented = true
// Record which Mac owns this session (mesh P3) so its connection forwards the transcript and
// the host-specific projected values follow it.
openSessionHostID = connection(owningSession: sessionID)?.hostID
discardPendingOpenEvents()
openEvents = []
openApprovals = []
openDiff = nil
diffLoading = false
markOpened(sessionID)
if demoMode { seedDemoTranscript(sessionID); return }
// Seed from the on-device cache so the transcript shows instantly — including fully offline,
// where the subscribe below is a no-op. Live events merge on top by seq (dedup).
loadCachedTranscript(sessionID)
// Tell the owning connection so it forwards the snapshot/events (and dedupes them), re-derive
// the context host (capabilities/scope/catalog/connectivity now follow it), then subscribe.
connection(owningSession: sessionID)?.openSessionID = sessionID
rebuildAggregate()
send(.subscribe(Subscribe(sessionID: sessionID, sinceSeq: nil, verbosity: .full)))
// Pull the full history too — the subscribe above only returns a 200-event tail, so without
// this the transcript would start at the connection point with no events from before it.
connection(owningSession: sessionID)?.fetchFullTranscript(sessionID)
}
/// Bind the open session to its owning Mac once that Mac is live — the deferred half of `open()`
/// for a session opened before any connection existed (a Live Activity / notification cold-launch
/// deep link). Because `open()` ran while the app was still dialing, `connection(owningSession:)`
/// found nothing: the host binding stayed nil, no `subscribe` went out, and the transcript
/// callbacks — all gated on `openSessionHostID` — dropped everything the host later sent, leaving
/// the detail stuck on "Disconnected" with an empty transcript and a dead composer. Once a host
/// connects and lists its sessions, `didUpdate` calls this: it finds the owner and finishes the
/// subscription so the transcript loads and connectivity/scope follow the bound host. Idempotent
/// and cheap — a no-op on the common path where `open()` already bound a live connection.
private func bindOpenSessionIfNeeded() {
guard !demoMode, let sessionID = openSessionID,
let conn = connection(owningSession: sessionID), conn.connectivity.isLive else { return }
// Already bound to this live host with its subscription in place — nothing to redo.
guard openSessionHostID != conn.hostID || conn.openSessionID != sessionID else { return }
openSessionHostID = conn.hostID
conn.openSessionID = sessionID
conn.send(.subscribe(Subscribe(sessionID: sessionID, sinceSeq: nil, verbosity: .full)))
conn.fetchFullTranscript(sessionID)
}
/// Ask the host for the open session's full patch (Diff tab). No-op when the host
/// doesn't advertise the capability — the view falls back to the diffstat summary.
func fetchDiff(_ sessionID: SessionID) {
if demoMode { openDiff = demoDiff(sessionID); return }
guard capabilities.canFetchDiff else { return }
diffLoading = openDiff == nil
send(.fetchDiff(sessionID))
}
// MARK: - Add a device to this mesh (relayed pairing-code mint)
/// Whether the "add a device" button should appear: a live Mac that advertises
/// `canMintPairingCode` is reachable to mint a join code (or we're in demo).
var canAddDeviceToMesh: Bool {
demoMode || pairingMintConnection != nil
}
/// The connection we route a mint request to: prefer the context host if it can mint, else any
/// live host that can. A code minted by *any* mesh member joins the whole group (mesh "join").
private var pairingMintConnection: HostConnection? {
if let ctx = contextConnection, ctx.connectivity.isLive, ctx.capabilities.canMintPairingCode {
return ctx
}
return connections.values.first { $0.connectivity.isLive && $0.capabilities.canMintPairingCode }
}
/// Ask a connected Mac to open a pairing window and hand back its join code. The reply arrives
/// asynchronously as `AddDeviceState.ready` (or `.unavailable`) via the host connection.
func requestPairingCode() {
if demoMode { addDevice = .ready(Self.demoPairingCode); return }
guard let conn = pairingMintConnection else { addDevice = .unavailable; return }
pairingMintHostID = conn.hostID
addDevice = .requesting
conn.send(.requestPairingCode)
}
/// The user dismissed the "add a device" sheet — tell the minting Mac to close its pairing
/// window (retire the one-time secret) and reset to idle. Any Mac-join awaiting approval is
/// abandoned here too; the host fails it closed when its pairing window shuts.
func cancelPairingCode() {
if !demoMode, let id = pairingMintHostID { connections[id]?.send(.cancelPairingCode) }
pairingMintHostID = nil
pendingMacPairRequest = nil
pendingMacPairHostID = nil
addDevice = .idle
}
/// Approve or deny a Mac joining via a code this phone shared (the forwarded confirm). Routes
/// the answer to the Mac that forwarded it and clears the prompt optimistically.
func respondMacPair(_ approve: Bool) {
guard let req = pendingMacPairRequest else { return }
if let id = pendingMacPairHostID { connections[id]?.send(.respondMacPair(req.deviceID, approve)) }
pendingMacPairRequest = nil
pendingMacPairHostID = nil
}
/// A stand-in join code for the offline demo so the QR/copy sheet renders without a Mac.
private static let demoPairingCode = "nucleic://pair?d=demo"
/// Record that the user looked at this session now (clears its unseen-completion wash).
func markOpened(_ sessionID: SessionID) {
lastOpenedAt[sessionID] = Date()
persistLastOpened()
}
/// Offline transcript fixture (NUCLEIC_DEMO) so the richer transcript surfaces — grouped
/// tools, Orchestra card, usage/cost, file changes, run outcome — render without a host.
private func seedDemoTranscript(_ sessionID: SessionID) {
func event(_ seq: UInt64, _ kind: AgentEvent.Kind) -> AgentEvent {
AgentEvent(sessionID: sessionID, seq: seq, at: Date(), backend: .claudeCode,
nativeType: nil, kind: kind)
}
// A realistic `git commit` (heredoc message) so the transcript's structured commit card
// is exercisable offline: expand the Bash call to see the subject + Markdown body.
let demoCommitCommand = "git commit -F - <<'EOF'\nfix: harden auth middleware\n\nRequire a Bearer token and reject a missing or blank one.\n\n- extract `requireSession`\n- add a `Bearer` prefix check\nEOF"
// A multi-step, destructive shell pipeline so the transcript's step list (with the delete
// flagged in red) is exercisable offline: expand the Bash call to see the breakdown.
let demoCleanupCommand = "cd ~/code/nucleic && rm -rf .worktrees/auth-old && git worktree prune && git branch -D nucleic/auth-old"
openEvents = [
event(1, .sessionStarted(SessionStarted(
backendSessionID: "demo", model: "claude-opus-4-8[1m]", cwd: "~/code/nucleic", toolNames: []))),
event(2, .userText(TextChunk(messageID: "u1", text: "Refactor the auth middleware and run the tests.", isPartial: false))),
event(3, .assistantText(TextChunk(messageID: "a1", text: "I'll update the auth middleware, then run the suite.\n\n**Plan:**\n- extract `requireSession`\n- add a `Bearer` check", isPartial: false))),
event(4, .toolCallStarted(ToolCall(toolCallID: "t1", name: "Edit", input: ["file_path": "auth/middleware.ts"]))),
event(5, .toolCallCompleted(ToolCall(toolCallID: "t1", name: "Edit", input: ["file_path": "auth/middleware.ts"]))),
event(6, .fileChange(FileChange(path: "auth/middleware.ts", kind: .update, toolCallID: "t1"))),
event(7, .toolResult(ToolResult(toolCallID: "t1", content: "Applied 2 edits to auth/middleware.ts", isError: false))),
event(8, .toolCallStarted(ToolCall(toolCallID: "t2", name: "Bash", input: ["command": "npm test"]))),
event(9, .toolCallCompleted(ToolCall(toolCallID: "t2", name: "Bash", input: ["command": "npm test"]))),
event(10, .toolResult(ToolResult(toolCallID: "t2", content: "42 passing\n0 failing", isError: false))),
event(11, .toolCallStarted(ToolCall(toolCallID: "t3", name: "Task", input: ["description": "Audit other call sites", "prompt": "Find every caller of the old auth API."]))),
event(12, .toolCallCompleted(ToolCall(toolCallID: "t3", name: "Task", input: ["description": "Audit other call sites"]))),
event(13, .toolResult(ToolResult(toolCallID: "t3", content: "Checked 7 files; 1 stale caller updated.", isError: false))),
event(14, .toolCallStarted(ToolCall(toolCallID: "t4", name: "Bash", input: ["command": .string(demoCommitCommand)]))),
event(15, .toolCallCompleted(ToolCall(toolCallID: "t4", name: "Bash", input: ["command": .string(demoCommitCommand)]))),
event(16, .toolResult(ToolResult(toolCallID: "t4", content: "[nucleic/auth-refactor 1a2b3c4] fix: harden auth middleware\n 2 files changed, 312 insertions(+), 40 deletions(-)", isError: false))),
event(17, .toolCallStarted(ToolCall(toolCallID: "t5", name: "Bash", input: ["command": .string(demoCleanupCommand)]))),
event(18, .toolCallCompleted(ToolCall(toolCallID: "t5", name: "Bash", input: ["command": .string(demoCleanupCommand)]))),
event(19, .toolResult(ToolResult(toolCallID: "t5", content: "Removed 1 worktree; deleted branch nucleic/auth-old.", isError: false))),
event(20, .usage(Usage(inputTokens: 84_300, outputTokens: 2_140, costUSD: 0.0421, contextInputTokens: 84_300))),
event(21, .runFinished(RunFinished(outcome: .completed, finalText: "Done."))),
]
// If this session is blocked on a human, surface a real approval card so the
// Allow/Deny loop is exercisable in the demo (the seeded `a1` session).
if sessions.first(where: { $0.sessionID == sessionID })?.status == .awaitingApproval {
openApprovals = [ApprovalRequest(
id: Self.demoApprovalID(for: sessionID),
sessionID: sessionID, toolCallID: "t-appr", toolName: "Bash",
input: ["command": "npm run deploy"], title: "Run npm run deploy",
risk: .execute, createdAt: Date())]
}
}
/// Deterministic approval id for a demo session's seeded approval, so re-opening the same
/// session doesn't stack duplicate cards.
private static func demoApprovalID(for sessionID: SessionID) -> ApprovalID {
ApprovalID(rawValue: "demo-appr-\(sessionID.rawValue)")
}
/// Close a session's live subscription. `id` names *which* session is closing — the detail
/// view passes its own. On iPad's split view, switching session A→B can mount B (which calls
/// `open(B)`, setting `openSessionID = B`) *before* A's detail disappears; so we always
/// unsubscribe the named session but only tear down the shared open-state when it still
/// belongs to that session — otherwise we'd wipe B's freshly-loaded transcript. Called with
/// no argument it closes whatever is currently open (the iPhone push/pop path, unchanged).
func closeOpen(_ id: SessionID? = nil) {
guard let target = id ?? openSessionID else { return }
send(.unsubscribe(target))
markOpened(target) // everything up to now has been seen
guard openSessionID == target else { return }
connection(owningSession: target)?.openSessionID = nil
openSessionID = nil
// Session view closed — restore the compact tab bar (the counterpart to `open`'s hide).
// On the Sessions tab the back-swipe has usually already cleared this early for a snappy
// re-show; this is the catch-all for the other entry points and programmatic closes.
compactDetailPresented = false
openSessionHostID = nil
// Persist the final transcript before clearing it, so it's warm for the next open / offline.
// Buffered streaming deltas are part of that transcript — publish them first.
drainPendingOpenEvents()
flushOpenTranscript(target, openEvents)
openEvents = []
openApprovals = []
openDiff = nil
diffLoading = false
// Context reverts to the aggregate now that no transcript is open.
if !demoMode { rebuildAggregate() }
}
func respond(_ approval: ApprovalRequest, _ decision: Decision) {
// Route to the Mac that owns the approval's session (the approval message carries no
// sessionID of its own). Demo resolves locally.
if demoMode {
demoHandle(.approvalRespond(approval.id, decision))
} else {
connection(owningSession: approval.sessionID)?.send(.approvalRespond(approval.id, decision))
}
openApprovals.removeAll { $0.id == approval.id } // optimistic dismiss; host confirms
}
func sendInput(_ text: String, to sessionID: SessionID) {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
send(.sendInput(sessionID, AgentInput(text: trimmed)))
}
func refreshSessions() { send(.listSessions); send(.listDashboard) }
// MARK: - Control intents (control scope; the same actions the Mac can take)
func startChat(in projectID: ProjectID, message: String, model: String? = nil,
effort: String? = nil, baseBranch: String? = nil,
useWorktree: Bool = true, auto: Bool? = nil) {
let text = message.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
send(.startChat(StartChatRequest(
projectID: projectID, message: text, model: model, effort: effort,
baseBranch: baseBranch, useWorktree: useWorktree, auto: auto)))
}
func captureTodo(_ text: String, projectID: ProjectID?) {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
send(.captureTodo(CaptureTodoRequest(text: trimmed, projectID: projectID)))
}
func dispatchTodo(_ id: TodoID, in projectID: ProjectID) { send(.dispatchTodo(id, projectID)) }
func completeTodo(_ id: TodoID) { send(.setTodoStatus(id, .done)) }
func deleteTodo(_ id: TodoID) { send(.deleteTodo(id)) }
func renameSession(_ id: SessionID, to title: String) {
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
send(.renameSession(id, trimmed))
}
func setFavorite(_ id: SessionID, _ favorite: Bool) { send(.setFavorite(id, favorite)) }
func setArchived(_ id: SessionID, _ archived: Bool) { send(.setArchived(id, archived)) }
func deleteSession(_ id: SessionID) {
send(.deleteSession(id))
if id == openSessionID { closeOpen() }
}
func integrate(_ id: SessionID, _ mode: IntegrationMode) { send(.integrate(id, mode)) }
/// Throw away the session's branch/worktree without landing it (the Mac's Discard…).
func discard(_ id: SessionID) { send(.discard(id)) }
func interrupt(_ id: SessionID) { send(.interrupt(id)) }
/// Cancel one queued (not-yet-sent) follow-up by id — the phone's per-message ✕.
func cancelQueuedMessage(_ id: SessionID, _ messageID: UUID) { send(.cancelQueuedMessage(id, messageID)) }
// Mid-session model / reasoning / automation — the same affordances the Mac header exposes.
// `nil` model/effort resets the session to the host/app default.
func setSessionModel(_ id: SessionID, _ model: String?) { send(.setSessionModel(id, model)) }
func setSessionEffort(_ id: SessionID, _ effort: String?) { send(.setSessionEffort(id, effort)) }
func setSessionAuto(_ id: SessionID, _ auto: Bool) { send(.setSessionAuto(id, auto)) }
func setSessionAutoShip(_ id: SessionID, _ autoShip: Bool) { send(.setSessionAutoShip(id, autoShip)) }
func setSessionShipBranch(_ id: SessionID, _ branch: String?) { send(.setSessionShipBranch(id, branch)) }
// MARK: - App Intents support
/// Bring networking online and dial the paired host(s) for an App Intent that runs in a *cold*
/// background process (Siri / Shortcuts / a widget or Live Activity button), where the SwiftUI
/// scene never mounts and `onAppear` never fires. Mirrors the push handler's silent-launch
/// bootstrap (`startNetworkingIfNeeded` + `reconnect`). Idempotent; a no-op in demo mode.
func bootstrapForIntent() {
guard !demoMode else { return }
startNetworkingIfNeeded()
if isPaired {
// Show the persisted session list immediately so a cold intent query (Siri/Spotlight)
// has data to answer with before any host connects — same seed as `onAppear`.
if sessions.isEmpty { sessions = cachedSummaries }
reconnect()
}
}
/// Whether any paired Mac currently has a live channel.
var hasLiveConnection: Bool { connections.values.contains { $0.connectivity.isLive } }
/// Await a live connection to any paired Mac, up to `timeout` seconds — an intent must act over a
/// *live* channel or fail clean (UX_IOS §6, §3.1). Brings networking up first if it's cold, then
/// polls until a link comes up or the deadline passes. Returns whether a link is live.
func awaitLiveConnection(timeout: TimeInterval = 6) async -> Bool {
if demoMode || hasLiveConnection { return true }
bootstrapForIntent()
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
try? await Task.sleep(for: .milliseconds(200))
if hasLiveConnection { return true }
}
return hasLiveConnection
}
/// Resolve an approval by id from an App Intent with a full `Decision`. Like
/// `respondFromNotification`, it prefers the owning Mac, else broadcasts to every live Mac (the
/// owner resolves; the rest see an unknown / `alreadyResolved` id and no-op), and queues briefly
/// on a dropped link so a decision made just as the socket blips still lands. `sessionID` (when
/// the surface knows it) routes directly. Returns whether it went out over a live channel now.
@discardableResult
func respondToApproval(id: ApprovalID, sessionID: SessionID?, decision: Decision) -> Bool {
if demoMode { demoHandle(.approvalRespond(id, decision)); return true }
if let sessionID, let conn = connection(owningSession: sessionID), conn.connectivity.isLive {
conn.send(.approvalRespond(id, decision))
openApprovals.removeAll { $0.id == id }
return true
}
let live = connections.values.filter { $0.connectivity.isLive }
if !live.isEmpty {
for conn in live { conn.send(.approvalRespond(id, decision)) }
openApprovals.removeAll { $0.id == id }
return true
}
pendingNotificationDecision = (id, decision, Date())
reconnect()
return false
}
// MARK: - Plumbing
/// Route an intent to the Mac that owns its target (mesh P3). Sessions/projects/to-dos are shown
/// merged across every connected Mac, so an intent goes to the connection that owns the session,
/// project, or to-do it names — not to a single "active" host. Host-agnostic pulls (list
/// sessions/dashboard) broadcast to every live Mac; a project-less to-do capture (no owner) goes
/// to the first live Mac. Demo has no connections and mutates the seeded aggregate directly.
private func send(_ msg: ClientMsg) {
if demoMode { demoHandle(msg); return }
switch msg {
// Session-owning intents → the Mac that has this session.
case .subscribe(let s):
connection(owningSession: s.sessionID)?.send(msg)
case .unsubscribe(let id), .interrupt(let id), .deleteSession(let id), .discard(let id),
.integrate(let id, _), .renameSession(let id, _), .setFavorite(let id, _),
.setArchived(let id, _), .setSessionModel(let id, _), .setSessionEffort(let id, _),
.setSessionAuto(let id, _), .setSessionAutoShip(let id, _), .setSessionShipBranch(let id, _),
.sendInput(let id, _), .cancelQueuedMessage(let id, _), .fetchDiff(let id):
connection(owningSession: id)?.send(msg)
// Project-owning intents → the Mac that has this project.
case .startChat(let req):
connection(owningProject: req.projectID)?.send(msg)
case .dispatchTodo(_, let projectID):
connection(owningProject: projectID)?.send(msg)
// To-do-owning intents → the Mac that has this to-do.
case .setTodoStatus(let id, _), .deleteTodo(let id):
connection(owningTodo: id)?.send(msg)
// A capture with a project goes to that Mac; a project-less one has no owner → first live.
case .captureTodo(let req):
if let pid = req.projectID, let conn = connection(owningProject: pid) { conn.send(msg) }
else { firstLiveConnection?.send(msg) }
// Approvals carry no sessionID — `respond(_:_:)` routes by the approval's session directly;
// a stray one here (or a notification-driven decision) broadcasts and the owner resolves it.
case .approvalRespond:
broadcastLive(msg)
// Host-agnostic pulls → every live Mac.
case .listSessions, .listDashboard:
broadcastLive(msg)
// Never originated from here (connection-internal, or handled by dedicated loops).
// `requestPairingCode`/`cancelPairingCode` are sent straight to the chosen host by
// `requestPairingCode()`/`cancelPairingCode()`, not through this owner-routing switch.
case .hello, .ping, .listPeers, .addressUpdate, .meshRoster,
.registerLiveActivity, .endLiveActivity, .registerPushToStartToken, .setForeground,
.transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript,
.requestPairingCode, .cancelPairingCode, .respondMacPair:
break
}
}
// MARK: - Live Activity push registration (UX_IOS §5.3)
/// The current Live Activity's APNS update token + id, shipped to every capable Mac so it can
/// refresh the lock-screen glance over APNs while the phone is backgrounded and its socket is
/// suspended. Sent to *all* connected hosts (not just the active one) — while the phone is
/// away, whichever Mac has work to report should be able to push.
private var liveActivityReg: (token: String, activityID: String)?
/// Host ids that already have the current token (re-sent to a host that (re)connects, and
/// re-sent to everyone when the token rotates).
private var liveActivitySentTo: Set<String> = []
/// This device's push-to-start token (iOS 17.2+), shipped to every capable Mac so it can create
/// the Live Activity over APNs when work starts before the app is opened. Device-scoped, so —
/// unlike `liveActivityReg` — it persists across activities and isn't cleared when one ends.
private var pushToStartToken: String?
/// Host ids that already have the current push-to-start token (mirrors `liveActivitySentTo`).
private var pushToStartSentTo: Set<String> = []
/// 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)
}
}
// MARK: - Demo simulator (offline, interactive)
//
// In demo mode there's no host, so writes can't go over the wire. Instead they mutate the
// already-`@Published` projection directly and (for the agent loop) stream a short canned run,
// so a reviewer can send messages, approve actions, start chats, and manage to-dos and see the
// UI respond — the read-only fixtures (`seedDemo`/`seedDemoTranscript`/`demoDiff`) already
// cover the passive surfaces.
private func demoHandle(_ msg: ClientMsg) {
switch msg {
case .sendInput(let id, let input):
demoRun(id, userText: input.plainText ?? "")
case .startChat(let req):
demoStartChat(req)
case .approvalRespond(let id, let decision):
demoResolveApproval(id, decision)
case .captureTodo(let req):
demoCaptureTodo(req)
case .setTodoStatus(let id, let status):
demoSetTodoStatus(id, status)
case .deleteTodo(let id):
demoMutateTodos { $0.filter { $0.id != id } }
case .dispatchTodo(let id, _):
demoSetTodoStatus(id, .dispatched)
case .renameSession(let id, let title):
demoUpdateSession(id) { $0.demoCopy(title: title) }
case .setFavorite(let id, let favorite):
demoUpdateSession(id) { $0.demoCopy(favorite: favorite) }
case .setArchived(let id, let archived):
demoUpdateSession(id) { $0.demoCopy(archived: archived) }
case .deleteSession(let id):
sessions.removeAll { $0.sessionID == id }
NotificationRouter.shared.updateBadge(needsYouCount)
LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions)
case .discard(let id):
sessions.removeAll { $0.sessionID == id }
NotificationRouter.shared.updateBadge(needsYouCount)
case .interrupt(let id):
demoUpdateSession(id) { $0.demoCopy(status: .interrupted, disposition: .some(nil)) }
case .integrate(let id, _):
demoAppend(id, .note(NoteEvent(text: "nvrsion: Landed to trunk.", icon: "arrow.triangle.branch")))
demoUpdateSession(id) { $0.demoCopy(status: .finished, disposition: .some(.completed)) }
case .setSessionModel(let id, let model):
demoUpdateSession(id) { $0.demoCopy(model: .some(model)) }
case .setSessionEffort(let id, let effort):
demoUpdateSession(id) { $0.demoCopy(effort: .some(effort)) }
case .setSessionAuto(let id, let auto):
demoUpdateSession(id) { $0.demoCopy(auto: auto) }
case .setSessionAutoShip(let id, let autoShip):
demoUpdateSession(id) { $0.demoCopy(autoShip: autoShip) }
case .setSessionShipBranch(let id, let branch):
demoUpdateSession(id) { $0.demoCopy(shipBranch: .some(branch)) }
case .hello, .listSessions, .listDashboard, .subscribe, .unsubscribe,
.ping, .cancelQueuedMessage, .fetchDiff, .fetchTranscript, .listPeers,
.addressUpdate, .meshRoster,
// Live Activity push registration is a real-connection concern (there's no host to
// push in demo), so it's inert here.
.registerLiveActivity, .endLiveActivity, .registerPushToStartToken, .setForeground,
// Session transfer (mesh P5) is a Mac↔Mac flow — the phone never originates these,
// and demo has no peer Macs, so they're inert here.
.transferOffer, .transferChunk, .transferCommit, .transferCancel,
// "Add a device" mint is handled directly against a live host, not via demoHandle;
// the demo path short-circuits in `requestPairingCode()` with a stand-in code.
.requestPairingCode, .cancelPairingCode, .respondMacPair:
break // passive / already handled by the seeded fixtures (demo has no mesh peers)
}
}
/// Append a transcript event to the open session (no-op if it isn't the one on screen).
private func demoAppend(_ sessionID: SessionID, _ kind: AgentEvent.Kind) {
guard sessionID == openSessionID else { return }
let seq = (openEvents.map(\.seq).max() ?? 0) + 1
let backend = sessions.first { $0.sessionID == sessionID }?.backend ?? .claudeCode
openEvents.append(AgentEvent(
sessionID: sessionID, seq: seq, at: Date(), backend: backend, nativeType: nil, kind: kind))
}
/// Replace a session summary in the list, keeping the badge / Live Activity in sync.
private func demoUpdateSession(_ id: SessionID, _ transform: (WireSessionSummary) -> WireSessionSummary) {
guard let i = sessions.firstIndex(where: { $0.sessionID == id }) else { return }
sessions[i] = transform(sessions[i])
NotificationRouter.shared.updateBadge(needsYouCount)
LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions)
}
/// Stream a short, believable canned turn for a session: assistant prose, a tool call +
/// result, usage, and a finish — flipping the summary running → awaiting-input. Each step
/// re-checks demo mode and cancellation so `exitDemo()` stops it cleanly. Runs entirely on the
/// main actor (the store is `@MainActor`, and a `Task` in an isolated method inherits it).
private func demoRun(_ sessionID: SessionID, userText: String?) {
if let userText, !userText.isEmpty {
demoAppend(sessionID, .userText(TextChunk(messageID: UUID().uuidString, text: userText, isPartial: false)))
}
demoUpdateSession(sessionID) { $0.demoCopy(status: .running, disposition: .some(nil)) }
let toolID = UUID().uuidString
let task = Task { [weak self] in
guard let self else { return }
@MainActor func pause(_ ms: Int) async -> Bool {
try? await Task.sleep(for: .milliseconds(ms))
if Task.isCancelled { return false }
return self.demoMode
}
guard await pause(600) else { return }
self.demoAppend(sessionID, .assistantText(TextChunk(
messageID: UUID().uuidString, text: "On it — let me take a look.", isPartial: false)))
guard await pause(700) else { return }
self.demoAppend(sessionID, .toolCallStarted(ToolCall(
toolCallID: toolID, name: "Bash", input: ["command": "npm test"])))
guard await pause(900) else { return }
self.demoAppend(sessionID, .toolResult(ToolResult(
toolCallID: toolID, content: "42 passing\n0 failing", isError: false)))
guard await pause(700) else { return }
self.demoAppend(sessionID, .assistantText(TextChunk(
messageID: UUID().uuidString,
text: "All green — tests pass and the change is in place. Anything else?",
isPartial: false)))
self.demoAppend(sessionID, .usage(Usage(
inputTokens: 12_400, outputTokens: 640, costUSD: 0.0088, contextInputTokens: 12_400)))
self.demoAppend(sessionID, .runFinished(RunFinished(outcome: .completed, finalText: "Done.")))
self.demoUpdateSession(sessionID) {
$0.demoCopy(status: .awaitingInput, disposition: .some(.completed))
}
}
demoTasks.append(task)
}
private func demoStartChat(_ req: StartChatRequest) {
let project = dashboard.projects.first { $0.id == req.projectID }
let id = SessionID(rawValue: "demo-\(UUID().uuidString.prefix(8))")
let title = String(req.message.prefix(48))
let summary = WireSessionSummary(
sessionID: id, projectID: req.projectID.rawValue,
projectName: project?.name ?? "project", backend: BackendID.forModel(req.model) ?? .claudeCode,
status: .running, disposition: nil, title: title.isEmpty ? "New chat" : title,
branch: "nucleic/\(id.rawValue)", lastSeq: 0, diffStat: nil,
pendingApprovalCount: 0, favorite: false, archived: false,
model: req.model, effort: req.effort, auto: req.auto ?? false,
updatedAt: Date())
sessions.insert(summary, at: 0)
LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions)
demoRun(id, userText: req.message)
}
private func demoResolveApproval(_ id: ApprovalID, _ decision: Decision) {
openApprovals.removeAll { $0.id == id }
NotificationRouter.shared.withdrawApproval(id)
guard let sessionID = openSessionID else { return }
switch decision {
case .deny, .cancelRun:
demoAppend(sessionID, .note(NoteEvent(text: "You denied the command.", icon: "hand.raised")))
demoUpdateSession(sessionID) {
$0.demoCopy(status: .awaitingInput, disposition: .some(.awaitingInput), pendingApprovalCount: 0)
}
case .allow, .allowAlways:
demoAppend(sessionID, .toolResult(ToolResult(
toolCallID: "t-appr", content: "Deployed successfully.", isError: false)))
demoUpdateSession(sessionID) { $0.demoCopy(pendingApprovalCount: 0) }
demoRun(sessionID, userText: nil) // wrap up the turn after the approved tool runs
}
}
private func demoCaptureTodo(_ req: CaptureTodoRequest) {
let project = req.projectID.flatMap { pid in dashboard.projects.first { $0.id == pid } }
let todo = WireTodo(
id: .generate(), text: req.text, summary: nil, projectID: req.projectID,
projectName: project?.name, status: .open, dispatchedSessionID: nil,
triage: nil, updatedAt: Date())
demoMutateTodos { [todo] + $0 }
}
private func demoSetTodoStatus(_ id: TodoID, _ status: TodoStatus) {
demoMutateTodos { todos in
todos.map { todo in
todo.id == id
? WireTodo(id: todo.id, text: todo.text, summary: todo.summary,
projectID: todo.projectID, projectName: todo.projectName,
status: status, dispatchedSessionID: todo.dispatchedSessionID,
triage: todo.triage, updatedAt: Date())
: todo
}
}
}
/// Rebuild the dashboard with a transformed to-do list (its fields are `let`).
private func demoMutateTodos(_ transform: ([WireTodo]) -> [WireTodo]) {
dashboard = DashboardSnapshot(
counts: dashboard.counts, activity: dashboard.activity, projects: dashboard.projects,
todos: transform(dashboard.todos), usage: dashboard.usage, statusFeeds: dashboard.statusFeeds)
}
/// Turn a raw `NWError` string into a short, fixable hint. The common onboarding failures are
/// a denied Local Network permission (EPERM / -65555) and the Mac not listening (refused).
static func friendlyTransportError(_ error: String) -> String {
let lower = error.lowercased()
if lower.contains("denied") || lower.contains("not permitted") || lower.contains("65555") {
return "Can't reach the local network. In Settings ▸ Nucleic, allow Local Network access, then try again."
}
if lower.contains("refused") {
return "Your Mac refused the connection. Check that remote access is still on in Nucleic ▸ Settings."
}
return "Couldn't connect to your Mac — make sure it's on the same Wi‑Fi and remote access is on."
}
/// Show a transient error bubble, replacing any current one; auto-dismisses after 6s
/// (matching the Mac's last-error overlay cadence).
private func showError(_ message: String, sessionID: SessionID?) {
let error = LastError(message: message, sessionID: sessionID)
lastError = error
errorDismissTask?.cancel()
errorDismissTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(6))
guard let self, self.lastError == error else { return }
self.lastError = nil
}
}
func dismissError() {
errorDismissTask?.cancel()
lastError = nil
}
}
extension Data {
/// Same fingerprint scheme as `DeviceIdentity.fingerprint` (first 8 bytes of SHA-256).
var fingerprintHex: String {
DeviceIdentity.fingerprint(ofStaticKey: self)
}
}
extension DashboardSnapshot {
/// Merge every connected Mac's dashboard into one aggregate projection (mesh P3), so Home,
/// Projects, and To-Dos show all your Macs together: projects and to-dos concatenated, activity
/// summed per day, counts summed (active-days recomputed from the merged activity), subscription
/// usage taken from the first Mac that reports it (a user's Macs share one account, so their
/// windows match — summing would double-count), and provider status feeds unioned by provider
/// (provider status is global, not per-Mac).
static func merged(_ snaps: [DashboardSnapshot]) -> DashboardSnapshot {
guard snaps.count != 1 else { return snaps[0] }
guard !snaps.isEmpty else { return .empty }
let projects = snaps.flatMap(\.projects)
let todos = snaps.flatMap(\.todos)
// Activity summed per start-of-day.
var byDay: [Date: (count: Int, tokens: Int)] = [:]
for snap in snaps {
for day in snap.activity {
let key = Calendar.current.startOfDay(for: day.day)
var entry = byDay[key] ?? (0, 0)
entry.count += day.count
entry.tokens += day.tokens
byDay[key] = entry
}
}
let activity = byDay
.map { ActivityDay(day: $0.key, count: $0.value.count, tokens: $0.value.tokens) }
.sorted { $0.day < $1.day }
let counts = DashboardCounts(
projects: projects.count,
chats: snaps.reduce(0) { $0 + $1.counts.chats },
activeChats: snaps.reduce(0) { $0 + $1.counts.activeChats },
messages: snaps.reduce(0) { $0 + $1.counts.messages },
activeDays: activity.filter { $0.count > 0 || $0.tokens > 0 }.count,
tokens: snaps.reduce(0) { $0 + $1.counts.tokens })
let usage = snaps.compactMap(\.usage).first
var seenProviders = Set<String>()
var statusFeeds: [WireStatusFeed] = []
for snap in snaps {
for feed in snap.statusFeeds where seenProviders.insert(feed.provider).inserted {
statusFeeds.append(feed)
}
}
return DashboardSnapshot(
counts: counts, activity: activity, projects: projects,
todos: todos, usage: usage, statusFeeds: statusFeeds)
}
}
extension WireSessionSummary {
/// Recency key for ordering session lists: the last *user* message, falling back to `updatedAt`
/// for sessions with no user message yet (or summaries from a host that predates the field).
/// Ordering on this keeps a chat's position steady until the user speaks again, rather than
/// reshuffling the list on every bit of agent activity (which bumps `updatedAt` every few
/// seconds). Mirrors the Mac sidebar's last-turn ordering.
var lastTurnAt: Date { lastUserMessageAt ?? updatedAt }
/// A copy with selected fields overridden — the demo simulator's only way to "mutate" a
/// summary, whose stored properties are all `let`. Nullable fields use a double optional so a
/// caller can distinguish "keep" (omit) from "set to nil" (`.some(nil)`). `updatedAt` bumps
/// to now unless given. Only the fields the simulator touches are exposed.
func demoCopy(
status: SessionStatus? = nil,
disposition: TurnDisposition?? = nil,
title: String? = nil,
favorite: Bool? = nil,
archived: Bool? = nil,
pendingApprovalCount: Int? = nil,
diffStat: DiffStat?? = nil,
model: String?? = nil,
effort: String?? = nil,
auto: Bool? = nil,
autoShip: Bool? = nil,
shipBranch: String?? = nil,
updatedAt: Date? = nil
) -> WireSessionSummary {
WireSessionSummary(
sessionID: sessionID, projectID: projectID, projectName: projectName, backend: backend,
status: status ?? self.status,
disposition: disposition ?? self.disposition,
title: title ?? self.title, branch: branch, lastSeq: lastSeq,
diffStat: diffStat ?? self.diffStat,
pendingApprovalCount: pendingApprovalCount ?? self.pendingApprovalCount,
favorite: favorite ?? self.favorite,
archived: archived ?? self.archived,
queuedMessage: queuedMessage, queuedMessages: queuedMessages,
model: model ?? self.model, effort: effort ?? self.effort,
auto: auto ?? self.auto, autoShip: autoShip ?? self.autoShip,
shipBranch: shipBranch ?? self.shipBranch,
contextInputTokens: contextInputTokens,
updatedAt: updatedAt ?? Date(),
movedTo: movedTo, arrivedFrom: arrivedFrom)
}
}