Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Models/RemoteStore.swift
T

3525 lines
196 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: "No devices reachable"
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<Void, Never>?
/// The merged mesh-cast ledger (mesh casting) — the activity feed / messages / runner
/// presence from every origin, deduped on cast identity across all connected Macs. Seeded
/// from disk at launch (renders offline, and its tips are the reconnect cursors), persisted
/// debounced on change. Feed/dispatch surfaces read projections of this.
@Published private(set) var castLedger: CastLedger = CastCache.loadLedger()
private var castPersistTask: Task<Void, Never>?
/// The merged fleet feed, oldest-first — the "activity" channel decoded across every origin.
/// Seeded from the ledger on appear (renders offline), appended by `applyCasts`.
@Published private(set) var activityFeed: [ActivityFeedItem] = []
/// When the user last viewed the feed — rows newer than this count as unread.
@Published private(set) var feedLastSeenAt: Date =
UserDefaults.standard.object(forKey: "nucleic.feed.lastSeenAt") as? Date ?? .distantPast
var feedUnreadCount: Int {
activityFeed.reduce(0) { $0 + ($1.event.at > feedLastSeenAt ? 1 : 0) }
}
func markFeedSeen() {
feedLastSeenAt = Date()
UserDefaults.standard.set(feedLastSeenAt, forKey: "nucleic.feed.lastSeenAt")
}
/// In-flight mesh dispatches awaiting their `chatStarted` ack, keyed by requestID.
private var chatStartedWaiters: [String: CheckedContinuation<WireChatStarted?, Never>] = [:]
/// The latest runner-presence card per host (mesh dispatch), decoded from the merged cast
/// ledger's `runner.presence` state channel — the dispatcher's candidate pool.
private var runnerPresenceByHost: [String: RunnerPresence] {
castLedger.stateValues(channel: MeshCastChannel.runnerPresence)
.compactMapValues { RunnerPresence.decode($0) }
}
// MARK: Mesh info (read-only projections for the Mesh Info screen)
/// Live connectivity for a directly-paired Mac, keyed by its `PairedHost.fingerprint` — the
/// same id `connections` is keyed by. `nil` when no connection object exists for it yet.
func connectivity(forPairedHost fingerprint: String) -> Connectivity? {
connections[fingerprint]?.connectivity
}
/// The mesh's runner-presence cards (Macs / cloud runners advertising capacity), sorted by
/// host name — the phone's window into the Hydrangea dispatch candidate pool. Keyed by the full
/// `deviceID` (distinct from `PairedHost.fingerprint`), so it's presented as its own section
/// rather than cross-matched against the directly-paired Macs.
var meshRunnerCards: [(hostID: String, presence: RunnerPresence)] {
runnerPresenceByHost
.map { (hostID: $0.key, presence: $0.value) }
.sorted {
$0.presence.hostName.localizedCaseInsensitiveCompare($1.presence.hostName)
== .orderedAscending
}
}
// MARK: Mesh device roster (per-device connection indicators)
/// How this phone reaches one device on the mesh — the per-device indicator's trailing state.
enum MeshReachability: Equatable {
/// A live direct link *from this phone*; the transport is the connection method.
case connected(SyncTransportHint)
/// Not reached directly, but a device we ARE connected to reports it online — so we reach it
/// by way of that device (its name).
case viaPeer(String)
/// A direct link is being (re)established right now.
case connecting
/// Known to the mesh but not reachable from here right now.
case offline
/// Counts as "on the mesh right now" — drives the green dot and the online tally.
var isOnline: Bool {
switch self {
case .connected, .viaPeer: true
case .connecting, .offline: false
}
}
/// The trailing label: the method for a direct link ("LAN" / "Relay" / "Direct (STUN)"), the
/// bridging device for an indirect one ("via MacBook Pro"), or the pending / offline state.
var detail: String {
switch self {
case .connected(.direct): "Direct (STUN)"
case .connected(let t): t.label
case .viaPeer(let name): "via \(name)"
case .connecting: "Connecting…"
case .offline: "Offline"
}
}
}
/// One device on the mesh as this phone sees it — a Mac or cloud runner it dials directly, a
/// device it only learns of through another connected host, or this device itself. Powers the
/// per-device connection indicators in Settings ▸ Connection and Mesh Info.
struct MeshDevice: Identifiable, Equatable {
let id: String // paired host: fingerprint; peer-only: full deviceID; self: "self"
let name: String
let kind: PeerKind
let reachability: MeshReachability
/// This iPhone/iPad itself — rendered as "This device", never removable.
var isSelf = false
/// A directly-paired host this phone dials — offers per-row unpair.
var isPairedHost = false
}
/// Whether two device identifiers name the same device. `PairedHost.fingerprint` is the first
/// 16 hex of a host's full `deviceID` (sha-256), while peer / runner-card records carry the full
/// id — so a match is "one is a prefix of the other" (≥16 chars, the fingerprint length).
private static func sameDevice(_ a: String, _ b: String) -> Bool {
if a == b { return true }
guard min(a.count, b.count) >= 16 else { return false }
return a.hasPrefix(b) || b.hasPrefix(a)
}
/// The transports this phone currently holds a live link over (deduped, across all hosts) — for
/// the mesh status detail line.
private var liveTransports: [SyncTransportHint] {
var seen = Set<SyncTransportHint>()
var result: [SyncTransportHint] = []
for conn in connections.values {
if let t = conn.connectivity.transport, seen.insert(t).inserted { result.append(t) }
}
return result
}
/// Whether this phone can reach the mesh at all — live if ANY host (a Mac OR a cloud runner) is
/// connected, so a phone that only talks to cloud runners over the relay still counts. Demo is
/// always "connected". This is the mesh-wide truth behind the redesigned Connection headline.
var isMeshConnected: Bool { demoMode || hasLiveConnection }
/// Every device on the mesh with this phone's reachability to it — self first, then online
/// devices (directly connected before reached-via-another), then connecting, then offline;
/// alphabetical within each group. Built entirely from state the phone already holds (the paired
/// registry + each live host's peer list), so it needs no wire change.
var meshDevices: [MeshDevice] {
let selfDevice = MeshDevice(
id: "self", name: deviceName, kind: .iphone,
reachability: isMeshConnected ? .connected(connectivity.transport ?? .lan) : .offline,
isSelf: true)
if demoMode {
let mac = MeshDevice(
id: "demo-mac", name: hostName.isEmpty ? "Demo Mac" : hostName, kind: .mac,
reachability: .connected(.lan), isPairedHost: true)
return [selfDevice, mac]
}
// Kind lookup by full deviceID, gathered from runner-presence cards + every host's roster.
var kindByID: [String: PeerKind] = [:]
for (hostID, presence) in runnerPresenceByHost { kindByID[hostID] = presence.kind }
for conn in connections.values {
for p in conn.meshPeers { kindByID[p.deviceID] = p.kind }
}
func kind(forFingerprint fp: String) -> PeerKind {
kindByID.first { Self.sameDevice($0.key, fp) }?.value ?? .mac
}
// 1. Every host this phone dials directly (Macs + cloud runners), from the paired registry.
var devices: [MeshDevice] = []
var pairedFingerprints: [String] = []
for host in IdentityStore.pairedHosts() {
let fp = host.fingerprint
pairedFingerprints.append(fp)
let reach: MeshReachability
switch connections[fp]?.connectivity {
case .connected(let t)?: reach = .connected(t)
case .connecting?, .reconnecting?: reach = .connecting
default:
// Not reachable directly — but maybe a host we ARE connected to sees it (a Mac behind
// a NAT the phone can't punch, reachable through another Mac): show that route.
reach = viaHostName(forFingerprint: fp).map(MeshReachability.viaPeer) ?? .offline
}
devices.append(MeshDevice(
id: fp, name: host.hostName.isEmpty ? "Mac" : host.hostName,
kind: kind(forFingerprint: fp), reachability: reach, isPairedHost: true))
}
// 2. Devices we only learn of through a connected host (the "via" rows): online peers in some
// live host's roster that we don't dial ourselves, and that aren't this phone.
let myDeviceID = IdentityStore.deviceID()
var addedPeerIDs = Set<String>()
for conn in connections.values where conn.connectivity.isLive {
for p in conn.meshPeers where p.online {
guard p.deviceID != myDeviceID,
!pairedFingerprints.contains(where: { Self.sameDevice($0, p.deviceID) }),
addedPeerIDs.insert(p.deviceID).inserted
else { continue }
devices.append(MeshDevice(
id: p.deviceID, name: p.label.isEmpty ? p.kind.displayName : p.label,
kind: p.kind, reachability: .viaPeer(conn.hostName)))
}
}
func rank(_ r: MeshReachability) -> Int {
switch r {
case .connected: 0
case .viaPeer: 1
case .connecting: 2
case .offline: 3
}
}
let others = devices.sorted {
let (ra, rb) = (rank($0.reachability), rank($1.reachability))
if ra != rb { return ra < rb }
return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
}
return [selfDevice] + others
}
/// The name of a connected host whose roster reports the given (currently-undialed) host online —
/// i.e. we reach that host "by way of" this one. Nil when no connected host sees it.
private func viaHostName(forFingerprint fp: String) -> String? {
for conn in connections.values where conn.connectivity.isLive {
if conn.meshPeers.contains(where: { $0.online && Self.sameDevice($0.deviceID, fp) }) {
return conn.hostName
}
}
return nil
}
// MARK: Mesh status headline (mesh-wide "Connected")
/// The one-line mesh connection state for the Settings headline — "connected to the mesh in
/// general", not to any one Mac. Live if this phone reaches ANY mesh host (a Mac or a cloud
/// runner), so a phone that only talks to cloud runners over the relay still reads "Connected".
var meshStatusHeadline: String {
if isMeshConnected { return "Connected" }
if !isPaired { return "Not paired" }
switch connectivity {
case .connecting: return "Connecting…"
case .reconnecting: return "Reconnecting…"
default: return "Not connected"
}
}
/// The mesh headline's supporting detail: how many devices are reachable and over what transport,
/// or why nothing is.
var meshStatusDetail: String {
if demoMode { return "Demo mesh" }
if !isPaired { return "Scan a Mac's QR code to join a mesh" }
if isMeshConnected {
let others = meshDevices.filter { !$0.isSelf }
let online = others.filter { $0.reachability.isOnline }.count
let count = "\(online) of \(others.count) device\(others.count == 1 ? "" : "s") online"
let transports = liveTransports.map(\.label).sorted().joined(separator: ", ")
return transports.isEmpty ? count : "\(count) · \(transports)"
}
switch connectivity {
case .connecting, .reconnecting: return "Reaching your mesh…"
default: return "No mesh devices reachable"
}
}
/// Whether the composer should offer "Auto (Mesh)" for the project the phone has selected:
/// some connected host advertising `canAcknowledgeDispatch` (other than the project's own
/// owner) holds a matching repo. The descriptor comes from the owning host's presence card.
func meshDispatchAvailable(forProject projectID: ProjectID) -> Bool {
meshDispatchCandidates(forProject: projectID) != nil
}
/// Resolve the (descriptor, candidates) for a mesh dispatch, or nil when it isn't offerable.
private func meshDispatchCandidates(
forProject projectID: ProjectID
) -> (descriptor: ProjectDescriptor, candidates: [MeshDispatchScorer.Candidate])? {
let presences = runnerPresenceByHost
// The chosen project belongs to one host; find its descriptor from that host's card.
guard let owner = connection(owningProject: projectID)?.hostID,
let descriptor = presences[owner]?.projects.first(where: { $0.projectID == projectID })
else { return nil }
var candidates: [MeshDispatchScorer.Candidate] = []
for (hostID, presence) in presences {
guard let conn = connections[hostID] else { continue }
candidates.append(MeshDispatchScorer.Candidate(
hostID: hostID, presence: presence,
live: conn.connectivity.isLive,
canAcknowledgeDispatch: conn.capabilities.canAcknowledgeDispatch))
}
// Offer only when at least one eligible host actually holds the repo.
guard !MeshDispatchScorer.rank(
candidates: candidates, project: descriptor, backend: nil).isEmpty
else { return nil }
return (descriptor, candidates)
}
/// Dispatch a new chat to the abstract **Hydrangea** destination from the phone: rank eligible
/// hosts by the shared scorer and try them in order, one at a time with a correlated,
/// idempotent ack (a timeout retries the same candidate once before moving on). On success
/// routes to the landed session. Mirrors `AppStore.dispatchChatToMesh`. The phone stamps
/// itself as the Hydrangea origin, so the landed session is Hydrangea-managed (its owner keeps it
/// on the optimal host between turns); the phone can't own sessions, so the origin exclusion
/// is naturally satisfied by every candidate.
/// `intelligence` rides along exactly as it does for a direct start — and matters more here:
/// the accepting host resolves the stop against *its own* providers and quota, which are the
/// ones the chat will actually run on, so shipping a stop rather than a pair is what lets a
/// dispatch land well on a host the phone knows nothing about.
func dispatchChatToMesh(
projectID: ProjectID, message: String, model: String? = nil,
effort: String? = nil, auto: Bool? = nil, intelligence: Int? = nil
) {
let text = message.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty, let (descriptor, candidates) =
meshDispatchCandidates(forProject: projectID) else { return }
Task { @MainActor in
var excluded: Set<String> = []
var tried = 0
while true {
guard let pick = MeshDispatchScorer.rank(
candidates: candidates, project: descriptor,
backend: nil, excluding: excluded).first
else {
showError(tried == 0
? "No Hydrangea host can take this chat right now."
: "No Hydrangea host could take this chat (\(tried) tried).", sessionID: nil)
return
}
tried += 1
// The phone hands over no clone URL, so every pick is a holder (non-nil id).
guard let targetProjectID = pick.projectID,
let conn = connections[pick.hostID], conn.connectivity.isLive else {
excluded.insert(pick.hostID); continue
}
let requestID = UUID().uuidString
let request = StartChatRequest(
projectID: targetProjectID, message: text, model: model, effort: effort,
auto: auto, requestID: requestID,
hydrangeaOriginDeviceID: IdentityStore.deviceID(),
intelligence: intelligence)
var outcome = await sendDispatch(request, on: conn, timeout: .seconds(10))
if outcome == nil {
outcome = await sendDispatch(request, on: conn, timeout: .seconds(5))
}
if let sessionID = outcome?.sessionID {
// The session lives on the target host; refresh its list and route to it.
conn.send(.listSessions)
conn.send(.listDashboard)
route(to: sessionID)
return
}
if let failure = outcome?.error {
showError("\(conn.hostName): \(failure)", sessionID: nil)
}
excluded.insert(pick.hostID)
}
}
}
private func sendDispatch(
_ request: StartChatRequest, on conn: HostConnection, timeout: Duration
) async -> WireChatStarted? {
guard let requestID = request.requestID else { return nil }
conn.send(.startChat(request))
return await withCheckedContinuation { continuation in
chatStartedWaiters[requestID] = continuation
Task { @MainActor in
try? await Task.sleep(for: timeout)
self.chatStartedWaiters.removeValue(forKey: requestID)?.resume(returning: nil)
}
}
}
/// Apply freshly received casts (live or catch-up) and persist if anything changed.
private func applyCasts(_ apply: (inout CastLedger) -> [WireCast]) {
var ledger = castLedger
let fresh = apply(&ledger)
guard !fresh.isEmpty else { return }
castLedger = ledger
activityFeed = ActivityFeedItem.merged(activityFeed, adding: fresh)
updateComposerTyping(fresh)
castPersistTask?.cancel()
castPersistTask = Task { [ledger] in
try? await Task.sleep(for: .milliseconds(500))
guard !Task.isCancelled else { return }
await CastCache.save(ledger)
}
}
// MARK: - Live composer streaming (the "composer.typing" channel)
/// The session's shared composer draft as other devices left it (mesh composer streaming) —
/// folded from `composer.typing` casts, *other* devices only (this phone's own typing,
/// republished by the session's host, is skipped so it never locks itself or re-adopts its
/// own text). An `editing` entry locks the session detail's composer and renders the
/// incoming draft above the field; a settled one moves into it. Settle-guarded locally too,
/// so an editor that vanished can't leave the composer locked — and its text is kept.
@Published private(set) var composerTypingBySession: [SessionID: ComposerTypingState] = [:]
/// Watcher-side settle timers for `composerTypingBySession` (the crash guard).
private var composerExpiryTasks: [SessionID: Task<Void, Never>] = [:]
/// Sender-side throttle + idle bookkeeping for streaming THIS device's drafts: the
/// trailing-edge send, the last send time, the newest not-yet-sent draft, the newest draft
/// known (retained past the send, so the idle settle can report the final text), the idle
/// timer, and which sessions have an active report out (so a settle reports exactly once).
private var composerSendTasks: [SessionID: Task<Void, Never>] = [:]
private var composerLastSentAt: [SessionID: Date] = [:]
private var composerPendingText: [SessionID: String] = [:]
private var composerLatestText: [SessionID: String] = [:]
private var composerIdleTasks: [SessionID: Task<Void, Never>] = [:]
private var composerStreamingSessions: Set<SessionID> = []
/// The composer's draft for `sessionID` changed on this phone — report it (throttled) to
/// the session's host, which republishes it on the `composer.typing` channel for the rest
/// of the mesh, and (re)arm the idle settle. An emptied field clears the shared draft
/// mesh-wide immediately. A no-op toward a host that never advertised `canStreamComposer`
/// (an older host would throw on the unknown tag).
func composerDraftChanged(_ sessionID: SessionID, text: String) {
guard !demoMode else { return }
guard !text.isEmpty else { composerDraftCleared(sessionID); return }
guard let conn = connection(owningSession: sessionID), conn.connectivity.isLive,
conn.capabilities.canStreamComposer else { return }
composerPendingText[sessionID] = text
composerLatestText[sessionID] = text
scheduleComposerIdleStop(sessionID)
guard composerSendTasks[sessionID] == nil else { return } // trailing edge armed
let elapsed = Date().timeIntervalSince(composerLastSentAt[sessionID] ?? .distantPast)
let delay = max(0, ComposerTypingState.minPublishInterval - elapsed)
composerSendTasks[sessionID] = Task { [weak self] in
if delay > 0 { try? await Task.sleep(for: .seconds(delay)) }
guard !Task.isCancelled else { return }
self?.flushComposerDraft(sessionID)
}
}
/// Editing stopped on this phone but the text stands — it went idle, or the detail closed.
/// Reports the final draft with `active: false`, which settles the session's entry: every
/// composer keeps that text and unlocks. (The host also idle-settles on its own after
/// `idleTimeout`, so a phone that vanishes still unlocks everyone.)
///
/// A no-op when this phone never claimed the editing lock — a chat whose shared draft it
/// merely adopted is left exactly as it found it, not settled *or* cleared.
func composerDraftEnded(_ sessionID: SessionID) {
cancelComposerSendWork(sessionID)
let text = composerLatestText.removeValue(forKey: sessionID) ?? ""
guard composerStreamingSessions.remove(sessionID) != nil else { return }
connection(owningSession: sessionID)?.send(
.composerTyping(WireComposerTyping(sessionID: sessionID, text: text, active: false)))
}
/// The shared draft was consumed on this phone — sent, or the field emptied by hand. Clears
/// the session's entry mesh-wide so every other composer empties too. Reported even when
/// this phone never streamed anything: it may be consuming a draft it merely adopted.
func composerDraftCleared(_ sessionID: SessionID) {
guard !demoMode else { return }
cancelComposerSendWork(sessionID)
composerLatestText[sessionID] = nil
let held = composerTypingBySession.removeValue(forKey: sessionID) != nil
composerExpiryTasks.removeValue(forKey: sessionID)?.cancel()
let streamed = composerStreamingSessions.remove(sessionID) != nil
guard streamed || held else { return }
guard let conn = connection(owningSession: sessionID), conn.connectivity.isLive,
conn.capabilities.canStreamComposer else { return }
conn.send(.composerTyping(WireComposerTyping(sessionID: sessionID, text: "", active: false)))
}
/// Drop the throttle's in-flight work for a session (shared by settle and clear).
private func cancelComposerSendWork(_ sessionID: SessionID) {
composerSendTasks.removeValue(forKey: sessionID)?.cancel()
composerPendingText[sessionID] = nil
composerIdleTasks.removeValue(forKey: sessionID)?.cancel()
composerLastSentAt[sessionID] = nil
}
/// One throttle-window flush: report the newest pending draft to the session's host.
private func flushComposerDraft(_ sessionID: SessionID) {
composerSendTasks[sessionID] = nil
guard let text = composerPendingText.removeValue(forKey: sessionID),
let conn = connection(owningSession: sessionID), conn.connectivity.isLive
else { return }
composerLastSentAt[sessionID] = Date()
composerStreamingSessions.insert(sessionID)
conn.send(.composerTyping(WireComposerTyping(sessionID: sessionID, text: text, active: true)))
}
/// Re-arm the editor-side idle settle: `idleTimeout` with no keystrokes hands the text to
/// every composer and unlocks them.
private func scheduleComposerIdleStop(_ sessionID: SessionID) {
composerIdleTasks[sessionID]?.cancel()
composerIdleTasks[sessionID] = Task { [weak self] in
try? await Task.sleep(for: .seconds(ComposerTypingState.idleTimeout))
guard !Task.isCancelled else { return }
self?.composerDraftEnded(sessionID)
}
}
/// Fold composer-typing casts into the watcher projection: a fresh `editing` entry from
/// another device locks (and renders above) that session's composer; a settled one hands
/// its text to the composer and unlocks; a tombstone empties it.
private func updateComposerTyping(_ casts: [WireCast]) {
for cast in casts where cast.channel == MeshCastChannel.composerTyping {
guard let state = ComposerTypingState.decode(cast) else { continue }
// This phone's own entries (republished by the session's host under its deviceID)
// never touch its own composer — it already holds what it typed.
guard state.deviceID != IdentityStore.deviceID() else { continue }
guard !cast.deleted else {
// The draft was consumed or emptied somewhere in the mesh. Authoritative from
// whichever device did it — any holder may consume a shared draft.
composerTypingBySession[state.sessionID] = nil
composerExpiryTasks.removeValue(forKey: state.sessionID)?.cancel()
continue
}
if state.locksComposers(at: Date()) {
composerTypingBySession[state.sessionID] = state
scheduleComposerExpiry(state.sessionID, publishedAt: state.at)
} else {
// Settled — or an `editing` entry so old it's plainly abandoned (a catch-up
// replay), which is the same thing: keep the text, lock nobody.
composerTypingBySession[state.sessionID] = state.settled()
composerExpiryTasks.removeValue(forKey: state.sessionID)?.cancel()
}
}
}
/// Arm (or re-arm) the watcher-side settle for a session's editing entry — the crash guard
/// for an editor that went silent without publishing its own settle. Its text is kept (it
/// moves into the composer, as a published settle would); only the lock is released.
private func scheduleComposerExpiry(_ sessionID: SessionID, publishedAt: Date) {
composerExpiryTasks[sessionID]?.cancel()
let deadline = publishedAt.addingTimeInterval(ComposerTypingState.staleTimeout)
let delay = max(0, deadline.timeIntervalSinceNow)
composerExpiryTasks[sessionID] = Task { [weak self] in
try? await Task.sleep(for: .seconds(delay))
guard !Task.isCancelled, let self else { return }
guard let entry = self.composerTypingBySession[sessionID],
entry.editing, !entry.isFresh(at: Date()) else { return }
self.composerTypingBySession[sessionID] = entry.settled()
self.composerExpiryTasks[sessionID] = nil
}
}
private var transcriptPersistTask: Task<Void, Never>?
@Published private(set) var capabilities = WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])
@Published private(set) var grantedScope: DeviceScope = .approve
/// The account-level synced settings (`SyncedSettings`) — the host is the source of truth; this
/// mirrors it. Seeded from a local `UserDefaults` cache so it's correct even before any host
/// connects (a cold background App Intent reads the same key directly via
/// `SyncedSettings.resolveApprovalsViaCloud(from:)`), then replaced by `Welcome.settings` /
/// `HostMsg.settings`. Drives the Settings toggle and the Live-Activity relay-first routing.
@Published private(set) var syncedSettings: SyncedSettings =
SyncedSettings(resolveApprovalsViaCloud:
UserDefaults.standard.bool(forKey: SyncedSettings.resolveApprovalsViaCloudKey))
/// 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
/// The context host's Intelligence ladder + resolved routes (SYNC §5.2), driving the
/// composers' rail and its route line. `.empty` until a routing host welcomes us — the
/// composers then fall back to the manual model/effort pickers, exactly as the Mac does with
/// the slider switched off.
@Published private(set) var intelligenceCatalog: WireIntelligenceCatalog = .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] = []
/// Stall ids the user acted on locally (Kill / Keep-waiting) before the host's authoritative
/// resolution note has arrived — an optimistic set so the alert row retires its buttons
/// immediately. The host's resolution note (carried in `openEvents`) is the durable signal;
/// `resolvedStalls` unions the two. Mirrors the Mac's `AppStore.resolvedStalls`.
@Published private var locallyResolvedStalls: Set<String> = []
/// Stall ids whose "host command looks hung" alert has been closed out — resolved locally
/// (optimistic) or by a resolution note (`ProcessStallNote.resolution != nil`) in the open
/// transcript. The transcript's alert row reads this to retire its Kill / Keep-waiting buttons
/// once the alert is done. Scans only the open transcript, and only a (rare) alert row reads it.
var resolvedStalls: Set<String> {
var ids = locallyResolvedStalls
for event in openEvents {
if case .note(let note) = event.kind, let stall = note.processStall,
stall.resolution != nil {
ids.insert(stall.id)
}
}
return ids
}
/// 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 Hydrangea 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?
/// Remote agent sign-in (docs/REMOTE_AGENT_LOGIN.md): the phone asks a host — a Mac or a
/// cloud runner — to run the Claude/Codex OAuth flow; the phone contributes the browser and
/// the redirect capture (its own loopback, or a pasted code), the host everything secret.
enum AgentLoginState: Equatable {
case idle
/// `agentLoginBegin` sent; waiting on the host's challenge.
case starting
/// Consent page presented; the loopback listener is armed and will auto-capture.
case browser(URL)
/// Consent page presented; the vendor's page renders a code the user pastes back.
case pasteCode(URL)
/// Captured code sent to the host; waiting on the exchange outcome.
case finishing
/// The attempt's definitive outcome (success, or the host's failure message).
case done(success: Bool, message: String?)
}
@Published private(set) var agentLogin: AgentLoginState = .idle
/// Which provider/host the in-flight attempt targets (drives sheet labels).
@Published private(set) var agentLoginProvider: AgentLoginProvider?
private(set) var agentLoginHostID: String?
/// The in-flight attempt's id — replies are matched on it, so a stale challenge/result
/// (user dismissed and restarted) can't settle the wrong attempt.
private var agentLoginRequestID: String?
/// The armed redirect listener (bound BEFORE `agentLoginBegin`, since the begin carries the
/// bound port). Stopped on every terminal transition.
private var agentLoginListener: OAuthRedirectListener?
/// Per-session high-water seq below which a tail auth failure is considered resolved by a
/// completed sign-in. Anchored at the transcript's tip by `resolveAuthBanners` the moment a
/// sign-in for that session's provider completes — this phone's own attempt
/// (`agentLoginResultReceived`) or one observed landing on a host, which is how a login the
/// user finished on *another* device reaches this banner — so the in-chat "Sign in" banner
/// clears immediately, without waiting for a fresh run to push a non-error event onto the
/// tail. A genuinely newer auth failure (a token that lapses again) carries a higher seq, so
/// it re-arms the banner.
/// Keyed by session because seqs are per-session; this is the *observable* dismissal signal,
/// deliberately not tied to credential *presence* (an expired-but-present login still reads as
/// "authenticated", so presence can't tell a stale token from a fresh sign-in).
@Published private(set) var authResolvedSeqBySession: [SessionID: UInt64] = [:]
/// 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)) }
}
// 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<Void, Never>?
/// 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, .markSessionDone, .deleteSession,
.discard, .integrate,
.interrupt, .cancelQueuedMessage, .setSessionModel, .setSessionEffort, .setSessionAuto,
.setSessionAutoShip, .setSessionShipBranch, .approvalRespond, .resolveProcessStall:
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)
}
// MARK: - Composer drafts (durable)
// Unsent composer text, kept until the user sends or clears the field. Unlike the ephemeral
// mesh typing buffer (`composerPendingText`, which is deliberately never persisted), these
// survive backgrounding, locking, view teardown, and a relaunch — so switching apps or
// locking the phone mid-message never loses what was typed. Persisted eagerly on every edit
// (small strings) so no scene-phase hook is needed to capture the latest value. Keyed by
// `SessionID.rawValue` for the in-session follow-up composer, and by project (or "" for the
// no-project picker) for the new-chat composer.
private static let sessionDraftsKey = "nucleic.composerDrafts"
private static let newChatDraftsKey = "nucleic.newChatDrafts"
private var sessionDrafts: [SessionID: String] = RemoteStore.loadSessionDrafts()
private var newChatDrafts: [String: String] = RemoteStore.loadNewChatDrafts()
private static func loadSessionDrafts() -> [SessionID: String] {
guard let raw = UserDefaults.standard.dictionary(forKey: sessionDraftsKey) else { return [:] }
return raw.reduce(into: [:]) { result, entry in
if let text = entry.value as? String { result[SessionID(rawValue: entry.key)] = text }
}
}
private static func loadNewChatDrafts() -> [String: String] {
UserDefaults.standard.dictionary(forKey: newChatDraftsKey) as? [String: String] ?? [:]
}
/// The saved follow-up draft for a session's composer, or "" if none.
func sessionDraft(_ id: SessionID) -> String { sessionDrafts[id] ?? "" }
/// Persist a session composer's draft — or drop it once the field is emptied, so a cleared
/// composer restores empty rather than reviving stale text.
func setSessionDraft(_ id: SessionID, _ text: String) {
if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
guard sessionDrafts[id] != nil else { return }
sessionDrafts[id] = nil
} else {
sessionDrafts[id] = text
}
let raw = sessionDrafts.reduce(into: [String: String]()) { $0[$1.key.rawValue] = $1.value }
UserDefaults.standard.set(raw, forKey: Self.sessionDraftsKey)
}
private func newChatKey(_ project: ProjectID?) -> String { project?.rawValue ?? "" }
/// The saved new-chat prompt for a project scope (or the no-project picker), or "" if none.
func newChatDraft(_ project: ProjectID?) -> String { newChatDrafts[newChatKey(project)] ?? "" }
/// Persist the new-chat prompt for a project scope — or drop it once the field is emptied.
func setNewChatDraft(_ project: ProjectID?, _ text: String) {
let key = newChatKey(project)
if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
guard newChatDrafts[key] != nil else { return }
newChatDrafts[key] = nil
} else {
newChatDrafts[key] = text
}
UserDefaults.standard.set(newChatDrafts, forKey: Self.newChatDraftsKey)
}
/// 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 }
var deviceName: String { IdentityStore.deviceName() }
// MARK: - Lifecycle
func onAppear() {
setupLiveActivityBridge()
if demoMode { seedDemo(); return }
// Seed the phone-vault mirror (what this phone can credential a runner with).
refreshPhoneVaultKinds()
startNetworkingIfNeeded()
if isPaired {
// Show the saved chat history immediately, before any host connects.
if sessions.isEmpty { sessions = cachedSummaries }
// …and the cached fleet feed (mesh casting) — live catch-up merges on top.
if activityFeed.isEmpty {
activityFeed = ActivityFeedItem.merged([], adding: castLedger.allCasts)
}
// 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()` establishes Relay first when available, so it avoids retrying the LAN link that
/// just failed and becomes usable 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,
canMarkSessionDone: true, canRouteIntelligence: 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")
intelligenceCatalog = Self.demoIntelligenceCatalog
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.openReverted = { [weak self] sessionID, throughSeq in
guard let self, hostID == self.openSessionHostID, sessionID == self.openSessionID else { return }
// A revert/undo on the owner truncated the transcript. Drain buffered deltas first (they
// belong to the transcript being truncated), then drop every event past the new tip —
// the one path that *shrinks* the on-screen transcript rather than growing it.
self.drainPendingOpenEvents()
self.openEvents = self.openEvents.filter { $0.seq <= throughSeq }
if self.openEvents.isEmpty {
// A full revert cleared the transcript — drop the cache too, else a later
// reopen-from-cache (offline) would resurrect the reverted events via the grow-only
// seed merge (`saveEvents` refuses an empty write, so it can't clear it for us).
SessionCache.removeEvents(for: sessionID)
} else {
self.persistOpenTranscript()
}
}
cb.transcriptReverted = { [weak self] reverted in
guard let self else { return }
// The transcript shrank — clear the prefetch watermark so the cache re-warms once
// the owner regrows, instead of waiting for the tip to pass its pre-revert high.
self.prefetchedSeq[reverted.sessionID] = nil
guard let epoch = reverted.revertEpoch else { return }
// The live revert path (openReverted / HostConnection's cache truncate) converges
// this device precisely. Record the epoch it converged to so the summary-level
// missed-revert heal (`healMissedReverts`) recognizes the bumped epoch in the next
// session list as already applied instead of re-dropping the whole cache.
self.appliedRevertEpochs[reverted.sessionID] = epoch
}
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.castCursors = { [weak self] in
// Mesh casting: subscribe with the MERGED ledger's tips (every origin), so this host
// serves only the gaps — including gaps for origins it merely mirrors.
self?.castLedger.cursors ?? []
}
cb.castsReceived = { [weak self] casts in
self?.applyCasts { $0.apply(casts) }
}
cb.castCatchUp = { [weak self] batch in
self?.applyCasts { $0.applyCatchUp(batch) }
}
cb.chatStarted = { [weak self] outcome in
// Mesh dispatch: resume the waiting `dispatchChatToMesh` by requestID.
self?.chatStartedWaiters.removeValue(forKey: outcome.requestID)?.resume(returning: outcome)
}
cb.credentialsChanged = { [weak self] in
// The phone vault changed under this connection (a rotation landed / a mesh-wide
// deletion cleared a kind) — refresh the held-kinds mirror Settings shows.
self?.refreshPhoneVaultKinds()
}
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")
}
}
cb.settingsChanged = { [weak self] settings in
// Account-level truth from a host (`Welcome.settings` or a `HostMsg.settings` broadcast).
// Every paired Mac reports the same value, so last-writer-wins is correct here.
self?.adoptSyncedSettings(settings)
}
cb.agentLoginChallenge = { [weak self] challenge in
// Remote agent sign-in: the host built the consent URL for this phone's attempt —
// only the host we asked can answer (correlated by requestID inside).
guard let self, hostID == self.agentLoginHostID else { return }
self.agentLoginChallengeReceived(challenge)
}
cb.agentLoginResult = { [weak self] result in
guard let self, hostID == self.agentLoginHostID else { return }
self.agentLoginResultReceived(result)
}
cb.agentAuthRefreshed = { [weak self] providers in
// A sign-in landed on that host without this phone driving it — the user finished the
// login on their Mac, or on another device that shares the mesh credential. Resolve
// the in-chat banner the same way this phone's own sign-in does; from any host,
// because a landed credential syncs to every member (and if some host somehow misses
// it, that host's next run fails again and re-arms the banner at a newer seq).
self?.resolveAuthBanners(for: providers)
}
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 {
// Before adopting the fresh list, compare it against the last summaries we held
// (seeded from disk on a cold launch): a session whose revert epoch moved while
// we weren't receiving the live `.transcriptReverted` — offline across a revert —
// has a cache that may hold pre-revert content at reused seqs, which every merge
// path here is too grow-only to ever fix. Heal it now, before the stale summaries
// are overwritten.
healMissedReverts(previous: cachedSummaries, incoming: 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)
setIfChanged(\.intelligenceCatalog, ctx?.intelligenceCatalog ?? .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<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)
/// Revert epochs already applied via the live `.transcriptReverted` path, per session — so
/// `healMissedReverts` can tell a revert this device truncated precisely (connected) from one
/// it slept through (offline), and only nukes the cache for the latter. In-memory only: a
/// relaunch that lost this map at worst re-drops a cache the prefetcher re-warms.
private var appliedRevertEpochs: [SessionID: UInt64] = [:]
/// Detect reverts this device missed and drop what they invalidated. A revert on the owner
/// resets the transcript's seq counter, so post-revert turns reuse dropped seqs with
/// *different* content; every merge here (seed, snapshot, live, backfill, prefetch) dedupes
/// on seq and only grows — a phone offline across the revert would keep the stale events
/// forever. The owner's `SessionSummary.revertEpoch` moving against the summary we last held
/// is the tip-independent signal: drop the cached transcript, requalify the prefetch, and —
/// if the session is on screen — rebuild the open transcript from the host cold. A legacy
/// host (no epoch) still gets the tip-regression heal: a `lastSeq` below what we held means
/// at least the tail is gone, so shrink the cache to it.
private func healMissedReverts(previous: [WireSessionSummary], incoming: [WireSessionSummary]) {
guard !previous.isEmpty else { return }
let prevByID = Dictionary(previous.map { ($0.sessionID, $0) }, uniquingKeysWith: { a, _ in a })
for summary in incoming {
guard let prev = prevByID[summary.sessionID] else { continue }
if let epoch = summary.revertEpoch,
appliedRevertEpochs[summary.sessionID] != epoch,
prev.revertEpoch.map({ $0 != epoch }) ?? (epoch > 0) {
// Missed revert (or a pre-epoch cached summary we can't vouch for on an
// ever-reverted session): the cache may hold another generation's content.
appliedRevertEpochs[summary.sessionID] = epoch
SessionCache.removeEvents(for: summary.sessionID)
prefetchedSeq[summary.sessionID] = nil
if summary.sessionID == openSessionID {
resubscribeOpenSessionCold(summary.sessionID)
}
} else if summary.revertEpoch == nil, summary.lastSeq < prev.lastSeq {
// Legacy host: no epoch to compare, but the tip regressed below what we held —
// drop at least the now-dropped tail (reused seqs below the new tip are not
// detectable without epochs).
SessionCache.truncateEvents(for: summary.sessionID, throughSeq: summary.lastSeq)
prefetchedSeq[summary.sessionID] = nil
if summary.sessionID == openSessionID {
drainPendingOpenEvents()
openEvents = openEvents.filter { $0.seq <= summary.lastSeq }
}
}
}
}
/// Rebuild the open session's transcript from the host, cold — after a missed revert made
/// everything we hold for it (screen, buffers, connection cursors, cache) untrustworthy.
/// Mirrors `open()`'s reset without touching navigation: clear the view state, reset the
/// owning connection's per-session cursor/dedup/fetch state (the `openSessionID` didSet),
/// then re-subscribe with no cursor and re-pull the full history.
private func resubscribeOpenSessionCold(_ sessionID: SessionID) {
discardPendingOpenEvents()
openEvents = []
guard let conn = connection(owningSession: sessionID) else { return }
conn.openSessionID = nil
conn.openSessionID = sessionID
conn.send(.subscribe(Subscribe(sessionID: sessionID, sinceSeq: nil, verbosity: .full)))
conn.fetchFullTranscript(sessionID)
}
/// 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.
var 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
}
if cachedLast > summary.lastSeq {
// The host's tip is *below* our cache — a revert we missed. This is not "already
// current": the host reuses the dropped seqs with different content, so the whole
// cached copy is suspect (a legacy host without revert epochs never triggers the
// epoch heal, and reading "ahead of the host" as current was what let the stale
// copy live forever). Drop it and re-pull from scratch.
SessionCache.removeEvents(for: id)
cachedLast = 0
}
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 mesh host at once (mesh P3 multiplexer): each `HostConnection` runs its own
/// IK reconnect (direct-first — LAN/tailnet — with Relay as the immediate fallback, backoff on
/// failure), so all your Macs and cloud runners 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 hosts are dropped. The launch + "Reconnect" path.
/// `preferRelay` is a source-compatible hint for App-Intent / quick-approve callers that forces
/// the Relay baseline first (see `HostConnection.buildCandidates`).
func reconnect(preferRelay: Bool = false) {
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, preferRelay: preferRelay) }
}
rebuildAggregate()
refreshAggregate()
}
/// Rename this phone in the mesh. Every live connection is deliberately re-handshaken so each
/// Mac updates its paired-device record and republishes the new label to the rest of the mesh.
@discardableResult
func renameDevice(to name: String) -> Bool {
guard IdentityStore.setDeviceName(name) else { return false }
guard !demoMode else { return true }
for host in IdentityStore.pairedHosts() {
connection(for: host).reconnect(to: host)
}
rebuildAggregate()
refreshAggregate()
return true
}
/// 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()
castLedger = CastLedger()
activityFeed = []
CastCache.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
}
// MARK: - Remote agent sign-in (docs/REMOTE_AGENT_LOGIN.md)
/// Hosts that can broker an agent sign-in right now, with their per-provider status — the
/// Agent Accounts list. Name-sorted for stability.
var agentAccountHosts: [(hostID: String, name: String, statuses: [WireProviderAuthStatus])] {
connections.values
.filter { $0.connectivity.isLive && !$0.agentAuthStatuses.isEmpty }
.map { ($0.hostID, $0.hostName, $0.agentAuthStatuses) }
.sorted { $0.1 < $1.1 }
}
/// The seq at/below which `sessionID`'s tail auth failure has been resolved by a completed
/// sign-in — 0 (nothing resolved yet) until an agent login succeeds while that session is open.
/// The in-chat banner shows only for an auth failure *newer* than this.
func authResolvedSeq(forSession sessionID: SessionID) -> UInt64 {
authResolvedSeqBySession[sessionID] ?? 0
}
/// Begin a provider sign-in brokered by `hostID`. Binds the redirect listener FIRST (the
/// begin message carries the bound port): Codex's OAuth client only permits the fixed
/// `localhost:1455`, Claude accepts any port — and no port at all falls back to Claude's
/// paste-a-code capture, which the host chooses when the begin carries none.
func beginAgentLogin(provider: AgentLoginProvider, onHost hostID: String) {
guard case .idle = agentLogin else { return }
guard let conn = connections[hostID], conn.connectivity.isLive,
conn.capabilities.canBrokerAgentLogin
else {
agentLogin = .done(success: false, message: "That host can't sign in right now.")
return
}
let desiredPort: UInt16? = provider == .codex ? 1455 : nil
let listener = OAuthRedirectListener.start(port: desiredPort)
let requestID = UUID().uuidString
agentLoginRequestID = requestID
agentLoginHostID = hostID
agentLoginProvider = provider
agentLoginListener = listener
agentLogin = .starting
conn.send(.agentLoginBegin(WireAgentLoginBegin(
requestID: requestID, provider: provider, boundLoopbackPort: listener?.port)))
}
/// Begin against the best host for `sessionID`'s backend — the in-chat "Sign in" CTA on an
/// auth failure. Prefers the session's own host (that's where the turn will retry).
func beginAgentLogin(forSession sessionID: SessionID) {
guard let session = sessions.first(where: { $0.sessionID == sessionID }),
let provider = AgentLoginProvider.forBackend(session.backend)
else { return }
let host = connection(owningSession: sessionID)
?? connections.values.first { $0.connectivity.isLive && $0.capabilities.canBrokerAgentLogin }
guard let host else {
agentLogin = .done(success: false, message: "No connected host can sign in right now.")
return
}
beginAgentLogin(provider: provider, onHost: host.hostID)
}
/// The user pasted the vendor's `code#state` blob (Claude's console fallback) — forward it.
func submitPastedLoginCode(_ code: String) {
let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
sendAgentLoginCallback(code: trimmed, state: nil)
}
/// The user dismissed the sign-in sheet (or it expired) — tell the host to discard the
/// attempt's PKCE state and reset for the next open. Safe in any state; after a terminal
/// result there's nothing host-side left to discard, so no cancel rides the wire.
func cancelAgentLogin() {
let resolved: Bool
if case .done = agentLogin { resolved = true } else { resolved = false }
if !resolved, let id = agentLoginRequestID, let hostID = agentLoginHostID {
connections[hostID]?.send(.agentLoginCancel(id))
}
agentLoginListener?.stop()
agentLoginListener = nil
agentLoginRequestID = nil
agentLoginHostID = nil
agentLoginProvider = nil
agentLogin = .idle
}
/// The host's consent challenge arrived (correlated by requestID): open the URL and capture
/// as instructed — the armed loopback for `.loopback`, a paste field for `.pasteCode`.
fileprivate func agentLoginChallengeReceived(_ challenge: WireAgentLoginChallenge) {
guard challenge.requestID == agentLoginRequestID,
let url = URL(string: challenge.authorizeURL)
else { return }
switch challenge.capture.mode {
case AgentLoginCapture.loopbackMode:
guard let listener = agentLoginListener, listener.port == challenge.capture.port else {
// The host echoed a port we no longer hold — unrecoverable for this attempt.
cancelAgentLogin()
agentLogin = .done(success: false, message: "The sign-in listener was lost — try again.")
return
}
agentLogin = .browser(url)
let timeout = max(30, challenge.expiresAt.timeIntervalSinceNow)
Task { [weak self] in
guard let callback = await listener.waitForCallback(timeout: timeout) else { return }
self?.agentLoginCaptured(callback)
}
case AgentLoginCapture.pasteCodeMode:
agentLoginListener?.stop()
agentLoginListener = nil
agentLogin = .pasteCode(url)
default:
// A capture shape this app predates — fail gracefully rather than hang the sheet.
cancelAgentLogin()
agentLogin = .done(
success: false, message: "This host needs a newer version of the app.")
}
}
private func agentLoginCaptured(_ callback: OAuthRedirectListener.Callback) {
// Only meaningful while the browser capture is armed; a late redirect after
// cancel/paste is dropped.
guard case .browser = agentLogin else { return }
sendAgentLoginCallback(code: callback.code, state: callback.state)
}
private func sendAgentLoginCallback(code: String, state: String?) {
guard let id = agentLoginRequestID, let hostID = agentLoginHostID,
let conn = connections[hostID]
else { return }
agentLogin = .finishing
conn.send(.agentLoginCallback(WireAgentLoginCallback(
requestID: id, code: code, state: state)))
}
/// The attempt's definitive outcome (correlated by requestID) — surface it; the refreshed
/// per-provider status rides the host's `agentAuthStatus` push separately.
fileprivate func agentLoginResultReceived(_ result: WireAgentLoginResult) {
guard result.requestID == agentLoginRequestID else { return }
agentLoginListener?.stop()
agentLoginListener = nil
agentLoginRequestID = nil
if result.succeeded, let provider = agentLoginProvider {
resolveAuthBanners(for: [provider])
}
agentLogin = .done(success: result.succeeded, message: result.error)
}
/// A sign-in for `providers` completed — here, or on any other device in the mesh. Resolve
/// every session those providers back: anchor its dismissal at the transcript's current tip so
/// an auth failure already sitting in the tail stops showing the in-chat banner immediately —
/// no waiting for a fresh run to rewrite the tail — while a genuinely newer failure (a token
/// that lapses again) carries a higher seq and re-arms it. Provider-matched, so a login for
/// one provider never dismisses another's banner, and `max` so an older signal can't lower an
/// anchor already set.
private func resolveAuthBanners(for providers: [AgentLoginProvider]) {
guard !providers.isEmpty else { return }
// The open session's on-screen tail can lead its summary's `lastSeq` (the events that
// arrived since the last summary push) — anchor past those too, or the very failure being
// resolved could sit above the anchor.
let openTip = openEvents.map(\.seq).max() ?? 0
for session in sessions {
guard let provider = AgentLoginProvider.forBackend(session.backend),
providers.contains(provider) else { continue }
let tip = session.sessionID == openSessionID
? max(session.lastSeq, openTip) : session.lastSeq
authResolvedSeqBySession[session.sessionID] = max(
authResolvedSeqBySession[session.sessionID] ?? 0, tip)
}
}
/// Whether `hostID` can take an API key from this phone right now (REMOTE_AGENT_LOGIN §8):
/// it advertises sealed-credential ingestion AND we hold its sealing key from the
/// post-hello `credentialNeeded` push.
func canSubmitAPIKey(toHost hostID: String) -> Bool {
guard let conn = connections[hostID] else { return false }
return conn.connectivity.isLive && conn.capabilities.canReceiveSealedCredentials
&& conn.credentialSealingKey != nil
}
// MARK: - Phone credential vault (phone as credential holder)
/// The credential kinds this phone's encrypted vault currently holds — the Agent Accounts
/// footer's "this iPhone can credential a fresh runner" note. Refreshed whenever a
/// connection lands an update or a deletion.
@Published private(set) var phoneVaultKinds: [CredentialKind] = []
/// Re-read the vault's held kinds (vault I/O runs on its own actor, off the main actor).
func refreshPhoneVaultKinds() {
Task { [weak self] in
let kinds = await PhoneCredentialVault.shared.heldKinds()
self?.phoneVaultKinds = kinds
}
}
/// Whether `hostID` can land a mesh-wide credential deletion right now.
func canRevokeCredentials(onHost hostID: String) -> Bool {
guard let conn = connections[hostID] else { return false }
return conn.connectivity.isLive && conn.capabilities.canRevokeCredentials
}
/// Delete a credential kind from every mesh member: clear this phone's own vault copy,
/// mint the tombstone (absorbing the copy's freshness so a stale holder can't resurrect
/// it), and send it at every live host that accepts the verb — each host clears its
/// stores, records the stone, and fans it out to its other clients and peers. The
/// provider rows flip via the hosts' refreshed `agentAuthStatus` push (the implicit ack).
func revokeCredential(kind: CredentialKind) {
guard !demoMode else { return }
Task { [weak self] in
let stone = await PhoneCredentialVault.shared.deleteCredential(
kind, deviceID: IdentityStore.deviceID())
guard let self else { return }
for conn in self.connections.values
where conn.connectivity.isLive && conn.capabilities.canRevokeCredentials {
conn.send(.credentialRevoke([stone]))
}
self.refreshPhoneVaultKinds()
}
}
/// Seal an API key directly to `hostID` and send it (REMOTE_AGENT_LOGIN §8 — the
/// ToS-defensive Console-key fallback). The key transits only as a sealed box inside the
/// E2EE channel and is NOT kept on the phone; the host lands it (Keychain stores on a Mac,
/// 0600 file + env on a runner) and the confirmation is implicit — the provider row flips
/// via the host's refreshed `agentAuthStatus` push. Returns false when the host can't take
/// it (caller shows the failure inline).
@discardableResult
func submitAPIKey(_ key: String, provider: AgentLoginProvider, toHost hostID: String) -> Bool {
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
let kind: CredentialKind
switch provider {
case .claude: kind = .anthropicAPIKey
case .codex: kind = .openAIAPIKey
case .grok: kind = .xaiAPIKey
default: return false
}
guard !trimmed.isEmpty,
let conn = connections[hostID], conn.connectivity.isLive,
conn.capabilities.canReceiveSealedCredentials,
let sealingKey = conn.credentialSealingKey
else { return false }
let record = SealedCredentialRecord(
kind: kind, updatedAt: Date(),
box: SealedCredentialBox(ephemeralPublicKey: Data(), ciphertext: Data()))
guard let box = try? SealedCredentialBox.seal(
Data(trimmed.utf8), to: sealingKey, additionalData: record.additionalData)
else { return false }
conn.send(.credentialProvision(SealedCredentialEnvelope(records: [
SealedCredentialRecord(kind: kind, updatedAt: Date(), box: box)
])))
return true
}
/// 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)
/// Start a chat on the owning Mac. The routed composer passes `intelligence` together with
/// the concrete model/effort displayed from the host's projected route table: the stop keeps
/// the rail positioned, while the explicit pair guarantees the session runs what was shown.
/// Older clients may still pass only the stop and let the host resolve it.
func startChat(in projectID: ProjectID, message: String, model: String? = nil,
effort: String? = nil, baseBranch: String? = nil,
useWorktree: Bool = true, auto: Bool? = nil,
attachments: [WireAttachment] = [], intelligence: Int? = nil) {
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,
intelligence: intelligence)))
}
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)) }
/// Hand-mark a chat "Done" — the phone's counterpart of the Mac sidebar's "Mark Done", for a
/// chat stuck reading "Awaiting Input" whose work is actually finished. The host flips the last
/// turn's disposition to `.completed` (and finalizes the chat the same way its own classifier
/// would, so an autoship-armed chat ships), then broadcasts the refreshed summary. Guarded on
/// `canMarkDone` so a tap can't fire against a chat the host would reject — and so a stale
/// button (the row changed state between render and tap) is inert rather than confusing.
func markSessionDone(_ id: SessionID) {
guard let summary = sessions.first(where: { $0.sessionID == id }), canMarkDone(summary) else { return }
send(.markSessionDone(id))
}
/// Whether "Mark Done" applies to this chat: the row reads "Awaiting Input" (the only state the
/// disposition refines, so anything else is a host-side no-op), it isn't already Done, this
/// device holds control scope, and the owning Mac speaks the verb. Mirrors the Mac sidebar's
/// own gate; a moved-away tombstone is excluded — its chat lives on another Mac now.
func canMarkDone(_ summary: WireSessionSummary) -> Bool {
canControl && capabilities.canMarkSessionDone && summary.movedTo == nil
&& summary.status == .awaitingInput && summary.disposition != .completed
}
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)) }
/// Resolve a live "host command looks hung" alert — the transcript's Kill (`kill: true`, tears
/// down the command's process tree) / Keep-waiting (`kill: false`, dismiss) buttons. Optimistically
/// marks the alert resolved so its buttons retire at once; the host then emits the authoritative
/// resolution note. Mirrors the Mac's `AppStore.resolveProcessStall`.
func resolveProcessStall(_ stallID: String, kill: Bool, inSession id: SessionID) {
locallyResolvedStalls.insert(stallID)
send(.resolveProcessStall(id, stallID: stallID, kill: kill))
}
/// 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)) }
/// Move an open chat's Intelligence rail. The phone names the *stop*; the host re-routes
/// inside the chat's fixed backend lane, applies the resulting model + effort, and broadcasts
/// the refreshed summary. Deliberately no optimistic local write of a model: only the host
/// knows what a stop resolves to, and guessing would show a pair that then changed under the
/// user a moment later. Gated on the capability — an older host *throws* on the unknown tag,
/// which would surface as a connection error for a control they just touched.
func setSessionIntelligence(_ id: SessionID, level: Int) {
guard capabilities.canRouteIntelligence else { return }
send(.setSessionIntelligence(id, level))
}
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(preferRelay: Bool = false) {
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(preferRelay: preferRelay)
}
}
/// 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.
///
/// `preferRelay` dials the Nucleic Edge first (a Live Activity approve/deny from a
/// backgrounded/locked device, where LAN is unreliable). The relay handshake — mint a
/// connection token, upgrade the WebSocket, run Noise — costs more than a LAN dial, so the
/// caller gives it a longer budget.
func awaitLiveConnection(timeout: TimeInterval = 6, preferRelay: Bool = false) async -> Bool {
if demoMode || hasLiveConnection { return true }
bootstrapForIntent(preferRelay: preferRelay)
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: - Account-level synced settings (SyncedSettings)
/// Adopt an account-level settings value the host advertised (`Welcome.settings` /
/// `HostMsg.settings`). Mirrors it to the local `UserDefaults` cache so a cold background App
/// Intent — which never mounts this store — reads the same truth directly. Idempotent.
private func adoptSyncedSettings(_ settings: SyncedSettings) {
UserDefaults.standard.set(
settings.resolveApprovalsViaCloud, forKey: SyncedSettings.resolveApprovalsViaCloudKey)
if syncedSettings != settings { syncedSettings = settings }
}
/// Request an account-level settings change from the phone (the Settings toggle). The host is
/// the source of truth, so this optimistically adopts the value (and caches it for the intent),
/// then sends `ClientMsg.updateSettings` to every live Mac that can sync settings; the host
/// applies, enforces, and echoes the authoritative value back as `HostMsg.settings`. A no-op
/// with no live control-scope host that syncs settings — returns whether it went out.
@discardableResult
func updateSyncedSettings(_ settings: SyncedSettings) -> Bool {
adoptSyncedSettings(settings)
if demoMode { return true }
let targets = connections.values.filter {
$0.connectivity.isLive && $0.grantedScope >= .approve && $0.capabilities.canSyncSettings
}
guard !targets.isEmpty else { return false }
for conn in targets { conn.send(.updateSettings(settings)) }
return true
}
// 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 .fetchHydrangeaHeads, .fetchHydrangeaManifests, .fetchHydrangeaShards, .hydrangeaShardAck:
// Hydrangea shard replication (docs/HYDRANGEA_SHARDING.md §8.6, D13): phones never
// mirror — no Hydrangea store, no key custody — so these verbs are never issued from
// iOS. Inert by contract.
break
case .hydrangeaLockAcquire, .hydrangeaLockRelease:
// Hydrangea lock verbs (HYDRANGEA_SHARDING §17.5): phones never acquire (D13) — the
// lock viewer stays a read-only sync-protocol surface. Inert by contract.
break
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, _), .markSessionDone(let id),
.setSessionModel(let id, _), .setSessionEffort(let id, _),
.setSessionIntelligence(let id, _),
.setSessionAuto(let id, _), .setSessionAutoShip(let id, _), .setSessionShipBranch(let id, _),
.sendInput(let id, _), .cancelQueuedMessage(let id, _), .fetchDiff(let id),
.resolveProcessStall(let id, _, _),
// "Transfer…" on a chat this device doesn't own (mesh P5): the owner runs its own
// `moveSession`, so the ask goes to the Mac holding the worktree.
.requestSessionTransfer(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 ride their own loops: manifests/provisions go out per-connection
// from `HostConnection` (gossip on ready, answers to `credentialNeeded`), and
// `credentialRevoke` goes to every capable host from `revokeCredential(kind:)`.
// `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, .credentialRevoke,
.createProject,
// Remote agent sign-in goes straight to the user-chosen host from
// `beginAgentLogin`/`submitPastedLoginCode`/`cancelAgentLogin` — the attempt is
// pinned to one host's PKCE state, so owner-routing can never apply.
.agentLoginBegin, .agentLoginCallback, .agentLoginCancel,
// Cast subscriptions are per-connection (each `HostConnection` subscribes on its
// own ready, with the merged ledger's cursors) — nothing routes them here.
.castSubscribe,
// Composer typing goes straight to the owning connection from
// `composerDraftChanged`/`flushComposerDraft` — ephemeral by design, it must
// never be queued for optimistic replay, so it skips this router entirely.
.composerTyping,
// Account-level settings go straight to every eligible host from
// `updateSyncedSettings`, not through this owner-routing switch.
.updateSettings,
// Hydrangea direct handshake (SYNC §3.3) goes straight to the owning
// `HostConnection`'s `SyncClient` from the upgrade driver — never routed here.
.directOffer, .directSelect:
break
}
}
// MARK: - Live Activity push registration (UX_IOS §5.3)
/// The current Live Activity's APNS update token + id, shipped to every capable Mac so it can
/// refresh the lock-screen glance over APNs while the phone is backgrounded and its socket is
/// suspended. Sent to *all* connected hosts (not just the active one) — while the phone is
/// away, whichever Mac has work to report should be able to push.
private var liveActivityReg: (token: String, activityID: String)?
/// Host ids that already have the current token (re-sent to a host that (re)connects, and
/// re-sent to everyone when the token rotates).
private var liveActivitySentTo: Set<String> = []
/// This device's push-to-start token (iOS 17.2+), shipped to every capable Mac so it can create
/// the Live Activity over APNs when work starts before the app is opened. Device-scoped, so —
/// unlike `liveActivityReg` — it persists across activities and isn't cleared when one ends.
private var pushToStartToken: String?
/// Host ids that already have the current push-to-start token (mirrors `liveActivitySentTo`).
private var pushToStartSentTo: Set<String> = []
/// Whether the app is currently foreground. Reported to capable Macs (`ClientMsg.setForeground`)
/// so they know whether to defer to the phone's own Live Activity creation (foreground) or
/// push-to-start the glance themselves (backgrounded — the phone can't reliably start one). Starts
/// `true`: `onAppear` runs in the foreground; the background adopt-wake flips it first (§5.3).
/// Mirrored to `LiveActivityManager` so it knows whether a "needs you" arrival should alert
/// through the glance (backgrounded) or stay silent while a banner announces it (foreground).
private var isForeground = true {
didSet { LiveActivityManager.shared.foreground = isForeground }
}
/// Host ids already told the *current* `isForeground` value — cleared whenever it flips so the
/// next sync re-notifies every host (mirrors `pushToStartSentTo`).
private var foregroundSentTo: Set<String> = []
/// Guards `setupLiveActivityBridge` — it's called from both `onAppear` and the background adopt
/// wake, and installing the callbacks / observers once is enough.
private var liveActivityBridgeSetup = false
private func setupLiveActivityBridge() {
guard !liveActivityBridgeSetup else { return }
liveActivityBridgeSetup = true
LiveActivityManager.shared.onPushToken = { [weak self] token, activityID in
guard let self, self.liveActivityReg?.token != token else { return }
self.liveActivityReg = (token, activityID)
self.liveActivitySentTo.removeAll() // a fresh token must reach every host again
self.syncLiveActivityRegistration()
}
LiveActivityManager.shared.onPushToStartToken = { [weak self] token in
guard let self, self.pushToStartToken != token else { return }
self.pushToStartToken = token
self.pushToStartSentTo.removeAll() // a fresh token must reach every host again
self.syncPushToStartRegistration()
}
// Start observing the push-to-start token (and adopting any push-started activity) now — it's
// device-scoped and must be captured even before any Activity exists.
LiveActivityManager.shared.beginPushToStartObservation()
LiveActivityManager.shared.onActivityEnded = { [weak self] activityID in
guard let self else { return }
self.liveActivityReg = nil
self.liveActivitySentTo.removeAll()
for conn in self.connections.values where conn.capabilities.canPushLiveActivity {
conn.send(.endLiveActivity(activityID))
}
}
}
/// Send the current Live Activity token to every live, capable host that hasn't got it yet.
/// Called when the token changes and on every connection update, so a host that just connected
/// — or reconnected while the phone was away — learns the token it needs to push.
private func syncLiveActivityRegistration() {
guard let reg = liveActivityReg else { return }
// A host that dropped should re-register when it returns.
liveActivitySentTo = liveActivitySentTo.filter { connections[$0]?.connectivity.isLive == true }
for (id, conn) in connections {
guard conn.connectivity.isLive, conn.capabilities.canPushLiveActivity,
!liveActivitySentTo.contains(id) else { continue }
conn.send(.registerLiveActivity(reg.token, reg.activityID))
liveActivitySentTo.insert(id)
}
}
/// Send the current push-to-start token to every live, capable host that hasn't got it yet — the
/// mirror of `syncLiveActivityRegistration` for the device-scoped token that lets a host create
/// the glance over APNs when work starts before the app is opened (iOS 17.2+).
private func syncPushToStartRegistration() {
guard let token = pushToStartToken else { return }
// A host that dropped should re-register when it returns.
pushToStartSentTo = pushToStartSentTo.filter { connections[$0]?.connectivity.isLive == true }
for (id, conn) in connections {
// Gate on the dedicated push-to-start bit, not `canPushLiveActivity`: an older host can
// advertise the latter yet throw on the unknown `registerPushToStartToken` tag.
guard conn.connectivity.isLive, conn.capabilities.canPushToStartLiveActivity,
!pushToStartSentTo.contains(id) else { continue }
conn.send(.registerPushToStartToken(token))
pushToStartSentTo.insert(id)
}
}
/// Tell every capable, live host the current foreground state so it can pick the Live Activity
/// path: defer to the phone's own creation while foreground, or push-to-start the glance while
/// backgrounded (UX_IOS §5.3). De-duped per host against the value already sent; the set is
/// cleared by `setForegroundState` when the value flips, and a host that dropped re-learns it when
/// it returns (via the `didUpdate` re-sync). Gated on the dedicated capability bit — an older host
/// (even one advertising push-to-start) throws on the unknown `setForeground` tag.
private func syncForegroundState() {
foregroundSentTo = foregroundSentTo.filter { connections[$0]?.connectivity.isLive == true }
for (id, conn) in connections {
guard conn.connectivity.isLive, conn.capabilities.canReceiveForegroundState,
!foregroundSentTo.contains(id) else { continue }
conn.send(.setForeground(isForeground))
foregroundSentTo.insert(id)
}
}
/// Record a foreground/background transition and push it to the hosts. No-op when unchanged so an
/// `inactive`↔`active` flutter (or a repeat call) doesn't re-notify; on a real flip it clears the
/// per-host dedup so `syncForegroundState` re-sends to everyone.
private func setForegroundState(_ foreground: Bool) {
guard isForeground != foreground else { return }
isForeground = foreground
foregroundSentTo.removeAll()
syncForegroundState()
}
// MARK: - Demo simulator (offline, interactive)
//
// In demo mode there's no host, so writes can't go over the wire. Instead they mutate the
// already-`@Published` projection directly and (for the agent loop) stream a short canned run,
// so a reviewer can send messages, approve actions, start chats, and manage to-dos and see the
// UI respond — the read-only fixtures (`seedDemo`/`seedDemoTranscript`/`demoDiff`) already
// cover the passive surfaces.
private func demoHandle(_ msg: ClientMsg) {
switch msg {
case .sendInput(let id, let input):
demoRun(id, userText: input.plainText ?? "")
case .fetchHydrangeaHeads, .fetchHydrangeaManifests, .fetchHydrangeaShards, .hydrangeaShardAck:
break // Hydrangea replication: never issued from iOS (D13); inert in demo too.
case .hydrangeaLockAcquire, .hydrangeaLockRelease:
break // Hydrangea locks (§17.5): phones never acquire (D13); inert in demo too.
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 .markSessionDone(let id):
// Same shape as the host's: the row keeps its `.awaitingInput` status and just stops
// reading "needs you", because the disposition — not the status — is what refines it.
demoUpdateSession(id) { $0.demoCopy(disposition: .some(.completed)) }
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 .setSessionIntelligence(let id, let level):
// No host to route, so stand in for it: resolve the stop through the demo catalog the
// same way the real host would and apply the pair, so the rail's round trip — move,
// wait, read the answer back — behaves as it does against a live Mac.
let route = intelligenceCatalog.route(purpose: .general, level: level)
demoUpdateSession(id) {
$0.demoCopy(model: .some(route?.model), effort: .some(route?.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 .resolveProcessStall(let id, let stallID, let kill):
// No real command runs in demo; echo the resolution note so the alert row retires just
// as it would against a live host (its `resolution` unions into `resolvedStalls`).
demoAppend(id, .note(NoteEvent(
text: kill ? "Killed the stalled command." : "Dismissed the stall alert.",
icon: kill ? "xmark.octagon" : "clock",
processStall: ProcessStallNote(
id: stallID, label: "", pidCount: 1, idleSeconds: 0,
resolution: kill ? "killed" : "dismissed"))))
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,
.requestSessionTransfer,
// "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,
// Hydrangea runner verbs (HYDRANGEA_RUNNER §5–6) — demo has no runner host.
.intelligenceResult, .credentialManifest, .credentialProvision, .credentialRevoke,
// Remote project creation (CLOUD_RUNTIME §4.3) — demo has no host to clone on.
.createProject,
// Remote agent sign-in (docs/REMOTE_AGENT_LOGIN.md) — demo has no host to broker
// an OAuth flow; the Agent Accounts section never renders (no status push).
.agentLoginBegin, .agentLoginCallback, .agentLoginCancel,
// Mesh casting (demo has no host to cast).
.castSubscribe,
// Live composer streaming — demo has no other devices to stream to, and
// `composerDraftChanged` already no-ops in demo mode before routing.
.composerTyping,
// Account-level settings sync — demo has no host to persist/enforce them; the local
// `syncedSettings` mirror is already updated optimistically by `updateSyncedSettings`.
.updateSettings,
// Hydrangea direct handshake (SYNC §3.3) — demo has no real relay session to upgrade.
.directOffer, .directSelect:
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, grokUsage: dashboard.grokUsage,
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
let grokUsage = snaps.compactMap(\.grokUsage).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, codexUsage: codexUsage, grokUsage: grokUsage,
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)
}
}