2026-06-13 01:12:55 -07:00
import Foundation
import Network
import SwiftUI
import NucleicProtocol
2026-07-03 03:50:45 -07:00
import NucleicTailnet
2026-06-13 01:12:55 -07:00
/// 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
2026-07-03 03:50:45 -07:00
case connected ( SyncTransportHint )
2026-06-13 01:12:55 -07:00
case hostOffline
case failed ( String )
var label : String {
switch self {
case . unpaired : "Not paired"
case . connecting : "Connecting…"
case . reconnecting : "Reconnecting…"
2026-07-03 03:50:45 -07:00
case . connected ( let transport ): "Connected · \( transport . label ) "
2026-06-13 01:12:55 -07:00
case . hostOffline : "Mac offline"
case . failed ( let m ): m
}
}
2026-07-03 03:50:45 -07:00
var isLive : Bool {
if case . connected = self { return true }
return false
}
2026-06-13 01:12:55 -07:00
}
@ Published private ( set ) var connectivity : Connectivity = . unpaired
@ Published private ( set ) var hostName : String = ""
2026-07-04 17:40:00 -07:00
/// The paired Mac this connection currently reflects (mesh P3), by its `HostID` (fingerprint).
/// `hostName`/`sessions`/etc. above are that host's projection; switching hosts re-points them
/// (live: reconnect to a different paired Mac; demo: swap the mock host). `nil` until connected.
@ Published private ( set ) var activeHostID : String ?
2026-06-13 01:12:55 -07:00
@ Published private ( set ) var sessions : [ WireSessionSummary ] = []
@ Published private ( set ) var capabilities = WireCapabilities ( canModifyToolInput : false , allowAlwaysScopes : [])
@ Published private ( set ) var grantedScope : DeviceScope = . approve
2026-06-21 04:03:53 -07:00
/// 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
2026-06-13 01:12:55 -07:00
2026-06-13 02:20:53 -07:00
/// Home / Projects / To-Dos state — the dashboard projection.
@ Published private ( set ) var dashboard = DashboardSnapshot . empty
2026-07-04 00:39:30 -07:00
/// 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 ] = []
2026-06-13 01:12:55 -07:00
// Open session projection.
@ Published private ( set ) var openSessionID : SessionID ?
@ Published private ( set ) var openEvents : [ AgentEvent ] = []
@ Published private ( set ) var openApprovals : [ ApprovalRequest ] = []
2026-07-02 04:20:25 -07:00
/// 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
2026-06-13 01:12:55 -07:00
2026-07-02 03:43:13 -07:00
/// 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 ()
2026-07-02 04:42:59 -07:00
/// 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 ?
/// 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 }
/// 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 )
if connectivity . isLive {
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 }
pendingNotificationDecision = nil
guard Date (). timeIntervalSince ( at ) < 30 else { return } // stale — require the app
send (. approvalRespond ( id , decision ))
}
2026-07-02 03:43:13 -07:00
private static let lastOpenedKey = "nucleic.lastOpenedAt"
private static func loadLastOpened () -> [ SessionID : Date ] {
guard let raw = UserDefaults . standard . dictionary ( forKey : lastOpenedKey ) else { return [:] }
return raw . reduce ( into : [:]) { result , entry in
if let date = entry . value as ? Date { result [ SessionID ( rawValue : entry . key )] = date }
}
}
private func persistLastOpened () {
let raw = lastOpenedAt . reduce ( into : [ String : Date ]()) { $0 [ $1 . key . rawValue ] = $1 . value }
UserDefaults . standard . set ( raw , forKey : Self . lastOpenedKey )
}
/// Whether `summary` completed its work after the user last looked at it on this device.
func unseenCompletion ( _ summary : WireSessionSummary ) -> Bool {
let done = summary . status == . finished
|| ( summary . status == . awaitingInput && summary . disposition == . completed )
guard done , summary . sessionID != openSessionID else { return false }
guard let opened = lastOpenedAt [ summary . sessionID ] else { return true }
return summary . updatedAt > opened
}
2026-06-13 02:20:53 -07:00
/// 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 }
2026-06-13 01:12:55 -07:00
private let identity = IdentityStore . loadOrCreateIdentity ()
let discovery = LANDiscovery ()
private var client : SyncClient ?
private var eventTask : Task < Void , Never >?
2026-07-03 03:50:45 -07:00
/// In-flight async connection setup (tailnet node start + dial); cancelled on teardown.
private var connectTask : Task < Void , Never >?
/// The pending reconnect backoff timer; cancelled on teardown so a stale retry can't
/// tear down a newer in-flight attempt.
private var retryTask : Task < Void , Never >?
/// The transport the current connection attempt uses (drives the "Connected · …" chip).
private var activeTransport : SyncTransportHint = . lan
/// The phone's embedded Tailscale node state, for Settings (nil = not running).
@ Published private ( set ) var tailnetStatus : String ?
2026-07-03 16:14:45 -07:00
/// 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 ?
2026-06-13 01:12:55 -07:00
private var seenSeq : Set < UInt64 > = []
private var reconnectAttempts = 0
2026-07-02 19:26:43 -07:00
/// 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 >] = []
2026-06-13 02:20:53 -07:00
2026-07-04 17:40:00 -07:00
// MARK: - Multi-host switching (mesh P3)
/// One selectable Mac for the host switcher — the paired Macs (live) or the mock hosts (demo).
struct HostChoice : Identifiable , Equatable {
let id : String // HostID (fingerprint), or a demo id
let name : String
let isActive : Bool
}
/// The Macs this phone can switch between. Live: the paired-host registry. Demo: the mock hosts.
/// One entry ⇒ the switcher hides itself and the app reads single-host. Today the phone holds one
/// *live* connection at a time (switching reconnects); simultaneous connections are the deferred
/// multiplexer step.
var hostChoices : [ HostChoice ] {
if demoMode {
return demoHosts . map { HostChoice ( id : $0 . id , name : $0 . name , isActive : $0 . id == activeHostID ) }
}
return IdentityStore . pairedHosts (). map {
HostChoice ( id : $0 . fingerprint , name : $0 . hostName , isActive : $0 . fingerprint == activeHostID )
}
}
/// Switch which paired Mac is active. Live: re-point the (single) connection via the existing
/// reconnect machinery. Demo: swap the mock host's projection, preserving in-demo edits.
func switchHost ( to id : String ) {
guard id != activeHostID else { return }
if demoMode {
if let i = demoHosts . firstIndex ( where : { $0 . id == activeHostID }) {
demoHosts [ i ]. sessions = sessions // keep any in-demo mutations
demoHosts [ i ]. dashboard = dashboard
}
guard let next = demoHosts . first ( where : { $0 . id == id }) else { return }
applyDemoHost ( next )
} else {
activeHostID = id
reconnect ()
}
}
/// The paired Mac the live connection targets — the explicitly-active one, else the most-recent.
private func activeHost () -> PairedHost ? {
if let id = activeHostID , let host = IdentityStore . pairedHost ( id : id ) { return host }
return IdentityStore . pairedHosts (). last
}
/// Mock hosts for the multi-host demo. Each carries its own name + sessions + dashboard;
/// switching swaps the flat active-host projection. Empty outside demo.
private struct DemoHost {
let id : String
let name : String
var sessions : [ WireSessionSummary ]
var dashboard : DashboardSnapshot
}
private var demoHosts : [ DemoHost ] = []
/// Project a mock host onto the flat active-host state (demo host switch).
private func applyDemoHost ( _ host : DemoHost ) {
activeHostID = host . id
hostName = host . name
sessions = host . sessions
dashboard = host . dashboard
openSessionID = nil ; openEvents = []; openApprovals = []; openDiff = nil
LiveActivityManager . shared . sync ( hostName : hostName , sessions : sessions )
NotificationRouter . shared . updateBadge ( needsYouCount )
}
2026-06-13 02:20:53 -07:00
var isPaired : Bool { demoMode || IdentityStore . loadPairedHost () != nil }
2026-06-13 01:12:55 -07:00
var deviceFingerprint : String { identity . fingerprint }
// MARK: - Lifecycle
func onAppear () {
2026-06-13 02:20:53 -07:00
if demoMode { seedDemo (); return }
2026-06-13 01:12:55 -07:00
discovery . start ()
if isPaired { reconnect () }
}
2026-06-13 02:20:53 -07:00
private func seedDemo () {
2026-07-03 03:50:45 -07:00
connectivity = . connected (. lan )
2026-06-13 02:20:53 -07:00
hostName = "Andrew's Mac"
grantedScope = . control
2026-07-02 04:20:25 -07:00
capabilities = WireCapabilities (
canModifyToolInput : true , allowAlwaysScopes : [. session , . toolName ], canFetchDiff : true )
2026-06-21 04:03:53 -07:00
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" )
2026-06-13 02:20:53 -07:00
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
2026-06-27 19:15:37 -07:00
let count = ( i * 7 ) % 6
// Sample tokens roughly track messages so the preview grid shades by usage.
2026-06-27 19:45:56 -07:00
return ActivityDay ( day : cal . date ( byAdding : . day , value : - i , to : today ) ! ,
count : count , tokens : count * 8_500 + ( i * 137 ) % 4_000 )
2026-06-13 02:20:53 -07:00
}
2026-07-04 17:40:00 -07:00
// 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 (
2026-06-27 19:15:37 -07:00
counts : DashboardCounts (
projects : 2 , chats : 5 , activeChats : 2 , messages : 142 , activeDays : 9 , tokens : 1_284_000 ),
2026-06-13 02:20:53 -07:00
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" ,
2026-07-02 04:20:25 -07:00
projectID : p2 , projectName : "website" , status : . open , dispatchedSessionID : nil ,
triage : "high" , updatedAt : Date ()),
2026-06-13 02:20:53 -07:00
WireTodo ( id : TodoID ( rawValue : "t2" ), text : "Investigate the memory leak in the sync server" , summary : "Sync server memory leak" ,
2026-07-02 04:20:25 -07:00
projectID : p1 , projectName : "nucleic" , status : . open , dispatchedSessionID : nil ,
triage : "critical" , updatedAt : Date ()),
2026-06-13 02:20:53 -07:00
WireTodo ( id : TodoID ( rawValue : "t3" ), text : "Write release notes" , summary : nil ,
2026-07-02 04:20:25 -07:00
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 : []),
2026-06-13 02:20:53 -07:00
])
2026-07-04 17:40:00 -07:00
// 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 : [])
demoHosts = [
DemoHost ( id : "demo-1" , name : "Andrew's Mac" , sessions : host1Sessions , dashboard : host1Dashboard ),
DemoHost ( id : "demo-2" , name : "Studio Mac" , sessions : host2Sessions , dashboard : host2Dashboard ),
]
applyDemoHost ( demoHosts [ 0 ])
2026-06-13 02:20:53 -07:00
}
2026-07-02 04:20:25 -07:00
/// 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)
}
2026-07-03 23:59:29 -07:00
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
2026-07-02 04:20:25 -07:00
""" )
}
2026-07-03 16:14:45 -07:00
/// One dialable way to reach the Mac. A connect builds an ordered candidate list — LAN
/// first (cheapest when reachable), then the tailnet (SYNC §3.2's LAN-then-fallback
/// ordering) — and `attempt` walks it until one carries a session.
private enum TransportAttempt {
case lan ( NWEndpoint )
case tailnet ( host : String , port : UInt16 )
}
/// The in-progress connect: remaining candidates plus everything needed to start a
/// client on whichever one succeeds. Cleared on `.ready` (chain done) and by teardown.
private struct ConnectPlan {
var remaining : [ TransportAttempt ]
let hostStaticKey : Data
let mode : SyncClient . Mode
let deviceID : String
let pairingPayload : PairingPayload ?
}
private var connectPlan : ConnectPlan ?
/// Kills a LAN attempt whose TCP connect just hangs (stale IP hint) so the chain can
/// move on — NWConnection's own timeout is far too slow for a fallback decision.
private var lanConnectTimeout : Task < Void , Never >?
/// Pair from a scanned QR (SYNC §4.2): try the QR's transports in order (LAN hint or
/// Bonjour first, then the Mac's tailnet IP via the phone's embedded node), run XXpsk0,
2026-06-13 01:12:55 -07:00
/// and on success pin the host key for future IK reconnects.
func pair ( with payload : PairingPayload ) {
teardown ()
connectivity = . connecting
hostName = payload . hostName
2026-07-04 17:40:00 -07:00
activeHostID = payload . hostStaticKey . fingerprintHex
2026-07-03 16:14:45 -07:00
guard let hint = payload . transportHint else {
2026-07-03 03:50:45 -07:00
connectivity = . failed ( "This pairing code needs a newer version of Nucleic Remote." )
2026-07-03 16:14:45 -07:00
return
}
guard hint != . relay else {
connectivity = . failed ( "Relay connections aren't supported yet." )
return
}
let candidates = buildCandidates (
2026-06-13 01:12:55 -07:00
fingerprint : payload . hostStaticKey . fingerprintHex ,
2026-07-03 16:14:45 -07:00
lanHost : payload . lanHost , lanPort : payload . lanPort ,
tailnet : hint == . tailnet ? ( payload . tailnetHost , payload . tailnetPort ) : nil )
guard ! candidates . isEmpty else {
connectivity = . failed ( hint == . tailnet && ! TailnetSupport . isBuiltIn
? TailnetError . notBuiltIn . errorDescription ?? "Tailscale support isn't built in"
: "No Mac found on this network" )
return
2026-07-03 03:50:45 -07:00
}
2026-07-03 16:14:45 -07:00
connectPlan = ConnectPlan (
remaining : candidates , hostStaticKey : payload . hostStaticKey ,
mode : . pair ( secret : payload . pairingSecret ), deviceID : IdentityStore . deviceID (),
pairingPayload : payload )
_ = tryNextCandidate ()
2026-06-13 01:12:55 -07:00
}
2026-07-03 16:14:45 -07:00
/// Reconnect to the already-paired host using IK against the pinned static key: LAN
/// when reachable, else the pairing's tailnet hint — so a phone that leaves the Mac's
/// Wi‑ Fi rolls over to the tailnet and rolls back when it returns.
2026-06-13 01:12:55 -07:00
func reconnect () {
2026-07-04 17:40:00 -07:00
guard let host = activeHost () else { connectivity = . unpaired ; return }
2026-06-13 01:12:55 -07:00
teardown ()
2026-07-04 17:40:00 -07:00
activeHostID = host . fingerprint
2026-06-13 01:12:55 -07:00
connectivity = reconnectAttempts == 0 ? . connecting : . reconnecting
hostName = host . hostName
2026-07-03 16:14:45 -07:00
guard host . transportHint != . relay else {
connectivity = . failed ( "Relay connections aren't supported yet." )
return
}
let candidates = buildCandidates (
fingerprint : host . fingerprint ,
lanHost : host . lanHost , lanPort : host . lanPort ,
tailnet : host . transportHint == . tailnet ? ( host . tailnetHost , host . tailnetPort ) : nil )
guard ! candidates . isEmpty else {
connectivity = . hostOffline
scheduleRetry ()
return
}
connectPlan = ConnectPlan (
remaining : candidates , hostStaticKey : host . hostStaticKey ,
mode : . reconnect , deviceID : host . deviceID , pairingPayload : nil )
_ = tryNextCandidate ()
}
/// LAN first (explicit hint, else a Bonjour match), tailnet second when the pairing
/// carries one and this build can dial it.
private func buildCandidates (
fingerprint : String ?, lanHost : String ?, lanPort : UInt16 ?,
tailnet : ( host : String ?, port : UInt16 ?)?
) -> [ TransportAttempt ] {
var candidates : [ TransportAttempt ] = []
if let endpoint = resolveEndpoint ( fingerprint : fingerprint , lanHost : lanHost , lanPort : lanPort ) {
candidates . append (. lan ( endpoint ))
}
if let tailnet , let host = tailnet . host , let port = tailnet . port , TailnetSupport . isBuiltIn {
candidates . append (. tailnet ( host : host , port : port ))
}
return candidates
}
/// Pop and dial the next candidate. False when the plan is exhausted (or gone) — the
/// caller then applies its terminal failure handling.
private func tryNextCandidate () -> Bool {
guard var plan = connectPlan , ! plan . remaining . isEmpty else { return false }
let next = plan . remaining . removeFirst ()
connectPlan = plan
attempt ( next , plan : plan )
return true
}
2026-06-13 01:12:55 -07:00
2026-07-03 16:14:45 -07:00
private func attempt ( _ candidate : TransportAttempt , plan : ConnectPlan ) {
teardownClient ()
switch candidate {
case . lan ( let endpoint ):
2026-07-03 03:50:45 -07:00
activeTransport = . lan
2026-07-03 16:14:45 -07:00
let channel = makeChannel ( endpoint )
// Close the channel if TCP isn't up within the window (a stale IP hint would
// otherwise hang the chain on NWConnection's slow timeout); the finished stream
// then advances to the next candidate. The timer checks readiness itself — it's
// bound to exactly this channel, so a stale timer can never hit a later attempt.
lanConnectTimeout ?. cancel ()
lanConnectTimeout = Task { [ weak channel ] in
try ? await Task . sleep ( for : . seconds ( 4 ))
guard ! Task . isCancelled , let channel , ! channel . isReady else { return }
channel . close ()
}
2026-07-03 03:50:45 -07:00
startClient (
2026-07-03 16:14:45 -07:00
channel : channel , hostStaticKey : plan . hostStaticKey ,
mode : plan . mode , deviceID : plan . deviceID , pairingPayload : plan . pairingPayload )
case . tailnet ( let host , let port ):
2026-07-03 03:50:45 -07:00
activeTransport = . tailnet
connectTask = Task { [ weak self ] in
guard let self else { return }
do {
2026-07-03 16:14:45 -07:00
let channel = try await self . tailnetChannel ( host : host , port : port )
2026-07-03 03:50:45 -07:00
guard ! Task . isCancelled else { channel . close (); return }
self . startClient (
2026-07-03 16:14:45 -07:00
channel : channel , hostStaticKey : plan . hostStaticKey ,
mode : plan . mode , deviceID : plan . deviceID , pairingPayload : plan . pairingPayload )
2026-07-03 03:50:45 -07:00
} catch {
guard ! Task . isCancelled else { return }
2026-07-03 16:14:45 -07:00
self . tailnetAttemptFailed ( error , isPairing : plan . pairingPayload != nil )
2026-07-03 03:50:45 -07:00
}
}
}
}
2026-06-13 01:12:55 -07:00
2026-07-03 16:14:45 -07:00
/// The tailnet is always the last candidate, so its failure ends the chain: terminal
/// for pairing and for anything retrying can't fix; otherwise offline + backoff.
private func tailnetAttemptFailed ( _ error : Error , isPairing : Bool ) {
if tryNextCandidate () { return }
if isPairing {
connectivity = . failed ( error . localizedDescription )
return
}
switch error {
case TailnetError . notBuiltIn , TailnetError . notConfigured :
connectivity = . failed ( error . localizedDescription )
default :
connectivity = . hostOffline
scheduleRetry ()
}
}
2026-07-03 03:50:45 -07:00
/// Create the `SyncClient` on an established channel and start consuming its events —
/// the tail of every connect path, LAN or tailnet, pair or reconnect.
private func startClient (
channel : any FrameChannel , hostStaticKey : Data , mode : SyncClient . Mode ,
deviceID : String , pairingPayload : PairingPayload ?
) {
2026-06-13 01:12:55 -07:00
let client = SyncClient (
2026-07-03 03:50:45 -07:00
channel : channel , identity : identity , hostStaticKey : hostStaticKey ,
mode : mode , deviceID : deviceID ,
deviceLabel : UIDevice . current . name , pushToken : PushRegistrar . shared . tokenHex ,
nvrsion: Add: Adjust Canary Build Auto-Update to run every hour, fix Keychain permission audit by updating files: ControlAuth.swift, GitHubCredentials.swift, HeartbeatReporter.swift, KeychainOwnedAccess.swift, HostIdentityStore.swift, PushRelayClient.swift, refactor Remote Build Icon to use “bell.and.waves.left.and.right.fill” in ios/NucleicRemote/NucleicRemote/Views/BuildBanner.swift, add a Remote Release Channel Check in AppStore.swift, Sync/ConnectionHandler.swift, Sync/SyncHostBridge.swift, Sync/ReleaseChannel.swift, Sync/SyncClient.swift, Sync/WireMessages.swift, tests/SyncHostTests.swift, tests/SyncTestSupport.swift, and align build number hash in ios/NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj, ios/NucleicRemote/NucleicRemote/Info.plist, ios/NucleicRemote/NucleicRemote/Views/BuildBanner.swift.
2026-07-02 21:54:40 -07:00
releaseChannel : BuildInfo . current . channel . releaseChannel )
2026-06-13 01:12:55 -07:00
self . client = client
2026-07-03 03:50:45 -07:00
consume ( client , pairingPayload : pairingPayload )
}
/// Bring the phone's embedded Tailscale node up (first run needs the auth key from
/// Settings ▸ Tailscale; afterwards the on-disk state carries the registration) and dial
/// the Mac's tailnet address.
private func tailnetChannel ( host : String , port : UInt16 ) async throws -> FDFrameChannel {
guard TailnetSupport . isBuiltIn else { throw TailnetError . notBuiltIn }
let config = Self . phoneTailnetConfig ()
tailnetStatus = "Starting…"
2026-07-03 16:14:45 -07:00
// Mirror node status while the start is in flight. A first start with no auth key
// goes through the interactive browser login; pairing usually runs from the scanner
// sheet — not Settings — so take the user straight to the approval page.
let watcher = Task { [ weak self ] in
for await status in await TailnetNode . shared . statusStream () {
guard let self , ! Task . isCancelled else { break }
self . tailnetStatus = status . label
if case . needsLogin ( let url ) = status , let loginURL = URL ( string : url ) {
if self . tailnetLoginURL != loginURL {
self . tailnetLoginURL = loginURL
// Eject to Safari only for user-initiated pairing — the user is
// actively watching. A routine reconnect that suddenly needs a
// login (node revoked, state wiped) must not yank them out of the
// app; Settings ▸ Tailscale carries the login link instead.
if self . connectPlan ?. pairingPayload != nil {
UIApplication . shared . open ( loginURL , options : [:], completionHandler : nil )
}
}
} else {
self . tailnetLoginURL = nil
}
}
}
defer {
watcher . cancel ()
tailnetLoginURL = nil
}
2026-07-03 03:50:45 -07:00
do {
try await TailnetNode . shared . ensureRunning ( config : config )
} catch TailnetError . timedOut ( let message ) {
2026-07-03 16:14:45 -07:00
// A login/auth timeout won't fix itself — retrying would just block per lap.
2026-07-03 03:50:45 -07:00
// Rethrow as .notConfigured so reconnect() treats it as terminal, not offline.
tailnetStatus = await TailnetNode . shared . status . label
throw TailnetError . notConfigured ( message )
} catch {
tailnetStatus = await TailnetNode . shared . status . label
throw error
}
tailnetStatus = await TailnetNode . shared . status . label
return try await TailnetNode . shared . dial ( host : host , port : port )
}
/// The phone's embedded-node config. State lives in this app's sandboxed Application
/// Support (no cross-channel collision — each channel is its own app container).
private static func phoneTailnetConfig () -> TailnetConfig {
let base = FileManager . default . urls ( for : . applicationSupportDirectory , in : . userDomainMask )[ 0 ]
. appendingPathComponent ( "Nucleic" , isDirectory : true )
. appendingPathComponent ( "tailnet" , isDirectory : true )
return TailnetConfig (
hostName : TailnetConfig . nodeName ( for : UIDevice . current . name ),
stateDirectory : base ,
authKey : TailnetAuthStore . loadAuthKey ())
2026-06-13 01:12:55 -07:00
}
func unpair () {
teardown ()
2026-07-04 17:40:00 -07:00
// Forget the *active* Mac specifically (the switcher may have made it not the last).
if let id = activeHostID { IdentityStore . removePairedHost ( id : id ) } else { IdentityStore . clearPairedHost () }
activeHostID = nil
// Mesh P3: if other Macs remain paired, switch to one rather than going fully unpaired.
if IdentityStore . pairedHosts (). last != nil {
reconnect ()
return
}
2026-06-13 01:12:55 -07:00
connectivity = . unpaired
sessions = []
2026-07-03 03:50:45 -07:00
// Nothing left to dial — spin the embedded Tailscale node down if it was running.
Task { await TailnetNode . shared . stop () }
tailnetStatus = nil
2026-07-02 04:42:59 -07:00
LiveActivityManager . shared . end ()
NotificationRouter . shared . updateBadge ( 0 )
2026-06-13 01:12:55 -07:00
}
2026-07-02 19:26:43 -07:00
/// 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 () {
teardown ()
demoMode = true
UserDefaults . standard . set ( true , forKey : Self . demoModeKey )
reconnectAttempts = 0
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 ()
2026-07-04 17:40:00 -07:00
demoHosts = []
activeHostID = nil
2026-07-02 19:26:43 -07:00
openSessionID = nil
openEvents = []
openApprovals = []
openDiff = nil
diffLoading = false
sessions = []
dashboard = . empty
LiveActivityManager . shared . end ()
NotificationRouter . shared . updateBadge ( 0 )
if IdentityStore . loadPairedHost () != nil {
reconnect ()
} else {
connectivity = . unpaired
}
}
2026-06-13 01:12:55 -07:00
// MARK: - Intents (UX_IOS §9)
func open ( _ sessionID : SessionID ) {
openSessionID = sessionID
openEvents = []
openApprovals = []
2026-07-02 04:20:25 -07:00
openDiff = nil
diffLoading = false
2026-06-13 01:12:55 -07:00
seenSeq . removeAll ()
2026-07-02 03:43:13 -07:00
markOpened ( sessionID )
2026-06-21 04:03:53 -07:00
if demoMode { seedDemoTranscript ( sessionID ); return }
2026-06-13 01:12:55 -07:00
send (. subscribe ( Subscribe ( sessionID : sessionID , sinceSeq : nil , verbosity : . full )))
}
2026-07-02 04:20:25 -07:00
/// 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 ))
}
2026-07-02 03:43:13 -07:00
/// Record that the user looked at this session now (clears its unseen-completion wash).
func markOpened ( _ sessionID : SessionID ) {
lastOpenedAt [ sessionID ] = Date ()
persistLastOpened ()
}
2026-06-21 04:03:53 -07:00
/// 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 )
}
2026-07-04 00:21:32 -07:00
// 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' \n fix: harden auth middleware \n\n Require a Bearer token and reject a missing or blank one. \n\n - extract `requireSession` \n - add a `Bearer` prefix check \n EOF"
2026-07-04 00:38:59 -07:00
// 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"
2026-06-21 04:03:53 -07:00
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 \n 0 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 ))),
2026-07-04 00:21:32 -07:00
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 ))),
2026-07-04 00:38:59 -07:00
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." ))),
2026-06-21 04:03:53 -07:00
]
2026-07-02 19:26:43 -07:00
// 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 ) " )
2026-06-21 04:03:53 -07:00
}
2026-07-03 23:59:29 -07:00
/// Close a session's live subscription. `id` names *which* session is closing — the detail
/// view passes its own. On iPad's split view, switching session A→B can mount B (which calls
/// `open(B)`, setting `openSessionID = B`) *before* A's detail disappears; so we always
/// unsubscribe the named session but only tear down the shared open-state when it still
/// belongs to that session — otherwise we'd wipe B's freshly-loaded transcript. Called with
/// no argument it closes whatever is currently open (the iPhone push/pop path, unchanged).
func closeOpen ( _ id : SessionID ? = nil ) {
guard let target = id ?? openSessionID else { return }
send (. unsubscribe ( target ))
markOpened ( target ) // everything up to now has been seen
guard openSessionID == target else { return }
2026-06-13 01:12:55 -07:00
openSessionID = nil
openEvents = []
openApprovals = []
2026-07-02 04:20:25 -07:00
openDiff = nil
diffLoading = false
2026-06-13 01:12:55 -07:00
}
func respond ( _ approval : ApprovalRequest , _ decision : Decision ) {
send (. approvalRespond ( approval . id , decision ))
openApprovals . removeAll { $0 . id == approval . id } // optimistic dismiss; host confirms
}
func sendInput ( _ text : String , to sessionID : SessionID ) {
let trimmed = text . trimmingCharacters ( in : . whitespacesAndNewlines )
guard ! trimmed . isEmpty else { return }
send (. sendInput ( sessionID , AgentInput ( text : trimmed )))
}
2026-06-13 02:20:53 -07:00
func refreshSessions () { send (. listSessions ); send (. listDashboard ) }
// MARK: - Control intents (control scope; the same actions the Mac can take)
func startChat ( in projectID : ProjectID , message : String , model : String ? = nil ,
effort : String ? = nil , baseBranch : String ? = nil ,
useWorktree : Bool = true , auto : Bool ? = nil ) {
let text = message . trimmingCharacters ( in : . whitespacesAndNewlines )
guard ! text . isEmpty else { return }
send (. startChat ( StartChatRequest (
projectID : projectID , message : text , model : model , effort : effort ,
baseBranch : baseBranch , useWorktree : useWorktree , auto : auto )))
}
func captureTodo ( _ text : String , projectID : ProjectID ?) {
let trimmed = text . trimmingCharacters ( in : . whitespacesAndNewlines )
guard ! trimmed . isEmpty else { return }
send (. captureTodo ( CaptureTodoRequest ( text : trimmed , projectID : projectID )))
}
func dispatchTodo ( _ id : TodoID , in projectID : ProjectID ) { send (. dispatchTodo ( id , projectID )) }
func completeTodo ( _ id : TodoID ) { send (. setTodoStatus ( id , . done )) }
func deleteTodo ( _ id : TodoID ) { send (. deleteTodo ( id )) }
func renameSession ( _ id : SessionID , to title : String ) {
let trimmed = title . trimmingCharacters ( in : . whitespacesAndNewlines )
guard ! trimmed . isEmpty else { return }
send (. renameSession ( id , trimmed ))
}
func setFavorite ( _ id : SessionID , _ favorite : Bool ) { send (. setFavorite ( id , favorite )) }
func setArchived ( _ id : SessionID , _ archived : Bool ) { send (. setArchived ( id , archived )) }
func deleteSession ( _ id : SessionID ) {
send (. deleteSession ( id ))
if id == openSessionID { closeOpen () }
}
func integrate ( _ id : SessionID , _ mode : IntegrationMode ) { send (. integrate ( id , mode )) }
2026-07-02 03:43:13 -07:00
/// Throw away the session's branch/worktree without landing it (the Mac's Discard…).
func discard ( _ id : SessionID ) { send (. discard ( id )) }
2026-06-13 02:20:53 -07:00
func interrupt ( _ id : SessionID ) { send (. interrupt ( id )) }
2026-07-01 19:01:20 -07:00
/// 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 )) }
2026-06-13 01:12:55 -07:00
2026-06-21 04:03:53 -07:00
// Mid-session model / reasoning / automation — the same affordances the Mac header exposes.
// `nil` model/effort resets the session to the host/app default.
func setSessionModel ( _ id : SessionID , _ model : String ?) { send (. setSessionModel ( id , model )) }
func setSessionEffort ( _ id : SessionID , _ effort : String ?) { send (. setSessionEffort ( id , effort )) }
func setSessionAuto ( _ id : SessionID , _ auto : Bool ) { send (. setSessionAuto ( id , auto )) }
func setSessionAutoShip ( _ id : SessionID , _ autoShip : Bool ) { send (. setSessionAutoShip ( id , autoShip )) }
func setSessionShipBranch ( _ id : SessionID , _ branch : String ?) { send (. setSessionShipBranch ( id , branch )) }
2026-06-13 01:12:55 -07:00
// MARK: - Plumbing
private func send ( _ msg : ClientMsg ) {
2026-07-02 19:26:43 -07:00
if demoMode { demoHandle ( msg ); return }
2026-06-13 01:12:55 -07:00
guard let client else { return }
Task { await client . send ( msg ) }
}
2026-07-02 19:26:43 -07:00
// MARK: - Demo simulator (offline, interactive)
//
// In demo mode there's no host, so writes can't go over the wire. Instead they mutate the
// already-`@Published` projection directly and (for the agent loop) stream a short canned run,
// so a reviewer can send messages, approve actions, start chats, and manage to-dos and see the
// UI respond — the read-only fixtures (`seedDemo`/`seedDemoTranscript`/`demoDiff`) already
// cover the passive surfaces.
private func demoHandle ( _ msg : ClientMsg ) {
switch msg {
case . sendInput ( let id , let input ):
demoRun ( id , userText : input . plainText ?? "" )
case . startChat ( let req ):
demoStartChat ( req )
case . approvalRespond ( let id , let decision ):
demoResolveApproval ( id , decision )
case . captureTodo ( let req ):
demoCaptureTodo ( req )
case . setTodoStatus ( let id , let status ):
demoSetTodoStatus ( id , status )
case . deleteTodo ( let id ):
demoMutateTodos { $0 . filter { $0 . id != id } }
case . dispatchTodo ( let id , _ ):
demoSetTodoStatus ( id , . dispatched )
case . renameSession ( let id , let title ):
demoUpdateSession ( id ) { $0 . demoCopy ( title : title ) }
case . setFavorite ( let id , let favorite ):
demoUpdateSession ( id ) { $0 . demoCopy ( favorite : favorite ) }
case . setArchived ( let id , let archived ):
demoUpdateSession ( id ) { $0 . demoCopy ( archived : archived ) }
case . deleteSession ( let id ):
sessions . removeAll { $0 . sessionID == id }
NotificationRouter . shared . updateBadge ( needsYouCount )
LiveActivityManager . shared . sync ( hostName : hostName , sessions : sessions )
case . discard ( let id ):
sessions . removeAll { $0 . sessionID == id }
NotificationRouter . shared . updateBadge ( needsYouCount )
case . interrupt ( let id ):
demoUpdateSession ( id ) { $0 . demoCopy ( status : . interrupted , disposition : . some ( nil )) }
case . integrate ( let id , _ ):
demoAppend ( id , . note ( NoteEvent ( text : "nvrsion: Landed to trunk." , icon : "arrow.triangle.branch" )))
demoUpdateSession ( id ) { $0 . demoCopy ( status : . finished , disposition : . some (. completed )) }
case . setSessionModel ( let id , let model ):
demoUpdateSession ( id ) { $0 . demoCopy ( model : . some ( model )) }
case . setSessionEffort ( let id , let effort ):
demoUpdateSession ( id ) { $0 . demoCopy ( effort : . some ( effort )) }
case . setSessionAuto ( let id , let auto ):
demoUpdateSession ( id ) { $0 . demoCopy ( auto : auto ) }
case . setSessionAutoShip ( let id , let autoShip ):
demoUpdateSession ( id ) { $0 . demoCopy ( autoShip : autoShip ) }
case . setSessionShipBranch ( let id , let branch ):
demoUpdateSession ( id ) { $0 . demoCopy ( shipBranch : . some ( branch )) }
case . hello , . listSessions , . listDashboard , . subscribe , . unsubscribe ,
2026-07-04 17:09:20 -07:00
. ping , . cancelQueuedMessage , . fetchDiff , . listPeers , . addressUpdate ,
// 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 :
2026-07-04 00:59:15 -07:00
break // passive / already handled by the seeded fixtures (demo has no mesh peers)
2026-07-02 19:26:43 -07:00
}
}
/// 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 \n 0 failing" , isError : false )))
guard await pause ( 700 ) else { return }
self . demoAppend ( sessionID , . assistantText ( TextChunk (
messageID : UUID (). uuidString ,
text : "All green — tests pass and the change is in place. Anything else?" ,
isPartial : false )))
self . demoAppend ( sessionID , . usage ( Usage (
inputTokens : 12_400 , outputTokens : 640 , costUSD : 0.0088 , contextInputTokens : 12_400 )))
self . demoAppend ( sessionID , . runFinished ( RunFinished ( outcome : . completed , finalText : "Done." )))
self . demoUpdateSession ( sessionID ) {
$0 . demoCopy ( status : . awaitingInput , disposition : . some (. completed ))
}
}
demoTasks . append ( task )
}
private func demoStartChat ( _ req : StartChatRequest ) {
let project = dashboard . projects . first { $0 . id == req . projectID }
let id = SessionID ( rawValue : "demo- \( UUID () . uuidString . prefix ( 8 )) " )
let title = String ( req . message . prefix ( 48 ))
let summary = WireSessionSummary (
sessionID : id , projectID : req . projectID . rawValue ,
projectName : project ?. name ?? "project" , backend : BackendID . forModel ( req . model ) ?? . claudeCode ,
status : . running , disposition : nil , title : title . isEmpty ? "New chat" : title ,
branch : "nucleic/ \( id . rawValue ) " , lastSeq : 0 , diffStat : nil ,
pendingApprovalCount : 0 , favorite : false , archived : false ,
model : req . model , effort : req . effort , auto : req . auto ?? false ,
updatedAt : Date ())
sessions . insert ( summary , at : 0 )
LiveActivityManager . shared . sync ( hostName : hostName , sessions : sessions )
demoRun ( id , userText : req . message )
}
private func demoResolveApproval ( _ id : ApprovalID , _ decision : Decision ) {
openApprovals . removeAll { $0 . id == id }
NotificationRouter . shared . withdrawApproval ( id )
guard let sessionID = openSessionID else { return }
switch decision {
case . deny , . cancelRun :
demoAppend ( sessionID , . note ( NoteEvent ( text : "You denied the command." , icon : "hand.raised" )))
demoUpdateSession ( sessionID ) {
$0 . demoCopy ( status : . awaitingInput , disposition : . some (. awaitingInput ), pendingApprovalCount : 0 )
}
case . allow , . allowAlways :
demoAppend ( sessionID , . toolResult ( ToolResult (
toolCallID : "t-appr" , content : "Deployed successfully." , isError : false )))
demoUpdateSession ( sessionID ) { $0 . demoCopy ( pendingApprovalCount : 0 ) }
demoRun ( sessionID , userText : nil ) // wrap up the turn after the approved tool runs
}
}
private func demoCaptureTodo ( _ req : CaptureTodoRequest ) {
let project = req . projectID . flatMap { pid in dashboard . projects . first { $0 . id == pid } }
let todo = WireTodo (
id : . generate (), text : req . text , summary : nil , projectID : req . projectID ,
projectName : project ?. name , status : . open , dispatchedSessionID : nil ,
triage : nil , updatedAt : Date ())
demoMutateTodos { [ todo ] + $0 }
}
private func demoSetTodoStatus ( _ id : TodoID , _ status : TodoStatus ) {
demoMutateTodos { todos in
todos . map { todo in
todo . id == id
? WireTodo ( id : todo . id , text : todo . text , summary : todo . summary ,
projectID : todo . projectID , projectName : todo . projectName ,
status : status , dispatchedSessionID : todo . dispatchedSessionID ,
triage : todo . triage , updatedAt : Date ())
: todo
}
}
}
/// Rebuild the dashboard with a transformed to-do list (its fields are `let`).
private func demoMutateTodos ( _ transform : ([ WireTodo ]) -> [ WireTodo ]) {
dashboard = DashboardSnapshot (
counts : dashboard . counts , activity : dashboard . activity , projects : dashboard . projects ,
todos : transform ( dashboard . todos ), usage : dashboard . usage , statusFeeds : dashboard . statusFeeds )
}
2026-06-13 01:12:55 -07:00
private func makeChannel ( _ endpoint : NWEndpoint ) -> NWFrameChannel {
2026-07-03 01:42:40 -07:00
let channel = NWFrameChannel ( endpoint : endpoint )
// Surface a transport-level failure with an actionable message. Without this, a refused
// connection or a denied local-network permission finished the frame stream and only
// showed up as a generic "handshake closed" — hiding the real (usually fixable) cause.
channel . onFailed = { [ weak self ] error in
Task { @ MainActor in self ?. handleTransportFailure ( error ) }
}
return channel
}
/// Map an `NWConnection` failure to a connectivity state the pairing/onboarding UI can act on.
2026-07-03 16:16:12 -07:00
/// Only meaningful while we're still establishing the link; once connected, a drop is the
/// normal `.closed` → reconnect path's job. And only when the connect chain has nothing
/// left to try — a failed LAN probe about to fall back to the tailnet is routine, not news.
2026-07-03 01:42:40 -07:00
private func handleTransportFailure ( _ error : String ) {
2026-07-03 16:16:12 -07:00
guard ! connectivity . isLive else { return }
guard connectPlan ?. remaining . isEmpty != false else { return }
2026-07-03 01:42:40 -07:00
connectivity = . failed ( Self . friendlyTransportError ( error ))
}
/// 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).
private 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."
2026-06-13 01:12:55 -07:00
}
private func resolveEndpoint ( fingerprint : String ?, lanHost : String ?, lanPort : UInt16 ?) -> NWEndpoint ? {
discovery . endpoint ( forFingerprint : fingerprint , lanHost : lanHost , lanPort : lanPort )
}
private func consume ( _ client : SyncClient , pairingPayload : PairingPayload ?) {
eventTask = Task { [ weak self ] in
let stream = await client . start ()
for await event in stream {
2026-07-03 16:14:45 -07:00
// A replaced client's tail events (`.failed` is always chased by `.closed`)
// must not leak into the new attempt — they'd advance the candidate chain
// or schedule retries against a connection that no longer exists.
guard let self , self . client === client else { break }
await self . handle ( event , pairingPayload : pairingPayload )
2026-06-13 01:12:55 -07:00
}
}
}
private func handle ( _ event : SyncClient . Event , pairingPayload : PairingPayload ?) async {
switch event {
case . connecting :
break
case . ready ( let welcome ):
reconnectAttempts = 0
2026-07-03 16:14:45 -07:00
connectPlan = nil // the chain found its transport
2026-07-03 03:50:45 -07:00
connectivity = . connected ( activeTransport )
2026-06-13 01:12:55 -07:00
hostName = welcome . host . hostName
capabilities = welcome . capabilities
grantedScope = welcome . grantedScope
2026-06-21 04:03:53 -07:00
modelCatalog = welcome . modelCatalog
2026-06-13 01:12:55 -07:00
if let payload = pairingPayload , let hostKey = await client ?. hostKey () {
IdentityStore . savePairedHost ( PairedHost (
deviceID : IdentityStore . deviceID (), hostName : welcome . host . hostName ,
hostStaticKey : hostKey , fingerprint : hostKey . fingerprintHex ,
2026-07-03 03:50:45 -07:00
lanHost : payload . lanHost , lanPort : payload . lanPort ,
transport : payload . transport , tailnetHost : payload . tailnetHost ,
tailnetPort : payload . tailnetPort ))
2026-06-13 01:12:55 -07:00
}
send (. listSessions )
2026-06-13 02:20:53 -07:00
send (. listDashboard )
2026-06-13 01:12:55 -07:00
if let id = openSessionID { send (. subscribe ( Subscribe ( sessionID : id , sinceSeq : nil , verbosity : . full ))) }
2026-07-02 04:42:59 -07:00
flushPendingNotificationDecision ()
2026-06-13 01:12:55 -07:00
case . sessionList ( let list ):
sessions = list
2026-07-02 04:42:59 -07:00
NotificationRouter . shared . updateBadge ( needsYouCount )
LiveActivityManager . shared . sync ( hostName : hostName , sessions : sessions )
2026-06-13 01:12:55 -07:00
case . sessionUpdated ( let summary ):
2026-07-02 04:42:59 -07:00
let previous : WireSessionSummary ?
if let i = sessions . firstIndex ( where : { $0 . sessionID == summary . sessionID }) {
previous = sessions [ i ]
sessions [ i ] = summary
} else {
previous = nil
sessions . append ( summary )
}
// Notify on the transition into "waiting on you" / "finished" — only when the
// app isn't foreground-active (in-app, the list's washes and badges carry it).
let becameWaiting = summary . status == . awaitingInput
&& previous ?. status != . awaitingInput
if becameWaiting , ! isActive , ! summary . archived {
NotificationRouter . shared . postSessionUpdate ( summary )
}
NotificationRouter . shared . updateBadge ( needsYouCount )
LiveActivityManager . shared . sync ( hostName : hostName , sessions : sessions )
2026-06-13 02:20:53 -07:00
case . dashboard ( let snapshot ):
dashboard = snapshot
2026-06-13 01:12:55 -07:00
case . snapshot ( let snapshot ):
guard snapshot . summary . sessionID == openSessionID else { break }
seenSeq = Set ( snapshot . recentEvents . map ( \ . seq ))
openEvents = snapshot . recentEvents
openApprovals = snapshot . pendingApprovals
case . events ( let batch ):
guard batch . sessionID == openSessionID else { break }
for e in batch . events where ! seenSeq . contains ( e . seq ) {
seenSeq . insert ( e . seq )
openEvents . append ( e )
}
case . approvalRequested ( let req ):
if req . sessionID == openSessionID , ! openApprovals . contains ( where : { $0 . id == req . id }) {
openApprovals . append ( req )
}
2026-07-02 04:42:59 -07:00
// Always post; the router suppresses the banner when the user is already
// looking at this session, and resolution (any device) withdraws it.
let title = sessions . first { $0 . sessionID == req . sessionID }?. title ?? "Approval"
NotificationRouter . shared . postApproval ( req , sessionTitle : title )
2026-06-13 01:12:55 -07:00
case . approvalResolved ( let resolved ):
openApprovals . removeAll { $0 . id == resolved . id }
2026-07-02 04:42:59 -07:00
NotificationRouter . shared . withdrawApproval ( resolved . id )
2026-07-02 04:20:25 -07:00
case . sessionDiff ( let diff ):
guard diff . sessionID == openSessionID else { break }
openDiff = diff
diffLoading = false
2026-07-04 00:39:30 -07:00
case . peerList ( let peers ):
// Mesh P4: the host's known peers, for the (future) mesh device list and transfer
// picker. Recorded now so multi-host UI can render it; single-host builds ignore it.
meshPeers = peers
2026-07-04 17:09:20 -07:00
case . transferAccept , . transferReject , . transferReady , . transferCommitted , . transferChunkAck :
// Session-transfer replies (mesh P5) only reach a *source* Mac driving a transfer; a
// phone is never a transfer source, so these are inert here.
break
2026-07-02 03:43:13 -07:00
case . wireError ( let error ):
nvrsion: Add: Adjust Canary Build Auto-Update to run every hour, fix Keychain permission audit by updating files: ControlAuth.swift, GitHubCredentials.swift, HeartbeatReporter.swift, KeychainOwnedAccess.swift, HostIdentityStore.swift, PushRelayClient.swift, refactor Remote Build Icon to use “bell.and.waves.left.and.right.fill” in ios/NucleicRemote/NucleicRemote/Views/BuildBanner.swift, add a Remote Release Channel Check in AppStore.swift, Sync/ConnectionHandler.swift, Sync/SyncHostBridge.swift, Sync/ReleaseChannel.swift, Sync/SyncClient.swift, Sync/WireMessages.swift, tests/SyncHostTests.swift, tests/SyncTestSupport.swift, and align build number hash in ios/NucleicRemote/NucleicRemote.xcodeproj/project.pbxproj, ios/NucleicRemote/NucleicRemote/Info.plist, ios/NucleicRemote/NucleicRemote/Views/BuildBanner.swift.
2026-07-02 21:54:40 -07:00
// A channel mismatch means the host and this remote were built from incompatible
// release channels. Surface it as a persistent failure with a clear message rather
// than a transient bubble; the follow-on `.closed` still schedules a backoff retry,
// so the connection recovers on its own once either side is updated to a matching
// channel.
if error . code == . channelMismatch {
connectivity = . failed ( error . message )
break
}
2026-07-02 03:43:13 -07:00
// Not fatal — surface as a transient bubble (the Mac's last-error overlay).
// Losing an approval race isn't an error worth interrupting for; the card
// collapses on the matching `approvalResolved`.
guard error . code != . alreadyResolved else { break }
2026-07-03 16:14:45 -07:00
// While the connect chain is still resolving a transport, a pre-ready error
// (e.g. "handshake closed" from a LAN probe about to fall back to tailnet) is
// routine, not news — the follow-on `.closed` advances the chain silently.
guard connectPlan == nil else { break }
2026-07-02 03:43:13 -07:00
showError ( error . message , sessionID : error . sessionID )
2026-06-13 01:12:55 -07:00
case . failed ( let message ):
2026-07-03 16:14:45 -07:00
// Another candidate may still carry the session (e.g. LAN died → tailnet).
if tryNextCandidate () { break }
connectPlan = nil
2026-07-03 16:16:12 -07:00
// The channel's onFailed may have just surfaced a friendlier transport-level
// cause (denied Local Network, connection refused) — don't clobber it.
if case . failed = connectivity {} else { connectivity = . failed ( message ) }
2026-06-13 01:12:55 -07:00
scheduleRetry ()
case . closed :
2026-07-03 16:14:45 -07:00
if ! connectivity . isLive , tryNextCandidate () { break }
if connectivity . isLive {
connectivity = . reconnecting
} else if connectPlan ?. pairingPayload != nil {
// A pairing chain died silently (every candidate closed pre-welcome).
// There's no retry loop before a pairing succeeds, so without a terminal
2026-07-03 16:16:12 -07:00
// state this would sit on "Connecting…" forever. Keep a friendlier
// transport-level failure if one was already surfaced.
if case . failed = connectivity {} else {
connectivity = . failed ( "Couldn't connect to your Mac — check that it's reachable, then scan again." )
}
2026-07-03 16:14:45 -07:00
}
connectPlan = nil
2026-06-13 01:12:55 -07:00
scheduleRetry ()
}
}
2026-07-02 03:43:13 -07:00
/// 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
}
2026-06-13 01:12:55 -07:00
private func scheduleRetry () {
guard isPaired else { return }
reconnectAttempts += 1
let delay = min ( Double ( reconnectAttempts ) * 1.5 , 10 )
2026-07-03 03:50:45 -07:00
retryTask ?. cancel ()
retryTask = Task { [ weak self ] in
// `try?` swallows the sleep's CancellationError, so check explicitly.
2026-06-13 01:12:55 -07:00
try ? await Task . sleep ( for : . seconds ( delay ))
2026-07-03 03:50:45 -07:00
guard ! Task . isCancelled , let self , ! self . connectivity . isLive else { return }
2026-06-13 01:12:55 -07:00
self . reconnect ()
}
}
private func teardown () {
2026-07-03 03:50:45 -07:00
retryTask ?. cancel ()
retryTask = nil
connectTask ?. cancel ()
connectTask = nil
2026-07-03 16:14:45 -07:00
connectPlan = nil
teardownClient ()
}
/// Drop just the current client/channel — what moving to the next transport candidate
/// needs, without discarding the rest of the plan.
private func teardownClient () {
lanConnectTimeout ?. cancel ()
lanConnectTimeout = nil
2026-06-13 01:12:55 -07:00
eventTask ?. cancel ()
eventTask = nil
if let client { Task { await client . disconnect () } }
client = nil
}
}
extension Data {
/// Same fingerprint scheme as `DeviceIdentity.fingerprint` (first 8 bytes of SHA-256).
var fingerprintHex : String {
DeviceIdentity . fingerprint ( ofStaticKey : self )
}
}
2026-07-02 19:26:43 -07:00
extension WireSessionSummary {
/// 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 ,
2026-07-04 17:02:15 -07:00
updatedAt : updatedAt ?? Date (),
movedTo : movedTo , arrivedFrom : arrivedFrom )
2026-07-02 19:26:43 -07:00
}
}