nvrsion: Add: Verify Codex tool availability (auto-allow to codex), adjust file operation logic to match Claude, update tests for WorktreeMutationGuard, fix unreliable push-based Live Activities launch in iOS Canary via TestFlight, verify Codex tool availability, fix foreground connection delay when reopening the iOS app after backgrounding or locking, refactor system development to handle VM artifact-driven project path reconstruction via mounting and symlinking, display VM Import Status for apps imported to macos or linux VMs.

Nucleic-Promote: 1
Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
2026-07-09 14:56:47 -07:00
co-authored by Nucleic
parent 7cd77d2e60
commit 3cf44bd799
@@ -38,6 +38,11 @@ final class RemoteStore: ObservableObject {
if case .connected = self { return true } if case .connected = self { return true }
return false 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 @Published private(set) var connectivity: Connectivity = .unpaired
@@ -198,6 +203,121 @@ final class RemoteStore: ObservableObject {
for conn in live { conn.send(.approvalRespond(id, decision)) } 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, .deleteSession, .discard, .integrate,
.interrupt, .cancelQueuedMessage, .setSessionModel, .setSessionEffort, .setSessionAuto,
.setSessionAutoShip, .setSessionShipBranch, .approvalRespond:
return true
default:
return false
}
}
private static let lastOpenedKey = "nucleic.lastOpenedAt" private static let lastOpenedKey = "nucleic.lastOpenedAt"
private static func loadLastOpened() -> [SessionID: Date] { private static func loadLastOpened() -> [SessionID: Date] {
guard let raw = UserDefaults.standard.dictionary(forKey: lastOpenedKey) else { return [:] } guard let raw = UserDefaults.standard.dictionary(forKey: lastOpenedKey) else { return [:] }
@@ -324,6 +444,9 @@ final class RemoteStore: ObservableObject {
if isPaired { if isPaired {
// Show the saved chat history immediately, before any host connects. // Show the saved chat history immediately, before any host connects.
if sessions.isEmpty { sessions = cachedSummaries } if sessions.isEmpty { sessions = cachedSummaries }
// Assume connected while the first handshake completes, so launch doesn't open on a
// grayed-out "Disconnected" shell for the second it takes to come up.
beginOptimisticWindow()
reconnect() reconnect()
} }
} }
@@ -359,6 +482,11 @@ final class RemoteStore: ObservableObject {
func onForeground() { func onForeground() {
endBackgroundHold() endBackgroundHold()
guard !demoMode, isPaired else { return } 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 // 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 // push-to-starting it (UX_IOS §5.3). Sent to already-live hosts; ones still reconnecting get
// it via `didUpdate` once live. // it via `didUpdate` once live.
@@ -641,6 +769,9 @@ final class RemoteStore: ObservableObject {
self.rebuildAggregate() self.rebuildAggregate()
self.refreshAggregate() self.refreshAggregate()
self.flushPendingNotificationDecision() 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 // 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 // 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. // the glance cold when work starts before the app is opened.
@@ -796,18 +927,35 @@ final class RemoteStore: ObservableObject {
?? WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])) ?? WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: []))
setIfChanged(\.modelCatalog, ctx?.modelCatalog ?? .empty) setIfChanged(\.modelCatalog, ctx?.modelCatalog ?? .empty)
var targetConnectivity: Connectivity
var targetScope: DeviceScope
if let id = openSessionHostID, let conn = connections[id] { if let id = openSessionHostID, let conn = connections[id] {
// While a transcript is open, the composer/controls act on *that* Mac — its connectivity // While a transcript is open, the composer/controls act on *that* Mac — its connectivity
// gates send and its scope drives the control affordances. // gates send and its scope drives the control affordances.
setIfChanged(\.connectivity, conn.connectivity) targetConnectivity = conn.connectivity
setIfChanged(\.grantedScope, conn.grantedScope) targetScope = conn.grantedScope
} else { } else {
setIfChanged(\.connectivity, aggregateConnectivity()) targetConnectivity = aggregateConnectivity()
// Optimistic new-chat gating: enabled if *any* Mac grants control (the owning Mac still // Optimistic new-chat gating: enabled if *any* Mac grants control (the owning Mac still
// enforces scope when the intent lands there). // enforces scope when the intent lands there).
setIfChanged(\.grantedScope, connections.values targetScope = connections.values
.filter { $0.connectivity.isLive }.map(\.grantedScope).max() ?? .approve) .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 /// Assign a `@Published` property only when the value actually differs, so a no-op rebuild
@@ -1348,7 +1496,10 @@ final class RemoteStore: ObservableObject {
if demoMode { if demoMode {
demoHandle(.approvalRespond(approval.id, decision)) demoHandle(.approvalRespond(approval.id, decision))
} else { } else {
connection(owningSession: approval.sessionID)?.send(.approvalRespond(approval.id, decision)) // 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 openApprovals.removeAll { $0.id == approval.id } // optimistic dismiss; host confirms
} }
@@ -1476,6 +1627,13 @@ final class RemoteStore: ObservableObject {
/// to the first live Mac. Demo has no connections and mutates the seeded aggregate directly. /// to the first live Mac. Demo has no connections and mutates the seeded aggregate directly.
private func send(_ msg: ClientMsg) { private func send(_ msg: ClientMsg) {
if demoMode { demoHandle(msg); return } 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 { switch msg {
// Session-owning intents → the Mac that has this session. // Session-owning intents → the Mac that has this session.
case .subscribe(let s): case .subscribe(let s):