import Foundation
import NucleicTailnet
import Observation

/// Rollup numbers for the home dashboard.
public struct DashboardStats: Sendable, Equatable {
    public var projects: Int
    public var chats: Int
    public var activeChats: Int
    public var messages: Int
    public var activeDays: Int
    /// Total tokens used across every session (`Usage.totalTokens` summed). Surfaced as its own
    /// home stat card; the per-day breakdown lives in `AppStore.tokensByDay`.
    public var tokens: Int

    public init(
        projects: Int = 0, chats: Int = 0, activeChats: Int = 0, messages: Int = 0,
        activeDays: Int = 0, tokens: Int = 0
    ) {
        self.projects = projects
        self.chats = chats
        self.activeChats = activeChats
        self.messages = messages
        self.activeDays = activeDays
        self.tokens = tokens
    }

    public static let empty = DashboardStats()
}

/// The live state of a project's in-flight safe-move: a 0...1 completion `fraction`, a short
/// human `phase` label ("Copying files…"), and `updatedAt` — the timestamp of the last *real*
/// progress event (a phase change, a download-backlog shrink, or the copy destination growing).
/// Surfaced in the sidebar as a progress bar + liveness/"Moving" indicator beside the project
/// name. `updatedAt` lets the UI tell a live-but-slow move from a genuinely stalled one: when it
/// stops advancing for a while, the move is flagged stalled (and offers Cancel). `canceling` is
/// set once the user has asked to cancel — the UI then shows a calm "Canceling…" state instead of
/// the stall alarm, since an in-flight uninterruptible step may take a moment to wind down.
public struct MoveProgress: Sendable, Equatable {
    public var fraction: Double
    public var phase: String
    public var updatedAt: Date
    public var canceling: Bool

    public init(fraction: Double, phase: String, updatedAt: Date = Date(), canceling: Bool = false) {
        self.fraction = fraction
        self.phase = phase
        self.updatedAt = updatedAt
        self.canceling = canceling
    }
}

/// A chat reached a state worth an audible cue: it finished its work (`.done`) or it
/// now needs the user (`.blocked` — awaiting an approval, or a reply). NucleicApp
/// installs a handler on `AppStore.chatStatusSoundHook` that plays the matching macOS
/// system sound; NucleicCore itself stays free of AppKit. Fired only on live
/// transitions — `subscribe()` replays no history, so launch/resume stay silent.
public enum ChatStatusSound: Sendable {
    /// The chat's work is done (a turn classified `.completed`, or a finished run).
    case done
    /// The chat is blocked on the user — awaiting an approval, or a reply.
    case blocked
}

/// A compact, `Sendable` view of a session for the sidebar / dashboard list.
public struct SessionSummary: Sendable, Identifiable, Equatable {
    public let id: SessionID
    public let projectID: ProjectID
    public var title: String
    public var status: SessionStatus
    /// Refines `.awaitingInput`: whether the last turn ended asking the user
    /// something vs. finishing the work. `nil` until classified.
    public var disposition: TurnDisposition?
    public var diffStat: DiffStat?
    public var pendingApprovalCount: Int
    /// When the chat is blocked on an `AskUserQuestion` approval, the number of
    /// questions it's asking — drives the sidebar's "Question Asked" / "Questions
    /// Asked" label (plural for a multi-question call). `nil` when the pending block
    /// isn't a question (a plain approval) or there's none.
    public var pendingQuestionCount: Int?
    public var auto: Bool
    /// The session's selected reasoning effort, or the orchestra sentinel when orchestration
    /// mode is on — raw per-session state (not default-resolved), so `nil` means the chat
    /// inherits the app default. Drives the sidebar's orchestra marker via `isOrchestra`.
    public var effort: String?
    public var favorite: Bool
    public var archived: Bool
    /// The chat finished its work but the user hasn't opened it since — drives the
    /// sidebar's unread dot. Only meaningful together with `isCompleted`.
    public var unseenCompletion: Bool
    /// Autoship was the most recent thing to happen in this chat (a merge-queue note,
    /// with nothing since) — drives the sidebar's autoship icon. See
    /// `Session.lastEventWasAutoship`.
    public var lastEventWasAutoship: Bool
    /// Autoship disabled itself on a hard failure and the user hasn't yet seen it — drives
    /// the sidebar's "autoship needs attention" icon. Cleared on re-enable or on opening the
    /// chat. See `Session.autoShipFailed`.
    public var autoShipFailed: Bool
    /// Autoship hit a merge *conflict* but stayed armed — drives the same sidebar "needs
    /// attention" icon, without implying autoship is off. Cleared when the work lands or the
    /// user re-arms. See `Session.autoShipConflict`.
    public var autoShipConflict: Bool
    public var createdAt: Date
    public var updatedAt: Date
    /// When the user last sent a message here; drives the sidebar's ordering. Falls
    /// back to `createdAt` when sorting if the chat has never received one (or predates
    /// the tracking). See `Session.lastUserMessageAt`.
    public var lastUserMessageAt: Date?
    /// Set once this session was moved to another Mac (mesh P5) — the row is then a read-only
    /// tombstone (`archived == true`) shown under "Archived" as "Moved to <deviceName>". `nil`
    /// for a live session. The name is resolved from the paired-device store when built.
    public var movedTo: MovedDestination?
    /// The `HostID` of the Mac this session arrived from (mesh P5), or `nil` for one created here.
    /// Carried raw (not name-resolved) because it rides every live-session summary; the sidebar
    /// resolves the display name live from the paired-device list. Populated straight from the
    /// session, so no call site has to thread it.
    public var arrivedFromDeviceID: String?
    /// The `HostID` of the peer Mac this session *lives on* (mesh session sync), or `nil` for a
    /// session on this Mac. This is the origin marker that lets one summary type — and one row
    /// view, one selection path, one set of store verbs — represent local and remote sessions
    /// identically; the UI differentiates only by a globe. Remote summaries are built from the
    /// peer's mirrored `WireSessionSummary` (see `init(wire:hostID:)`).
    public var hostID: String?
    /// Set when this session is a machine-spawned subagent (Orchestra's `nucleic_subagent` tool) —
    /// the spawning parent session's id. Drives `isSubagent`: hidden from the sidebar, listed under
    /// its parent's Subagents panel. `nil` for a user-started chat. Not carried on the wire, so a
    /// remote summary always reads `nil` (see the Mesh caveat).
    public var spawnedBySessionID: SessionID?
    /// The root of this subagent's spawn lineage — the top-level chat every worker in the tree
    /// descends from (a subagent may itself spawn subagents). Lets a root chat's Subagents panel
    /// roll up its *entire* descendant tree, not just its direct workers. `nil` for a user-started
    /// chat; like `spawnedBySessionID`, not carried on the wire. See `Session.rootSpawnedBySessionID`.
    public var rootSpawnedBySessionID: SessionID?

    public init(
        _ session: Session, pendingApprovalCount: Int, pendingQuestionCount: Int? = nil,
        movedTo: MovedDestination? = nil
    ) {
        self.id = session.id
        self.projectID = session.projectID
        self.title = session.title
        self.status = session.status
        self.disposition = session.lastTurnDisposition
        self.diffStat = session.diffStat
        self.pendingApprovalCount = pendingApprovalCount
        self.pendingQuestionCount = pendingQuestionCount
        self.auto = session.auto
        self.effort = session.effort
        self.favorite = session.favorite
        self.archived = session.archived
        self.unseenCompletion = session.unseenCompletion
        self.lastEventWasAutoship = session.lastEventWasAutoship
        self.autoShipFailed = session.autoShipFailed
        self.autoShipConflict = session.autoShipConflict
        self.createdAt = session.createdAt
        self.updatedAt = session.updatedAt
        self.lastUserMessageAt = session.lastUserMessageAt
        self.movedTo = movedTo
        self.arrivedFromDeviceID = session.arrivedFromDeviceID
        self.hostID = nil
        self.spawnedBySessionID = session.spawnedBySessionID
        self.rootSpawnedBySessionID = session.rootSpawnedBySessionID
    }

    /// Build a summary for a session that lives on a peer Mac (mesh session sync), from the
    /// wire summary the peer mirrors to us. Fields the wire doesn't carry are view-state the
    /// owner tracks per-Mac (unseen dot, autoship attention markers) — they default to their
    /// quiet values rather than guessing. `pendingQuestionCount` *is* carried on the wire, so a
    /// remote's status line reads "Question(s) Asked" for an `AskUserQuestion` block instead of
    /// the generic "Awaiting approval", mirroring the host. `lastUserMessageAt` is carried on the
    /// wire so the shared sidebar comparators (favorites first, then last-turn recency) order
    /// remote rows on the last *user* message the way local ones do — not on every bit of agent
    /// activity, which would reshuffle the list every few seconds. `createdAt` falls back to
    /// `updatedAt` (the wire omits it); when a peer predates the field, `lastUserMessageAt` is
    /// `nil` and the comparator falls back to that, matching the old activity-based order.
    public init(wire: WireSessionSummary, hostID: String) {
        self.id = wire.sessionID
        self.projectID = ProjectID(rawValue: wire.projectID)
        self.title = wire.title
        self.status = wire.status
        self.disposition = wire.disposition
        self.diffStat = wire.diffStat
        self.pendingApprovalCount = wire.pendingApprovalCount
        self.pendingQuestionCount = wire.pendingQuestionCount
        self.auto = wire.auto
        self.effort = wire.effort
        self.favorite = wire.favorite
        self.archived = wire.archived
        self.unseenCompletion = false
        self.lastEventWasAutoship = false
        self.autoShipFailed = false
        self.autoShipConflict = false
        self.createdAt = wire.updatedAt
        self.updatedAt = wire.updatedAt
        self.lastUserMessageAt = wire.lastUserMessageAt
        self.movedTo = wire.movedTo
        self.arrivedFromDeviceID = wire.arrivedFrom?.deviceID
        self.hostID = hostID
        self.spawnedBySessionID = nil
        self.rootSpawnedBySessionID = nil
    }

    /// List-row identity for the sidebar's per-project session rows. Origin-qualified because
    /// the same `SessionID` can legitimately appear twice in the one sidebar `List` — a
    /// moved-away tombstone under its old local project while the live session is mirrored
    /// back under its new owner's project — and duplicate `ForEach` IDs make SwiftUI reuse one
    /// row's content for the other (the same bug `recentsRowID` guards against).
    public var sidebarRowID: String { "\(hostID ?? "local")-\(id.rawValue)" }

    /// Builds a summary from the session's pending approvals, deriving both the
    /// approval count and — when the active block is an `AskUserQuestion` — its
    /// question count, so the sidebar can name that block "Question(s) Asked".
    public init(_ session: Session, pendingApprovals: [ApprovalRequest]) {
        self.init(
            session,
            pendingApprovalCount: pendingApprovals.count,
            pendingQuestionCount: SessionSummary.questionCount(of: pendingApprovals.first))
    }

    /// The number of questions an `AskUserQuestion` approval is asking, or `nil` if the
    /// active block isn't one (a plain approval, or none).
    static func questionCount(of approval: ApprovalRequest?) -> Int? {
        guard let approval, approval.toolName == AskUserQuestion.toolName,
            let questions = AskUserQuestion.questions(from: approval.input)
        else { return nil }
        return questions.count
    }

    /// Whether this chat is a machine-spawned subagent (Orchestra worker) — the marker the sidebar
    /// filters on and the Subagents panel lists by. See `spawnedBySessionID`.
    public var isSubagent: Bool { spawnedBySessionID != nil }

    /// Whether the user has selected orchestra (orchestration) mode for this chat — the *raw*
    /// per-session effort, not control-gated. This stays true for a stray "orchestra" effort on a
    /// project that isn't (or is no longer) under Nucleic Control, where the host actually withholds
    /// the fan-out consent (`SessionController.orchestraActive`). UI that signals "orchestra is on"
    /// (the sidebar's gold marker) should gate on `AppStore.orchestraActive(_:)` instead, so it
    /// never implies an orchestration the host has demoted to plain `xhigh`.
    public var isOrchestra: Bool { OrchestrationMode.isOrchestra(effort) }

    /// The sidebar's status line. Refines the raw `SessionStatus` label two ways: a
    /// completed interactive turn reads "Done" rather than "Awaiting input", and a chat
    /// blocked on an `AskUserQuestion` names that block "Question Asked" / "Questions
    /// Asked" (plural for a multi-question call) instead of the generic "Awaiting approval".
    public var statusLabel: String {
        if let pendingQuestionCount {
            return pendingQuestionCount > 1 ? "Questions Asked" : "Question Asked"
        }
        if status == .awaitingInput, disposition == .completed { return "Done" }
        return status.displayName
    }

    /// Whether the chat's work is *done* and nothing is required from the user — a
    /// finished run, or an interactive chat whose last turn was classified
    /// `.completed`. A chat that ended asking a question, has a pending approval, or
    /// hasn't been classified yet is not "completed". Drives auto-archive eligibility.
    public var isCompleted: Bool {
        switch status {
        case .finished: return true
        case .awaitingInput: return disposition == .completed
        default: return false
        }
    }

    /// Whether this chat is *blocked on the user* and should pull their eye in the
    /// sidebar (drives the row's attention pulse). True when it's waiting on a decision
    /// (a pending approval, or status `.awaitingApproval`), ended its last turn asking
    /// the user something (`.awaitingInput` not classified `.completed`), or autoship
    /// stalled needing a hand (a hard failure, or an armed-but-conflicted merge). A chat
    /// that simply *finished* its work is not "needs attention" — it carries the unread
    /// dot (`unseenCompletion`) instead. Mirrors `ProjectView`'s home "Needs your
    /// attention" predicate, widened with the approval/autoship markers the row shows.
    public var needsAttention: Bool {
        if pendingApprovalCount > 0 || autoShipFailed || autoShipConflict { return true }
        switch status {
        case .awaitingApproval: return true
        case .awaitingInput: return disposition != .completed
        default: return false
        }
    }
}

/// Open to-dos for one project (or the unassigned bucket), with a glanceable
/// model/heuristic summary of the cluster — the home to-do inbox, grouped.
public struct TodoGroup: Identifiable, Sendable, Equatable {
    /// The project's id, or "" for the unassigned bucket.
    public let id: String
    public let projectID: ProjectID?
    public let projectName: String
    public var todos: [Todo]
    /// One-line gist of the group's ideas ("" until generated).
    public var summary: String

    public init(id: String, projectID: ProjectID?, projectName: String, todos: [Todo], summary: String) {
        self.id = id
        self.projectID = projectID
        self.projectName = projectName
        self.todos = todos
        self.summary = summary
    }
}

/// The top-level UI state and object-graph root (RUNTIME §1). `@MainActor` +
/// `@Observable`: it owns the projects/session list and spawns/owns a
/// `SessionController` per live session, observing each one's canonical event
/// stream to keep the observable summaries + open transcript current. The UI binds
/// to this and sends intents; it never touches a backend or git directly.
@MainActor
@Observable
public final class AppStore: ConflictArbiter {
    /// Builds the backend for a new session. The app injects a `ClaudeCodeBackend`
    /// factory; tests inject a scripted one.
    public typealias BackendFactory = @Sendable (Session) -> any AgentBackend

    public private(set) var projects: [Project] = []
    /// Archived projects (hidden from the active list), most-recently-archived first.
    public private(set) var archivedProjects: [Project] = []
    /// Whether a Nucleic Control project exists, which *pins* the container-service master
    /// switch on (Control can't function without it). While true, Settings forces the
    /// "Enable container service" toggle on and disables it so the user can't strand a
    /// control project by turning the service off underneath it. Maintained by
    /// `reconcileContainerService()`; read by the Sandbox/Control settings tabs.
    public private(set) var containerServiceLockedOn: Bool = false

    /// Latest result of the sandbox base-image asset auto-update check (registry manifest-digest
    /// compare). Drives the Settings ▸ Control maintenance surface: `.updateAvailable` invites the
    /// user to apply it via Force re-creation. Updated by `checkForImageUpdate()` (launch + periodic
    /// background checks and the manual "Check for updates" button). `@ObservationIgnored`-free so the
    /// settings row observes it.
    public private(set) var imageUpdateStatus: ImageUpdateStatus = .idle
    /// The periodic background image-update check loop; cancelled/restarted with the service.
    @ObservationIgnored private var imageUpdateCheckTask: Task<Void, Never>?

    public private(set) var summaries: [SessionSummary] = []
    /// Captured ideas (the home "to-do" inbox), most-recently-updated first.
    public private(set) var todos: [Todo] = []
    /// Drives the global quick-add to-do sheet (toggled by the Cmd-T command).
    public var quickTodoPresented: Bool = false
    /// Drives the global floating new-chat composer (toggled by the ⌘N / ⌘⇧N commands) — a
    /// quick-launch version of the home chat bar, presented over the window from anywhere.
    public var newChatComposerPresented: Bool = false
    /// Whether the floating composer, when its chat starts, should leave the user on their
    /// current view (⌘N, background) rather than jumping to the new session (⌘⇧N, reveal).
    public var newChatComposerStartsInBackground: Bool = false
    /// Whether the floating composer should open straight into ephemeral (Scratch) chat mode,
    /// set by the "New Scratch Chat" command. Reset each time the composer is presented.
    public var newChatComposerStartsEphemeral: Bool = false
    /// Project to pre-select in the quick-add sheet (set when capturing from a
    /// project page; nil for a global Cmd-T capture).
    public var quickTodoProjectID: ProjectID?
    /// When set, the edit-to-do sheet is shown for this idea (home or project page).
    public var editingTodoID: TodoID?
    /// Per-group to-do summaries, keyed by group id (project id, or "" unassigned).
    /// Filled asynchronously; `todoGroups` reads it so the UI updates as they land.
    private var todoSummaries: [String: String] = [:]
    /// Content fingerprint per group so a group is only re-summarized when its ideas
    /// actually change (avoids a model call on every unrelated mutation).
    private var todoSummarySignatures: [String: Int] = [:]
    // The collapsed tool-call block's AI headline is built family-by-family. Bash takes a
    // map→reduce path: each command is gisted once (`bashCallSummaries`), and the block's
    // running list of gists is condensed into one live line (`bashBlockLines`), re-merged
    // additively as the block grows. Every other eligible family (Grep/Glob/… under the
    // "all" scope) is merged once it settles (`familySummaryLines`). The view composes
    // these per-family, showing a loading placeholder for an eligible family until its
    // line lands — so the deterministic heuristic never flashes in for a summarized call.

    /// Per-command Bash gist (the map step), keyed by the single call's content signature
    /// (`TranscriptProjection.toolGroupSignature([call])`) so each distinct command is
    /// summarized exactly once and reused as the block grows. Filled asynchronously.
    private var bashCallSummaries: [Int: String] = [:]
    /// Single-call signatures with a map task in flight (don't re-summarize the same command).
    private var bashCallSummariesInFlight: Set<Int> = []
    /// The merged Bash line per block (the reduce step), keyed by the block's STABLE id —
    /// the first Bash call's `toolCallID`, which doesn't change as the run appends — so the
    /// line updates in place (no flicker) as the block grows. Filled asynchronously; the
    /// transcript's Bash block reads it as it lands.
    private var bashBlockLines: [String: String] = [:]
    /// The signature of the per-command gist list that produced `bashBlockLines[id]`, so a
    /// re-merge fires exactly when the block's gists actually change (a new command lands)
    /// and not on every unrelated transcript mutation.
    private var bashBlockInputs: [String: Int] = [:]
    /// Block ids with a map/reduce task in flight. Without this guard each event spawns a
    /// rival task; the on-device model is non-deterministic, so rivals write different lines
    /// and the headline visibly flashes. A block is worked exactly once per gist-list change.
    private var bashBlocksInFlight: Set<String> = []
    /// AFM-merged lines per non-Bash eligible family (Grep/Glob/… under the "all tool calls"
    /// scope), keyed by the family's content signature. Filled asynchronously once the
    /// family settles. Read families are never here — they always get the heuristic merge.
    private var familySummaryLines: [Int: [String]] = [:]
    /// Family signatures with a summary task in flight (summarize each settled family once).
    private var familyLinesInFlight: Set<Int> = []
    /// Tool-group signatures already present when the open chat was loaded from a PRIOR
    /// app run — its pre-existing history. These are never AI-summarized: opening an old
    /// chat must not fire a burst of on-device model calls over its whole transcript.
    /// Only groups produced by live turns after a chat is opened get summarized. Empty
    /// for chats created this run (all their activity is live). Recomputed on each open.
    private var historicalToolGroupSignatures: Set<Int> = []
    /// Sessions created during this app run. Their transcripts have no "history" to skip —
    /// every tool group is live activity, so opening one summarizes everything (whereas a
    /// chat carried over from a previous run baselines its loaded groups as historical).
    private var sessionsCreatedThisRun: Set<SessionID> = []
    /// Home-dashboard rollup across all projects/sessions (recomputed on demand).
    public private(set) var dashboard: DashboardStats = .empty
    /// User-message counts per calendar day, for the activity grid and the streak.
    public private(set) var activityByDay: [Date: Int] = [:]
    /// Tokens used per calendar day — the activity grid's primary intensity signal (it falls back
    /// to `activityByDay` for days with no recorded usage), and the per-day series behind the
    /// dashboard's total-tokens metric.
    public private(set) var tokensByDay: [Date: Int] = [:]
    public var openSessionID: SessionID? {
        didSet {
            guard oldValue != openSessionID else { return }
            // Tear down any open *remote* session subscription (mesh session sync): stop consuming
            // the peer's stream and unsubscribe on it. `openHostID` resets to local; a remote open
            // re-sets it right after assigning `openSessionID` (see `openRemoteSession`).
            if openHostID != nil {
                remoteSessionTask?.cancel()
                remoteSessionTask = nil
                let pc = peerClient
                Task { await pc?.closeRemoteSession() }
            }
            openHostID = nil
            // Opening a chat is the user "returning to view it" — silence its escalating alarm.
            if let opened = openSessionID { updateAlarm(clearing: opened) }
            // Reset synchronously so the (possibly shorter) newly-selected session's
            // transcript can replace the previous one; the async reload then fills it.
            openTranscript = []
            openTranscriptVersion &+= 1
            openApprovals = []
            openSession = nil
            openSummary = ""
            clearToolGroupSummaries()  // the previous session's tool-group summaries don't apply
            historicalToolGroupSignatures = []  // recomputed for the newly-opened chat in reloadOpen
            summaryToken += 1  // invalidate any in-flight summary for the previous session
            intelligenceWorkSeq += 1
            // Mesh session sync: a session that lives on a peer Mac routes remotely from
            // right here — the one selection path — so every writer of `openSessionID`
            // (sidebar taps, Recents, deep links) opens local and remote sessions
            // identically. Local records win inside `remoteSessionLocation`, so a moved-away
            // tombstone still opens as the local tombstone, not its remote twin.
            if let id = openSessionID, let remote = remoteSessionLocation(id) {
                openHostID = remote.deviceID
                openSession = Self.remoteSession(from: remote.summary)
                remoteSessionTask = Task { [weak self] in
                    await self?.streamRemoteSession(id, on: remote.deviceID)
                }
            }
            reloadTask = Task { await reloadOpen() }
        }
    }
    /// When the open session lives on a *peer* Mac (mesh session sync), its `HostID`; `nil` for a
    /// local session. Drives remote transcript streaming + routes input/approvals to the owner.
    public private(set) var openHostID: String?
    private var remoteSessionTask: Task<Void, Never>?
    /// When set (and no session is open), the detail pane shows the project
    /// overview page instead of the home dashboard. Cleared by `goHome()`.
    public var openProjectID: ProjectID?
    public private(set) var openTranscript: [AgentEvent] = []
    /// Monotonic token bumped on every assignment to `openTranscript` (the three writers:
    /// the session-switch reset above, `revertOpenSession`, and `applyOpenTranscript`). The
    /// transcript view keys its projection cache on this, so the many body re-evaluations
    /// that don't touch the transcript (scrolling, hover, viewport measurement) can skip
    /// recomputing the O(n) projection. A plain `count`/`last.seq` signature is *not* enough:
    /// `revert` resets the canonical seq counter (Transcript.swift), so a revert-then-regrow
    /// can reuse a prior (count, seq) pair over different content — a strictly increasing
    /// version cannot collide.
    public private(set) var openTranscriptVersion: Int = 0
    public private(set) var openApprovals: [ApprovalRequest] = []
    /// Full state of the open session (title, model, effort, status…) for the detail view.
    public private(set) var openSession: Session?
    /// Model-generated "what just happened" blurb for the open session's summary card.
    public private(set) var openSummary: String = ""
    public private(set) var summarizing: Bool = false
    /// Surfaced transient error for the UI; settable so views can post their own.
    /// Changing the message clears any deep-link (`lastErrorSessionID`) so a generic
    /// error never inherits a stale chat target — set the link *after* the message.
    public var lastError: String? {
        didSet { if lastError != oldValue { lastErrorSessionID = nil; lastErrorIsBenign = false } }
    }
    /// When `lastError` was raised by a specific chat (e.g. autoship), the session it
    /// belongs to — lets the error banner deep-link straight to the responsible chat.
    public internal(set) var lastErrorSessionID: SessionID?
    /// True when the current `lastError` is really an informational note (e.g. "nothing on
    /// the trunk to promote"), not a failure — the banner renders it green instead of red.
    /// Reset to false whenever `lastError` changes, so it never leaks onto the next real error;
    /// set it *after* assigning `lastError`.
    public internal(set) var lastErrorIsBenign: Bool = false
    /// The conflict currently shown to the user (head of `conflictQueue`), or nil. The
    /// asking agent is blocked until `resolveConflict` answers it.
    public private(set) var pendingConflict: ConflictPrompt?

    /// Progress for any in-flight safe-move (relocate a project's repo to a new path),
    /// keyed by project. The move runs in the background so the rest of Nucleic stays
    /// usable; only the moving project is locked. Drives the sidebar's per-project
    /// "Moving…" lock + progress bar, and guards against re-entrancy. See `moveProject`.
    public private(set) var moves: [ProjectID: MoveProgress] = [:]

    /// A cancel handle for each in-flight move/convert, so `cancelMove` can stop it (type-erased
    /// so it holds both `moveProject`'s and `cloneProjectIntoControl`'s differently-typed tasks).
    /// Cancellation is cooperative: the pipeline checks `Task.isCancelled` at each step and rolls
    /// back any partial copy, leaving the original untouched. An in-flight bulk `copyItem` isn't
    /// interruptible, so a cancel during the copy takes effect at the next step boundary — but the
    /// long iCloud download phase (where moves most often appear stuck) bails promptly.
    private var moveTasks: [ProjectID: @Sendable () -> Void] = [:]

    /// The Smart Move diagnostics logger for each in-flight move/convert (only writing when the
    /// "Smart Move logging" setting is on; otherwise a no-op). Held here so `cancelMove` can note
    /// the cancel and the orchestration can trace + finish it. See `MoveLog`.
    private var moveLogs: [ProjectID: MoveLog] = [:]

    /// True while any project is being moved. Kept for callers that only need to know a
    /// move is in flight (e.g. tests waiting for completion).
    public var moving: Bool { !moves.isEmpty }

    /// Progress for the in-flight move of `id`, or nil if it isn't moving — the sidebar
    /// reads this to gray the project out and draw its progress bar.
    public func moveProgress(of id: ProjectID) -> MoveProgress? { moves[id] }

    /// Request cancellation of the in-flight move/convert of `id`. Best-effort and cooperative:
    /// the move stops at its next checkpoint, cleans up any partial copy, and leaves the original
    /// in place. No-op if `id` isn't moving.
    public func cancelMove(_ id: ProjectID) {
        guard let cancel = moveTasks[id] else { return }
        moveLogs[id]?.line("cancel requested by user at "
            + "\(Int((moves[id]?.fraction ?? 0) * 100))% (\(moves[id]?.phase ?? "?"))")
        if let current = moves[id] {
            // Flag canceling so the UI shows a calm "Canceling…" rather than the stall alarm while
            // an in-flight step winds down, and stamp `updatedAt` fresh so it isn't already "stale".
            moves[id] = MoveProgress(
                fraction: current.fraction, phase: "Canceling…", updatedAt: now(), canceling: true)
        }
        cancel()
    }

    /// Record a phase + completion for the in-flight move of `id` (no-op once the move
    /// has finished and been cleared, so late async callbacks can't resurrect it). Stamps
    /// `updatedAt` so the UI's stall detector sees this as a fresh, real progress event. Once the
    /// user has asked to cancel, the `canceling` flag (and its "Canceling…" label) is sticky — a
    /// late heartbeat mustn't flip the UI back to a normal working state.
    private func reportMove(_ id: ProjectID, _ fraction: Double, _ phase: String) {
        guard let current = moves[id] else { return }
        if current.canceling {
            moves[id] = MoveProgress(
                fraction: fraction, phase: "Canceling…", updatedAt: now(), canceling: true)
        } else {
            moves[id] = MoveProgress(fraction: fraction, phase: phase, updatedAt: now())
        }
    }

    /// Watch a copy/clone `dest` grow while the (opaque, uninterruptible) bulk copy runs, easing
    /// the move's fraction toward `band.upperBound` each time real bytes land. Because it reports
    /// *only* on growth, a wedged copy goes quiet and the UI flags it stalled. Returns a task the
    /// caller cancels once the copy finishes. The size walk runs off the main actor.
    private func startCopyHeartbeat(
        _ id: ProjectID, dest: String, band: ClosedRange<Double>, log: MoveLog?
    ) -> Task<Void, Never> {
        Task { [weak self] in
            var lastSize: Int64 = -1
            while !Task.isCancelled {
                try? await Task.sleep(nanoseconds: 1_500_000_000)
                if Task.isCancelled { return }
                let size = await Task.detached { ProjectRelocator.directoryAllocatedSize(dest) }.value
                guard let self, self.moves[id] != nil else { return }
                if size > lastSize {
                    lastSize = size
                    let current = self.moves[id]?.fraction ?? band.lowerBound
                    let next = min(band.upperBound, current + (band.upperBound - current) * 0.2)
                    self.reportMove(id, next, self.moves[id]?.phase ?? "Copying files…")
                    log?.line("copy heartbeat: destination grew to "
                        + "\(ByteCountFormatter.string(fromByteCount: size, countStyle: .file)) "
                        + "(\(Int(next * 100))%)")
                }
            }
        }
    }

    /// A clone-and-register (Add Project from a git URL) is in flight — drives the add
    /// sheet's spinner and guards against re-entrancy. See `addClonedProject`.
    public private(set) var cloning = false

    /// Soft-AI provider for summaries + auto-naming (the app swaps in Apple
    /// Foundation Models; defaults to a local heuristic for tests / when off).
    public var intelligence: any IntelligenceProviding = HeuristicIntelligence()
    /// Installed by NucleicApp to play a macOS system sound the moment a chat's
    /// displayed status settles to "Done" (`.done`) or "needs you" (`.blocked`).
    /// Called on the main actor; the per-cue on/off switches live in the app layer.
    /// Left `nil` in tests/headless, where firing is a no-op.
    public var chatStatusSoundHook: (@MainActor (ChatStatusSound) -> Void)?
    /// Installed by NucleicApp to drive the *escalating* alarm — a system sound repeated
    /// every few seconds while a chat needs the user and they aren't looking, until they
    /// open it. Called with `true` when at least one chat is blocked-and-unviewed and
    /// `false` once none remain (see `alarmingSessions`). Off by default; the app layer
    /// owns the repeating timer and the Settings switch. Left `nil` in tests/headless.
    public var chatAlarmHook: (@MainActor (Bool) -> Void)?
    /// Installed by NucleicApp to drive the Dock-icon bounce. Fired with the *same* edge as
    /// `chatAlarmHook`: `true` the moment a chat becomes blocked-and-unviewed, `false` once
    /// none remain (see `alarmingSessions`). The app layer adds the "for more than a minute"
    /// delay — only if a chat is *still* blocked-and-unviewed a minute later does the Dock icon
    /// begin bouncing (until the app is foregrounded). It also owns the on-by-default Settings
    /// switch. Left `nil` in tests/headless.
    public var chatDockBounceHook: (@MainActor (Bool) -> Void)?
    private var summaryToken = 0
    private var namedSessions: Set<SessionID> = []
    /// Per-session bookkeeping for the one-time post-run rename (see `maybeRenameSession`):
    /// the opening message and the provisional title we assigned, plus the sessions whose
    /// rename pass has already run so it fires at most once.
    private var firstMessages: [SessionID: String] = [:]
    private var autoTitles: [SessionID: String] = [:]
    private var renamedSessions: Set<SessionID> = []

    /// When true, `openTodos` is ordered by the stored triage ranking instead of FIFO.
    public private(set) var triageSortEnabled = false
    /// A background triage pass is in flight (the Triage button shows a spinner). True
    /// while ANY per-project pass runs; flips off only once they've all settled.
    public private(set) var triaging = false
    /// Per-project token, bumped each time that project's triage pass is issued; a pass
    /// stamps its verdicts only while still current, so a later add/edit's pass supersedes
    /// an in-flight one of the SAME project (and never disturbs another project's pass).
    /// Keyed like `todoGroups` (project id, or "" for the unassigned bucket).
    private var triageTokens: [String: Int] = [:]
    /// How many per-project triage passes are currently outstanding; `triaging` mirrors
    /// `> 0` so concurrent passes don't clear the spinner before the last one finishes.
    private var outstandingTriagePasses = 0

    /// In-flight intelligence work, tracked so tests can deterministically await it
    /// (see `awaitOpenSessionSettled`) instead of sleeping. These chain: reopening a
    /// session can spawn a summary, and a turn's classification spawns a summary too.
    private var reloadTask: Task<Void, Never>?
    private var classifyTask: Task<Void, Never>?
    private var summaryTask: Task<Void, Never>?
    /// Most recently issued tool-group summary task (one of possibly several per
    /// transcript update); drained by the settle helper alongside the others.
    private var toolGroupTask: Task<Void, Never>?
    /// Trailing catch-up flush for the open session's display state, scheduled when events
    /// arrive faster than `openDisplayRefreshInterval` (see `refreshOpenDisplayThrottled`).
    /// Drained by the settle helper so tests observe the fully-applied transcript.
    private var openDisplayCatchUpTask: Task<Void, Never>?
    /// When the most recent display refresh began, driving the throttle window.
    private var lastOpenDisplayRefreshAt: ContinuousClock.Instant?
    /// The session whose `runFinished` is awaiting its display flush — carried across a
    /// coalesced window so the trailing refresh still regenerates the turn summary. Keyed
    /// by session so a chat switch mid-window can't leak a stale turn-end into the new chat.
    private var openDisplayPendingTurnEnd: SessionID?
    /// Minimum spacing between two open-display refreshes during an event burst. One frame
    /// at ~12 fps: streaming text still reads as live, while a burst of hundreds of events
    /// (a long tool run, a fat turn replay) collapses into a handful of O(n) passes instead
    /// of one per event.
    private static let openDisplayRefreshInterval: Duration = .milliseconds(80)
    /// Bumped whenever any of the tasks above is (re)issued; lets the settle helper
    /// detect work spawned while it was awaiting and loop until none remains.
    private var intelligenceWorkSeq = 0
    /// Highest canonical event seq the observer has finished ingesting, per session.
    /// Lets the settle helper confirm the observer has caught up to a run's terminal
    /// event (and thus spawned its classification) before draining tasks.
    private var lastIngestedSeq: [SessionID: UInt64] = [:]

    /// Defaults applied to newly-created chats (set by the app from Settings).
    public var defaultModel: String?
    public var defaultEffort: String?
    public var defaultSupervisorModel: String?
    public var defaultOrchestraWorkerModel: String?
    /// Cap on how many Orchestra workers one supervisor may run concurrently (set by the app from
    /// Settings). `<= 0` means unlimited. Enforced by `spawnOrchestraSubagent`, which parks a
    /// worker's `nucleic_subagent` call until a slot frees, so an over-eager fan-out queues rather
    /// than launching an unbounded wave. Defaults to a modest cap so a headless/test store that's
    /// never configured still bounds concurrency.
    public var defaultOrchestraMaxConcurrentWorkers: Int = 4
    public var defaultAuto: Bool = false
    public var defaultAutoShip: Bool = false

    /// Idle window after which a *completed* chat is auto-archived, in seconds (set by
    /// the app from Settings). `nil` — the "Never" choice — disables the sweep. Left
    /// `nil` by default so a store that's never configured (tests, headless) doesn't
    /// archive anything on its own; the app sets the real threshold at launch.
    public var autoArchiveIdleInterval: TimeInterval?

    /// How long a chat may stay archived before its worktree checkout is reclaimed to free
    /// disk (set by the app from Settings), in seconds. The branch is kept and any WIP is
    /// committed first, so unarchiving the chat re-creates the worktree exactly. `nil` — the
    /// "Never" choice — disables cleanup. Left `nil` by default so an unconfigured store
    /// (tests, headless) never deletes anything; the app sets the real threshold at launch.
    public var archivedWorktreeCleanupInterval: TimeInterval?

    /// Whether to summarize runs of tool calls (set by the app from Settings). The
    /// calls are coalesced into one block either way; when false the block lists them
    /// plainly, so the AFM summary work for tool-call groups is skipped entirely.
    /// Toggling it re-summarizes (or clears) the open transcript's groups right away.
    public var summarizeToolCalls: Bool = true {
        didSet { if summarizeToolCalls != oldValue { regenerateToolGroupSummaries() } }
    }

    /// Which tool-call families the AI rewrites when `summarizeToolCalls` is on (Bash
    /// only, or every non-Read family — Read is always a deterministic merge). The
    /// summary cache is keyed by group content alone, so a scope change must clear it
    /// before re-summarizing or stale lines from the old scope would survive.
    public var toolSummaryScope: ToolSummaryScope = .bashOnly {
        didSet {
            guard toolSummaryScope != oldValue else { return }
            clearToolGroupSummaries()
            regenerateToolGroupSummaries()
        }
    }

    /// The home chat bar's in-progress message. Held here (not in the transient
    /// HomeView) so it survives bouncing between the dashboard and open sessions.
    public var homeDraft: String = ""

    private let database: any SessionMetadataStore
    private let worktrees: any WorktreeManaging
    private let backendFactory: BackendFactory
    private let transcriptsDir: URL
    private let now: @Sendable () -> Date
    /// Shared sandbox orchestrator (nil in tests / non-sandbox builds). Same instance the
    /// backend factory hands to each `ClaudeCodeBackend`, so lifecycle stays consistent.
    private let containerManager: ContainerManager?
    /// The macOS-guest VM orchestrator (Apple `Virtualization`), for the `mac_vm_exec` tool and the
    /// Virtual Machines settings panel. `nil` in tests / host-only setups. See docs/MACOS_VM.md.
    let macVMManager: MacVMManager?
    /// Shared conflict coordinator (nil in tests). The same instance the backend factory hands
    /// to each `ClaudeCodeBackend`; `activateConflictArbitration()` registers self as its arbiter.
    private let conflictCoordinator: ConflictCoordinator?

    private var controllers: [SessionID: SessionController] = [:]
    private var projectsByID: [ProjectID: Project] = [:]
    private var observers: [SessionID: Task<Void, Never>] = [:]

    // MARK: - Ephemeral (out-of-project) chats

    /// Reserved id for the single hidden pseudo-project that owns every ephemeral, out-of-project
    /// chat. It has no DB row and never enters `projects`/`projectsByID`, so it can't leak into the
    /// sidebar, project pickers, the dashboard, or peer/iOS sync — its sessions live only in the
    /// popup Scratch window. Chats under it run the full agent (tools and all) in a disposable
    /// per-session scratch dir instead of a project worktree; there's no branch, worktree, or ship.
    public static let ephemeralProjectID = ProjectID(rawValue: "nucleic.ephemeral")

    /// Base directory for per-session ephemeral scratch workspaces: `<support>/scratch` (a sibling
    /// of the owned `sessions/` transcript tree). Each ephemeral chat gets its own git-init'd
    /// subdirectory `<base>/<sessionID>` so concurrent scratch chats never collide.
    var ephemeralScratchBase: URL {
        transcriptsDir.deletingLastPathComponent().appendingPathComponent("scratch", isDirectory: true)
    }

    /// The in-memory record backing ephemeral chats. Never persisted and never added to
    /// `projects`/`projectsByID`; `resolvedProject(_:)` is the only way to reach it. Non-control, so
    /// its sessions run on the host with full tools and never gain autoship/orchestra.
    var ephemeralProject: Project {
        Project(
            id: Self.ephemeralProjectID, name: "Scratch", rootPath: ephemeralScratchBase.path,
            defaultBranch: GitRef("main"), defaultBackend: .claudeCode)
    }

    /// Ephemeral sessions the user has marked "Saved" (kept, project-less) rather than truly
    /// ephemeral (discarded with their scratch dir when their window closes). Seeded at creation
    /// from the `savedByDefault` setting; flipped by the in-window Ephemeral/Saved switch. Saved
    /// chats already persist in the DB under the reserved id, so this set only governs the
    /// discard-on-close decision — a truly-ephemeral chat is absent from it.
    private var ephemeralPersistedSessions: Set<SessionID> = []

    /// On-disk cache of transcripts synced from other devices (full-transcript mesh sync), a
    /// sibling of the owned `sessions/` tree. Lets this Mac serve and instantly open a peer's
    /// *full* history rather than a 200-event tail. Lazily created from `transcriptsDir`;
    /// `@ObservationIgnored` because it's an actor handle, not observable UI state (and `@Observable`
    /// forbids a plain `lazy var`).
    @ObservationIgnored lazy var mirrorStore = TranscriptMirrorStore(
        root: transcriptsDir.deletingLastPathComponent()
            .appendingPathComponent("mirrors", isDirectory: true))

    /// Keeps transcript mirrors caught up with the mesh (full-transcript mesh sync). Created when
    /// the peer client starts; nil while the sync server is down. Driven by peer presence updates
    /// and a periodic ticker.
    private var transcriptSyncCoordinator: TranscriptSyncCoordinator?
    private var transcriptSyncTicker: Task<Void, Never>?

    /// Autoship merge queue: serializes per-target merges of finished autoship sessions.
    /// Created here; its integrator + status observer are wired in `activateConflictArbitration`.
    private let mergeQueue = MergeQueue()
    private var mergeQueueObserver: Task<Void, Never>?
    /// The dedicated file-lock system (LOCKING §4): authoritative per-root-tree locks that
    /// replace the old `unmergedFiles`-derived "lock". Acquired via `arbitrate` on edit, released
    /// by `reconcileLocks` once work lands in the parent (any merger), plus mediated/lifecycle.
    private let lockManager = LockManager()
    /// Host-side coordinator for the shared nvrsion trunk (NVRSION). Owns each nvrsion project's
    /// `nucleic/trunk` checkout and lands per-edit commits into it; nil-effect for non-nvrsion
    /// projects (it's only ever invoked when `project.nvrsionActive`).
    private let nvrsionTrunk = NvrsionTrunk()
    /// Keep-warm governor (NVRSION §4): tracks which landed files a session still holds and which
    /// have gone idle past their project's `keepWarmIdle`, so the sweep can release them mid-turn.
    /// Internal (not private) so tests can drive `warmed`/`sweep` against a controlled clock.
    let nvrsionGovernor: NvrsionReleaseGovernor
    /// The keep-warm sweep loop; nil when nothing is warm (NVRSION §4). Restarts on the next land.
    private var nvrsionKeepWarmTask: Task<Void, Never>?
    /// How often the keep-warm sweep runs while files are warm. Internal so tests can shorten it (or
    /// drive `runNvrsionSweep()` directly with a controlled clock).
    var nvrsionKeepWarmInterval: Duration = .seconds(1)
    /// Per-project: does the nvrsion trunk hold work not yet integrated into the base branch? Drives
    /// the sidebar's Integrate button — an up-arrow when there's work to promote, a checkmark once
    /// everything's integrated. Updated optimistically when work lands and after a promote, and
    /// recomputed from git on launch (`reconcileNvrsionTrunks`). Absent/`false` ⇒ nothing to integrate.
    public private(set) var nvrsionPendingByID: [ProjectID: Bool] = [:]
    /// Per-project: the reason a promote couldn't complete — a conflict or a hard failure — or nil
    /// when the last attempt was clean. Drives the sidebar's red-✗ Integrate state: when set, the
    /// trunk has work that *could not be integrated automatically* and needs a hand (resolve on the
    /// base branch, then retry the button). Set on a conflicted/failed promote; cleared on a clean
    /// one (`.promoted`/`.nothingToPromote`) or once the trunk recomputes to nothing-pending. While
    /// set, the auto-integrate countdown stands down — it won't keep retrying a wedged promote.
    public private(set) var nvrsionIntegrateFailureByID: [ProjectID: String] = [:]
    /// Per-project armed countdown to auto-integrate (NVRSION §6): once a project's trunk has been
    /// continuously *integratable* — pending work, no chat mid-turn, not relocating — for
    /// `nvrsionAutoIntegrateDelay`, this fires `promoteNvrsionTrunk` on its own, so a finished batch
    /// of agents ships itself without a manual press. The task is cancelled the instant availability
    /// lapses (a chat picks up a turn, a move starts, a manual promote lands), so it only ever fires
    /// against a genuinely-quiet, still-pending project. Absent ⇒ no countdown in flight.
    private var nvrsionAutoIntegrateTasks: [ProjectID: Task<Void, Never>] = [:]
    /// How long a project's trunk must stay continuously integratable before it auto-promotes.
    /// Internal so tests can shorten it (or drive `fireNvrsionAutoIntegration` directly).
    var nvrsionAutoIntegrateDelay: Duration = .seconds(3)
    /// Repo-relative paths each nvrsion session has landed into the trunk during its *current* turn
    /// (NVRSION §6). Accumulated as edits land and consumed at turn-end (`.runFinished`) to fold a
    /// per-turn digest into the trunk's running change summary, then cleared. Keyed by session.
    private var nvrsionTurnLanded: [SessionID: Set<String>] = [:]
    /// Per-project serial chain for the running-summary fold (NVRSION §6): each turn's fold awaits
    /// the prior one, so the read-modify-write of the trunk's summary sidecar can't race — without
    /// holding the trunk's index gate, so a slow on-device fold never stalls a sibling's land. The
    /// stored task is always the latest in the chain; awaiting it (e.g. before a promote reads the
    /// summary) awaits every fold queued so far.
    private var nvrsionSummaryFold: [ProjectID: Task<Void, Never>] = [:]
    /// The release-reconcile loop; nil when nothing holds a lock (LOCKING §4.4).
    private var lockReconcileTask: Task<Void, Never>?
    /// Re-check cadence for `reconcileLocks` — a slow backstop, since release is also event-driven
    /// (a mediated merge releases immediately). Internal so tests can shorten it.
    var lockReconcileInterval: Duration = .seconds(3)
    /// Live ship status per session, mirrored from the merge queue for the UI's status pill.
    /// Write through `setShipStatus` so the retire clock (`shippedAt`) stays in sync.
    public private(set) var shipStatuses: [SessionID: ShipStatus] = [:]
    /// When a session's status last became a benign terminal (`.merged`/`.skipped`), so the
    /// Control panel can retire the row after `autoshipShippedTTL` and the list doesn't grow
    /// without bound. Cleared when the status leaves that state (new work) or the session is torn
    /// down. Only benign terminals are stamped — conflicts/failures stay until the user acts.
    private var shippedAt: [SessionID: Date] = [:]
    /// How long a settled "Shipped"/"Nothing to ship" row lingers in the Control panel's Autoship
    /// section before it retires. Internal so tests can shorten it.
    var autoshipShippedTTL: TimeInterval = 300

    /// The most recent rate-limit telemetry seen from any session (`rate_limit_event`).
    /// The Claude usage quota is per-account, not per-session, so the latest event from
    /// *any* running agent reflects the current window state — the UI's global quota
    /// indicator mirrors this. Nil until the first agent turn reports one. Used as the
    /// coarse fallback when `subscriptionUsage` (the richer source) is unavailable.
    public private(set) var latestRateLimit: RateLimit?

    /// Quantitative Pro/Max subscription usage (rolling 5-hour + weekly windows, as
    /// percentages) from the OAuth usage endpoint — the primary source for the quota
    /// indicator. Refreshed by `startQuotaPolling()` on a timer and at turn boundaries;
    /// nil until the first successful fetch (and stays at the last good value if a
    /// later fetch fails, so a transient error doesn't blank the indicator).
    public private(set) var subscriptionUsage: SubscriptionUsage?

    /// Codex (ChatGPT-subscription) usage — the two rolling rate-limit windows the `codex`
    /// app-server pushes during a turn (`account/rateLimits/updated`). The Codex counterpart to
    /// `subscriptionUsage`; unlike Claude's polled endpoint there's no fetch to poll, so this is
    /// the newest snapshot any running Codex agent has reported (nil until the first one lands,
    /// then held at the last good value). Drives the Codex quota card on the dashboard/settings.
    public private(set) var codexUsage: CodexUsage?
    private var quotaPollTask: Task<Void, Never>?
    /// When the last usage fetch was attempted, to coalesce bursts (every session's
    /// turn end triggers a refresh — without this, parallel sessions stampede the
    /// endpoint and get 429'd).
    private var lastUsageFetchAt: Date?
    /// Set after a 429 so we stop hammering the rate-limited endpoint until it clears.
    private var usageBackoffUntil: Date?

    /// Live service status for the providers we watch (Claude, OpenAI), each reduced to the
    /// incidents touching its watched components (Claude Code / Claude API, Codex / OpenAI
    /// APIs). Populated by `startStatusPolling()`; a provider that fails to refresh keeps its
    /// last good value, so a transient error doesn't blank the indicator. Empty until the
    /// first successful fetch — once populated it holds one `StatusFeed` per provider, whose
    /// `incidents` may be empty when everything is operational.
    public private(set) var statusFeeds: [StatusFeed] = []
    private var statusPollTask: Task<Void, Never>?

    /// Last time each provider's feed was force-refreshed because one of its agents errored — the
    /// debounce behind `refreshStatusFeedOnError(for:)`. An `api_retry` storm fires many `.error`
    /// events within seconds across sessions; coalescing to one refresh per provider per
    /// `errorStatusRefreshInterval` picks up an incident promptly without hammering the status page.
    private var lastErrorStatusRefresh: [StatusProvider: Date] = [:]
    /// Minimum spacing between error-triggered refreshes of the same provider (see above). An
    /// incident that's up stays up for minutes, so a tight window buys nothing.
    static let errorStatusRefreshInterval: TimeInterval = 20

    /// The providers whose status we actually poll and surface. Narrowed from Settings so the
    /// indicator ignores providers the user doesn't use (an unused provider's status is noise).
    /// Defaults to all; set via `setEnabledStatusProviders(_:)`.
    public private(set) var enabledStatusProviders: Set<StatusProvider> = Set(StatusProvider.allCases)

    /// The in-effect, status-driven default-model switch, or nil when the default model's
    /// provider is healthy. While set, `defaultModel` points at the alternative; the original
    /// is restored automatically once its provider recovers (see `refreshStatusFeeds`). The
    /// switch is offered, never forced — the UI engages it on the user's confirmation (see
    /// `engageModelFailover`).
    public private(set) var modelFailover: ModelFailover?

    /// Sync server fan-out (SYNC_PROTOCOL): the host projects every controller event to any
    /// connected iPhone via `SyncHost`, which consumes this stream. Set lazily on the first
    /// `hostBroadcasts()` call; `broadcast(_:)` is a no-op until then.
    private var syncBroadcastContinuation: AsyncStream<HostBroadcast>.Continuation?
    /// Display name advertised to paired devices (the Mac's name).
    public var syncHostName: String = "Mac"

    /// This host's mesh kind (docs/ANTIMATTER_RUNNER.md §3): `.mac` for the cockpit app, `.cloud`
    /// when running headless as an Antimatter runner (`nucleicd` sets this before starting the
    /// sync server). Advertised in `Welcome.hostKind` (direct pairings) and the outbound
    /// `Hello.deviceKind` the peer dialer sends, so peers pin this device with the right kind.
    public var meshSelfKind: PeerKind = .mac

    /// The running sync server (nil when remote access is off).
    private var syncHost: SyncHost?
    private var syncListener: (any SyncListener)?
    /// The child listeners behind `syncListener` (a composite on tailnet), kept for the
    /// pairing QR's connection hints: the LAN port is OS-assigned, the tailnet port fixed.
    /// (The LAN listener is Network.framework — absent on a Linux runner, which listens
    /// relay/tailnet-first; docs/ANTIMATTER_RUNNER.md §9.)
    #if canImport(Network)
    private var syncLANListener: LANListener?
    #endif
    private var syncTailnetListener: TailnetListener?
    private var syncRelayListener: RelayListener?
    /// Bonjour browser for sibling Macs (mesh LAN reconnect) — resolves a peer's live endpoint by
    /// fingerprint so `PeerClient` can redial a Mac that relaunched onto a new OS-assigned port
    /// without waiting for a stored-hint refresh. Runs only while the LAN method is active.
    #if canImport(Network)
    private var syncLANBrowser: LANBrowser?
    /// Watches this Mac's network path and nudges the mesh dialer to re-plan transports the moment
    /// the link changes (Wi-Fi ⇄ cellular/wired), so a peer connection stuck on a now-dead LAN socket
    /// re-forms over the tailnet immediately instead of waiting out TCP keepalive. Rides the sync
    /// server lifecycle alongside the peer client.
    private var meshPathMonitor: MeshPathMonitor?
    #endif
    /// The host's relay account (mesh P2): PoP enrollment + membership minting. Built when
    /// the sync server starts with the Nucleic Private Relay method enabled; nil otherwise —
    /// the membership provider passed to `SyncHost` reads it, so it mints only while the
    /// method is on.
    private var relayAccess: RelayAccess?
    /// The connection methods whose listeners actually came up (⊆ the Settings selection).
    /// Snapshotted at `startSyncServer`; changing the checkboxes restarts the server.
    public private(set) var syncActiveTransports: Set<SyncTransportHint> = []
    /// Per-method start failures for the Settings status rows ("Tailnet: login pending…").
    /// A method missing here and present in `syncActiveTransports` is healthy.
    public private(set) var syncTransportHealth: [SyncTransportHint: String] = [:]
    /// The canonical host identifier (full SHA-256 of the host static key) advertised in
    /// `Welcome.host.hostID` — rename-proof, unlike `syncHostName`. Set when the sync
    /// server loads the host identity.
    public private(set) var syncHostID: String?
    /// This Mac's `ResponderLabel` — its display name plus the short fingerprint of its
    /// identity key — stamped as `decidedBy` on approvals answered here in the local UI. The
    /// fingerprint is stable across launches and matches the one peers see for this host, so
    /// two Macs sharing a name stay distinguishable in the transcript.
    public var localResponderID: String {
        ResponderLabel.compose(name: syncHostName, fingerprint: localDeviceFingerprint)
    }
    /// Loaded once from the identity Keychain (independent of whether sync is running) and
    /// cached, since the identity never changes for the life of a device.
    @ObservationIgnored
    private lazy var localDeviceFingerprint: String = HostIdentityStore.loadOrCreate().fingerprint
    /// Whether the iPhone remote server is currently listening.
    public private(set) var syncRunning = false
    /// The embedded Tailscale node's state, for Settings ▸ Remote (nil = not running).
    public 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). Settings auto-opens it and shows a button.
    public private(set) var tailnetLoginURL: URL?
    /// Single-flight guard for `startSyncServer`: the tailnet path can await for many
    /// seconds (node login — minutes for a browser login), and the `syncHost == nil` check
    /// alone doesn't cover that window. A stop requested mid-start is honored at the
    /// commit points. Public read-only so the Settings toggle can show the in-flight start
    /// as ON instead of snapping back for the whole login window.
    public private(set) var syncStartInFlight = false
    private var syncStopRequested = false
    /// The active pairing QR string to render (nil unless an "Add iPhone" window is open).
    public private(set) var pairingQR: String?
    /// Labels of currently paired devices (for the Settings list).
    public private(set) var pairedDevices: [PairedDevice] = []
    /// The Mac-side mesh dialer (mesh P4): keeps outbound connections to paired sibling Macs
    /// while the sync server runs. Its lifecycle rides `startSyncServer`/`stopSyncServer`.
    private var peerClient: PeerClient?
    private var peerPresenceTask: Task<Void, Never>?
    /// Watches the embedded tailnet node and re-publishes this Mac's dialable addresses to peers
    /// each time the node reaches `.running` — the node usually finishes starting *after* the first
    /// address snapshot goes out (which therefore carries no tailnet IP), and its IP can also change
    /// across a reconnection, so without this a peer never learns where to reach us over the tailnet.
    private var tailnetAddressTask: Task<Void, Never>?
    /// The shared paired-device store the running sync server + peer dialer both use (mesh "join").
    /// Held here so the roster merge / gossip can read and mutate it. Rides the server lifecycle.
    private var syncPairedStore: (any PairedDeviceStore)?
    /// The last roster we gossiped, to skip a redundant re-push when nothing actually changed
    /// (breaks a two-node re-gossip ping-pong — the merge already reports no-change, this guards
    /// the membership-changed callers too).
    private var lastGossipedRoster: MeshRosterPush?
    /// Live presence of paired peer Macs, for the Settings peers list (mesh P4).
    public private(set) var meshPeers: [PeerClient.PeerPresence] = []

    /// The Home stat counts folded across the mesh (mesh session sync): this Mac's counts plus every
    /// connected peer's, so the Home stat cards show the *union* — and, because summing is symmetric,
    /// every Mac in the mesh shows identical totals. Equal to the local counts when no peer is
    /// connected. Per-session totals sum across Macs; `activeDays` takes the max (days can't be summed
    /// without double-counting a day two Macs were both active, and the counts alone lack per-day
    /// granularity). The activity grid + project/to-do lists stay per-Mac for now.
    public var meshDashboard: DashboardStats {
        var merged = dashboard
        for peer in meshPeers {
            guard peer.connected, let c = peer.dashboard?.counts else { continue }
            merged.projects += c.projects
            merged.chats += c.chats
            merged.activeChats += c.activeChats
            merged.messages += c.messages
            merged.tokens += c.tokens
            merged.activeDays = max(merged.activeDays, c.activeDays)
        }
        return merged
    }
    /// Every project the sidebar shows — this Mac's plus every connected peer Mac's, mapped
    /// into the one `ProjectSummary` shape and sorted by name exactly like the local list, so
    /// remote projects interleave as if they were local (differentiated only by the globe
    /// badge their `hostID` drives). A peer's projects come from its dashboard projection,
    /// with headers synthesized from its session summaries until that dashboard arrives; a
    /// peer that disconnects drops out (and takes its projects with it) the moment
    /// `meshPeers` updates.
    public var sidebarProjects: [ProjectSummary] {
        var items = projects.map(ProjectSummary.init)
        for peer in meshPeers where peer.connected {
            let label = peer.label.isEmpty ? "another Mac" : peer.label
            var seen: Set<ProjectID> = []
            for wire in peer.dashboard?.projects ?? [] {
                seen.insert(wire.id)
                items.append(ProjectSummary(
                    id: wire.id, name: wire.name,
                    isNucleicControlled: wire.isNucleicControlled,
                    hostID: peer.deviceID, hostLabel: label))
            }
            for s in peer.sessions where !s.archived {
                let pid = ProjectID(rawValue: s.projectID)
                guard !seen.contains(pid) else { continue }
                seen.insert(pid)
                items.append(ProjectSummary(
                    id: pid, name: s.projectName, hostID: peer.deviceID, hostLabel: label))
            }
        }
        return items.sorted { $0.name < $1.name }
    }

    /// Resolve a project *anywhere in the mesh* — local first, then each connected peer's
    /// dashboard, then a header synthesized from the peer's session summaries. The unified
    /// counterpart of `project(_:)` for call sites (window title, palettes, Recents subtext)
    /// that must resolve remote projects identically to local ones.
    public func projectSummary(_ id: ProjectID) -> ProjectSummary? {
        if let local = projectsByID[id] { return ProjectSummary(local) }
        for peer in meshPeers where peer.connected {
            let label = peer.label.isEmpty ? "another Mac" : peer.label
            if let wire = peer.dashboard?.projects.first(where: { $0.id == id }) {
                return ProjectSummary(
                    id: wire.id, name: wire.name,
                    isNucleicControlled: wire.isNucleicControlled,
                    hostID: peer.deviceID, hostLabel: label)
            }
            if let s = peer.sessions.first(where: { $0.projectID == id.rawValue }) {
                return ProjectSummary(
                    id: id, name: s.projectName, hostID: peer.deviceID, hostLabel: label)
            }
        }
        return nil
    }

    /// Where a session lives when it isn't this Mac's: the owning connected peer and its
    /// mirrored wire summary. Local records win — a session with a controller *or* a local
    /// summary row (including a moved-away tombstone, whose live twin is mirrored back from
    /// its new owner under the same `SessionID`) is never reported as remote, so ID-only
    /// writers of `openSessionID` and the id-addressed local verbs can't be hijacked. Among
    /// peers, a *live* copy wins over another peer's moved-away tombstone (both mirror the
    /// same `SessionID` after a transfer between two other Macs) — routing to the tombstone's
    /// host would subscribe to a session it no longer runs.
    func remoteSessionLocation(_ id: SessionID) -> (deviceID: String, summary: WireSessionSummary)? {
        guard controllers[id] == nil, !summaries.contains(where: { $0.id == id }) else { return nil }
        var tombstone: (deviceID: String, summary: WireSessionSummary)?
        for peer in meshPeers where peer.connected {
            if let s = peer.sessions.first(where: { $0.sessionID == id }) {
                if s.movedTo == nil { return (peer.deviceID, s) }
                if tombstone == nil { tombstone = (peer.deviceID, s) }
            }
        }
        return tombstone
    }

    /// Test hook: inject mesh presence directly. The live path assigns `meshPeers` from the
    /// peer dialer's presence stream (`startSyncServer`), which tests don't stand up.
    func setMeshPeersForTesting(_ peers: [PeerClient.PeerPresence]) {
        meshPeers = peers
    }

    /// Devices holding a live *inbound* connection right now (they dialed us) — refreshed on
    /// every mesh change, so the peers list can show presence for a Mac we can't dial back.
    public private(set) var inboundConnectedDeviceIDs: Set<String> = []
    /// The destination-side session-transfer importer (mesh P5), built lazily on first inbound
    /// transfer verb. Stages incoming sessions non-runnable and activates them on commit.
    private var _transferImporter: SessionTransferImporter?
    /// Sessions currently being moved away (mesh P5). While a session is in this set it is inert
    /// on the source — hidden from the sync session list and refusing input — so a connected peer
    /// can't run it during the transfer window (before the controller is torn down). This upholds
    /// the "never runs in two places" invariant across the post-tombstone commit round-trip.
    private var transferringSessions: Set<SessionID> = []
    /// Sessions with a delete in progress (mesh P5) — a synchronous claim so a concurrent
    /// `moveSession` can't grab a session that's about to be deleted (and vice versa).
    private var deletingSessions: Set<SessionID> = []
    /// Single-flight guard for `recoverInterruptedTransfers` (mesh P5) — the launch call and the
    /// per-peer-connect call must not run the same reconcile twice concurrently.
    private var isRecoveringTransfers = false
    /// A Mac is mid-pairing and needs the user's confirm (mesh P4 — pairing a Mac grants it
    /// control of this one, so QR possession alone isn't enough). Drives a dialog on the
    /// pairing sheet; `resolveMacPairing` answers it.
    public private(set) var pendingMacPairRequest: MacPairRequest?
    /// Inbound transfers stranded at `.ready` by a relaunch (mesh P5) — staged + verified here, but
    /// the source vanished before commit. The UI offers each an "Activate anyway" / "Discard".
    /// Populated by `recoverInterruptedTransfers`; cleared as the user resolves each.
    public private(set) var pendingArrivedTransfers: [ArrivedTransferPrompt] = []
    private var macPairContinuation: CheckedContinuation<Bool, Never>?
    /// The fail-closed timer for the pending confirm, cancelled the instant it resolves so a
    /// stale timer from a prior attempt can never answer a later one.
    private var macPairTimeoutTask: Task<Void, Never>?
    /// Sessions currently blocked in the `LockManager` queue, so the UI can show a "waiting"
    /// indicator on the blocked chat (LOCKING §4). Driven by `arbitrate` + `reconcileLocks`.
    public private(set) var sessionsWaitingForAccess: Set<SessionID> = []
    /// Sessions caught in a wait-for cycle the queue can't clear on its own — a genuine deadlock
    /// (LOCKING §8). Non-empty drives the toolbar's File Locks button to its orange alarm state.
    /// Recomputed alongside `sessionsWaitingForAccess`.
    public private(set) var deadlockedSessions: Set<SessionID> = []

    /// Host commands currently running on the shared macOS host, keyed by the session running each
    /// (HOST_EXEC §concurrency). Every `host_exec` command claims an entry here for its duration via
    /// `beginHostCommand`/`endHostCommand`, so a *new* host command can detect when a similar one is
    /// already running elsewhere (the simultaneous-builds case) and offer the user an override.
    /// Observable so a future UI could show "the host is busy"; the contention check reads it
    /// synchronously, which is what keeps the check-and-claim atomic (no `await` mid-claim).
    public private(set) var runningHostCommands: [SessionID: RunningHostCommand] = [:]

    /// Sessions parked in the FIFO wait queue for a host-run slot (HOST_EXEC §concurrency), in the
    /// order they chose **Wait** on a conflict prompt (front = next in line). A waiter polls
    /// `claimQueuedHostCommand` until its running twin finishes and no earlier waiter is still ahead
    /// of it, then claims a `runningHostCommands` slot and leaves. Observable so a future UI can show
    /// "N waiting behind the running build"; self-heals as dead sessions are pruned on each touch.
    public private(set) var hostCommandWaitQueue: [QueuedHostCommand] = []

    /// Recent git operations the in-container interceptor shim observed for Nucleic Control
    /// sessions, newest-first and capped at `maxInterceptorEvents`. Feeds the Control panel's
    /// activity section — the ground-truth record of what the agents' own git did (see
    /// `observeGitOp`/`recordInterceptorEvent`). Control-only: non-control sessions never report.
    public private(set) var gitInterceptorEvents: [GitInterceptorEvent] = []
    private let maxInterceptorEvents = 80

    /// Recent ordinary (non-git) commands the in-container interceptor observed for Nucleic Control
    /// sessions — `grep`/`cat`/`find`/`npm`/… — newest-first and capped at `maxCommandEvents`. Feeds
    /// the Control panel's command section (see `observeCommand`). Control-only: non-control sessions
    /// never report. This feed is chattier than git's, so it's capped higher.
    public private(set) var commandInterceptorEvents: [CommandInterceptorEvent] = []
    private let maxCommandEvents = 250

    /// Last-known live resource sample for the Control panel's container meter, refreshed *off* the
    /// snapshot's critical path. The probe (`controlContainerUsage` → `statistics()`) is a vsock
    /// round-trip to the in-guest agent that can stall when the guest is under memory pressure or
    /// busy — awaiting it inline once froze the whole Control panel on its spinner. We keep the most
    /// recent reading and refresh it in the background, so `controlSnapshot` returns immediately and
    /// the meter just shows the last value (or nothing yet). `usageProbe` is a single-flight guard so
    /// a slow probe doesn't pile up a new one on every 2 s poll.
    private var controlUsageSample: ContainerResourceSample?
    private var controlUsageProbe: Task<Void, Never>?

    /// Last-known resource samples for the Control panel's per-session macOS-VM meters, keyed by VM
    /// name and refreshed *off* the snapshot's critical path — same rationale as `controlUsageSample`:
    /// the probe SSHes into the guest (a two-sample `top` sleeps ~1 s), so `controlSnapshot` serves the
    /// cached reading and a background task keeps it fresh. `macVMUsageProbes` is the per-name
    /// single-flight guard, and both are pruned to the currently-running set each snapshot.
    private var macVMUsageSamples: [String: MacVMResourceSample] = [:]
    private var macVMUsageProbes: Set<String> = []


    /// The shared sandbox container's in-flight first-run download (kernel / runtime / image pull +
    /// unpack), or `nil` when nothing is downloading — the steady state once everything is cached.
    /// Observed by the Control panel (which renders a progress bar) and by a busy chat (which names
    /// it as the cause of a slow first response). Refreshed by `refreshContainerDownload()`.
    public private(set) var containerDownload: ContainerDownloadProgress?

    /// Sessions whose current landed work has already been recorded as an agent-performed ship
    /// (`markShippedIfLanded`), so a follow-up merge in the same idle window — e.g. the agent
    /// syncing the parent back in, or a second observed op for the same already-landed work —
    /// doesn't re-post the "merged" note. Cleared the moment the session edits again (a fresh
    /// `arbitrate` grant), since new work can land and ship anew. Lives on the `@MainActor` so
    /// the check-and-set in `markShippedIfLanded` is atomic against concurrent git reports.
    private var agentMergeShippedSessions: Set<SessionID> = []

    /// Per session, the id of the edit tool call that most recently arbitrated each (normalized,
    /// repo-relative) path. `recordLockEvent` reads it to tag a lock-acquire/wait note with the
    /// edit it brackets, so a subagent's lock notes route into its transcript card (via the
    /// projection's tool-call→parent map) instead of the main transcript. Keyed by exactly the
    /// `ConflictDetector.normalize`d path the `LockManager` reports back, so lookup is exact.
    /// Bounded by the files a live session has touched; cleared wholesale in `forgetShipState`.
    private var editToolUseByPath: [SessionID: [String: String]] = [:]

    public init(
        database: any SessionMetadataStore,
        worktrees: any WorktreeManaging,
        transcriptsDir: URL,
        containerManager: ContainerManager? = nil,
        macVMManager: MacVMManager? = nil,
        conflictCoordinator: ConflictCoordinator? = nil,
        now: @escaping @Sendable () -> Date = { Date() },
        backendFactory: @escaping BackendFactory
    ) {
        self.database = database
        self.worktrees = worktrees
        self.transcriptsDir = transcriptsDir
        self.containerManager = containerManager
        self.macVMManager = macVMManager
        self.conflictCoordinator = conflictCoordinator
        self.now = now
        self.nvrsionGovernor = NvrsionReleaseGovernor(now: now)
        self.backendFactory = backendFactory
    }

    // MARK: - Projects

    public func loadProjects() async {
        let loaded = (try? await database.loadProjects()) ?? []
        // Active projects drive the sidebar/pickers; archived ones are kept aside but still
        // resolvable by id (so a lingering session/todo lookup never comes back empty).
        projects = loaded.filter { !$0.isArchived }.sorted { $0.name < $1.name }
        archivedProjects = loaded.filter { $0.isArchived }
            .sorted { ($0.archivedAt ?? .distantPast) > ($1.archivedAt ?? .distantPast) }
        projectsByID = Dictionary(loaded.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
        // Keep the service lock current after every reload (create/convert/delete all reload
        // here), and run the one-shot first-install remnant adopt on the first launch pass.
        reconcileContainerService()
    }

    /// Reconcile the container-service master switch against the presence of Nucleic Control
    /// projects — the switch is subordinate to Control (Control's whole point is the shared
    /// sandbox + git interceptor, so it can't run with the service off). Two rules, both here so
    /// the launch path has one call:
    ///   • **First-install remnant adopt** (one-shot, gated on `didCompleteFirstLaunchKey`): on a
    ///     genuinely fresh install, if `~/.nucleic/control/` still holds leftover controlled repos
    ///     from a prior install (whose metadata DB is gone), auto-enable the service so those repos
    ///     work again once re-added instead of looking broken.
    ///   • **Lock-on:** if any registered project is Nucleic-controlled, force the service on and
    ///     publish `containerServiceLockedOn = true` so Settings pins the toggle on and disables it.
    ///
    /// Called at the end of `loadProjects()`, so it re-runs after every project create/convert/
    /// delete (all of which reload). Idempotent — safe to run repeatedly.
    public func reconcileContainerService() {
        let defaults = ContainerServiceSettings.defaults
        // First-install remnant scan runs exactly once, before the marker is set.
        if !defaults.bool(forKey: ContainerServiceSettings.didCompleteFirstLaunchKey) {
            if !ProjectCloner.controlledRemnants().isEmpty {
                ContainerServiceSettings.enableService()
            }
            defaults.set(true, forKey: ContainerServiceSettings.didCompleteFirstLaunchKey)
        }
        // A controlled project (active or archived — its repo is still Nucleic's to manage) pins
        // the service on for as long as it exists.
        let hasControlProject = projectsByID.values.contains(where: \.isNucleicControlled)
        if hasControlProject {
            ContainerServiceSettings.enableService()
        }
        containerServiceLockedOn = hasControlProject
        // Start/stop the background image-update checker to match the (possibly just-changed)
        // service state. Idempotent; a disabled service tears the loop down.
        startImageUpdateChecksIfNeeded()
    }

    /// Reload persisted sessions into the sidebar on launch and rebuild a resumable
    /// (idle) controller for each — without this, chats vanish from the sidebar after
    /// a relaunch even though they're still in the database.
    ///
    /// Two phases so the sidebar appears instantly and the main thread never blocks on
    /// transcript I/O (the old launch freeze + row-by-row fill-in):
    ///   • Phase 1 publishes a `SessionSummary` for every persisted session from its row
    ///     alone — no transcript reads — in one batch, so the whole sidebar paints at once.
    ///   • Phase 2 reads each transcript ONCE off the main actor, then rebuilds the
    ///     `SessionController` (resumable), backfills disposition, and warms the dashboard
    ///     activity cache from those same events.
    public func loadSessions() async {
        // Match the original scope: only sessions of *active* (non-archived) projects feed
        // the sidebar and get a reconstructed controller.
        let activeProjectIDs = Set(projects.map(\.id))
        let allSessions = (try? await database.loadAllSessions()) ?? []

        // --- Phase 1: instant sidebar, no transcripts ---
        var pending: [Session] = []
        var normalized: Set<SessionID> = []  // status was reset → needs a persist in Phase 2
        for var session in allSessions
        where controllers[session.id] == nil && activeProjectIDs.contains(session.projectID)
            // A session moved to another Mac (mesh P5) is a tombstone — never rebuild a live,
            // runnable controller for it, or a relaunch would resurrect it on the source.
            && session.movedToDeviceID == nil {
            // A session that was mid-run when the app last quit has no live process now;
            // make it resumable rather than stuck "running". The interrupted turn's outcome
            // is unknown, so drop any stale disposition — but a session already idle at
            // awaitingInput keeps its classification.
            if !session.status.isTerminal && session.status != .awaitingInput {
                session.status = .awaitingInput
                session.lastTurnDisposition = nil
                normalized.insert(session.id)
            }
            pending.append(session)
        }
        // Moved-away sessions (mesh P5) get no controller (see the tombstone guard above), but they
        // still surface as read-only "Moved to <Mac>" rows so the move stays visible after a
        // relaunch — matching the live teardown broadcast. Their project must be active to show.
        let movedTombstones = allSessions.filter {
            $0.movedToDeviceID != nil && controllers[$0.id] == nil
                && activeProjectIDs.contains($0.projectID)
        }
        // One assignment → one observable update → the sidebar fills in all at once.
        upsertSummaries(
            pending.map { SessionSummary($0, pendingApprovalCount: 0) }
            + movedTombstones.map {
                SessionSummary($0, pendingApprovalCount: 0, movedTo: movedDestination(for: $0))
            })

        // --- Phase 2: transcript-backed work, file reads off the main actor ---
        let calendar = Calendar.current
        let prepared = await Self.prepareSessions(
            pending.map { (id: $0.id, path: $0.transcriptPath) }, calendar: calendar)

        var failed: [SessionID] = []  // transcript gone → can't reconstruct → drop from sidebar
        for var session in pending {
            // `resolvedProject` (not `projectsByID`) so a saved ephemeral chat, whose reserved
            // project has no DB row, still reconstructs its controller on launch.
            guard let project = resolvedProject(session.projectID) else { continue }
            let prep = prepared[session.id]
            let events = prep?.events ?? []
            var changed = normalized.contains(session.id)
            // Backfill: an idle, unclassified session gets a cheap heuristic verdict from its
            // last reply (computed off-main in prepareSessions), so the sidebar shows "Done"
            // vs "Awaiting input" without waiting for a fresh turn. Live turns still use the
            // full Intelligence provider via classifyDisposition.
            if session.status == .awaitingInput, session.lastTurnDisposition == nil,
               let reply = prep?.lastReply {
                session.lastTurnDisposition = HeuristicTurnClassifier.classify(reply)
                changed = true
            }
            guard let controller = reconstructController(for: session, in: project, events: events)
            else { failed.append(session.id); continue }  // transcript gone (reconcile cleans up)
            controllers[session.id] = controller
            namedSessions.insert(session.id)  // already named; don't rename on resume
            // An ephemeral chat still in the DB at launch survived a prior run — a truly-ephemeral
            // one is discarded (row deleted) on window close, so its presence here means "Saved".
            // Seed the Saved set so its scratch dir is kept and its window reads "Saved".
            if isEphemeral(session) { ephemeralPersistedSessions.insert(session.id) }
            observe(controller, session.id)
            // Re-publish only when Phase 1's summary is now stale (normalized/backfilled),
            // and persist the normalized row then too.
            if changed {
                try? await database.saveSession(session)
                upsertSummary(SessionSummary(session, pendingApprovalCount: 0))
            }
            // Warm the dashboard activity cache from the events we already read, so opening
            // Home is instant (no transcript re-scan). Keyed by the session's lastSeq so it's
            // reused until the next turn (or a revert) moves it.
            if let prep {
                try? await database.saveActivityCache(session.id, ActivityCacheEntry(
                    lastSeq: session.lastSeq,
                    messageCount: prep.messageCount, activityByDay: prep.activityByDay,
                    tokenCount: prep.tokenCount, tokensByDay: prep.tokensByDay))
            }
        }
        if !failed.isEmpty {
            let gone = Set(failed)
            summaries.removeAll { gone.contains($0.id) }
        }

        // Orchestra orphan sweep: a worker whose supervisor no longer exists — deleted before
        // deletion cascaded to workers, or through a path that bypassed the cascade — is
        // unreachable from every surface (`isSubagent` hides it from the sidebar, and the
        // Subagents panel that listed it died with the supervisor), yet it still costs a
        // reconstructed controller + transcript read here every launch, counts toward
        // active-chat rollups, and holds a worktree. Finish the interrupted cascade: delete it
        // (recursively taking any workers of its own). Scoped to sessions we just built a
        // controller for, so the delete reclaims the worktree and branch; a supervisor moved to
        // another Mac still has a tombstone row here, so its workers are correctly spared.
        let knownIDs = Set(allSessions.map(\.id))
        let orphanedWorkerIDs = allSessions
            .filter { session in
                guard let supervisorID = session.spawnedBySessionID else { return false }
                return !knownIDs.contains(supervisorID) && controllers[session.id] != nil
            }
            .map(\.id)
        for orphanID in orphanedWorkerIDs {
            await deleteSession(orphanID)
        }

        // Clean up any sandbox containers orphaned by a previous run/crash.
        await containerManager?.reconcile(activeSessions: Array(controllers.keys))
        // Begin the background base-image asset auto-update check (cheap digest HEADs; gated on the
        // service + preference). Launch is the first check; the loop then re-checks periodically.
        startImageUpdateChecksIfNeeded()
        // Same on-disk GC for macOS-VM clones (daemonless — no live guest survives the process).
        await macVMManager?.reconcile(activeSessions: Array(controllers.keys))
        // Teach the VM engine which chats' VMs are reclaimable when the concurrent-VM ceiling is hit,
        // so a fan-out of agents evicts a stalest *done* chat's VM (or queues) instead of failing a boot.
        await macVMManager?.setEvictionPolicy { [weak self] occupying in
            await self?.evictableVMsRanked(among: occupying) ?? []
        }
        // Rebuild held locks from authoritative git state now that controllers exist (LOCKING §6).
        await reconstructLocks()
        // Commit any uncommitted residue a crash left on an nvrsion trunk (NVRSION §8).
        await reconcileNvrsionTrunks()
    }

    /// Per-session work derived off the main actor from a single transcript read: the
    /// event history (for the controller), the last reply (for disposition backfill), and
    /// the activity rollup (for the dashboard cache).
    private struct PreparedSession: Sendable {
        let id: SessionID
        let events: [AgentEvent]
        let lastReply: String?
        let messageCount: Int
        let activityByDay: [Date: Int]
        let tokenCount: Int
        let tokensByDay: [Date: Int]
    }

    /// Read every session's transcript concurrently OFF the main actor (this is the work
    /// that used to freeze launch), decoding once and computing all three derived pieces.
    nonisolated private static func prepareSessions(
        _ items: [(id: SessionID, path: String)], calendar: Calendar
    ) async -> [SessionID: PreparedSession] {
        await withTaskGroup(of: PreparedSession.self) { group in
            for item in items {
                group.addTask {
                    let events = (try? TranscriptReader(
                        url: URL(fileURLWithPath: item.path)).read().events) ?? []
                    let contribution = activityContribution(events: events, calendar: calendar)
                    return PreparedSession(
                        id: item.id, events: events, lastReply: lastReply(in: events),
                        messageCount: contribution.messages, activityByDay: contribution.messagesByDay,
                        tokenCount: contribution.tokens, tokensByDay: contribution.tokensByDay)
                }
            }
            var result: [SessionID: PreparedSession] = [:]
            for await prep in group { result[prep.id] = prep }
            return result
        }
    }

    /// The last complete assistant reply in an already-read transcript, or nil if
    /// empty — the input the turn classifier reasons over.
    nonisolated static func lastReply(in events: [AgentEvent]) -> String? {
        guard let reply = HeuristicSummary.lastTurn(events).reply,
              !reply.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
        else { return nil }
        return reply
    }

    /// One session's contribution to the home dashboard: total user messages and total tokens
    /// used, each with its per-day (start-of-day) histogram. Pure over an already-read event list,
    /// so it runs off the main actor (Phase 2 warm-up and the dashboard's stale-entry recompute).
    ///
    /// Tokens are summed from `.usage` events — one per turn, carrying that turn's cumulative
    /// totals (`Usage.totalTokens`). Only `.usage` is counted; `turnCompleted`/`runFinished` carry
    /// copies of the same numbers, so tallying them too would double-count.
    nonisolated static func activityContribution(
        events: [AgentEvent], calendar: Calendar
    ) -> (messages: Int, messagesByDay: [Date: Int], tokens: Int, tokensByDay: [Date: Int]) {
        var messagesByDay: [Date: Int] = [:]
        var tokensByDay: [Date: Int] = [:]
        var messages = 0
        var tokens = 0
        for event in events {
            switch event.kind {
            case .userText:
                messages += 1
                messagesByDay[calendar.startOfDay(for: event.at), default: 0] += 1
            case .usage(let usage):
                let used = usage.totalTokens
                guard used > 0 else { continue }
                tokens += used
                tokensByDay[calendar.startOfDay(for: event.at), default: 0] += used
            default:
                break
            }
        }
        return (messages, messagesByDay, tokens, tokensByDay)
    }

    private func reconstructController(
        for session: Session, in project: Project, events: [AgentEvent]
    ) -> SessionController? {
        let url = URL(fileURLWithPath: session.transcriptPath)
        guard let writer = try? TranscriptWriter(
            appendingTo: url, lastSeq: events.last?.seq ?? session.lastSeq)
        else { return nil }  // transcript file gone — skip (reconcile would clean it up)

        var worktree: Worktree?
        if let path = session.worktreePath, let branch = session.branch, let baseSHA = session.baseSHA {
            worktree = Worktree(
                sessionID: session.id, path: path, branch: branch,
                baseSHA: baseSHA, createdAt: session.createdAt)
        }
        return SessionController(
            session: session, backend: backendFactory(session), transcript: writer,
            worktreeManager: worktrees, project: project, worktree: worktree,
            metadataStore: database, conversational: true, initialHistory: events, now: now)
    }

    @discardableResult
    public func addProject(name: String, rootPath: String, defaultBranch: GitRef) async -> Project? {
        // Reject a repo that's already registered (same path, symlinks resolved).
        let canonical = GitWorktreeManager.canonical(rootPath)
        if let existing = projects.first(where: { GitWorktreeManager.canonical($0.rootPath) == canonical }) {
            lastError = "“\(existing.name)” already uses that repository."
            return nil
        }
        var project = Project(name: name, rootPath: rootPath, defaultBranch: defaultBranch, createdAt: now())
        if ContainerServiceSettings.sandboxByDefault { project.sandbox = ProjectSandbox(enabled: true) }
        // Seed nvrsion on a new Control project when it's the app-wide default (NVRSION §10).
        if ContainerServiceSettings.nvrsionByDefault, project.isNucleicControlled {
            project.nvrsion = ProjectNvrsion(enabled: true)
        }
        do {
            try await database.saveProject(project)
            await loadProjects()
            return project
        } catch {
            lastError = "Add project failed: \(error)"
            return nil
        }
    }

    /// Clone a remote git repository and register the result as a project. When
    /// `nucleicControl` is true the repo is cloned under `~/.nucleic/control/<name>` (kept
    /// out of iCloud and away from manual edits) and `destinationParent` is ignored;
    /// otherwise it's cloned into `destinationParent/<name>`. The folder name comes from the
    /// git URL; the project's default branch is detected from what the clone checks out
    /// (`branch` clones a specific branch instead of the remote's default).
    /// On any clone/registration failure no project is added and a partial checkout is
    /// cleaned up.
    @discardableResult
    public func addClonedProject(
        name: String, gitURL: String, nucleicControl: Bool, destinationParent: String?,
        branch: GitRef? = nil
    ) async -> Project? {
        guard !cloning else { return nil }
        let url = gitURL.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !url.isEmpty else { lastError = "Enter a git URL to clone."; return nil }

        let cloner = ProjectCloner()
        let folder = ProjectCloner.repoFolderName(fromGitURL: url)

        // Resolve where the clone lands.
        let destination: String
        if nucleicControl {
            destination = ProjectCloner.uniqueDestination(parent: ProjectCloner.controlBase, folder: folder)
        } else {
            guard let parent = destinationParent?.trimmingCharacters(in: .whitespacesAndNewlines),
                  !parent.isEmpty else {
                lastError = "Choose a folder to clone into, or enable Nucleic Control."
                return nil
            }
            destination = (parent as NSString).appendingPathComponent(folder)
            if FileManager.default.fileExists(atPath: destination) {
                lastError = "Can't clone: “\(destination)” already exists."
                return nil
            }
        }

        // Don't register the same working copy twice.
        let canonical = GitWorktreeManager.canonical(destination)
        if let existing = projects.first(where: { GitWorktreeManager.canonical($0.rootPath) == canonical }) {
            lastError = "“\(existing.name)” already uses that location."
            return nil
        }

        cloning = true
        defer { cloning = false }

        do {
            try await cloner.clone(from: url, into: destination, branch: branch)
        } catch {
            try? FileManager.default.removeItem(atPath: destination)
            lastError = "Clone failed: \(error)"
            return nil
        }

        let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
        let projectName = trimmedName.isEmpty ? folder : trimmedName
        let branch = await cloner.detectDefaultBranch(at: destination)
        var project = Project(name: projectName, rootPath: destination, defaultBranch: branch, createdAt: now())
        if ContainerServiceSettings.sandboxByDefault { project.sandbox = ProjectSandbox(enabled: true) }
        // Seed nvrsion on a new Control clone when it's the app-wide default (NVRSION §10).
        if ContainerServiceSettings.nvrsionByDefault, nucleicControl {
            project.nvrsion = ProjectNvrsion(enabled: true)
        }
        do {
            try await database.saveProject(project)
            await loadProjects()
            return project
        } catch {
            try? FileManager.default.removeItem(atPath: destination)
            lastError = "Add project failed: \(error)"
            return nil
        }
    }

    /// Create a brand-new project on disk from just a name — no existing repo, no clone. Two
    /// placements:
    ///   • **Nucleic Control** (`nucleicControl == true`): a new managed repo under
    ///     `~/.nucleic/control/<slug>` (uniquified against existing controlled repos), always
    ///     `git init`'d with a seed commit and fully set up to run sandboxed, autoship-eligible
    ///     sessions (its `effectiveSandbox`/nvrsion defaults follow from the control path).
    ///     `parentDir` is ignored and the container service must be enabled.
    ///   • **Plain** (`nucleicControl == false`): creates `<parentDir>/<slug>` and registers it.
    ///     When `initGitRepo` is true it's `git init`'d with a seed commit so it's usable
    ///     immediately; otherwise it's left an empty directory (a session's first run lazily
    ///     initializes a repo — see `GitWorktreeManager.create`).
    ///
    /// The typed `name` is kept as the display name; the on-disk folder is a filesystem-safe
    /// slug of it. On any failure nothing is registered and a directory this call created is
    /// removed. New projects always start on `main`.
    @discardableResult
    public func createProject(
        name: String, parentDir: String?, nucleicControl: Bool, initGitRepo: Bool
    ) async -> Project? {
        let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmedName.isEmpty else { lastError = "Enter a name for the project."; return nil }
        let folder = Slug.sanitize(trimmedName, fallback: "project")

        // Nucleic Control requires the container service (the shared sandbox + git interceptor).
        if nucleicControl, !ContainerServiceSettings.serviceEnabled {
            lastError = "Nucleic Control needs the container service — enable it in "
                + "Settings → Sandbox first."
            return nil
        }

        // Resolve where the new project directory lands.
        let destination: String
        if nucleicControl {
            try? FileManager.default.createDirectory(
                atPath: ProjectCloner.controlBase, withIntermediateDirectories: true)
            destination = ProjectCloner.uniqueDestination(
                parent: ProjectCloner.controlBase, folder: folder)
        } else {
            guard let parent = parentDir?.trimmingCharacters(in: .whitespacesAndNewlines),
                  !parent.isEmpty else {
                lastError = "Choose a parent folder for the new project."
                return nil
            }
            destination = (parent as NSString).appendingPathComponent(folder)
            if FileManager.default.fileExists(atPath: destination) {
                lastError = "Can't create: “\(destination)” already exists."
                return nil
            }
        }

        // Don't register the same working copy twice (symlinks resolved).
        let canonical = GitWorktreeManager.canonical(destination)
        if let existing = projects.first(where: { GitWorktreeManager.canonical($0.rootPath) == canonical }) {
            lastError = "“\(existing.name)” already uses that location."
            return nil
        }

        do {
            try FileManager.default.createDirectory(atPath: destination, withIntermediateDirectories: true)
        } catch {
            lastError = "Couldn't create the project folder: \(error)"
            return nil
        }

        // A Control project must be a real git repo to function (worktrees, autoship, nvrsion);
        // a plain project is initialized only when the user asked for it.
        let branch = GitRef("main")
        if nucleicControl || initGitRepo {
            do {
                try await ProjectCloner().initRepo(at: destination, branch: branch)
            } catch {
                try? FileManager.default.removeItem(atPath: destination)
                lastError = "Couldn't initialize a git repo: \(error)"
                return nil
            }
        }

        var project = Project(name: trimmedName, rootPath: destination, defaultBranch: branch, createdAt: now())
        if ContainerServiceSettings.sandboxByDefault { project.sandbox = ProjectSandbox(enabled: true) }
        // Seed nvrsion on a new Control project when it's the app-wide default (NVRSION §10).
        if ContainerServiceSettings.nvrsionByDefault, project.isNucleicControlled {
            project.nvrsion = ProjectNvrsion(enabled: true)
        }
        do {
            try await database.saveProject(project)
            await loadProjects()
            return project
        } catch {
            try? FileManager.default.removeItem(atPath: destination)
            lastError = "Add project failed: \(error)"
            return nil
        }
    }

    /// Persist edits to a project's configuration (e.g. sandbox settings) and refresh the
    /// in-memory list. Sandbox changes take effect on the next turn started in the project.
    public func updateProject(_ project: Project) async {
        let wasSandboxed = projectsByID[project.id]?.sandbox?.enabled == true
        // Toggling nvrsion is always safe: a session's versioning mode is fixed at creation (its
        // working dir is either the shared trunk or a per-session worktree, see `isNvrsionSession`),
        // so flipping the project only affects *new* chats — existing chats keep their mode and never
        // mix. No guard needed.
        do {
            try await database.saveProject(project)
            await loadProjects()
        } catch {
            lastError = "Update project failed: \(error)"
            return
        }
        // Sandboxing turned off → tear down every container in the project now. The save
        // above means new turns are already host-spawned, so we only need to drain and
        // remove the existing ones. Done in the background (it waits for in-flight turns to
        // finish) so the settings UI isn't blocked; failures still surface via lastError.
        if wasSandboxed, project.sandbox?.enabled != true {
            Task { await cleanupProjectContainers(project.id, waitForActive: true) }
        }
    }

    /// Tear down all of a project's session containers, collecting any that couldn't be
    /// removed into a single user-facing warning. `waitForActive` drains in-flight turns
    /// first (used when disabling sandboxing); pass false to remove immediately.
    private func cleanupProjectContainers(_ id: ProjectID, waitForActive: Bool) async {
        guard let containerManager else { return }
        let sessions = (try? await database.loadSessions(projectID: id)) ?? []
        var failed: [String] = []
        for session in sessions {
            if let name = await containerManager.teardown(session.id, waitForActive: waitForActive) {
                failed.append(name)
            }
        }
        reportSandboxCleanupFailures(failed)
    }

    /// Surface a clear, actionable warning when one or more sandbox containers survived a
    /// cleanup attempt, so the user can remove them by hand right away.
    private func reportSandboxCleanupFailures(_ names: [String]) {
        guard !names.isEmpty else { return }
        let list = names.joined(separator: " ")
        lastError = "Couldn't remove sandbox container(s): \(list). "
            + "Remove manually with: container delete -f \(list)"
    }

    /// Surface a warning when a session's guest VM survived teardown (archive/delete), so the user
    /// knows a clone is still on disk. Best-effort — launch-time `reconcile` reclaims it as a backstop.
    private func reportVMCleanupFailure(_ name: String) {
        lastError = "Couldn't remove the virtual machine for this chat (\(name)). "
            + "It'll be reclaimed the next time Nucleic launches."
    }

    /// Stop a running guest VM by name from the Control sidebar's Virtual Machines list. Frees its
    /// host RAM now; the on-disk clone stays, so the chat transparently reboots it on its next turn.
    /// No-op when the VM service isn't wired into this build.
    public func stopControlVM(name: String) async {
        await macVMManager?.stop(name: name)
    }

    /// Remove Nucleic's shared primary control container once no live, non-archived session
    /// still needs it. Per-session `teardown` can't reach it (it derives a `nucleic-<short>`
    /// name), so this runs at the permanent session/project exits (archive, delete, project
    /// delete) to avoid leaking the shared container for the rest of the app's lifetime. A
    /// no-op while any control session survives — its bind mounts are stable, so a later
    /// control session simply recreates it. Launch-time `reconcile` is the backstop.
    private func reapSharedControlContainerIfUnused() async {
        guard let containerManager else { return }
        let stillUsed = summaries.contains { s in
            !s.archived && controllers[s.id] != nil
                && (projectsByID[s.projectID]?.usesSharedControlContainer ?? false)
        }
        if !stillUsed { await containerManager.teardownShared() }
    }

    /// Force-rebuild the shared Nucleic Control sandbox (Settings → Control → "Force re-creation"):
    /// delete the shared container and the bundled image so the next control session rebuilds the
    /// image from the current Dockerfile and recreates the container. No-op when the container
    /// service is off or no manager is wired. A control session mid-turn will lose its container
    /// and must retry, so this is a deliberate user action, not automatic.
    public func recreateControlSandbox() async {
        guard let containerManager else { return }
        await containerManager.recreateShared()
    }

    /// Restart the shared Nucleic Control container in place. Routine memory reclamation is now
    /// automatic (the autoballoon loop), so this is for a full VM reset — clearing in-VM state and
    /// returning the VM's own runtime overhead. Lighter than `recreateControlSandbox` — keeps the
    /// image and instrumentation, just reboots the VM. Skips a container mid-turn. No-op when the
    /// service is off / unwired.
    public func restartControlSandbox() async {
        guard let containerManager else { return }
        await containerManager.restartShared()
    }

    /// Force an immediate balloon-reclaim pass (Settings → Control → "Reclaim memory now") so idle
    /// guest memory is returned to the host now rather than at the autoballoon loop's next tick.
    /// No-op when the service is off / unwired or memory management is set to Off.
    public func reclaimControlMemory() async {
        guard let containerManager else { return }
        await containerManager.reclaimMemoryNow()
    }

    /// Check for and apply agent-CLI updates in the running control container(s) (Settings → Control
    /// → "Check for updates"): `npm install -g …@latest` for Codex + Claude Code in place, so a new
    /// Codex release and the models it unlocks are usable immediately — no image re-pull or app
    /// update. Returns a per-container summary (empty when the service is off / unwired or no shared
    /// control container is currently running). Lighter than `recreateControlSandbox`, which re-pulls
    /// the whole base image.
    public func updateControlAgentCLIs() async -> [AgentCLIUpdateResult] {
        guard let containerManager else { return [] }
        return await containerManager.updateAgentCLIs()
    }

    // MARK: - Sandbox base-image asset auto-update

    /// Check the registry for a newer sandbox **base image** than the one unpacked (a cheap
    /// manifest-digest HEAD, no download) and publish the result to `imageUpdateStatus`. This is the
    /// asset auto-update check — it lets a refreshed image reach the app without an app release; the
    /// user applies an available update with `applyImageUpdate()`. No-op (leaves status untouched)
    /// when the service is off / unwired. `userInitiated` flips the transient `.checking` state on so
    /// the manual button can show progress; background checks pass `false` to stay silent until they
    /// have a verdict.
    @discardableResult
    public func checkForImageUpdate(userInitiated: Bool = false) async -> ImageUpdateStatus {
        guard let containerManager, ContainerServiceSettings.serviceEnabled else {
            return imageUpdateStatus
        }
        if userInitiated { imageUpdateStatus = .checking }
        let status = await containerManager.checkImageUpdate()
        imageUpdateStatus = status
        return status
    }

    /// Apply an available base-image update: re-pull the image and recreate the shared control
    /// container (same path as Force re-creation, which evicts the rootfs + image-store caches so the
    /// fresh digest is fetched), then mark the image up to date. A control session mid-turn loses its
    /// container and must retry — a deliberate user action.
    public func applyImageUpdate() async {
        await recreateControlSandbox()
        // The next session re-pulls the current digest, so we're up to date by construction.
        imageUpdateStatus = .upToDate
    }

    /// Start (or restart) the background base-image update checker: one check shortly after launch,
    /// then every 6 hours. Gated on the container service being on and the auto-check preference
    /// (default on); a disabled preference or service tears the loop down. Idempotent — safe to call
    /// after any service/preference change. The check only resolves a digest (no layer download), so
    /// it's cheap to run periodically.
    public func startImageUpdateChecksIfNeeded() {
        imageUpdateCheckTask?.cancel()
        guard containerManager != nil,
            ContainerServiceSettings.serviceEnabled,
            ContainerServiceSettings.autoCheckImageUpdates
        else {
            imageUpdateCheckTask = nil
            return
        }
        imageUpdateCheckTask = Task { [weak self] in
            // Small startup delay so launch-time pulls/reconcile settle before the first check.
            try? await Task.sleep(nanoseconds: 20 * 1_000_000_000)
            while !Task.isCancelled {
                guard let self else { return }
                await self.checkForImageUpdate()
                try? await Task.sleep(nanoseconds: 6 * 3600 * 1_000_000_000)
            }
        }
    }

    // MARK: - macOS VMs (Virtual Machines panel)

    /// Whether the host can run macOS guests at all (Apple silicon). Drives the settings panel's
    /// availability note.
    public nonisolated var macVMSupported: Bool { MacVMManager.isSupported }

    /// Admission policy for the concurrent-VM ceiling (injected into ``MacVMManager`` at launch). Given
    /// the names of the VMs currently holding slots, return the subset whose owning chat is *done* — no
    /// turn in flight, i.e. sitting in the brief idle-hold window a chat keeps its VM after completing —
    /// ordered **stalest-interaction first**. When a session's VM boot would exceed the ceiling, the
    /// engine reclaims the first name returned here (shutting down the least-recently-used done chat's
    /// VM and keeping the more recent), granting the freed slot to the requester; an empty result tells
    /// it to queue instead (every occupied slot is an actively-working chat). VMs owned by busy chats are
    /// never returned, so live work is never evicted out from under a running command. Runs on the main
    /// actor off `summaries`, the same session state the sidebar shows. See ``MacVMEngine/ensureRunning(_:)``.
    func evictableVMsRanked(among occupying: [String]) -> [String] {
        Self.rankEvictableVMs(among: occupying, summaries: summaries)
    }

    /// Pure ranking behind ``evictableVMsRanked(among:)`` — factored out so the admission policy can be
    /// unit-tested without standing up a full `AppStore`. Keeps only VMs owned by a local, non-archived,
    /// *done* chat (no turn in flight) and orders them stalest-interaction first. See that method.
    nonisolated static func rankEvictableVMs(
        among occupying: [String], summaries: [SessionSummary]
    ) -> [String] {
        // Invert the deterministic per-session VM names back to their chats. Only local, non-archived
        // sessions can own a VM running on this host (a peer/mirrored session's VM lives on its own Mac).
        var summaryByVM: [String: SessionSummary] = [:]
        for summary in summaries where summary.hostID == nil && !summary.archived {
            summaryByVM[MacVMManager.vmName(for: summary.id)] = summary
            summaryByVM[MacVMManager.linuxVMName(for: summary.id)] = summary
        }
        let candidates: [(name: String, lastUsed: Date)] = occupying.compactMap { name in
            guard let summary = summaryByVM[name], !summary.status.hasTurnInFlight else { return nil }
            // Rank by last *user* interaction; fall back to `updatedAt` for a chat that never received a
            // user message (or predates the field), so a candidate always has a comparable timestamp.
            return (name, summary.lastUserMessageAt ?? summary.updatedAt)
        }
        return candidates.sorted { $0.lastUsed < $1.lastUsed }.map(\.name)
    }

    /// The one-time base-image build progress (download / install / provision), for the panel's bar.
    /// `nil` when nothing is building. No-op → `nil` when no manager is wired.
    public func macVMBaseProgress() async -> MacVMBaseProgress? {
        await macVMManager?.baseProgress()
    }

    /// The installed guest macOS version of the base image (e.g. "27.0"), for the panel. `nil` when no
    /// base exists or it lacks version metadata.
    public func macVMBaseOSVersion() async -> String? {
        await macVMManager?.baseImageOSVersion()
    }

    /// The base image's provisioning status (installed / exec-ready / computer-use-ready), for the
    /// panel — drives the "needs SIP disabled once" guidance and the Recovery button.
    public func macVMBaseStatus() async -> MacVMBaseStatus {
        await macVMManager?.baseStatus() ?? MacVMBaseStatus()
    }

    /// Filesystem path of the complete base bundle, for the Recovery helper. `nil` when none exists.
    public func macVMBaseBundleRootPath() async -> String? {
        await macVMManager?.baseBundleRootPath()
    }

    /// Claim the base for an exclusive Recovery boot (blocks builds + fresh session clones meanwhile);
    /// returns the base bundle path. Throws when the base is busy or missing. Pair with
    /// ``endMacVMBaseRecovery()`` when the Recovery window closes.
    public func beginMacVMBaseRecovery() async throws -> String {
        guard let macVMManager else {
            throw MacVMError.unavailable("the macOS VM service isn't wired in this build")
        }
        return try await macVMManager.beginBaseRecovery()
    }

    /// Release the base after the Recovery window's VM powered off.
    public func endMacVMBaseRecovery() async {
        await macVMManager?.endBaseRecovery()
    }

    /// Inject the app-layer host-side computer-use surface (VZVirtualMachineView capture + HID),
    /// wired once at app startup so computer use runs SIP-free through the guest's virtual display.
    public func setMacVMSurfaceHost(_ host: any MacVMSurfaceHost) async {
        await macVMManager?.setSurfaceHost(host)
    }

    /// The macOS VMs currently running (per-session guests), for the panel's live list.
    public func runningMacVMs() async -> [MacVMEntry] {
        guard let macVMManager else { return [] }
        return await macVMManager.runningVMs()
    }

    /// Copy the currently-configured "Included apps" (``MacVMSettings/bundledAppPaths``) into every
    /// running per-session macOS VM over vsock, so live clones get them without a base rebuild. Returns
    /// a short, human-readable summary for the settings panel (how many VMs got the apps, and any
    /// per-VM failures). Never throws — a VM that fails is folded into the summary.
    public func pushIncludedAppsToRunningMacVMs() async -> String {
        guard let macVMManager else { return "The macOS VM service isn't available in this build." }
        let apps = MacVMSettings.bundledAppPaths
        guard !apps.isEmpty else { return "No included apps are configured to copy." }
        let running = await macVMManager.runningVMs()
        guard !running.isEmpty else { return "No macOS VMs are running to copy into." }

        var succeeded = 0
        var failures: [String] = []
        for vm in running {
            do {
                let installed = try await macVMManager.installApps(name: vm.name, appPaths: apps)
                if !installed.isEmpty { succeeded += 1 }
            } catch {
                failures.append("\(vm.name): \(error)")
            }
        }

        let appWord = apps.count == 1 ? "app" : "apps"
        let vmWord = running.count == 1 ? "VM" : "VMs"
        if failures.isEmpty {
            return "Copied \(apps.count) \(appWord) into \(succeeded) running \(vmWord)."
        }
        let failLine = failures.joined(separator: "; ")
        if succeeded == 0 {
            return "Couldn't copy into any running \(vmWord): \(failLine)"
        }
        return "Copied into \(succeeded) of \(running.count) \(vmWord); \(failures.count) failed: \(failLine)"
    }

    /// Inject the currently-configured "Included apps" into the golden base image without a full
    /// rebuild (the engine boots the base briefly, copies them in, and powers it off), so every future
    /// session clone includes them. Returns a short, human-readable summary for the settings panel;
    /// never throws — a failure is folded into the message.
    public func addIncludedAppsToMacVMBase() async -> String {
        guard let macVMManager else { return "The macOS VM service isn't available in this build." }
        let apps = MacVMSettings.bundledAppPaths
        guard !apps.isEmpty else { return "No included apps are configured to add." }
        do {
            let installed = try await macVMManager.installAppsIntoBase(appPaths: apps)
            let n = installed.count
            guard n > 0 else { return "No apps were added (the selected bundles no longer exist)." }
            return "Added \(n) app\(n == 1 ? "" : "s") to the base image. New VMs will include "
                + "\(n == 1 ? "it" : "them")."
        } catch {
            return "Couldn't add apps to the base image: \(error)"
        }
    }

    /// Install the currently-selected "Common packages" (``MacVMSettings/selectedPackageIDs``) into
    /// every running per-session macOS VM over vsock, so live clones get them without a base rebuild.
    /// Returns a short, human-readable summary for the settings panel. Never throws — a VM that fails is
    /// folded into the summary.
    public func pushSelectedPackagesToRunningMacVMs() async -> String {
        guard let macVMManager else { return "The macOS VM service isn't available in this build." }
        let ids = MacVMSettings.selectedPackageIDs
        guard !ids.isEmpty else { return "No common packages are selected to install." }
        let running = await macVMManager.runningVMs()
        guard !running.isEmpty else { return "No macOS VMs are running to install into." }

        var succeeded = 0
        var failures: [String] = []
        for vm in running {
            do {
                let installed = try await macVMManager.installPackages(name: vm.name, packageIDs: ids)
                if !installed.isEmpty { succeeded += 1 }
            } catch {
                failures.append("\(vm.name): \(error)")
            }
        }

        let pkgWord = ids.count == 1 ? "package" : "packages"
        let vmWord = running.count == 1 ? "VM" : "VMs"
        if failures.isEmpty {
            return "Installed \(ids.count) \(pkgWord) into \(succeeded) running \(vmWord)."
        }
        let failLine = failures.joined(separator: "; ")
        if succeeded == 0 {
            return "Couldn't install into any running \(vmWord): \(failLine)"
        }
        return "Installed into \(succeeded) of \(running.count) \(vmWord); \(failures.count) failed: \(failLine)"
    }

    /// Install the currently-selected "Common packages" into the golden base image without a full
    /// rebuild (the engine boots the base briefly, installs them, and powers it off), so every future
    /// session clone includes them. Returns a short, human-readable summary; never throws.
    public func addSelectedPackagesToMacVMBase() async -> String {
        guard let macVMManager else { return "The macOS VM service isn't available in this build." }
        let ids = MacVMSettings.selectedPackageIDs
        guard !ids.isEmpty else { return "No common packages are selected to add." }
        do {
            let installed = try await macVMManager.installPackagesIntoBase(packageIDs: ids)
            let n = installed.count
            guard n > 0 else { return "No packages were installed (none of the selection succeeded)." }
            return "Added \(n) package\(n == 1 ? "" : "s") to the base image. New VMs will include "
                + "\(n == 1 ? "it" : "them")."
        } catch {
            return "Couldn't add packages to the base image: \(error)"
        }
    }

    /// The current GUI screenshot (base64 JPEG) of a running per-session macOS VM, for the live monitor
    /// panel. `nil` when the service isn't wired, the VM isn't running, or capture is unavailable.
    public func captureMacVMScreen(name: String) async -> String? {
        guard let macVMManager else { return nil }
        return await macVMManager.captureScreen(name: name)
    }

    /// Whether the named per-session VM is running a headless `mac_vm_exec` command right now with no
    /// screen-driving (`mac_vm_computer`) action in flight — the guest is working but its screen is
    /// static. The live monitor polls this to overlay a "Working in the background…" hint so a frozen
    /// frame doesn't read as a hang. `false` when the service isn't wired.
    public func macVMWorkingInBackground(name: String) async -> Bool {
        guard let macVMManager else { return false }
        return await macVMManager.isWorkingInBackground(name: name)
    }

    /// Show or hide the **operator-assist viewer** for the named session VM — the live, interactive
    /// guest window the user drives by hand when the agent calls `mac_vm_request_operator`. `visible:
    /// true` brings it on-screen (over which the walkthrough popup floats); `false` returns it
    /// off-screen once the hand-off is done. No-op when the VM service isn't wired.
    public func presentMacVMOperatorViewer(name: String, visible: Bool) async {
        await macVMManager?.presentOperatorAssist(name: name, visible: visible)
    }

    /// Kick off the (long, one-time) golden base-image build: download + install macOS, create the
    /// key-authorized `agent` account (macOS 27 declarative first boot), then provision the dev
    /// toolchain and — when computer use is enabled — the native `NucleicVMAgent` over SSH. Reentrant:
    /// on an already-installed base it re-runs only provisioning (the "disable SIP, Build again" path).
    /// Progress is polled via ``macVMBaseProgress()``. Throws on failure so the panel can surface it.
    public func buildMacVMBaseImage(localRestoreImagePath: String? = nil) async throws {
        guard let macVMManager else {
            throw MacVMError.unavailable("the macOS VM service isn't wired in this build")
        }
        try await macVMManager.buildBaseImage(localRestoreImagePath: localRestoreImagePath)
    }

    /// Force a re-provision of any engine-built VM base (macOS + Linux) whose provisioning recipe is
    /// older than the one this app version ships — the case where an app update changed the base's
    /// baked-in toolchain/agent. Long-running when it fires, so it's launched off the startup path (a
    /// background Task in the app's launch sequence). A no-op when the VM service isn't wired, no base
    /// is built, or every base is already current.
    public func reprovisionStaleMacVMBasesIfNeeded() async {
        await macVMManager?.reprovisionStaleBaseImagesIfNeeded()
    }

    /// Delete the built base image so the next ``buildMacVMBaseImage(localRestoreImagePath:)`` reinstalls
    /// macOS **from scratch** (the full install, not the reentrant re-provision). When
    /// `includingRestoreImages` is set, the cached `.ipsw` is cleared too, so the restore image is
    /// re-downloaded. Throws if the base is busy (a build in flight or a Recovery window open).
    public func deleteMacVMBaseImage(includingRestoreImages: Bool) async throws {
        guard let macVMManager else {
            throw MacVMError.unavailable("the macOS VM service isn't wired in this build")
        }
        try await macVMManager.deleteBaseImage(includingRestoreImages: includingRestoreImages)
    }

    /// Show or hide the diagnostic monitor window for the in-flight base build — a live
    /// `VZVirtualMachineView` of the provisioning guest, for watching a build that's stuck.
    public func setMacVMBaseObserver(visible: Bool) async {
        await macVMManager?.setBaseProvisionObserver(visible: visible)
    }

    // MARK: - Linux VM base image (settings panel)

    /// The **Linux** base image's provisioning status (installed / desktop+toolchain provisioned), for
    /// the settings panel. Shares the build-progress bar with the macOS base via ``macVMBaseProgress()``
    /// (only one base builds at a time).
    public func linuxVMBaseStatus() async -> MacVMBaseStatus {
        await macVMManager?.linuxBaseStatus() ?? MacVMBaseStatus(os: .linux)
    }

    /// Kick off the (long, one-time) golden **Linux** base-image build: download the kernel + rootfs,
    /// assemble the root filesystem in-guest, then provision the minimal Wayland desktop + toolchain.
    /// Progress is polled via ``macVMBaseProgress()``. Throws on failure so the panel can surface it.
    public func buildLinuxVMBaseImage() async throws {
        guard let macVMManager else {
            throw MacVMError.unavailable("the Linux VM service isn't wired in this build")
        }
        try await macVMManager.buildLinuxBaseImage()
    }

    /// Delete the built Linux base so the next ``buildLinuxVMBaseImage()`` rebuilds from scratch.
    /// Throws if the base is busy (a build in flight).
    public func deleteLinuxVMBaseImage() async throws {
        guard let macVMManager else {
            throw MacVMError.unavailable("the Linux VM service isn't wired in this build")
        }
        try await macVMManager.deleteLinuxBaseImage()
    }

    public func renameProject(_ id: ProjectID, to name: String) async {
        let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty, var project = projectsByID[id] else { return }
        project.name = trimmed
        do {
            try await database.saveProject(project)
            await loadProjects()
        } catch {
            lastError = "Rename failed: \(error)"
        }
    }

    /// Archive (or unarchive) a project. Archiving hides it from the active list and stops its
    /// live sessions — shutting down controllers and freeing their sandbox containers, exactly
    /// like archiving a chat — but leaves every worktree, branch, file, and record in place so
    /// `setProjectArchived(_:false)` restores it untouched. The on-disk repo is never modified.
    public func setProjectArchived(_ id: ProjectID, _ archived: Bool) async {
        guard var project = projectsByID[id] else { return }
        if archived {
            // Put the project away: stop its sessions (controllers, observers, locks) and free
            // their containers, then collapse the open chat/project if it belonged to this one.
            let sessions = (try? await database.loadSessions(projectID: id)) ?? []
            await stopSessions(sessions)
            for session in sessions where openSessionID == session.id { openSessionID = nil }
            if openProjectID == id { openProjectID = nil }
        }
        project.archivedAt = archived ? now() : nil
        do {
            try await database.saveProject(project)
            await loadProjects()
            // The active-project set changed, and the session list only covers active
            // projects — push the refreshed list so viewers drop (or regain) this
            // project's sessions without waiting for a reconnect.
            broadcastSessionListChanged()
        } catch {
            lastError = "\(archived ? "Archive" : "Unarchive") project failed: \(error)"
        }
        // Archiving a control project may have freed the last user of the shared container.
        if archived { await reapSharedControlContainerIfUnused() }
    }

    /// Delete a project and everything under it: stop any live sessions, remove their worktrees
    /// + branches, and drop the records. For a project added from **outside** Nucleic's control
    /// filesystem the user's own repository on disk is never touched — only the
    /// `.nucleic/worktrees` checkouts. For a **Nucleic Control** project (one Nucleic itself
    /// cloned under `~/.nucleic/control/`), the whole managed directory is Nucleic's to own, so
    /// it is removed from disk along with the records.
    public func deleteProject(_ id: ProjectID) async {
        guard let project = projectsByID[id] else { return }
        let sessions = (try? await database.loadSessions(projectID: id)) ?? []
        var failedContainers: [String] = []
        for session in sessions {
            if let controller = controllers[session.id] {
                await controller.shutdown()
                observers[session.id]?.cancel()
                observers[session.id] = nil
                controllers[session.id] = nil
            }
            forgetShipState(session.id)
            if let name = await containerManager?.teardown(session.id, waitForActive: false) {
                failedContainers.append(name)
            }
            if let path = session.worktreePath, let branch = session.branch, let baseSHA = session.baseSHA {
                let worktree = Worktree(
                    sessionID: session.id, path: path, branch: branch,
                    baseSHA: baseSHA, createdAt: session.createdAt)
                try? await worktrees.discard(worktree, in: project, force: true)
            }
            try? await database.deleteSession(id: session.id)
            summaries.removeAll { $0.id == session.id }
            updateAlarm(clearing: session.id)  // the chat is gone — stop ringing for it
            if openSessionID == session.id { openSessionID = nil }
        }
        reportSandboxCleanupFailures(failedContainers)
        // Keep the project's captured ideas — just unassign them so they survive as
        // unfiled to-dos rather than pointing at a project that no longer exists.
        for var todo in todos where todo.projectID == id {
            todo.projectID = nil
            todo.updatedAt = now()
            await persistTodo(todo)
        }
        do {
            try await database.deleteProject(id: id)
            // A Nucleic Control repo lives entirely inside `~/.nucleic/control/` — Nucleic owns
            // that tree, so deleting the project removes its directory and everything under it
            // (the `.nucleic/worktrees` checkouts included). Re-check the canonical path is
            // genuinely under the control base before unlinking, so we can never `rm` a project
            // the user added from their own folders.
            if project.isNucleicControlled {
                let root = GitWorktreeManager.canonical(project.rootPath)
                if ProjectCloner.isControlled(root) {
                    try? FileManager.default.removeItem(atPath: root)
                }
            }
            await loadProjects()
        } catch {
            lastError = "Delete project failed: \(error)"
        }
        // The per-session teardown above bypasses deleteSession, so push the refreshed list
        // once for the whole batch — viewers drop the project's rows without a reconnect.
        if !sessions.isEmpty { broadcastSessionListChanged() }
        // Deleting a control project may have removed the last user of the shared container.
        await reapSharedControlContainerIfUnused()
    }

    /// Convert an existing project into a Nucleic-Control repo by safe-moving its tree into
    /// `~/.nucleic/control/` — out of iCloud / the user's folders and into Nucleic's managed
    /// area. A thin wrapper over `moveProject`: it ensures the control base exists and
    /// targets it as the destination parent, reusing the full stop → copy → repair → verify
    /// → rewrite → delete-source machinery. No-op if the repo is already controlled.
    public func convertProjectToControl(_ id: ProjectID) async {
        guard let project = projectsByID[id] else { return }
        if project.isNucleicControlled {
            lastError = "“\(project.name)” is already a Nucleic Control repo."
            return
        }
        // Nucleic Control requires the container service (the shared sandbox + git interceptor).
        guard ContainerServiceSettings.serviceEnabled else {
            lastError = "Nucleic Control needs the container service — enable it in "
                + "Settings → Sandbox first."
            return
        }
        try? FileManager.default.createDirectory(
            atPath: ProjectCloner.controlBase, withIntermediateDirectories: true)
        await moveProject(id, toParent: URL(fileURLWithPath: ProjectCloner.controlBase))
    }

    /// Non-destructively convert an existing project into Nucleic Control: *clone* its repo
    /// into `~/.nucleic/control/` as a fresh managed checkout and register that as a NEW
    /// project, leaving the original project and its on-disk working copy exactly where they
    /// are. Unlike `convertProjectToControl` (which moves the tree and deletes the source),
    /// this is the keep-a-copy path — for a repo the user still works in by hand.
    ///
    /// The clone carries the source's full committed history and branches but NOT its
    /// uncommitted/untracked changes or its `.nucleic/worktrees` session state (git-excluded),
    /// so the control copy starts clean — the right starting point for a Nucleic-managed repo.
    /// The clone's `origin` is repointed at the source's upstream (see `adoptUpstreamRemote`).
    /// Reuses the per-project `moves` lock + progress bar so the sidebar shows the work and a
    /// second conversion of the same project can't race. Returns the new project, or nil on
    /// failure (leaving the original untouched and cleaning up any partial clone).
    @discardableResult
    public func cloneProjectIntoControl(_ id: ProjectID) async -> Project? {
        guard moves[id] == nil, let project = projectsByID[id] else { return nil }
        if project.isNucleicControlled {
            lastError = "“\(project.name)” is already a Nucleic Control repo."
            return nil
        }
        // Nucleic Control requires the container service (the shared sandbox + git interceptor).
        guard ContainerServiceSettings.serviceEnabled else {
            lastError = "Nucleic Control needs the container service — enable it in "
                + "Settings → Sandbox first."
            return nil
        }

        let log = MoveLog(label: "convert \(project.name)")
        log.line("convert-to-control requested for \(project.rootPath)")
        moveLogs[id] = log
        moves[id] = MoveProgress(fraction: 0, phase: "Preparing…", updatedAt: now())
        let task = Task { await self.runCloneIntoControl(id, project: project, log: log) }
        moveTasks[id] = { task.cancel() }
        let result = await task.value
        moveTasks[id] = nil
        moves[id] = nil
        log.finish(result != nil ? "converted to \(result!.rootPath)" : (lastError ?? "failed/canceled"))
        moveLogs[id] = nil
        return result
    }

    /// The body of `cloneProjectIntoControl`, run inside a tracked, cancellable task so
    /// `cancelMove` can stop it. On cancellation it removes any partial clone and leaves the
    /// original untouched (the convert is non-destructive either way).
    private func runCloneIntoControl(_ id: ProjectID, project: Project, log: MoveLog) async -> Project? {
        let cloner = ProjectCloner()
        let relocator = ProjectRelocator(log: log)
        let source = GitWorktreeManager.canonical(project.rootPath)
        let folder = (source as NSString).lastPathComponent
        try? FileManager.default.createDirectory(
            atPath: ProjectCloner.controlBase, withIntermediateDirectories: true)
        let destination = ProjectCloner.uniqueDestination(
            parent: ProjectCloner.controlBase, folder: folder)

        // Materialize any undownloaded iCloud placeholders first — `git clone` reads the
        // source's `.git/objects`, which fail to copy if iCloud left them as stubs. Only the
        // object store needs to be present to clone from, NOT the (potentially huge) working
        // trees of every linked worktree, so scope the download to `.git`. This only reads the
        // source (it's never deleted), keeping the convert non-destructive.
        let gitDir = (source as NSString).appendingPathComponent(".git")
        let prepRoot = FileManager.default.fileExists(atPath: gitDir) ? gitDir : source
        reportMove(id, 0.10, "Downloading from iCloud…")
        log.line("phase: Downloading from iCloud (prep root \(prepRoot))")
        await relocator.prepareICloud(at: prepRoot) { [weak self] frac in
            Task { @MainActor in self?.reportMove(id, 0.10 + frac * 0.35, "Downloading from iCloud…") }
        }
        if Task.isCancelled { log.line("cancelled before clone"); lastError = "Convert canceled — the original is unchanged."; return nil }

        // Clone the source repo into the control area (off the main actor — a large repo's clone
        // is slow). A heartbeat watches the destination grow for real progress + liveness. A
        // failure (or cancel) cleans up the partial checkout and leaves the original be.
        reportMove(id, 0.45, "Cloning into Nucleic Control…")
        log.line("phase: Cloning into \(destination)")
        let heartbeat = startCopyHeartbeat(id, dest: destination, band: 0.45...0.82, log: log)
        do {
            try await Task.detached { try await cloner.clone(from: source, into: destination) }.value
        } catch {
            heartbeat.cancel()
            try? FileManager.default.removeItem(atPath: destination)
            log.line("clone failed: \(error)")
            lastError = "Convert to Nucleic Control failed cloning the repo; the original was "
                + "left untouched. \(error)"
            return nil
        }
        heartbeat.cancel()
        if Task.isCancelled {
            try? FileManager.default.removeItem(atPath: destination)
            log.line("cancelled after clone; removed partial checkout")
            lastError = "Convert canceled — the original is unchanged."
            return nil
        }
        log.line("clone complete; adopting upstream remote")
        await cloner.adoptUpstreamRemote(fromLocalSource: source, into: destination)

        // Register the clone as a new project, carrying over the user's project-level config
        // that still applies. The new repo's path puts it under Nucleic Control, so its
        // `effectiveSandbox` switches the managed-container behavior on automatically.
        reportMove(id, 0.85, "Registering with Nucleic…")
        log.line("phase: Registering with Nucleic")
        let branch = await cloner.detectDefaultBranch(at: destination)
        var control = Project(
            name: project.name, rootPath: destination, defaultBranch: branch, createdAt: now())
        control.autoShipBranch = project.autoShipBranch
        control.defaultBackend = project.defaultBackend
        control.worktreeBase = nil  // managed repo uses the default `.nucleic/worktrees` base
        control.setupScript = project.setupScript
        control.setupPolicy = project.setupPolicy
        control.sandbox = project.sandbox
        do {
            try await database.saveProject(control)
            await loadProjects()
            log.line("registered control project at \(destination)")
            return control
        } catch {
            try? FileManager.default.removeItem(atPath: destination)
            log.line("register failed: \(error)")
            lastError = "Convert to Nucleic Control failed registering the project; the "
                + "original was left untouched. \(error)"
            return nil
        }
    }

    /// Safe-move: relocate a project's repository tree to a new parent directory (e.g.
    /// out of iCloud) without breaking Nucleic's link to it. Stops the project's live
    /// sessions, materializes/cleans iCloud state, copies the tree, repairs git's linked
    /// worktrees, rewrites the stored paths, rebuilds the sessions against the new
    /// location, and only then deletes the source. On any failure before the DB is
    /// updated, the original is left untouched and a partial copy is cleaned up.
    ///
    /// The new repo folder keeps the source's folder name: `destParent/<repoFolderName>`.
    public func moveProject(_ id: ProjectID, toParent destParent: URL) async {
        guard moves[id] == nil, let project = projectsByID[id] else { return }
        // A Nucleic Control project can't be moved — that would relocate it out of
        // ~/.nucleic/control/ and release it from control, which isn't permitted once a project
        // is under Nucleic Control. (`convertProjectToControl` moves a NON-control project *into*
        // control, so this guard never blocks it.)
        if project.isNucleicControlled {
            lastError = "“\(project.name)” is a Nucleic Control project and can't be moved or released."
            return
        }
        let oldRoot = GitWorktreeManager.canonical(project.rootPath)
        let folderName = (oldRoot as NSString).lastPathComponent
        let newRoot = destParent.appendingPathComponent(folderName).path
        let canonNew = GitWorktreeManager.canonical(newRoot)

        // Guards: don't clobber an existing dir, don't move a project into itself, and
        // don't collide with another registered project's repo.
        if FileManager.default.fileExists(atPath: newRoot) {
            lastError = "Can't move: “\(newRoot)” already exists."
            return
        }
        if canonNew == oldRoot || canonNew.hasPrefix(oldRoot + "/") {
            lastError = "Can't move a project inside its own folder."
            return
        }
        if let existing = projects.first(where: {
            $0.id != id && GitWorktreeManager.canonical($0.rootPath) == canonNew
        }) {
            lastError = "“\(existing.name)” already uses that location."
            return
        }

        let log = MoveLog(label: "move \(project.name)")
        log.line("move requested: \(oldRoot) → \(newRoot)")
        moveLogs[id] = log
        moves[id] = MoveProgress(fraction: 0, phase: "Preparing…", updatedAt: now())
        let task = Task { await self.runMove(id, project: project, oldRoot: oldRoot, newRoot: newRoot, log: log) }
        moveTasks[id] = { task.cancel() }
        await task.value
        moveTasks[id] = nil
        moves[id] = nil
        log.finish(lastError == nil ? "moved to \(newRoot)" : (lastError ?? "failed/canceled"))
        moveLogs[id] = nil
    }

    /// The body of `moveProject`, run inside a tracked, cancellable task so `cancelMove` can stop
    /// it. The cancellation checkpoints sit before the copy and before the DB save — once the new
    /// paths are persisted the move has effectively committed, so it runs to completion past that
    /// point. Any cancel/failure before commit removes the partial copy and rebuilds the
    /// controllers at the untouched source.
    private func runMove(_ id: ProjectID, project: Project, oldRoot: String, newRoot: String, log: MoveLog) async {
        let relocator = ProjectRelocator(log: log)

        // 1. Stop the project's live sessions (leave their worktrees on disk — they move
        //    with the tree). Capture the real linked worktrees from the still-valid source.
        reportMove(id, 0.05, "Pausing chats…")
        let sessions = (try? await database.loadSessions(projectID: id)) ?? []
        log.line("phase: Pausing \(sessions.count) chat(s)")
        await stopSessions(sessions)
        let oldWorktrees = (try? await relocator.linkedWorktrees(in: oldRoot)) ?? []
        let newWorktrees = oldWorktrees.map {
            ProjectRelocator.rewrite(path: $0, oldRoot: oldRoot, newRoot: newRoot)
        }
        log.line("linked worktrees: \(oldWorktrees.count)")

        // 2. Make the iCloud source safe to copy (download placeholders, drop conflicts).
        //    Materializing placeholders is the long pole on an iCloud tree, so thread its
        //    completion through to the progress bar (occupies the 0.10–0.45 band).
        reportMove(id, 0.10, "Downloading from iCloud…")
        log.line("phase: Downloading from iCloud (source \(oldRoot))")
        await relocator.prepareICloud(at: oldRoot) { [weak self] frac in
            Task { @MainActor in self?.reportMove(id, 0.10 + frac * 0.35, "Downloading from iCloud…") }
        }
        if Task.isCancelled {
            log.line("cancelled before copy; restoring sessions")
            await loadSessions()
            lastError = "Move canceled — the original is unchanged."
            return
        }

        // 3. Copy the tree, then repair + verify the relocated repo. The copy is a large
        //    synchronous FileManager operation — run it off the main actor so the rest of
        //    Nucleic stays responsive while the project is locked. A heartbeat watches the
        //    destination grow so the bar advances on real bytes (and goes quiet if it wedges).
        reportMove(id, 0.45, "Copying files…")
        log.line("phase: Copying files → \(newRoot)")
        let heartbeat = startCopyHeartbeat(id, dest: newRoot, band: 0.45...0.72, log: log)
        do {
            try await Task.detached { try relocator.copyTree(from: oldRoot, to: newRoot) }.value
        } catch {
            heartbeat.cancel()
            try? await Task.detached { try relocator.remove(newRoot) }.value  // clean up the partial copy
            await loadSessions()            // rebuild controllers at the untouched source
            log.line("copy failed: \(error)")
            lastError = "Move failed; the original was left untouched. \(error)"
            return
        }
        heartbeat.cancel()
        if Task.isCancelled {
            try? await Task.detached { try relocator.remove(newRoot) }.value
            await loadSessions()
            log.line("cancelled after copy; removed partial copy at \(newRoot)")
            lastError = "Move canceled — the original is unchanged."
            return
        }
        log.line("copy complete")
        do {
            reportMove(id, 0.75, "Repairing git worktrees…")
            log.line("phase: Repairing + verifying git worktrees")
            try await relocator.repairGit(newRoot: newRoot, worktreePaths: newWorktrees)
            try await relocator.verify(newRoot: newRoot, worktreePaths: newWorktrees)
        } catch {
            try? await Task.detached { try relocator.remove(newRoot) }.value
            await loadSessions()
            log.line("repair/verify failed: \(error)")
            lastError = "Move failed; the original was left untouched. \(error)"
            return
        }

        // 4. Rewrite the stored paths (source still intact, so a save failure is recoverable).
        reportMove(id, 0.85, "Updating Nucleic…")
        log.line("phase: Updating Nucleic (rewriting stored paths)")
        var moved = project
        moved.rootPath = newRoot
        if let base = moved.worktreeBase {
            moved.worktreeBase = ProjectRelocator.rewrite(path: base, oldRoot: oldRoot, newRoot: newRoot)
        }
        do {
            try await database.saveProject(moved)
        } catch {
            try? await Task.detached { try relocator.remove(newRoot) }.value
            await loadSessions()
            log.line("save failed: \(error)")
            lastError = "Move failed saving the project; the original was left untouched. \(error)"
            return
        }
        for var session in sessions {
            if let path = session.worktreePath {
                session.worktreePath = ProjectRelocator.rewrite(path: path, oldRoot: oldRoot, newRoot: newRoot)
            }
            if let native = session.nativeTranscriptPath {
                session.nativeTranscriptPath = ProjectRelocator.rewrite(path: native, oldRoot: oldRoot, newRoot: newRoot)
            }
            try? await database.saveSession(session)
        }

        // 5. Best-effort: carry each session's Claude native transcript to its new
        //    cwd-derived directory so `--resume` keeps finding prior history.
        migrateNativeTranscripts(sessions: sessions, oldRoot: oldRoot, newRoot: newRoot)

        // 6. Reload from the rewritten DB and rebuild controllers at the new location.
        reportMove(id, 0.92, "Finishing up…")
        log.line("phase: Finishing up (reloading projects + sessions)")
        await loadProjects()
        await loadSessions()

        // 7. The new copy is live — remove the iCloud source. If this fails the move still
        //    succeeded (Nucleic points at the new copy); just tell the user to clean up.
        reportMove(id, 0.97, "Cleaning up the original…")
        log.line("phase: Cleaning up the original at \(oldRoot)")
        do {
            try await Task.detached { try relocator.remove(oldRoot) }.value
            log.line("removed original source")
        } catch {
            log.line("could not remove original: \(error)")
            lastError = "Moved to \(newRoot), but the original at \(oldRoot) couldn't be "
                + "removed: \(error.localizedDescription). Delete it manually."
        }
    }

    /// Stop the given sessions' live controllers (shutdown, drop observer + controller)
    /// and tear down any sandbox containers — without touching their worktrees. Shared by
    /// `deleteProject` and `moveProject`.
    private func stopSessions(_ sessions: [Session]) async {
        var failedContainers: [String] = []
        for session in sessions {
            // Release any file locks before the controller goes away. Critical for moveProject:
            // a move can flip the project out of Nucleic Control (rootPath leaves the control
            // dir), after which `reconstructLocks` skips it (lockDomain is nil) and would never
            // re-establish OR clear the control-era locks — stranding a phantom holder. Releasing
            // here keeps the lock table consistent; if the project is still controlled after the
            // move, `reconstructLocks` rebuilds the locks from git.
            await lockManager.releaseAll(session.id)
            if controllers[session.id] != nil {
                await controllers[session.id]?.shutdown()
                observers[session.id]?.cancel()
                observers[session.id] = nil
                controllers[session.id] = nil
            }
            forgetShipState(session.id)
            if let name = await containerManager?.teardown(session.id, waitForActive: false) {
                failedContainers.append(name)
            }
        }
        reportSandboxCleanupFailures(failedContainers)
    }

    /// Copy each session's Claude Code native transcript JSONL from its old cwd-derived
    /// project directory under `~/.claude` to the new one. Claude names that directory by
    /// the worktree it was launched in (`SessionController.claudeProjectDirName`), so a
    /// moved worktree lands in a new directory; without this, resuming a moved session
    /// would lose its prior native history. Best-effort and non-fatal.
    private func migrateNativeTranscripts(sessions: [Session], oldRoot: String, newRoot: String) {
        let fm = FileManager.default
        let projectsDir = (NSHomeDirectory() as NSString)
            .appendingPathComponent(".claude/projects")
        for session in sessions {
            guard let sid = session.backendSessionID, !sid.isEmpty,
                  let oldWorktree = session.worktreePath else { continue }
            let newWorktree = ProjectRelocator.rewrite(path: oldWorktree, oldRoot: oldRoot, newRoot: newRoot)
            let newDir = (projectsDir as NSString)
                .appendingPathComponent(SessionController.claudeProjectDirName(newWorktree))
            // Claude derives the dir from the cwd it was launched with, which may be the raw
            // or symlink-resolved worktree path — try both source names.
            for variant in Set([oldWorktree, GitWorktreeManager.canonical(oldWorktree)]) {
                let oldFile = (projectsDir as NSString)
                    .appendingPathComponent(SessionController.claudeProjectDirName(variant))
                    + "/\(sid).jsonl"
                guard fm.fileExists(atPath: oldFile) else { continue }
                try? fm.createDirectory(atPath: newDir, withIntermediateDirectories: true)
                let newFile = (newDir as NSString).appendingPathComponent("\(sid).jsonl")
                if !fm.fileExists(atPath: newFile) {
                    try? fm.copyItem(atPath: oldFile, toPath: newFile)
                }
            }
        }
    }

    public func project(_ id: ProjectID) -> Project? { resolvedProject(id) }

    /// Resolve a project id to its record, including the hidden ephemeral pseudo-project (which
    /// lives only in memory, never in `projectsByID`). Every path that must reconstruct or theme a
    /// session's project — controller rebuild on load, title/palette lookups, transcript export —
    /// goes through here so an ephemeral session resolves without leaking the reserved project into
    /// the sidebar/pickers (which map `projects`, not this).
    func resolvedProject(_ id: ProjectID) -> Project? {
        id == Self.ephemeralProjectID ? ephemeralProject : projectsByID[id]
    }

    /// Whether a session is an ephemeral, out-of-project (Scratch) chat.
    func isEphemeral(_ session: Session) -> Bool { session.projectID == Self.ephemeralProjectID }
    func isEphemeral(_ summary: SessionSummary) -> Bool {
        summary.projectID == Self.ephemeralProjectID
    }

    /// Whether Orchestra is *actually in effect* for `summary`: selected AND its project is under
    /// Nucleic Control — the same gate the host applies in `SessionController.orchestraActive`. The
    /// sidebar's orchestra marker uses this rather than the raw `SessionSummary.isOrchestra` so a
    /// stray "orchestra" effort on an uncontrolled project (left over after it leaves control, or
    /// arriving from a remote) doesn't imply an orchestration the host has withheld.
    public func orchestraActive(_ summary: SessionSummary) -> Bool {
        summary.isOrchestra && projectSummary(summary.projectID)?.isNucleicControlled == true
    }

    /// The project to act on by default: the open chat's project, else the first.
    public var currentProject: Project? {
        if let projectID = openSession?.projectID, let project = projectsByID[projectID] {
            return project
        }
        return projects.first
    }

    /// Cmd-N: start a new chat in the current project.
    @discardableResult
    public func newSessionInCurrentProject() async -> SessionID? {
        guard let project = currentProject else { return nil }
        return await newSession(in: project)
    }

    /// Start a new chat in `project` seeded with `message` (the first turn begins
    /// immediately). Used by the home dashboard's chat bar. An empty message just opens a
    /// fresh idle chat. `reveal` navigates to the new session (the default); pass `false` to
    /// start it in the background and leave the user on their current view.
    @discardableResult
    public func startChat(
        in project: Project, message: String, model: String? = nil, effort: String? = nil,
        base: GitRef? = nil, useWorktree: Bool = true, auto: Bool? = nil, autoShip: Bool? = nil,
        attachments: [PendingAttachment] = [], reveal: Bool = true
    ) async -> SessionID? {
        let text = message.trimmingCharacters(in: .whitespacesAndNewlines)
        do {
            let id = try await createSession(
                in: project, title: RandomName.generate(), prompt: text, base: base,
                model: model, effort: effort, useWorktree: useWorktree, auto: auto, autoShip: autoShip,
                attachments: attachments)
            if reveal { openSessionID = id }
            return id
        } catch {
            lastError = "New chat failed: \(error)"
            return nil
        }
    }

    // MARK: - Ephemeral chat lifecycle

    /// Start an ephemeral, out-of-project (Scratch) chat: the full agent runs with its tools in a
    /// disposable per-session scratch dir under the reserved pseudo-project — no worktree, branch,
    /// or ship, and never broadcast to peers/iOS. Returns the new session id (the caller opens it
    /// in the popup Scratch window); does NOT touch `openSessionID` since the reserved project isn't
    /// in the sidebar and the main window can't render it.
    @discardableResult
    public func startEphemeralChat(
        message: String, model: String? = nil, effort: String? = nil, auto: Bool? = nil
    ) async -> SessionID? {
        let text = message.trimmingCharacters(in: .whitespacesAndNewlines)
        do {
            return try await createSession(
                in: ephemeralProject, title: RandomName.generate(), prompt: text,
                model: model, effort: effort, useWorktree: false, auto: auto, autoShip: false,
                ephemeral: true)
        } catch {
            lastError = "New scratch chat failed: \(error)"
            return nil
        }
    }

    /// All ephemeral chats, newest first — the source for the popup window's Scratch-chats list.
    /// (They're deliberately absent from the main sidebar, which lists per visible project.)
    public var ephemeralSummaries: [SessionSummary] {
        summaries.filter { $0.projectID == Self.ephemeralProjectID }
            .sorted { $0.updatedAt > $1.updatedAt }
    }

    /// Whether an ephemeral chat is marked "Saved" (kept) rather than truly ephemeral.
    public func isEphemeralSaved(_ id: SessionID) -> Bool { ephemeralPersistedSessions.contains(id) }

    /// Flip an ephemeral chat between Saved (kept, project-less) and truly ephemeral (discarded on
    /// window close). Driven by the in-window Ephemeral/Saved switch.
    public func setEphemeralPersistence(_ id: SessionID, saved: Bool) {
        if saved { ephemeralPersistedSessions.insert(id) }
        else { ephemeralPersistedSessions.remove(id) }
    }

    /// Called when an ephemeral chat's window closes: a truly-ephemeral chat (not in the Saved set)
    /// is discarded outright — session record, transcript, and scratch dir all removed. A Saved
    /// chat is left intact so it can be reopened from the Scratch-chats list.
    public func handleEphemeralWindowClosed(_ id: SessionID) async {
        guard !ephemeralPersistedSessions.contains(id) else { return }
        await deleteEphemeralSession(id)
    }

    /// Tear down an ephemeral chat and reclaim its scratch dir. Reuses the standard `deleteSession`
    /// teardown (controller discard is a no-op for a worktree-less session), then removes the
    /// per-session scratch directory. Never broadcasts — ephemeral chats were never shared.
    public func deleteEphemeralSession(_ id: SessionID) async {
        ephemeralPersistedSessions.remove(id)
        await deleteSession(id)
        EphemeralScratch.remove(base: ephemeralScratchBase, session: id)
        // deleteSession removes the DB row but leaves the transcript on disk; a discarded scratch
        // chat leaves nothing behind, so drop its transcript directory too.
        try? FileManager.default.removeItem(
            at: transcriptsDir.appendingPathComponent(id.rawValue, isDirectory: true))
    }

    // MARK: - Session-scoped drivers (for the ephemeral popup window)
    //
    // The main window renders exactly one session through the single `openSession*` slot. The
    // Scratch popup window hosts a session *off* that slot, so it drives its controller directly by
    // id through these helpers instead of going through `openSessionID`. They're generic by id but
    // exist for the popup — a project chat is always driven through the open-session path.

    /// The current session record + pending approvals for a live session, or nil if it has no
    /// controller (e.g. already discarded).
    public func liveSnapshot(_ id: SessionID) async -> SessionController.Snapshot? {
        await controllers[id]?.snapshot
    }

    /// The full transcript so far for a live session — the popup's initial seed before it starts
    /// streaming new events via `subscribeLive`.
    public func liveTranscript(_ id: SessionID) async -> [AgentEvent] {
        await controllers[id]?.transcriptSoFar() ?? []
    }

    /// A live event stream for a session, for the popup to follow output as it arrives. nil if the
    /// session has no controller.
    public func subscribeLive(_ id: SessionID) async -> AsyncStream<AgentEvent>? {
        await controllers[id]?.subscribe()
    }

    /// Send a message to a specific (not-necessarily-open) session — the popup composer's send.
    public func sendToSession(_ id: SessionID, _ text: String) async {
        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty, let controller = controllers[id] else { return }
        do {
            try await controller.sendInput(AgentInput(text: trimmed), display: QueuedMessage(text: text))
            maybeNameSession(id, firstMessage: trimmed)
        } catch {
            lastError = "Send failed: \(error)"
        }
    }

    /// Answer an approval in a specific session — the popup's approval bar.
    public func respondToApproval(
        _ approvalID: ApprovalID, _ decision: Decision, inSession id: SessionID
    ) async {
        guard let controller = controllers[id] else { return }
        do { try await controller.respondToApproval(approvalID, decision, by: localResponderID) }
        catch { lastError = "Approval failed: \(error)" }
    }

    /// Interrupt the running turn of a specific session — the popup's Stop button.
    public func interruptSession(_ id: SessionID) async {
        await controllers[id]?.interrupt()
    }

    /// Local branches in a project's repo, for the new-chat base-branch selector.
    /// Falls back to the project's default branch on any error.
    public func branches(in project: Project) async -> [String] {
        let result = try? await GitRunner().run(
            ["for-each-ref", "--format=%(refname:short)", "refs/heads"], in: project.rootPath)
        let names = (result?.stdout ?? "")
            .split(whereSeparator: \.isNewline).map(String.init)
            .filter { !$0.isEmpty }
        return names.isEmpty ? [project.defaultBranch.value] : names
    }

    /// Return to the home dashboard.
    public func goHome() { openSessionID = nil; openProjectID = nil }

    /// Show the project overview page in the detail pane (closing any open chat).
    public func openProject(_ id: ProjectID) {
        openSessionID = nil
        openProjectID = id
    }

    /// Full session records for a project, most-recent first, for the overview
    /// page. Carries the cached `summary` blurb that `SessionSummary` omits, so a
    /// returning user can see what each chat was about at a glance.
    public func sessions(for projectID: ProjectID) async -> [Session] {
        let sessions = (try? await database.loadSessions(projectID: projectID)) ?? []
        return sessions.sorted { $0.updatedAt > $1.updatedAt }
    }

    // MARK: - To-do inbox

    /// The project the user is currently working in — the open chat's project, or
    /// the open project page — else nil on the home dashboard.
    public var contextProjectID: ProjectID? {
        let resolved: ProjectID?
        if let sessionID = openSessionID {
            resolved = openSession?.projectID
                ?? summaries.first(where: { $0.id == sessionID })?.projectID
        } else {
            resolved = openProjectID
        }
        // The reserved ephemeral project is not a real, taggable project — treat it as unfiled so a
        // quick to-do captured from an (unlikely) open scratch chat isn't tagged to it.
        return resolved == Self.ephemeralProjectID ? nil : resolved
    }

    /// Open the quick-add sheet seeded with the project the user is currently in
    /// (e.g. Cmd-T inside a session pre-tags that session's project); nil on home.
    public func presentQuickTodo() {
        presentQuickTodo(projectID: contextProjectID)
    }

    /// Open the quick-add sheet pre-tagging a specific project (nil = unfiled).
    public func presentQuickTodo(projectID: ProjectID?) {
        quickTodoProjectID = projectID
        quickTodoPresented = true
    }

    /// Open the floating new-chat composer over the window — the home chat bar as a quick-launch
    /// panel, reachable from anywhere (a session, a project page, or home). `background` (⌘N)
    /// starts the chat without navigating to it; the default (⌘⇧N) jumps to the new session.
    public func presentNewChatComposer(background: Bool = false, ephemeral: Bool = false) {
        newChatComposerStartsInBackground = background
        newChatComposerStartsEphemeral = ephemeral
        newChatComposerPresented = true
    }

    public func todo(_ id: TodoID) -> Todo? { todos.first { $0.id == id } }

    public func loadTodos() async {
        todos = (try? await database.loadTodos()) ?? []
        todos.sort { $0.createdAt < $1.createdAt }
        regenerateTodoSummaries()
        ensureTodoItemSummaries()
        ensureTodoTriage()
    }

    /// Open ideas grouped by project (each project in display order, then the
    /// unassigned bucket last), with a glanceable summary per group.
    /// The to-do group bucket a todo belongs to: its project's id, or "" when unassigned
    /// (or pointing at a since-deleted project, which folds into the unassigned bucket).
    /// The shared key behind `todoGroups`, the per-group gist summaries, and the
    /// per-project triage tokens — so display, gisting, and triage all line up.
    private func todoGroupKey(for todo: Todo) -> String {
        todo.projectID.flatMap { projectsByID[$0] } != nil ? todo.projectID!.rawValue : ""
    }

    public var todoGroups: [TodoGroup] {
        let open = openTodos
        guard !open.isEmpty else { return [] }

        var byKey: [String: [Todo]] = [:]
        for todo in open { byKey[todoGroupKey(for: todo), default: []].append(todo) }

        var groups: [TodoGroup] = []
        for project in projects {  // already sorted by name
            guard let items = byKey[project.id.rawValue] else { continue }
            groups.append(TodoGroup(
                id: project.id.rawValue, projectID: project.id, projectName: project.name,
                todos: items, summary: todoSummaries[project.id.rawValue] ?? ""))
        }
        if let unassigned = byKey[""] {
            groups.append(TodoGroup(
                id: "", projectID: nil, projectName: "Unassigned",
                todos: unassigned, summary: todoSummaries[""] ?? ""))
        }
        return groups
    }

    /// Refresh each group's summary, skipping groups whose ideas are unchanged, and
    /// dropping summaries for groups that no longer exist. Each summary is produced
    /// off the main run loop and lands into `todoSummaries` as it completes.
    private func regenerateTodoSummaries() {
        let groups = todoGroups
        let live = Set(groups.map(\.id))
        todoSummaries = todoSummaries.filter { live.contains($0.key) }
        todoSummarySignatures = todoSummarySignatures.filter { live.contains($0.key) }

        let intelligence = self.intelligence
        for group in groups {
            let texts = group.todos.map(\.text)
            // Fingerprint the SET of ideas, not their order: the gist names common themes,
            // which don't depend on order, but `group.todos` follows the live display order —
            // and that flips when triage re-ranks the group (see `openTodos`). Hashing the
            // raw order would treat a pure reorder as new ideas and re-gist an unchanged set
            // on the next regenerate, burning the on-device model on identical work. Sorting
            // first makes the fingerprint order-independent; `texts` keeps display order for
            // the gist itself.
            let signature = texts.sorted().joined(separator: "\u{1}").hashValue
            // Already handled this exact set of ideas — skip it. The signature is recorded
            // synchronously (below) the moment a pass is enqueued, so a match means a gist
            // is either already in flight OR its summary has landed; re-enqueuing would just
            // burn the on-device model on identical work. (The earlier check also required a
            // landed summary, which re-ran the gist on every regenerate during the in-flight
            // window — the duplicate-event bug.) A pass later superseded by a newer signature
            // never strands content unsummarized: the newer signature's pass covers it.
            if todoSummarySignatures[group.id] == signature { continue }
            todoSummarySignatures[group.id] = signature
            let id = group.id
            Task {
                let summary = await intelligence.summarizeTodos(texts, group: id)
                // Only apply if this group still wants this exact signature.
                guard self.todoSummarySignatures[id] == signature else { return }
                self.todoSummaries[id] = summary
            }
        }
    }

    /// Capture a new idea. `projectID` is optional — an unassigned idea picks its
    /// project at dispatch time. Returns nil (and surfaces nothing) for blank text.
    @discardableResult
    public func addTodo(_ text: String, projectID: ProjectID? = nil) async -> Todo? {
        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else { return nil }
        let todo = Todo(text: trimmed, projectID: projectID, createdAt: now(), updatedAt: now())
        do {
            try await database.saveTodo(todo)
            upsertTodo(todo)
            summarizeTodoItem(todo.id)
            retriageOpenTodos()  // a new idea reshuffles the relative ranking
            return todo
        } catch {
            lastError = "Add to-do failed: \(error)"
            return nil
        }
    }

    /// Edit an idea's text and/or assigned project (e.g. from the home list).
    public func updateTodo(_ id: TodoID, text: String? = nil, projectID: ProjectID?? = nil) async {
        guard var todo = todos.first(where: { $0.id == id }) else { return }
        var textChanged = false
        if let text {
            let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
            guard !trimmed.isEmpty else { return }
            if trimmed != todo.text { todo.text = trimmed; todo.summary = nil; textChanged = true }
        }
        if let projectID { todo.projectID = projectID }
        todo.updatedAt = now()
        await persistTodo(todo)
        if textChanged {
            summarizeTodoItem(id)   // re-summarize the new text
            retriageOpenTodos()     // ...and re-triage, since impact may have changed
        }
    }

    /// Reassign an idea to a different project, or `nil` to unfile it — for fixing a
    /// wrong/forgotten project (via the row's move menu or a drag between groups).
    /// Text and summary are unaffected.
    public func moveTodo(_ id: TodoID, toProject projectID: ProjectID?) async {
        guard var todo = todos.first(where: { $0.id == id }), todo.projectID != projectID else { return }
        todo.projectID = projectID
        todo.updatedAt = now()
        await persistTodo(todo)
    }

    public func setTodoStatus(_ id: TodoID, _ status: TodoStatus) async {
        guard var todo = todos.first(where: { $0.id == id }) else { return }
        let wasOpen = todo.status == .open
        todo.status = status
        todo.updatedAt = now()
        await persistTodo(todo)
        // The open set changed (an idea left or rejoined it), so the relative ranking
        // is stale — re-triage to keep stored levels and ranks consistent.
        if wasOpen || status == .open { retriageOpenTodos() }
    }

    public func deleteTodo(_ id: TodoID) async {
        let wasOpen = todos.first { $0.id == id }?.status == .open
        try? await database.deleteTodo(id: id)
        todos.removeAll { $0.id == id }
        regenerateTodoSummaries()
        if wasOpen { retriageOpenTodos() }  // removing an open idea reshuffles the ranking
    }

    /// Open ideas (not yet dispatched or done). FIFO (oldest first) by default, or in
    /// the AI triage ranking once `triageSortEnabled` (most-impactful first; ideas with
    /// no verdict — e.g. captured after the last pass — fall back to FIFO at the end).
    public var openTodos: [Todo] {
        let open = todos.filter { $0.status == .open }
        guard triageSortEnabled else { return open.sorted { $0.createdAt < $1.createdAt } }
        return open.sorted { a, b in
            let ra = a.triageRank ?? Int.max
            let rb = b.triageRank ?? Int.max
            if ra != rb { return ra < rb }
            return a.createdAt < b.createdAt
        }
    }

    /// The stored triage verdict for an idea, if it's been triaged (nil otherwise).
    public func triageItem(for id: TodoID) -> TriageItem? {
        guard let todo = todos.first(where: { $0.id == id }), let level = todo.triageLevel
        else { return nil }
        return TriageItem(id: id, level: level, rank: todo.triageRank ?? 0, reason: todo.triageReason)
    }

    /// Convenience: just the level (the row badge's color), if any.
    public func triageLevel(for id: TodoID) -> TriageLevel? {
        todos.first { $0.id == id }?.triageLevel
    }

    /// Switch the inbox to the stored impact ranking. The verdicts are computed and
    /// persisted when ideas are added/edited (see `retriageOpenTodos`), so this is
    /// instant — no pass runs on the click.
    public func enableTriageSort() { triageSortEnabled = true }

    /// Drop the impact ordering and go back to FIFO. The stored verdicts are untouched,
    /// so toggling back on is instant.
    public func clearTriageSort() { triageSortEnabled = false }

    /// Flip between FIFO and the stored impact ranking (the Triage button's action).
    public func toggleTriageSort() { triageSortEnabled.toggle() }

    /// Re-triage the open ideas in the background and persist each verdict onto its
    /// `Todo` (level, rank, reason). Issued whenever the open set changes — an idea
    /// added, its text edited, or one leaving/rejoining the set — so the stored ranking
    /// stays current without recomputing on every Triage-button click. Levels and order
    /// come from the intelligence provider (the on-device model when available, else a
    /// deterministic heuristic). Triage runs once PER PROJECT, each pass guarded by its own
    /// `triageTokens` entry so an older pass can't clobber a newer one's verdicts — and a
    /// re-triage of one project never touches another's.
    private func retriageOpenTodos() {
        let open = todos.filter { $0.status == .open }
        guard !open.isEmpty else { return }
        // Triage each project on its own: a project's ideas are ranked among themselves, and
        // re-triaging one never disturbs another's verdicts or its queued pass. Bucket
        // exactly as `todoGroups` does so the triage groups line up with the displayed ones.
        var byKey: [String: [Todo]] = [:]
        for todo in open { byKey[todoGroupKey(for: todo), default: []].append(todo) }
        let intelligence = self.intelligence
        for (key, group) in byKey {
            let inputs = group.sorted { $0.createdAt < $1.createdAt }
                .map { TriageInput(id: $0.id, text: $0.text) }
            let token = (triageTokens[key] ?? 0) + 1
            triageTokens[key] = token
            outstandingTriagePasses += 1
            triaging = true
            Task {
                let items = await intelligence.triageTodos(inputs, group: key)
                // A newer pass for THIS project started while this one ran — let it win.
                guard token == self.triageTokens[key] else { return self.finishTriagePass() }
                let verdicts = Dictionary(items.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
                // Stamp every verdict synchronously (no await between writes) so the list
                // re-orders atomically, then persist the updated rows.
                var toPersist: [Todo] = []
                for input in inputs {
                    guard let index = self.todos.firstIndex(where: { $0.id == input.id }),
                          let v = verdicts[input.id] else { continue }
                    self.todos[index].triageLevel = v.level
                    self.todos[index].triageRank = v.rank
                    self.todos[index].triageReason = v.reason
                    toPersist.append(self.todos[index])
                }
                self.finishTriagePass()
                for todo in toPersist { try? await self.database.saveTodo(todo) }
            }
        }
    }

    /// One per-project triage pass finished — applied, or discarded because a newer pass
    /// for its project superseded it. Drop the outstanding count and clear the spinner only
    /// once every project's pass has settled.
    private func finishTriagePass() {
        outstandingTriagePasses = max(0, outstandingTriagePasses - 1)
        if outstandingTriagePasses == 0 { triaging = false }
    }

    /// After a load, triage the open ideas if any lacks a stored verdict (e.g. captured
    /// before this feature, or freshly migrated). One pass per project covers the set.
    private func ensureTodoTriage() {
        if todos.contains(where: { $0.status == .open && $0.triageLevel == nil }) {
            retriageOpenTodos()
        }
    }

    /// Dispatch an idea: start a chat in `project` seeded with the idea's text, mark
    /// the idea dispatched (linking the spawned session), and — when `follow` is set —
    /// navigate to the new session. The user chooses when to do this — e.g. once other
    /// agents are idle. With `follow` false the agent fires but the current view stays
    /// put, so you can dispatch several ideas without being pulled into each one.
    @discardableResult
    public func dispatchTodo(_ id: TodoID, in project: Project, follow: Bool = true) async -> SessionID? {
        guard let todo = todos.first(where: { $0.id == id }) else { return nil }
        // startChat opens the new session; remember where we were so we can stay put
        // when the caller only wants to fire-and-forget.
        let previousOpen = openSessionID
        guard let sessionID = await startChat(in: project, message: todo.text) else { return nil }
        if !follow { openSessionID = previousOpen }
        var updated = todo
        updated.status = .dispatched
        updated.projectID = project.id
        updated.dispatchedSessionID = sessionID
        updated.updatedAt = now()
        await persistTodo(updated)
        retriageOpenTodos()  // the dispatched idea left the open set — reshuffle the rest
        return sessionID
    }

    private func persistTodo(_ todo: Todo) async {
        do {
            try await database.saveTodo(todo)
            upsertTodo(todo)
        } catch {
            lastError = "Save to-do failed: \(error)"
        }
    }

    private func upsertTodo(_ todo: Todo) {
        if let index = todos.firstIndex(where: { $0.id == todo.id }) {
            todos[index] = todo
        } else {
            todos.append(todo)
        }
        // Oldest first; a stable sort keeps insertion order for equal timestamps.
        todos.sort { $0.createdAt < $1.createdAt }
        regenerateTodoSummaries()
    }

    /// Generate per-item summaries for any open ideas that lack one (after a load).
    private func ensureTodoItemSummaries() {
        for todo in todos where todo.summary == nil && todo.status == .open {
            summarizeTodoItem(todo.id)
        }
    }

    /// Summarize one idea's text off the main run loop and store the result. Stores
    /// `""` when the text needs no distinct summary, so it isn't retried every load.
    private func summarizeTodoItem(_ id: TodoID) {
        guard let todo = todos.first(where: { $0.id == id }) else { return }
        let text = todo.text
        let intelligence = self.intelligence
        Task {
            let raw = await intelligence.summarizeTodo(text)
            let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
            let summary = (trimmed.isEmpty || trimmed.caseInsensitiveCompare(text) == .orderedSame)
                ? "" : trimmed
            // Bail if the idea was edited (newer summarize will run) or already current.
            // Stamp only the summary onto the CURRENT row synchronously (no stale write
            // back after the save's await), so a concurrent move/edit/delete of this todo
            // during persistence isn't clobbered.
            guard let index = self.todos.firstIndex(where: { $0.id == id }),
                  self.todos[index].text == text, self.todos[index].summary != summary
            else { return }
            self.todos[index].summary = summary
            try? await self.database.saveTodo(self.todos[index])
        }
    }

    // MARK: - Conflict coordination (internal project management)

    /// Register self as the conflict arbiter on the shared coordinator, and wire the
    /// autoship merge queue (integrator + status observer). Called once at app startup
    /// (the coordinator holds the arbiter weakly).
    public func activateConflictArbitration() async {
        await conflictCoordinator?.setArbiter(self)
        await activateMergeQueue()
    }

    /// Attach the merge-queue integrator (runs the actual squash-merge for a request) and
    /// subscribe to its status stream so finished/failed merges reflect into the UI and
    /// clear the per-session ship flag. Idempotent.
    private func activateMergeQueue() async {
        await mergeQueue.setIntegrator { [weak self] request in
            await self?.performShip(request) ?? .failed("app store gone")
        }
        await lockManager.setUpdateOnGrant { [weak self] sid, files in
            guard let self else { return .clean }
            return await self.runUpdateOnGrant(sid, files: files)
        }
        await lockManager.setLockEventHandler { [weak self] sid, event in
            await self?.recordLockEvent(sid, event)
        }
        guard mergeQueueObserver == nil else { return }
        let stream = await mergeQueue.updates()
        mergeQueueObserver = Task { [weak self] in
            for await update in stream {
                guard let self else { return }
                await self.handleShipUpdate(update)
            }
        }
    }

    /// The merge-queue integrator: perform one session's squash-merge, off the main loop.
    /// Re-validates eligibility against the *live* session (the agent may have started a
    /// new turn while queued) and merges against the live target tip via the controller.
    private func performShip(_ request: ShipRequest) async -> ShipStatus {
        guard let controller = controllers[request.sessionID] else {
            return .skipped("session no longer exists")
        }
        let session = await controller.snapshot.session
        // Bail unless the session is still autoship, idle, and the agent classified its
        // last turn as done — never ship mid-question or after the user turned ship off.
        guard session.autoShip, !session.archived,
              session.status == .awaitingInput, session.lastTurnDisposition == .completed
        else { return .skipped("session no longer eligible to ship") }
        // Last-line control gate: a project that lost Nucleic Control while a request sat in
        // the queue must not merge (autoship is a Control-only capability).
        guard projectsByID[session.projectID]?.isNucleicControlled == true
        else { return .skipped("autoship requires Nucleic Control") }
        guard await hasShippableChanges(controller, session) else { return .skipped("nothing to ship") }

        do {
            // Reuses the manual-merge path: finalize (commit the worktree) then merge into
            // the live default-branch tip under the worktree manager's per-repo lock. The
            // closures surface the otherwise-invisible sub-steps as transcript notes,
            // bracketing the queue's "merging…"/"merged" lines: a `committed` note when
            // finalize records the worktree's work, a `set aside` note when a dirty target
            // checkout had to be stashed so the merge could run on a clean tree, a `synced`
            // note when the session branch was pulled forward, and a `signed` note when the
            // host's signing config recorded a signature on the commit this merge created.
            let target = await shipTargetLabel(request.sessionID)
            let result = try await controller.integrate(
                strategy: request.strategy,
                onCommit: { [weak controller] in
                    await controller?.note(
                        "\(NoteEvent.autoshipPrefix) committed changes", icon: "checkmark.circle")
                },
                onStash: { [weak controller] in
                    await controller?.note(
                        "\(NoteEvent.autoshipPrefix) set aside local changes in \(target) before merging",
                        icon: "archivebox")
                },
                onSync: { [weak controller] in
                    await controller?.note(
                        "\(NoteEvent.autoshipPrefix) synced this chat's branch from \(target)",
                        icon: "arrow.triangle.2.circlepath")
                },
                onSigned: { [weak controller] commit in
                    await controller?.note(
                        "\(NoteEvent.autoshipPrefix) signed commit \(commit.prefix(7))",
                        icon: "signature")
                })
            switch result {
            case .clean(_, let commit):
                return .merged(commit: commit)
            case .conflicted(let paths):
                return .conflicted(paths.map(\.path))
            }
        } catch {
            return .failed("\(error)")
        }
    }

    /// Whether a session has file changes worth shipping: files this chat contributed that
    /// aren't already merged into its target (`SessionController.unmergedFiles`). Empty for a
    /// purely conversational turn (no edits) and for a follow-up turn after the work already
    /// landed — the footprint is anchored at the target's merge base, so it empties the moment
    /// the work merges, unlike `diffStat` (vs. the fixed fork base) which stays non-zero and
    /// would re-ship the same already-merged work on every later turn. On a transient git error
    /// the footprint can't be computed, so fall back to the coarser diff-vs-base count rather
    /// than silently dropping a real ship.
    private func hasShippableChanges(_ controller: SessionController, _ session: Session) async -> Bool {
        if let footprint = try? await controller.shippableFiles() { return !footprint.isEmpty }
        return (session.diffStat?.filesChanged ?? 0) > 0
    }

    /// A status the Autoship row can safely retire after a while: the work shipped (`.merged`) or
    /// there was nothing to ship (`.skipped`). Conflicts/failures are NOT benign — they stay until
    /// the user acts. `nil` (plain "Armed") never retires.
    private func isBenignTerminal(_ status: ShipStatus?) -> Bool {
        switch status {
        case .merged, .skipped: return true
        default: return false
        }
    }

    /// The single write path for `shipStatuses`, keeping the `shippedAt` retire clock in sync:
    /// stamp the moment a benign terminal is reached, drop the stamp otherwise. Passing `nil`
    /// forgets the status entirely (e.g. when new work supersedes a settled ship).
    private func setShipStatus(_ id: SessionID, _ status: ShipStatus?) {
        guard let status else {
            shipStatuses[id] = nil
            shippedAt[id] = nil
            return
        }
        shipStatuses[id] = status
        shippedAt[id] = isBenignTerminal(status) ? now() : nil
    }

    /// Drop all autoship bookkeeping for a session that's going away (deleted/stopped/unloaded),
    /// so these dictionaries don't accumulate entries for sessions that no longer exist.
    private func forgetShipState(_ id: SessionID) {
        shipStatuses[id] = nil
        shippedAt[id] = nil
        agentMergeShippedSessions.remove(id)
        editToolUseByPath[id] = nil
    }

    /// React to a merge-queue status change: mirror it for the UI, log a transcript note
    /// so the chat shows what autoship did, and on a terminal outcome update the session
    /// (disable ship + surface error on conflict/failure).
    private func handleShipUpdate(_ update: ShipUpdate) async {
        setShipStatus(update.sessionID, update.status)
        lockLog.notice("LOCKTRACE ship session=\(update.sessionID.rawValue, privacy: .public) status=\(String(describing: update.status), privacy: .public)")
        let controller = controllers[update.sessionID]
        switch update.status {
        case .queued, .skipped:
            break
        case .merging:
            let target = await shipTargetLabel(update.sessionID)
            await controller?.note(
                "\(NoteEvent.autoshipPrefix) merging into \(target)…", icon: "arrow.triangle.merge")
        case .merged(let commit):
            // Landed — the session's branch merged into its parent. Release its locks now
            // (mediated release, LOCKING §4.4) so any waiters it was blocking are granted
            // immediately, rather than waiting for the reconcile poll to detect the merge.
            let target = await shipTargetLabel(update.sessionID)
            await controller?.note(
                "\(NoteEvent.autoshipPrefix) merged into \(target) · \(commit.prefix(7))",
                icon: "checkmark.seal", autoshipShipped: true)
            await lockManager.releaseAll(update.sessionID)
            // This work is now shipped (by the queue). Mark it so a follow-up agent sync-merge
            // observed in-container doesn't re-post a duplicate "merged by the agent" note;
            // cleared when the session edits again (`arbitrate`).
            agentMergeShippedSessions.insert(update.sessionID)
            // A successful merge resolves any flagged conflict — drop the sticky marker so the
            // sidebar's "needs attention" icon clears now that the work landed.
            await mutateSession(update.sessionID) { await $0.clearAutoShipConflict() }
        case .conflicted(let paths):
            // A conflict does NOT turn autoship off — leave it armed so the work re-ships on the
            // next clean merge — but flag it prominently: a sticky marker (sidebar icon), an
            // in-chat note, and the header/Control-panel ship status (set to `.conflicted` at the
            // top of this method). The branch is left intact; the merge is simply not applied.
            await mutateSession(update.sessionID) { await $0.markAutoShipConflict() }
            let who = await shipErrorLabel(update.sessionID)
            let list = paths.prefix(3).joined(separator: ", ")
            let more = paths.count > 3 ? "…" : ""
            await controller?.note(
                "\(NoteEvent.autoshipPrefix) merge conflict in \(list)\(more) — branch left "
                    + "intact. Autoship stays armed; resolve the conflict and it ships on the "
                    + "next merge.",
                icon: "exclamationmark.triangle")
            lastError = "Autoship for \(who) hit a merge conflict (\(list)\(more)). Branch left "
                + "intact; autoship is still armed and needs attention."
            lastErrorSessionID = update.sessionID
        case .failed(let reason):
            await mutateSession(update.sessionID) { await $0.markAutoShipFailed() }
            let who = await shipErrorLabel(update.sessionID)
            await controller?.note(
                "\(NoteEvent.autoshipPrefix) failed — \(reason). Autoship turned off.",
                icon: "exclamationmark.triangle")
            lastError = "Autoship for \(who) failed: \(reason). Autoship turned off for this chat."
            lastErrorSessionID = update.sessionID
            await clearAutoShipFailedSeen(update.sessionID)
        }
    }

    /// The branch autoship merges a session into, for transcript notes (e.g. `dev`).
    /// Falls back to a generic label when the project can't be resolved.
    private func shipTargetLabel(_ sessionID: SessionID) async -> String {
        guard let session = await controllers[sessionID]?.snapshot.session,
              let project = projectsByID[session.projectID]
        else { return "the default branch" }
        return session.shipDestination(in: project).value
    }

    /// Human label for an autoship error, naming the responsible chat and its branch
    /// (e.g. `“Fix login” on branch nucleic/fix-login`). Falls back gracefully when the
    /// session or its branch can't be resolved.
    private func shipErrorLabel(_ sessionID: SessionID) async -> String {
        guard let session = await controllers[sessionID]?.snapshot.session else {
            return "this chat"
        }
        if let branch = session.branch {
            return "“\(session.title)” on branch \(branch)"
        }
        return "“\(session.title)”"
    }

    /// The lock domain for a session: its root tree (`rootRef`), falling back to its parent then
    /// the project default. nil only when nothing resolves (then arbitration fails open).
    ///
    /// File locking now applies ONLY to Nucleic Control projects: nil for any non-control
    /// project, so its sessions never acquire locks (`arbitrate` fails open) and none are
    /// reconstructed at launch. Rationale — a lock must release when its work *lands* (a `git
    /// merge`), and the ONLY place Nucleic can observe that merge with certainty is inside the
    /// control container (the `git` interceptor shim, [[git-event]]). Outside it we couldn't
    /// reliably unlock, so we don't lock at all rather than strand files.
    private func lockDomain(for session: Session) -> LockDomain? {
        guard let project = projectsByID[session.projectID], project.isNucleicControlled else { return nil }
        // An nvrsion session already carries the trunk branch as its `rootRef` (set at creation,
        // NVRSION §10), so the whole project shares one domain — no project-toggle check needed here,
        // which is also what keeps an existing worktree session on its own domain after a flip.
        if let root = session.rootRef { return LockDomain(root) }
        if let parent = session.parentRef { return LockDomain(parent) }
        return LockDomain(project.defaultBranch.value)
    }

    /// Whether this session edits the project's shared nvrsion trunk (NVRSION) — its working dir *is*
    /// the trunk. Fixed at creation, so it's independent of the project's *current* nvrsion toggle:
    /// flipping the project changes only how *new* chats are created; existing chats keep their mode.
    private func isNvrsionSession(_ session: Session, in project: Project) -> Bool {
        guard let wt = session.worktreePath else { return false }
        return wt == project.resolvedTrunkPath
    }

    /// Update-on-grant hook for the LockManager (LOCKING §4.5): bring a just-granted session's
    /// worktree up to the parent, scoped to the `files` it's acquiring, so it sees the latest before
    /// proceeding. `.clean` if gone; `.changed` when the parent moved one of those files.
    private func runUpdateOnGrant(_ id: SessionID, files: [String]) async -> LockUpdateResult {
        guard let controller = controllers[id] else { return .clean }
        let session = await controller.snapshot.session
        // nvrsion (NVRSION §3.2): there's no per-session worktree to merge a parent into — the
        // session edits the shared trunk live. "Re-ground" = did a requested file move in the trunk
        // since this session last had it? If so, tell the agent to re-read before editing.
        if let project = projectsByID[session.projectID], isNvrsionSession(session, in: project),
            let trunk = session.worktreePath {
            return await nvrsionTrunk.regroundOnGrant(trunkPath: trunk, session: id, files: files)
        }
        return await controller.updateFromParent(requestedFiles: files)
    }

    /// Surface a lock-lifecycle event (LOCKING §4) in the session's chat log as a muted note, so
    /// the contention story — waiting on a file, acquiring it, releasing it — is visible in-chat,
    /// not just in the `LOCKTRACE` os_log. Fed by the `LockManager` hook with the exact paths.
    private func recordLockEvent(_ sessionID: SessionID, _ event: LockEvent) async {
        guard let controller = controllers[sessionID] else { return }
        // Each note also carries structured `lock` detail (state + paths) so the transcript can
        // fold it onto the edit's tool card it brackets (LOCKING §4) instead of leaving it as a
        // standalone row — which, anchored after the tool-use start, otherwise always renders
        // after the call. The plain text/icon remain the fallback for an unfoldable note.
        //
        // `waiting`/`acquired` are also tagged with the edit that acquired the lock (looked up by
        // path), so a subagent's contention notes nest in its transcript card rather than leaking
        // to the main transcript. `released` is left untagged: it fires at land/turn-end — after
        // the editing subagent has returned — so it is a session-level moment, and tagging a note
        // emitted outside its subagent's live scope would also risk the incremental projector
        // diverging across a seal seam.
        let owner = editToolUseByPath[sessionID]
        func editOf(_ paths: [String]) -> String? { paths.lazy.compactMap { owner?[$0] }.first }
        switch event {
        case .waiting(let paths):
            await controller.note("Waiting for a lock on \(Self.lockPathList(paths))…", icon: "hourglass",
                                  lockEvent: true, lock: NoteLock(state: .waiting, paths: paths),
                                  toolCallID: editOf(paths))
        case .acquired(let paths):
            await controller.note("Lock acquired on \(Self.lockPathList(paths)).", icon: "lock.fill",
                                  lockEvent: true, lock: NoteLock(state: .acquired, paths: paths),
                                  toolCallID: editOf(paths))
        case .released(let paths):
            await controller.note("Lock released on \(Self.lockPathList(paths)).", icon: "lock.open",
                                  lockEvent: true, lock: NoteLock(state: .released, paths: paths))
        }
    }

    /// Format locked paths for a chat note: up to three repo-relative paths, then a "+N more" tail.
    private static func lockPathList(_ paths: [String]) -> String {
        guard !paths.isEmpty else { return "no files" }
        let shown = paths.prefix(3).joined(separator: ", ")
        let extra = paths.count - min(paths.count, 3)
        return extra > 0 ? "\(shown) +\(extra) more" : shown
    }

    /// The agent-facing re-ground message (LOCKING §4.5), surfaced as the blocked edit's denial. Worded
    /// as transient + recoverable so the agent re-issues rather than abandoning the task; falls back to a
    /// re-read instruction when the diff was too large to inline (`diff` empty).
    private static func regroundNotice(files: [String], diff: String) -> String {
        let head =
            "Your edit was not applied. Before granting the lock, Nucleic merged upstream changes into "
            + "your worktree and a file you're editing changed on disk. This is expected and recoverable "
            + "— do not stop or report a failure. Re-read the affected region (the up-to-date file is "
            + "already on disk) and re-issue your edit against the new content. The lock is held, so the "
            + "retry proceeds immediately."
        if diff.isEmpty {
            return head + "\n\nChanged file(s) to re-read before editing: " + files.joined(separator: ", ")
        }
        return head + "\n\nWhat changed:\n```diff\n" + diff + "\n```"
    }

    /// Rebuild the LockManager's held locks at launch (LOCKING §6): every live worktree session
    /// holds locks on the files it has changed but not yet landed in its parent (its
    /// `unmergedFiles`, in its root-tree domain). Self-correcting — already-landed work isn't
    /// reconstructed, so no stale lock survives a restart. Queue order is transient (a restart's
    /// agents are gone; they re-acquire on their next edit).
    public func reconstructLocks() async {
        var locks: [PersistedLock] = []
        for (sid, controller) in controllers {
            let session = await controller.snapshot.session
            guard !session.status.isTerminal, !session.archived,
                  await controller.hasWorktree, let domain = lockDomain(for: session)
            else { continue }
            let files = (try? await controller.unmergedFiles()) ?? []
            for file in files {
                let path = ConflictDetector.normalize(file)
                if !path.isEmpty { locks.append(.init(domain: domain.ref, path: path, holder: sid)) }
            }
        }
        guard !locks.isEmpty else { return }
        await lockManager.restore(locks)
        startLockReconcileIfNeeded()  // so reconstructed locks release once their work lands
    }

    /// `ConflictArbiter` (LOCKING §4.6): the agent in `sessionID` is about to edit `files`.
    /// Acquire their locks in the session's root-tree domain — granted instantly if free, else the
    /// call parks in the queue (all-or-nothing + demotion) until every file is free, then resumes.
    /// No prompt (automatic). Fail-open on any gap so a coordination miss never blocks an agent.
    public func arbitrate(
        sessionID: SessionID, task: String, files: [String], toolUseID: String? = nil
    ) async -> ArbitrationOutcome {
        guard let controller = controllers[sessionID] else { return ArbitrationOutcome(.noConflict) }
        let session = await controller.snapshot.session
        guard let domain = lockDomain(for: session) else { return ArbitrationOutcome(.noConflict) }
        // The agent is editing again — new work that can land and ship anew. Re-arm the
        // agent-merge ship note so a later landing of this fresh work is reported (a no-op
        // sync-merge with no new edits stays deduped).
        agentMergeShippedSessions.remove(sessionID)
        // New, not-yet-landed edits supersede the last ship: drop a settled "Shipped"/"Nothing to
        // ship" status so the Control panel + header revert to "Armed" for this fresh work. It
        // re-records `.merged` when the new work ships (handleShipUpdate / markShippedIfLanded).
        // This is what reverts a shipped row to armed — replacing the old poll-derived demotion.
        if isBenignTerminal(shipStatuses[sessionID]) { setShipStatus(sessionID, nil) }
        let candidateFiles = files
            .map { stripWorktreePrefix($0, session.worktreePath) }
            .map(ConflictDetector.normalize).filter { !$0.isEmpty }
        guard !candidateFiles.isEmpty else { return ArbitrationOutcome(.noConflict) }  // nothing predicted to lock yet

        // Remember which edit is acquiring these paths so the lock-lifecycle notes the LockManager
        // emits (keyed only by session + path) can be attributed back to that edit — and thus nest
        // in the subagent's card when the edit belongs to one. Only the real edit gate carries a
        // tool-use id; the pre-edit `check_conflict` probe passes nil and leaves the map untouched.
        if let toolUseID, !toolUseID.isEmpty {
            for path in candidateFiles { editToolUseByPath[sessionID, default: [:]][path] = toolUseID }
        }

        let willWait = await lockManager.wouldBlock(sessionID, domain: domain, files: candidateFiles)
        if willWait {
            sessionsWaitingForAccess.insert(sessionID)
            deadlockedSessions = await lockManager.deadlockedSessions()
        }
        startLockReconcileIfNeeded()
        let outcome = await lockManager.acquire(sessionID, domain: domain, files: candidateFiles)
        sessionsWaitingForAccess.remove(sessionID)
        deadlockedSessions = await lockManager.deadlockedSessions()
        switch outcome {
        case .granted: return ArbitrationOutcome(.proceed)
        case .grantedNeedsReground(let files, let diff):
            // The lock was granted and the worktree refreshed, but the parent moved a file this edit
            // targets — the agent's in-context view is stale (LOCKING §4.5). Block this one edit and
            // hand the agent the diff so it re-grounds; the lock is held, so its retry proceeds.
            await controller.note(
                "Re-grounding on \(Self.lockPathList(files)): the parent advanced while acquiring the "
                    + "lock; the worktree is updated — re-issue your edit.",
                icon: "arrow.triangle.2.circlepath", lockEvent: true)
            return ArbitrationOutcome(.proceed, refreshNotice: Self.regroundNotice(files: files, diff: diff))
        case .cancelled: return ArbitrationOutcome(.cancelled)
        case .updateConflict(let paths):
            // The lock was granted but bringing the worktree up to the parent conflicted; the
            // session keeps its lock and pauses for resolution (LOCKING §4.5). Stop this edit.
            let list = paths.prefix(3).joined(separator: ", ")
            await controller.note(
                "Lock granted, but updating from the parent conflicted in \(list) — "
                    + "resolve in the worktree and retry.",
                icon: "exclamationmark.triangle")
            return ArbitrationOutcome(.cancelled)
        }
    }

    // MARK: - Host-command concurrency (HOST_EXEC §concurrency)

    /// Whether `sessionID` runs in its **own** dedicated worktree — a build directory shared with no
    /// other session — and is therefore exempt from the host-command conflict gate (HOST_EXEC
    /// §concurrency). Contention there comes entirely from a *shared* build directory: nvrsion
    /// sessions all editing the one trunk (`resolvedTrunkPath`), or a worktree-less session editing
    /// the project root (`rootPath`). Anything else is a per-session checkout that can't collide, so
    /// its host commands never conflict and are never registered. Resolved from the live controller
    /// snapshot; if the session or its project can't be resolved we answer `false` (apply the gate),
    /// failing toward the pre-existing behavior. Mirrors `isNvrsionSession`'s trunk comparison.
    private func hostCommandSessionHasOwnWorktree(_ sessionID: SessionID) async -> Bool {
        guard let session = await controllers[sessionID]?.snapshot.session,
            let worktree = session.worktreePath,
            let project = resolvedProject(session.projectID)
        else { return false }
        return worktree != project.resolvedTrunkPath && worktree != project.rootPath
    }

    /// `ConflictArbiter`: claim a host-run slot for `sessionID` before its `host_exec` command runs
    /// on the shared host. Registers the command and returns `.clear` when no *similar* command is
    /// already running in another session (the fuzzy `HostCommandConflictDetector`); otherwise
    /// registers nothing and returns `.conflict` naming the overlapping session(s), so the backend
    /// can surface the in-chat override prompt. `override == true` forces the claim past a dismissed
    /// false positive.
    ///
    /// **Atomicity:** there is no `await` between reading `runningHostCommands` and writing it, so two
    /// sessions racing into this method are serialized on the MainActor — the first claims its slot,
    /// the second then observes it and conflicts. Without that, both could pass the check and run.
    public func beginHostCommand(sessionID: SessionID, command: String, override: Bool) async -> HostCommandClearance {
        let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else { return .clear }  // nothing to coordinate
        // A session running in its **own** worktree has a build directory shared with no one, so a
        // "similar command" elsewhere can't collide with it — skip the conflict gate entirely
        // (HOST_EXEC §concurrency). It also registers nothing, so it never appears in another
        // session's conflict check either; it can only ever reach here via `.clear`, so the whole
        // wait-queue machinery downstream is unreachable for it. Only the shared-build-directory
        // cases (nvrsion sessions on the one trunk, or worktree-less sessions on the project root)
        // actually contend and stay gated. The `await` is before the atomic prune→check→register
        // window below, so it doesn't break that section's MainActor serialization.
        if await hostCommandSessionHasOwnWorktree(sessionID) { return .clear }
        // Contention is per-project (collisions come from shared build state — most sharply a shared
        // trunk's build directory). Without a known project we can't scope it, so fail open.
        guard let summary = summaries.first(where: { $0.id == sessionID }) else { return .clear }
        let projectID = summary.projectID

        // Self-heal first: drop slots whose session is no longer live (a session that vanished
        // mid-command without releasing — a crash, an interrupt) so it can't pin a phantom conflict.
        // Doing it here keeps the observable registry tidy without threading cleanup through every
        // session-teardown path. No `await`, so the prune→check→claim stays one atomic MainActor turn.
        let liveIDs = Set(summaries.lazy.filter { !$0.archived }.map(\.id))
        runningHostCommands = runningHostCommands.filter { liveIDs.contains($0.key) }
        hostCommandWaitQueue = hostCommandWaitQueue.filter { liveIDs.contains($0.sessionID) }

        if !override {
            let conflicts = HostCommandConflictDetector.conflicts(
                command: trimmed, sessionID: sessionID, projectID: projectID,
                against: Array(runningHostCommands.values))
            if !conflicts.isEmpty { return .conflict(conflicts) }  // claim nothing; let the user decide
        }

        runningHostCommands[sessionID] = RunningHostCommand(
            sessionID: sessionID, projectID: projectID, title: summary.title, command: trimmed,
            startedAt: now())
        return .clear
    }

    /// `ConflictArbiter`: release the slot claimed by `beginHostCommand` once the command finishes
    /// (or the claim is abandoned). Idempotent — releasing a never-claimed session is a no-op. A
    /// released slot lets the next queued waiter (if any) claim on its following poll.
    public func endHostCommand(sessionID: SessionID) async {
        runningHostCommands[sessionID] = nil
    }

    /// `ConflictArbiter`: join the FIFO wait queue for a host-run slot (HOST_EXEC §concurrency) — the
    /// user chose **Wait** on the conflict prompt. Appends the session at the back, preserving arrival
    /// order (the FIFO invariant); a session already queued keeps its place, so a stray double-enqueue
    /// never jumps the line. No project → nothing to scope against, so it's a no-op (the backend's
    /// poll then fails open to a plain run).
    public func enqueueHostCommandWait(sessionID: SessionID, command: String) async {
        let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else { return }
        guard let summary = summaries.first(where: { $0.id == sessionID }) else { return }
        guard !hostCommandWaitQueue.contains(where: { $0.sessionID == sessionID }) else { return }
        hostCommandWaitQueue.append(QueuedHostCommand(
            sessionID: sessionID, projectID: summary.projectID, command: trimmed, enqueuedAt: now()))
    }

    /// `ConflictArbiter`: poll for a queued waiter's turn (HOST_EXEC §concurrency). Prunes dead
    /// sessions, ensures this session is queued (a first poll establishes its place), then — if no
    /// running twin conflicts and no earlier waiter is still ahead of it
    /// (`queuedWaiterMayClaim`) — atomically registers the running slot, removes itself from the
    /// queue, and returns `.clear`. Otherwise it stays queued and returns `.conflict` (the running
    /// twins it's still behind, possibly empty when only an earlier *waiter* blocks it), and the
    /// backend polls again.
    ///
    /// **Atomicity** mirrors `beginHostCommand`: no `await` between reading the registries and
    /// writing them, so two waiters polling at once are serialized on the MainActor — only one
    /// claims, the other sees it and keeps waiting.
    public func claimQueuedHostCommand(sessionID: SessionID, command: String) async -> HostCommandClearance {
        let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else {
            hostCommandWaitQueue.removeAll { $0.sessionID == sessionID }
            return .clear  // nothing to coordinate
        }
        // No known project → can't scope contention; fail open so a waiter is never trapped.
        guard let summary = summaries.first(where: { $0.id == sessionID }) else {
            hostCommandWaitQueue.removeAll { $0.sessionID == sessionID }
            return .clear
        }
        let projectID = summary.projectID

        // Self-heal, then ensure we're in line — all before any `await`, so prune→check→claim is one
        // atomic MainActor turn (as in `beginHostCommand`).
        let liveIDs = Set(summaries.lazy.filter { !$0.archived }.map(\.id))
        runningHostCommands = runningHostCommands.filter { liveIDs.contains($0.key) }
        hostCommandWaitQueue = hostCommandWaitQueue.filter { liveIDs.contains($0.sessionID) }
        if !hostCommandWaitQueue.contains(where: { $0.sessionID == sessionID }) {
            hostCommandWaitQueue.append(QueuedHostCommand(
                sessionID: sessionID, projectID: projectID, command: trimmed, enqueuedAt: now()))
        }

        guard HostCommandConflictDetector.queuedWaiterMayClaim(
            sessionID: sessionID, command: trimmed, projectID: projectID,
            running: Array(runningHostCommands.values), queue: hostCommandWaitQueue)
        else {
            // Still blocked — report the running twins (if any) for the caller; an empty list means
            // it's only an earlier waiter ahead of us. Either way the backend just keeps polling.
            let conflicts = HostCommandConflictDetector.conflicts(
                command: trimmed, sessionID: sessionID, projectID: projectID,
                against: Array(runningHostCommands.values))
            return .conflict(conflicts)
        }

        hostCommandWaitQueue.removeAll { $0.sessionID == sessionID }
        runningHostCommands[sessionID] = RunningHostCommand(
            sessionID: sessionID, projectID: projectID, title: summary.title, command: trimmed,
            startedAt: now())
        return .clear
    }

    /// `ConflictArbiter`: leave the wait queue without claiming (HOST_EXEC §concurrency) — the waiter
    /// gave up (Stop / teardown). Idempotent.
    public func dequeueHostCommandWait(sessionID: SessionID) async {
        hostCommandWaitQueue.removeAll { $0.sessionID == sessionID }
    }

    /// Start the release-reconcile driver if it isn't already running (LOCKING §4.4). The driver
    /// is **persistent**: once started it lives until the store is torn down (the signal stream
    /// finishing), parking with zero work while idle and polling only while locks are held.
    ///
    /// This replaces the old self-stopping loop, whose stop decision read a `rawState` snapshot
    /// taken *before* a later `await`, so a lock acquired during that await could be missed and the
    /// loop would stop *holding a lock no longer watched* — that lock then never got the detected
    /// (agent/user-merge) release and leaked until a mediated merge or manual release. Liveness now
    /// keys off the LockManager's authoritative `isIdle()`, so the driver cannot stop while a lock
    /// is held; `reconcileSignals()` (coalesced) wakes it the instant a new lock is acquired.
    private func startLockReconcileIfNeeded() {
        guard lockReconcileTask == nil else { return }
        lockReconcileTask = Task { [weak self] in
            guard let self else { return }
            var signals = await self.lockManager.reconcileSignals().makeAsyncIterator()
            while !Task.isCancelled {
                // Drain on the backstop cadence until the table is idle. Runs immediately on
                // start, so a lock already held when the driver (re)starts is covered even though
                // the grant that signaled it predated this subscription.
                while await !self.lockManager.isIdle() {
                    try? await Task.sleep(for: self.lockReconcileInterval)
                    if Task.isCancelled { return }
                    await self.reconcileLocks()
                }
                // Idle — park until the next acquire signals activity. A signal raised between the
                // `isIdle()` check above and here is buffered (newest-1), so it is never lost.
                if await signals.next() == nil { return }
            }
        }
    }

    /// One reconcile pass (LOCKING §4.4): for each holder, release files now landed in its parent
    /// (detected the same way for any merger — autoship, the agent's own `git merge`, or the
    /// user — by reusing `unmergedFiles`), and release everything for a terminal/archived holder.
    /// Refreshes the "waiting"/deadlock indicators. Liveness is owned by the driver (`isIdle`), so
    /// this no longer decides when to stop — it just does the work.
    /// The nvrsion edit loop (NVRSION §3–4), driven off each session's event stream. On every
    /// completed edit (`.fileChange`, emitted only after a successful tool_result) land the file
    /// into the shared trunk as a path-scoped commit. At turn-end (`.runFinished`) release the
    /// turn's held files — "keep-warm within a turn", so a sibling can't interleave between two of
    /// the agent's edits to one file, but can take it once the turn settles (NVRSION §4). No-op
    /// unless the session's project has nvrsion active.
    private func handleNvrsionEvent(
        _ sessionID: SessionID, _ controller: SessionController, _ event: AgentEvent
    ) async {
        // Cheap pre-filter: only a completed edit or a turn boundary drives nvrsion — skip the
        // snapshot fetch for the common text/thinking/tool-delta events (every session, every run).
        switch event.kind {
        case .fileChange, .runFinished: break
        default: return
        }
        let session = await controller.snapshot.session
        guard let project = projectsByID[session.projectID], isNvrsionSession(session, in: project),
            let trunkPath = session.worktreePath
        else { return }
        switch event.kind {
        case .fileChange(let change):
            // Land only edits that live inside the trunk worktree. Agents report absolute paths
            // (NVRSION §3.1); a session writing a plan, memory, or transcript under the app-support
            // dir (claude-home) produces an absolute path that ISN'T under the trunk. Such a path
            // can't be reduced to repo-relative, so handing it to `land` makes `git add` reject a
            // pathspec it can't resolve ("did not match any file(s) known to git") — those aren't
            // trunk files, so skip them rather than surfacing a spurious "could not land" note.
            guard let rel = trunkRelativePath(change.path, in: trunkPath) else { return }
            let message = session.title.isEmpty ? "nvrsion edit" : session.title
            let result = await nvrsionTrunk.land(
                trunkPath: trunkPath, session: sessionID, paths: [rel], message: message,
                prelandHook: project.nvrsion?.resolvedPrelandHook)
            switch result {
            case .landed, .noop:
                // A real commit advanced the trunk past base, so there's now work to integrate —
                // flip the sidebar's Integrate icon to its up-arrow (a `.noop` left HEAD unchanged,
                // so it can't have added anything). Optimistic: avoids a git diff on every edit.
                if case .landed(let sha) = result {
                    nvrsionPendingByID[session.projectID] = true
                    // Remember this file as part of the turn's contribution, to fold into the trunk's
                    // running change summary at turn-end (NVRSION §6).
                    nvrsionTurnLanded[sessionID, default: []].insert(rel)
                    // Newly-pending: arm the auto-integrate countdown (a no-op until this project's
                    // chats fall quiet, since the landing session is itself mid-turn right now).
                    reconsiderNvrsionAutoIntegration(session.projectID)
                    // Surface the land as a transcript note — nvrsion's in-chat ops log. A worktree
                    // session sees autoship's commit/merge lines in its chat; an nvrsion session
                    // commits per edit straight to the trunk, so this is the equivalent record of
                    // what landed (a `.noop` changed nothing, so it stays silent).
                    await controller.note(
                        "\(NoteEvent.nvrsionPrefix) landed \(rel) · \(sha.prefix(7))",
                        icon: "checkmark.circle", toolCallID: change.toolCallID)
                }
                // Keep-warm (NVRSION §4): the file is landed but stays held; mark it warm so the
                // sweep releases it once it goes idle past the project's keepWarmIdle (or at turn-end).
                let idle = project.nvrsion?.keepWarmIdleSeconds ?? ProjectNvrsion.defaultKeepWarmIdleSeconds
                await nvrsionGovernor.warmed(sessionID, rel, idleSeconds: idle)
                startNvrsionKeepWarmIfNeeded()
            case .rejected(let why):
                await controller.note("\(NoteEvent.nvrsionPrefix) pre-land check rejected \(rel): \(why)",
                                      icon: "exclamationmark.triangle", toolCallID: change.toolCallID)
            case .failed(let why):
                await controller.note("\(NoteEvent.nvrsionPrefix) could not land \(rel): \(why)",
                                      icon: "exclamationmark.triangle", toolCallID: change.toolCallID)
            }
        case .runFinished:
            // Turn-end: if this turn landed work, fold its contribution into the trunk's running
            // change summary (NVRSION §6) — built incrementally, per turn, so the eventual promotion
            // ships a meaningful, on-device-accurate message instead of summarizing the whole diff.
            if let landed = nvrsionTurnLanded.removeValue(forKey: sessionID), !landed.isEmpty {
                let digest = Self.nvrsionTurnDigest(
                    title: session.title, events: await controller.transcriptSoFar(),
                    files: landed.sorted())
                foldNvrsionSummary(projectID: session.projectID, trunkPath: trunkPath, digest: digest)
            }
            // Release every file the turn held warm (NVRSION §4).
            await lockManager.releaseAll(sessionID)
            await nvrsionGovernor.forget(sessionID)
        default:
            break
        }
    }

    /// Start the keep-warm sweep loop if it isn't running (NVRSION §4). It wakes on its interval,
    /// releases any landed file that's gone idle past its window, and parks itself once nothing is
    /// warm — the next land restarts it. Cheap: it only runs while files are actually held warm.
    private func startNvrsionKeepWarmIfNeeded() {
        guard nvrsionKeepWarmTask == nil else { return }
        nvrsionKeepWarmTask = Task { [weak self] in
            while let self, !Task.isCancelled {
                try? await Task.sleep(for: self.nvrsionKeepWarmInterval)
                if Task.isCancelled { break }
                if await self.runNvrsionSweep() { break }  // nothing left warm → park
            }
            await self?.clearNvrsionKeepWarmTask()
        }
    }
    private func clearNvrsionKeepWarmTask() { nvrsionKeepWarmTask = nil }

    /// One keep-warm sweep pass: release each landed file that's gone idle past its window, then
    /// report whether the governor is now empty (so the loop can park). Internal so tests can drive
    /// it deterministically against a controlled clock.
    @discardableResult
    func runNvrsionSweep() async -> Bool {
        for (session, paths) in await nvrsionGovernor.sweep() {
            await lockManager.release(session, paths: paths)
        }
        return await nvrsionGovernor.isEmpty
    }

    /// Launch crash-recovery (NVRSION §8): for each nvrsion project, ensure its trunk exists and
    /// commit any uncommitted residue a crash mid-edit may have left, so the trunk starts clean.
    /// Lock state itself needs no reconstruction — a restart kills every agent, so in-flight nvrsion
    /// locks correctly vanish (and worktree-less nvrsion sessions are already skipped by
    /// `reconstructLocks`); only the trunk's working tree can carry residue across a crash.
    private func reconcileNvrsionTrunks() async {
        for project in projects where project.nvrsionActive {
            guard let nvrsion = project.nvrsion else { continue }
            await nvrsionTrunk.recover(
                root: project.rootPath, trunkPath: project.resolvedTrunkPath,
                trunkBranch: nvrsion.trunkBranch, base: project.defaultBranch.value)
            // Seed the sidebar's Integrate icon from git, post-recovery: a trunk carrying
            // un-promoted work (or residue just recovered) shows the up-arrow; one in sync shows
            // the checkmark.
            await refreshNvrsionPending(project.id)
        }
    }

    /// Fold one nvrsion session turn's contribution (its `digest`) into the project trunk's running
    /// change summary (NVRSION §6), off the hot path. Serialized per project via `nvrsionSummaryFold`
    /// so each fold reads the prior fold's result before laying its own increment over it — and runs
    /// OUTSIDE the trunk's index gate, so a slow on-device fold can never stall a sibling's land. The
    /// read-modify-write goes through the sidecar (`NvrsionTrunk.changeSummary`/`recordChangeSummary`)
    /// so it survives a restart, exactly as the trunk itself does.
    private func foldNvrsionSummary(projectID: ProjectID, trunkPath: String, digest: String) {
        let intelligence = self.intelligence
        let trunk = self.nvrsionTrunk
        let prior = nvrsionSummaryFold[projectID]
        nvrsionSummaryFold[projectID] = Task {
            _ = await prior?.value  // chain after the previous fold so the sidecar isn't raced
            let previous = await trunk.changeSummary(trunkPath: trunkPath)
            let updated = await intelligence.updateTrunkSummary(previous: previous, with: digest)
            await trunk.recordChangeSummary(trunkPath: trunkPath, updated)
        }
    }

    /// A concise, model- and heuristic-friendly description of one session turn's contribution to the
    /// trunk — the chat's intent (its title and the turn's request) plus the files it landed — that
    /// `updateTrunkSummary` folds into the running summary (NVRSION §6). Kept short and at task
    /// altitude (not a command log) so the on-device fold stays accurate. The `Chat:`/`Goal:` labels
    /// are what the heuristic fold's `trunkClause` keys off when no model is available.
    static func nvrsionTurnDigest(title: String, events: [AgentEvent], files: [String]) -> String {
        var parts: [String] = []
        let chat = title.trimmingCharacters(in: .whitespacesAndNewlines)
        if !chat.isEmpty { parts.append("Chat: \(chat)") }
        let request = HeuristicSummary.lastTurn(events).request?
            .replacingOccurrences(of: "\n", with: " ")
            .trimmingCharacters(in: .whitespaces)
        if let request, !request.isEmpty { parts.append("Goal: \(request.prefix(200))") }
        if !files.isEmpty {
            let shown = files.prefix(8).joined(separator: ", ")
            parts.append("Files changed: \(shown)\(files.count > 8 ? ", …" : "")")
        }
        return parts.joined(separator: "\n")
    }

    /// The commit message for a whole-trunk promotion (NVRSION §6): the running change summary
    /// maintained as sessions landed work, as `nvrsion: <summary>`. Falls back to the plain
    /// `nvrsion: promote trunk to <base>` when no summary exists (Intelligence off, or nothing
    /// folded yet). The summary's internal line breaks collapse to a single subject line.
    static func nvrsionPromoteMessage(summary: String, base: String) -> String {
        let collapsed = summary
            .split(whereSeparator: \.isNewline)
            .map { $0.trimmingCharacters(in: .whitespaces) }
            .filter { !$0.isEmpty }
            .joined(separator: " ")
        return collapsed.isEmpty ? "nvrsion: promote trunk to \(base)" : "nvrsion: \(collapsed)"
    }

    /// Promote an nvrsion project's accumulated trunk work into its real base branch (NVRSION §6) —
    /// the explicit "ship everything on the trunk" action. Squashes the trunk into the base as one
    /// commit, captioned with the running change summary built up as sessions landed work. Returns
    /// true on success; reports nothing-to-promote / conflicts / failure via `lastError`.
    ///
    /// `manual` is true for an explicit user press (settings/sidebar/chat button) and false for the
    /// background auto-integrate countdown. A *nothing-to-promote* outcome is only surfaced as a
    /// banner for a manual press — an automatic pass that finds the trunk already in sync stays
    /// silent, so the app doesn't flash "nothing to promote" on launch when the timer fires.
    @discardableResult
    public func promoteNvrsionTrunk(_ projectID: ProjectID, manual: Bool = true) async -> Bool {
        guard let project = projectsByID[projectID], project.nvrsionActive, let nvrsion = project.nvrsion
        else { lastError = "nvrsion is not active for this project."; return false }
        let base = project.defaultBranch.value
        // Make sure the running summary reflects every landed turn before we read it for the message:
        // await any in-flight fold for this project (each turn's fold chains onto the stored task).
        await nvrsionSummaryFold[projectID]?.value
        let summary = await nvrsionTrunk.changeSummary(trunkPath: project.resolvedTrunkPath)
        switch await nvrsionTrunk.promote(
            root: project.rootPath, trunkPath: project.resolvedTrunkPath,
            trunkBranch: nvrsion.trunkBranch, base: base,
            message: Self.nvrsionPromoteMessage(summary: summary, base: base))
        {
        case .promoted(let sha):
            lastError = nil
            // The trunk is now squashed into base and resynced onto it — nothing left to integrate,
            // and a clean run clears any prior conflict/failure (the sidebar drops its red ✗).
            nvrsionPendingByID[projectID] = false
            nvrsionIntegrateFailureByID[projectID] = nil
            reconsiderNvrsionAutoIntegration(projectID)  // nothing pending now → stand the timer down
            lockLog.notice("nvrsion promoted project=\(projectID.rawValue, privacy: .public) base=\(base, privacy: .public) sha=\(sha, privacy: .public)")
            return true
        case .nothingToPromote:
            // Already in sync — settle the icon to its checkmark state in case it was stale, and
            // clear any stale failure (there's nothing left that could be wedged).
            nvrsionPendingByID[projectID] = false
            nvrsionIntegrateFailureByID[projectID] = nil
            reconsiderNvrsionAutoIntegration(projectID)
            // Being already in sync isn't a failure. A background pass says nothing at all (else the
            // banner flashes on launch when the timer fires); an explicit press gets a green,
            // informational note rather than the red error styling.
            if manual {
                lastError = "Nothing on the nvrsion trunk to promote to \(base)."
                lastErrorIsBenign = true
            }
            return false
        case .conflicted(let files):
            // Can't integrate automatically — flag the project so the sidebar shows a red ✗ and the
            // auto-integrate countdown stands down until a manual retry resolves it.
            let reason = "Promoting the nvrsion trunk conflicted in \(Self.lockPathList(files)). "
                + "Resolve on \(base) and retry."
            lastError = reason
            nvrsionIntegrateFailureByID[projectID] = reason
            reconsiderNvrsionAutoIntegration(projectID)
            return false
        case .failed(let why):
            let reason = "nvrsion promote failed: \(why)"
            lastError = reason
            nvrsionIntegrateFailureByID[projectID] = reason
            reconsiderNvrsionAutoIntegration(projectID)
            return false
        }
    }

    /// Promote a **single chat's** landed work from the nvrsion trunk into the real base branch
    /// (NVRSION §6, per-session promotion) — ship one finished chat without waiting for the whole
    /// project to fall quiet. Squashes only that session's attributed trunk commits into the base as
    /// one commit; sibling chats' in-flight work stays on the trunk and the trunk's lock domain and
    /// re-ground state are untouched, so every session stays aware of the others' work (NVRSION §4, §7).
    /// Returns true on success; reports nothing-to-promote / conflict / failure via `lastError`.
    @discardableResult
    public func promoteNvrsionSession(_ sessionID: SessionID) async -> Bool {
        guard let summary = summaries.first(where: { $0.id == sessionID }),
              let project = projectsByID[summary.projectID], project.nvrsionActive,
              let nvrsion = project.nvrsion
        else { lastError = "nvrsion is not active for this chat."; return false }
        // Only ship a chat that has returned — promoting mid-turn would squash a half-written change.
        // Crucially we do NOT require the project's *other* chats to be idle (unlike the whole-trunk
        // promote): shipping a finished chat while a long-running sibling keeps working is the point.
        guard !summary.status.hasTurnInFlight else {
            lastError = "This chat is still working — let its turn finish before integrating its work."
            return false
        }
        let base = project.defaultBranch.value
        let label = summary.title.isEmpty ? sessionID.rawValue : summary.title
        switch await nvrsionTrunk.promoteSession(
            root: project.rootPath, trunkPath: project.resolvedTrunkPath,
            trunkBranch: nvrsion.trunkBranch, base: base, session: sessionID,
            message: "nvrsion: promote chat \"\(label)\" to \(base)")
        {
        case .promoted(let sha):
            lastError = nil
            // Sibling chats may still hold work on the trunk, so recompute pending from git rather
            // than assuming it drained. A clean per-chat ship also clears any stale whole-trunk
            // failure flag (the trunk just moved, so the prior conflict may no longer apply).
            nvrsionIntegrateFailureByID[summary.projectID] = nil
            await refreshNvrsionPending(summary.projectID)
            lockLog.notice("nvrsion promoted session=\(sessionID.rawValue, privacy: .public) base=\(base, privacy: .public) sha=\(sha, privacy: .public)")
            return true
        case .nothingToPromote:
            lastError = "This chat has nothing on the nvrsion trunk to promote to \(base)."
            return false
        case .conflicted(let files):
            // A per-chat conflict means this chat's edit built on a sibling's change to the same
            // file, so it can't ship alone. Surface it without flagging the whole project (that flag
            // governs the whole-trunk auto-integrate, a separate concern); promoting the whole trunk
            // — which carries both chats' work — is the clean way through.
            lastError = "Integrating this chat conflicted in \(Self.lockPathList(files)) — it builds on "
                + "another chat's change to the same file. Integrate the whole trunk instead, or resolve on \(base)."
            return false
        case .failed(let why):
            lastError = "nvrsion chat promote failed: \(why)"
            return false
        }
    }

    /// Whether `projectID`'s nvrsion trunk has work waiting to be integrated into its base branch.
    /// Drives the sidebar Integrate button's icon (up-arrow when there's work, checkmark when the
    /// trunk is in sync). Defaults to `false` (nothing to integrate) until the first check resolves.
    public func nvrsionHasPendingIntegration(_ projectID: ProjectID) -> Bool {
        nvrsionPendingByID[projectID] ?? false
    }

    /// Recompute — from git — whether `projectID`'s trunk holds work to integrate, and publish it so
    /// the sidebar's Integrate icon reflects it. No-op for non-nvrsion projects.
    public func refreshNvrsionPending(_ projectID: ProjectID) async {
        guard let project = projectsByID[projectID], project.nvrsionActive, let nvrsion = project.nvrsion
        else { return }
        let pending = await nvrsionTrunk.hasPendingWork(
            root: project.rootPath, trunkBranch: nvrsion.trunkBranch, base: project.defaultBranch.value)
        nvrsionPendingByID[projectID] = pending
        // A trunk that's recomputed to nothing-pending can't be wedged — drop any stale failure so
        // the sidebar settles back to its checkmark rather than a lingering red ✗.
        if !pending { nvrsionIntegrateFailureByID[projectID] = nil }
        reconsiderNvrsionAutoIntegration(projectID)
    }

    /// The reason `projectID`'s last integration attempt couldn't complete — a conflict or hard
    /// failure — or nil when it's clean. Drives the sidebar's red-✗ Integrate state and its tooltip.
    public func nvrsionIntegrationFailure(_ projectID: ProjectID) -> String? {
        nvrsionIntegrateFailureByID[projectID]
    }

    /// Whether `projectID`'s nvrsion trunk can be integrated *right now*: it's an nvrsion project with
    /// work waiting on the trunk, no chat in it is mid-turn, and it isn't being relocated. This is the
    /// same gate the sidebar's Integrate button enforces (minus the view-local promoting spinner), and
    /// the precondition the auto-integrate countdown watches.
    public func nvrsionIntegrationAvailable(_ projectID: ProjectID) -> Bool {
        guard let project = projectsByID[projectID], project.nvrsionActive else { return false }
        guard moveProgress(of: projectID) == nil else { return false }     // not mid-relocation
        guard nvrsionHasPendingIntegration(projectID) else { return false }  // there's work to ship
        return !isAnySessionRunning(in: projectID)                          // and no chat is working
    }

    /// Re-evaluate `projectID`'s auto-integrate countdown after anything that could change its
    /// availability (work landed, a turn ended, a move began, a promote finished). If it's now
    /// integratable and no countdown is already running, arm one; if it's no longer integratable,
    /// cancel any in-flight countdown. Idempotent and cheap — safe to call on every UI event.
    private func reconsiderNvrsionAutoIntegration(_ projectID: ProjectID) {
        guard nvrsionIntegrationAvailable(projectID) else {
            // Availability lapsed — drop any pending countdown so it can't fire against a project
            // that's gone busy (or has already been promoted).
            nvrsionAutoIntegrateTasks.removeValue(forKey: projectID)?.cancel()
            return
        }
        // A flagged conflict/failure needs a human: stand the countdown down rather than auto-retry a
        // promote that just failed. A manual press (which clears the flag on success) is the way out.
        guard nvrsionIntegrateFailureByID[projectID] == nil else {
            nvrsionAutoIntegrateTasks.removeValue(forKey: projectID)?.cancel()
            return
        }
        // Already counting down → let the in-flight timer run rather than restarting it, so a burst
        // of events while the project stays quiet doesn't keep pushing the deadline out.
        guard nvrsionAutoIntegrateTasks[projectID] == nil else { return }
        nvrsionAutoIntegrateTasks[projectID] = Task { [weak self] in
            let delay = self?.nvrsionAutoIntegrateDelay ?? .seconds(3)
            try? await Task.sleep(for: delay)
            guard let self, !Task.isCancelled else { return }
            await self.fireNvrsionAutoIntegration(projectID)
        }
    }

    /// The auto-integrate countdown elapsed: promote the trunk if it's *still* integratable. Re-checks
    /// on the MainActor at the firing instant because the window may have closed during the wait (a
    /// chat picked up a turn, a move started, or a manual promote already shipped the trunk). Internal
    /// so tests can drive it without waiting out the real delay.
    func fireNvrsionAutoIntegration(_ projectID: ProjectID) async {
        nvrsionAutoIntegrateTasks.removeValue(forKey: projectID)?.cancel()
        guard nvrsionIntegrationAvailable(projectID) else { return }
        await promoteNvrsionTrunk(projectID, manual: false)
    }

    /// Whether an auto-integrate countdown is currently armed for `projectID` (test hook).
    func nvrsionAutoIntegrationArmed(_ projectID: ProjectID) -> Bool {
        nvrsionAutoIntegrateTasks[projectID] != nil
    }

    private func reconcileLocks() async {
        for sid in await lockManager.sessionsHoldingLocks() {
            let heldPaths = await lockManager.heldPaths(sid)
            guard let controller = controllers[sid] else {
                lockLog.notice("LOCKTRACE reconcile releaseAll(no-controller) session=\(sid.rawValue, privacy: .public) held=\(heldPaths.joined(separator: ","), privacy: .public)")
                await lockManager.releaseAll(sid); continue
            }
            let session = await controller.snapshot.session
            if session.status.isTerminal || session.archived {
                lockLog.notice("LOCKTRACE reconcile releaseAll(lifecycle terminal=\(session.status.isTerminal) archived=\(session.archived)) session=\(sid.rawValue, privacy: .public)")
                await lockManager.releaseAll(sid); continue
            }
            // Worktree-less sessions edit the branch directly; their commit-based release is a
            // follow-up — for now they release only via lifecycle/mediated/manual paths.
            guard await controller.hasWorktree else {
                lockLog.notice("LOCKTRACE reconcile skip(worktree-less) session=\(sid.rawValue, privacy: .public) held=\(heldPaths.joined(separator: ","), privacy: .public)")
                continue
            }
            let held = Set(heldPaths)
            guard !held.isEmpty else { continue }
            // Fail CLOSED: a git failure computing the unmerged set (an `index.lock` race with the
            // agent's own git in the shared worktree, an unresolvable parentRef mid-operation) must
            // NOT release a lock. A bare `try? … ?? []` would coerce the error into "nothing
            // unmerged → release everything", freeing work that never landed (LOCKING invariant 4).
            // Keep what's held and retry on the next pass.
            let unmerged: Set<String>
            do {
                unmerged = Set(try await controller.unmergedFiles())
            } catch {
                lockLog.notice("LOCKTRACE reconcile unmerged-failed keep session=\(sid.rawValue, privacy: .public) held=\(held.sorted().joined(separator: ","), privacy: .public) error=\(String(describing: error), privacy: .public)")
                continue
            }
            let landed = held.subtracting(unmerged)
            if !landed.isEmpty {
                await lockManager.release(sid, paths: Array(landed))
            } else {
                // Still held: its files still differ from the parent. This line reveals *why* a
                // lock lingers — e.g. `unmerged` still lists them (target/parentRef mismatch, or
                // the work didn't actually land where we look).
                lockLog.notice("LOCKTRACE reconcile still-held session=\(sid.rawValue, privacy: .public) parentRef=\(session.parentRef ?? "nil", privacy: .public) held=\(held.sorted().joined(separator: ","), privacy: .public) unmerged=\(unmerged.sorted().joined(separator: ","), privacy: .public)")
            }
        }
        let raw = await lockManager.rawState()
        sessionsWaitingForAccess = Set(raw.waits.map(\.session))
        deadlockedSessions = await lockManager.deadlockedSessions()
    }

    /// `ConflictArbiter` (LOCKING §4.4): a git op the in-container interceptor shim observed for
    /// `sessionID`, with certainty. This is the reliable `git merge` signal the file-lock system
    /// is now built on — and the reason locking applies ONLY to Nucleic Control projects
    /// (`lockDomain` is nil elsewhere): the control container is the one place Nucleic can see
    /// the agent's own merge land.
    ///
    /// On a successful landing-capable op (merge/commit/rebase/reset/cherry-pick/revert/restore)
    /// in a control session it (1) kicks `reconcileLocks` immediately so files unlock the moment
    /// their work lands — content-verified, certain, no 3-second poll lag — and (2) records an
    /// agent-performed merge that fully landed as an autoship ship (note + shipped marker), so a
    /// merge the agent did itself reads exactly like one Nucleic shipped. It also (3) appends every
    /// mutating op (including failures) to `gitInterceptorEvents` for the Control panel's feed.
    public func observeGitOp(sessionID: SessionID, argv: [String], exitCode: Int) async {
        guard let op = GitCommandSummary.classify(argv: argv) else { return }
        guard let controller = controllers[sessionID] else { return }
        let session = await controller.snapshot.session
        guard projectsByID[session.projectID]?.isNucleicControlled == true else { return }
        // Record for the Control activity feed: every state-changing op the interceptor saw,
        // including non-zero exits (a failed merge/commit is itself worth surfacing). Reads
        // (status/log/diff) the shim shouldn't report anyway are skipped.
        if op.isMutating {
            recordInterceptorEvent(sessionID, title: session.title, op: op, exitCode: exitCode)
        }
        // The rest is the landing-op fast path for lock release + the autoship shipped marker,
        // and applies only to ops that actually succeeded.
        guard exitCode == 0 else { return }
        // Only ops that can move work toward "landed in the parent" warrant a release check; a
        // push/fetch/branch/tag/switch changes nothing we lock on. Reconcile is content-based,
        // so this is purely a fast-path trigger — the periodic poll remains the backstop.
        let landing: Set<String> = ["merge", "commit", "rebase", "reset", "cherryPick", "revert", "restore"]
        guard landing.contains(op.kind) else { return }
        lockLog.notice("LOCKTRACE observed-git session=\(sessionID.rawValue, privacy: .public) op=\(op.kind, privacy: .public)")
        // Release first: any landing op that makes a held file match the parent unlocks it,
        // whether the work merged or was discarded (e.g. `git reset --hard`) — either way the
        // file is no longer contested. But only a genuine MERGE is a *ship*: a reset/restore
        // that happens to land content discarded the work, so don't falsely mark it shipped.
        await reconcileLocks()
        if op.isMerge { await markShippedIfLanded(sessionID) }
    }

    /// A ground-truth `gh` action reported by the in-container interceptor shim (same seam as
    /// `observeGitOp`). Records the agentic GitHub action — open/merge a PR, cut a release, write
    /// the API — in the Control activity feed, including failed attempts (a non-zero exit is itself
    /// worth surfacing). Reads (list/view/status) classify to nil and are skipped. No-op outside
    /// Nucleic Control. Unlike git ops there is no local lock/ship side effect: a remote PR merge
    /// doesn't land local content, so this purely observes.
    public func observeGhOp(sessionID: SessionID, argv: [String], exitCode: Int) async {
        guard let op = GhCommandSummary.classify(argv: argv) else { return }
        guard let controller = controllers[sessionID] else { return }
        let session = await controller.snapshot.session
        guard projectsByID[session.projectID]?.isNucleicControlled == true else { return }
        let event = GitInterceptorEvent(
            sessionID: sessionID, sessionTitle: session.title, tool: .gh, kind: op.kind,
            label: op.label, symbol: op.symbol, isMerge: op.isMerge, exitCode: exitCode, at: now())
        appendInterceptorEvent(event)
    }

    /// Append an interceptor-observed git op to the Control activity feed — newest-first, capped.
    private func recordInterceptorEvent(
        _ sessionID: SessionID, title: String, op: GitCommandSummary.ObservedGitOp, exitCode: Int
    ) {
        appendInterceptorEvent(GitInterceptorEvent(
            sessionID: sessionID, sessionTitle: title, tool: .git, kind: op.kind, label: op.label,
            symbol: op.symbol, isMerge: op.isMerge, exitCode: exitCode, at: now()))
    }

    /// Insert an interceptor event newest-first and trim the feed to its cap. Shared by the `git`
    /// and `gh` recorders.
    private func appendInterceptorEvent(_ event: GitInterceptorEvent) {
        gitInterceptorEvents.insert(event, at: 0)
        if gitInterceptorEvents.count > maxInterceptorEvents {
            gitInterceptorEvents.removeLast(gitInterceptorEvents.count - maxInterceptorEvents)
        }
    }

    /// `ConflictArbiter`: a non-git command the in-container interceptor observed for `sessionID`
    /// (a shim report carrying argv + capped output, or a bash-tracer report carrying a raw command
    /// line + metadata). Classifies it with ``CommandSummary`` and — for Nucleic Control sessions
    /// only — records it into the command-activity feed (newest-first, capped) and the `CMDTRACE`
    /// log. Purely observational: unlike `observeGitOp` it never touches locks or autoship.
    public func observeCommand(sessionID: SessionID, call: MCPApprovalServer.CommandReportCall) async {
        let observed: CommandSummary.ObservedCommand?
        if !call.argv.isEmpty {
            observed = CommandSummary.classify(argv: call.argv)
        } else if let line = call.commandLine, !line.isEmpty {
            observed = CommandSummary.classify(commandLine: line)
        } else {
            observed = nil
        }
        guard let controller = controllers[sessionID] else { return }
        let session = await controller.snapshot.session
        guard let project = projectsByID[session.projectID], project.isNucleicControlled else { return }

        // Structured-edit backstop (NVRSION): a raw shell command may have mutated worktree files
        // without emitting a `fileChange` (e.g. `sed -i`, `echo > file` that slipped past the deny
        // in exec/full-auto mode). Capture that residue into the trunk so nothing an agent changed
        // is left untracked. Runs for unclassified commands (unknown effect) and known-mutating
        // ones; known read-only commands and git/gh (owned by their own interceptors) are skipped.
        if isNvrsionSession(session, in: project), let trunkPath = session.worktreePath,
            observed?.isMutating != false, !Self.isVCSCommand(call)
        {
            await captureCommandResidue(
                sessionID: sessionID, controller: controller, session: session, trunkPath: trunkPath)
        }

        guard let op = observed else { return }
        let preview = Self.outputPreview(stdout: call.stdout, stderr: call.stderr)
        let event = CommandInterceptorEvent(
            sessionID: sessionID, sessionTitle: session.title, command: op.command,
            category: op.category, label: op.label, symbol: op.symbol, isMutating: op.isMutating,
            exitCode: call.exitCode, durationMs: call.durationMs, outputPreview: preview,
            source: call.source == .shim ? .shim : .tracer, at: now())
        commandInterceptorEvents.insert(event, at: 0)
        if commandInterceptorEvents.count > maxCommandEvents {
            commandInterceptorEvents.removeLast(commandInterceptorEvents.count - maxCommandEvents)
        }
        commandLog.notice(
            "CMDTRACE session=\(sessionID.rawValue, privacy: .public) src=\(call.source.rawValue, privacy: .public) cmd=\(op.command, privacy: .public) exit=\(call.exitCode, privacy: .public) dur=\(call.durationMs ?? -1, privacy: .public)ms label=\(op.label, privacy: .public)")
    }

    /// Land any untracked worktree residue a shell command left behind, attributed to this session
    /// but never sweeping up a file another session currently holds a lock on (the trunk is one
    /// shared working tree). Surfaces a transcript note and arms auto-integration when it commits.
    private func captureCommandResidue(
        sessionID: SessionID, controller: SessionController, session: Session, trunkPath: String
    ) async {
        let ownedByOthers = Set(
            await lockManager.rawState().holds
                .filter { $0.session != sessionID }
                .map(\.path))
        let landed = await nvrsionTrunk.landResidue(
            trunkPath: trunkPath, session: sessionID, excluding: ownedByOthers,
            message: "nvrsion: captured shell edit")
        guard !landed.isEmpty else { return }
        nvrsionPendingByID[session.projectID] = true
        for rel in landed { nvrsionTurnLanded[sessionID, default: []].insert(rel) }
        reconsiderNvrsionAutoIntegration(session.projectID)
        let list = landed.count <= 3
            ? landed.joined(separator: ", ")
            : "\(landed.prefix(3).joined(separator: ", ")) +\(landed.count - 3) more"
        await controller.note(
            "\(NoteEvent.nvrsionPrefix) captured untracked shell edit: \(list)",
            icon: "checkmark.circle")
    }

    /// Whether a reported command is a git/gh invocation — those mutate files as a side effect of
    /// version-control ops that the dedicated git/gh interceptors already track, so residue capture
    /// skips them to avoid re-committing (e.g.) a `git checkout`'s working-tree changes.
    private static func isVCSCommand(_ call: MCPApprovalServer.CommandReportCall) -> Bool {
        let head =
            call.argv.first
            ?? call.commandLine?.split(whereSeparator: { $0 == " " || $0 == "\t" }).first.map(String.init)
            ?? ""
        let name = (head as NSString).lastPathComponent
        return name == "git" || name == "gh"
    }

    /// A single-line, length-capped preview of a command's captured output for the feed's
    /// secondary line: the first non-empty line of stdout, or of stderr when stdout was empty.
    private static func outputPreview(stdout: String, stderr: String, max: Int = 120) -> String {
        func firstLine(_ s: String) -> String? {
            for raw in s.split(separator: "\n", omittingEmptySubsequences: false) {
                let line = raw.trimmingCharacters(in: .whitespaces)
                if !line.isEmpty { return line }
            }
            return nil
        }
        guard let line = firstLine(stdout) ?? firstLine(stderr) else { return "" }
        return line.count <= max ? line : String(line.prefix(max - 1)) + "…"
    }

    /// After an agent-performed merge/landing, if this session's work has now fully landed
    /// (nothing left to ship — the same footprint check autoship uses), recover the autoship UI
    /// and — when autoship is armed — surface the "merged by the agent" note + shipped marker
    /// exactly once per landing. This integrates the interceptor with autoship: a merge the
    /// AGENT ran (bypassing Nucleic's own merge queue) still reflects in the UI, and
    /// `shipIfCompleted`'s empty-footprint guard then prevents a redundant autoship merge of the
    /// same work.
    ///
    /// Two parts, deliberately split around the `autoShip` guard:
    ///   1. **Recovery (runs whenever the work landed, armed or not).** Clear any sticky
    ///      `autoShipConflict` and set the live ship status to `.merged`. This is the fix for a
    ///      stuck conflict: when an earlier autoship attempt conflicted and the agent then
    ///      resolved + merged by hand, the Control panel/header pill (which read `shipStatuses`)
    ///      and the sidebar conflict icon must drop the stale conflict — even if the session was
    ///      somehow disarmed in between. It never clobbers an existing `.merged`.
    ///   2. **Note (armed only, once per landing).** Dedup uses the MainActor-local
    ///      `agentMergeShippedSessions` set, NOT `lastEventWasAutoship` (which `ingest` clears on
    ///      every non-note event). `insert(...).inserted` is a synchronous check-and-set — atomic
    ///      against concurrent git reports — and the LAST gate before the async note. It's cleared
    ///      when the session next edits (`arbitrate`), so genuinely new work that lands later
    ///      ships anew, while a no-op sync-merge after a ship never re-posts.
    private func markShippedIfLanded(_ sessionID: SessionID) async {
        guard let controller = controllers[sessionID] else { return }
        let session = await controller.snapshot.session
        guard await controller.hasWorktree else { return }
        guard await !hasShippableChanges(controller, session) else { return }  // hasn't landed yet
        // 1. Recovery — reflect the landing in the live ship status (so a stuck `.conflicted` in
        //    the Control panel / header pill recovers) and clear a flagged conflict. Only touch
        //    ship status for a session that actually participates in autoship — armed now, or
        //    already carrying a status (e.g. a `.conflicted` left by an earlier attempt). This
        //    keeps an ordinary `git merge` in a NON-autoship chat (syncing from the target) from
        //    conjuring a spurious "Shipped" entry in the autoship panel. Never clobbers `.merged`.
        if session.autoShip || shipStatuses[sessionID] != nil {
            if case .merged = shipStatuses[sessionID] {} else {
                setShipStatus(sessionID, .merged(commit: ""))
            }
        }
        if session.autoShipConflict {
            await mutateSession(sessionID) { await $0.clearAutoShipConflict() }
        }
        // 2. Note + shipped marker — only while autoship is armed, and only once per landing.
        guard session.autoShip else { return }
        guard agentMergeShippedSessions.insert(sessionID).inserted else { return }  // already noted (atomic)
        let target = await shipTargetLabel(sessionID)
        await controller.note(
            "\(NoteEvent.autoshipPrefix) merged into \(target) by the agent",
            icon: "checkmark.seal", autoshipShipped: true)
    }

    /// Event-driven release when a chat finishes its turn (LOCKING §4.4), so a *completed* chat
    /// doesn't keep a lock until the background poll or an archive sweep clears it. A completed
    /// chat is `awaitingInput` + disposition `.completed` — **not** terminal/archived — so the
    /// reconcile loop's lifecycle branch never releases it, and `shipIfCompleted` releases nothing
    /// when there's nothing to ship (the agent declared files via `check_conflict`, acquiring
    /// locks, but the work already landed or never changed). Those locks then lingered, blocking
    /// other agents, until a manual release. Releasing them at the completion event closes that:
    ///
    /// - **Worktree-backed:** release the held files that have **landed** (match the parent — the
    ///   same content check the poll uses). In-flight work pending an autoship merge still differs
    ///   from the parent, so it stays locked and releases via the merge (`.merged` → `releaseAll`)
    ///   — preserving "release only on merge-into-parent" for unmerged work.
    /// - **Worktree-less:** the agent edits the branch directly, so finishing the turn *is* the
    ///   landing (its `unmergedFiles` is empty by construction, and the poll skips it entirely).
    ///   Release everything it holds.
    ///
    /// Internal (not private) only so `AppStoreTests` can drive it directly — completion is
    /// classification-driven and awkward to combine with a manual lock acquire in a test.
    func releaseCompletedSessionLocks(_ sessionID: SessionID) async {
        guard let controller = controllers[sessionID] else {
            await lockManager.releaseAll(sessionID); return
        }
        let held = await lockManager.heldPaths(sessionID)
        guard !held.isEmpty else { return }
        if await controller.hasWorktree {
            // Fail CLOSED. The landed set is `held − unmerged`, so if we can't compute what's
            // still unmerged we must NOT release: a bare `try? … ?? []` coerces a git failure
            // (an `index.lock` race with the agent's own git in the shared worktree, a parentRef
            // that won't resolve mid-operation) into "nothing unmerged → everything landed",
            // dumping every lock on work that never merged (LOCKING invariant 4). Keep the locks
            // and let the backstop poll retry once git is healthy.
            let unmerged: Set<String>
            do {
                unmerged = Set(try await controller.unmergedFiles())
            } catch {
                lockLog.notice("LOCKTRACE completion unmerged-failed keep-all session=\(sessionID.rawValue, privacy: .public) held=\(held.joined(separator: ","), privacy: .public) error=\(String(describing: error), privacy: .public)")
                return
            }
            let landed = Set(held).subtracting(unmerged)
            guard !landed.isEmpty else {
                lockLog.notice("LOCKTRACE completion still-unmerged session=\(sessionID.rawValue, privacy: .public) held=\(held.joined(separator: ","), privacy: .public) unmerged=\(unmerged.sorted().joined(separator: ","), privacy: .public)")
                return
            }
            lockLog.notice("LOCKTRACE completion release-landed session=\(sessionID.rawValue, privacy: .public) landed=\(landed.sorted().joined(separator: ","), privacy: .public)")
            await lockManager.release(sessionID, paths: Array(landed))
        } else {
            lockLog.notice("LOCKTRACE completion release-all(worktree-less) session=\(sessionID.rawValue, privacy: .public) held=\(held.joined(separator: ","), privacy: .public)")
            await lockManager.releaseAll(sessionID)
        }
    }

    /// Retired: contention is handled automatically by the `LockManager` queue now (LOCKING
    /// §4.6), so there is no interactive prompt to resolve. Kept for UI/remote API
    /// compatibility — `pendingConflict` is always nil, so this is never meaningfully called.
    public func resolveConflict(_ choice: ConflictChoice) async {}

    /// Force-release every file lock a session is holding: clear its conflict footprint so
    /// it stops blocking other agents. A session's unmerged files act as an implicit lock
    /// (other sessions' `check_conflict` calls and queued "Wait for Access" waiters see them
    /// via `gatherActiveWork`); when that work stalls without merging, the lock persists and
    /// can block others indefinitely. This is the manual escape hatch. The release lasts until
    /// the footprint genuinely empties or the session ends, then re-arms automatically. Any
    /// queued waiters this was blocking are granted on the next poll cycle (≤ a couple seconds).
    public func forceReleaseLocks(_ sessionID: SessionID) async {
        lockLog.notice("LOCKTRACE manual releaseAll session=\(sessionID.rawValue, privacy: .public)")
        await lockManager.releaseAll(sessionID)
    }

    /// Whether `sessionID` is currently force-released (its footprint suppressed), so the UI
    /// can reflect the override.
    public func locksForceReleased(_ sessionID: SessionID) -> Bool {
        false  // release is immediate now (LockManager.releaseAll); no persistent override state.
    }

    /// Force-release the open session's file locks (UI convenience over `forceReleaseLocks`).
    public func releaseOpenSessionLocks() async {
        guard let sessionID = openSessionID else { return }
        await forceReleaseLocks(sessionID)
    }

    /// Everything the sidebar "Control" panel renders, gathered in one MainActor call: the shared
    /// container's health, the autoship/merge-queue state of each Control session, the file-lock
    /// queue, and the git-interceptor activity feed. All four are Nucleic-Control-only, so this is
    /// the one place that aggregates them. The view polls this while the Control pane is open.
    public func controlSnapshot() async -> ControlSnapshot {
        // Container health. Resources are config; running/exists is a best-effort runtime probe,
        // only attempted while the service is on and a manager is wired (else all false).
        let serviceEnabled = ContainerServiceSettings.serviceEnabled
        // Keep the first-run download indicator fresh on the panel's own poll (cheap, no I/O); the
        // panel reads `containerDownload` directly for its progress bar.
        await refreshContainerDownload()
        var exists = false, running = false
        var usage: ContainerResourceSample?
        var containerEntries: [ControlContainerEntry] = []
        if serviceEnabled, let containerManager {
            let status = await containerManager.controlContainerStatus()
            exists = status.exists
            running = status.running
            containerEntries = await containerManager.controlContainers()
            // The live CPU/RAM meter is best-effort and must NOT block the panel: its probe is a
            // vsock `statistics()` round-trip that can stall on a busy/pressured guest, and awaiting
            // it inline here froze the whole Control panel on its spinner. Refresh it in the
            // background and serve the last-known reading; clear it when nothing's running.
            if running {
                refreshControlUsageInBackground()
                usage = controlUsageSample
            } else {
                controlUsageSample = nil
            }
        }

        // Walk the live controllers once: count Control sessions, build their autoship entries, and
        // index every session by its guest-VM name(s) so a running VM below can show its chat's title
        // and be observable (mapping covers all sessions, not just Control ones — VMs are per-session,
        // and a session may run either a macOS or a Linux guest).
        let controlProjectIDs = Set(projectsByID.values.filter(\.isNucleicControlled).map(\.id))
        let nowDate = now()
        var activeSessions = 0
        var autoship: [AutoshipEntry] = []
        var sessionByVMName: [String: (id: SessionID, title: String)] = [:]
        for (id, controller) in controllers {
            let session = await controller.snapshot.session
            sessionByVMName[MacVMManager.vmName(for: session.id)] = (session.id, session.title)
            sessionByVMName[MacVMManager.linuxVMName(for: session.id)] = (session.id, session.title)
            guard controlProjectIDs.contains(session.projectID), !session.archived else { continue }
            activeSessions += 1
            // Show a session when autoship is armed or the queue has acted on it (so the section
            // spans armed → queued → merging → landed/failed).
            guard session.autoShip || shipStatuses[id] != nil else { continue }
            let status = shipStatuses[id]
            // Retire a settled "Shipped"/"Nothing to ship" row once it has lingered past the TTL so
            // the section doesn't grow without bound. The status stays recorded (bounded by live
            // sessions, cleared on teardown), so the row reappears as "Armed" the moment the agent
            // starts new work (`arbitrate` clears the stale status). Conflicts/failures never retire.
            if isBenignTerminal(status), let at = shippedAt[id],
               nowDate.timeIntervalSince(at) >= autoshipShippedTTL {
                continue
            }
            autoship.append(AutoshipEntry(
                id: id, title: session.title, target: await shipTargetLabel(id), status: status))
        }
        autoship.sort { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }

        let container = ControlContainerInfo(
            serviceEnabled: serviceEnabled, exists: exists, running: running,
            entries: containerEntries,
            cpus: ContainerServiceSettings.controlContainerCPUs,
            memoryGiB: ContainerServiceSettings.controlContainerMemoryGiB,
            activeSessions: activeSessions, controlProjects: controlProjectIDs.count,
            usage: usage)

        // Per-session guest VMs. Each running VM becomes a row with its session title, guest OS,
        // live status (booting until it has an IP), and a cached CPU/RAM sample the background probe
        // keeps fresh. Only attempted on a host that can run guests; the caches are pruned to the
        // running set so a stopped VM's last reading doesn't linger.
        var vms: [ControlVMEntry] = []
        if macVMSupported, let macVMManager {
            let running = await macVMManager.runningVMs()
            let liveNames = Set(running.map(\.name))
            macVMUsageSamples = macVMUsageSamples.filter { liveNames.contains($0.key) }
            macVMUsageProbes = macVMUsageProbes.filter { liveNames.contains($0) }
            for entry in running {
                let mapped = sessionByVMName[entry.name]
                vms.append(ControlVMEntry(
                    sessionID: mapped?.id, name: entry.name,
                    title: mapped?.title ?? (entry.os == .linux ? "Linux VM" : "macOS VM"),
                    os: entry.os,
                    booting: entry.ipAddress == nil,
                    usage: macVMUsageSamples[entry.name]))
                refreshMacVMUsageInBackground(name: entry.name)
            }
            vms.sort { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }
            // The transient base-image VM booted to inject user apps (Settings → Import) is
            // system-managed — not in the per-session `live` registry — so surface it as its own row
            // (no Observe/Stop, with a live activity note) after the session guests, so the user sees
            // the base is running while apps install.
            if let m = await macVMManager.baseMaintenanceVM() {
                vms.append(ControlVMEntry(
                    sessionID: nil, name: m.name, title: "Base image", os: m.os,
                    booting: m.booting, usage: nil, detail: m.activity, stoppable: false))
            }
        }

        return ControlSnapshot(
            container: container, autoship: autoship,
            locks: await lockQueueSnapshot(), activity: gitInterceptorEvents,
            // The store keeps a deeper history (for the log), but the panel only renders the recent
            // slice — bounds the per-poll snapshot copy + SwiftUI diffing of this chattier feed.
            commands: Array(commandInterceptorEvents.prefix(80)),
            vms: vms)
    }

    /// Refresh one VM's cached resource sample off the snapshot's critical path. Single-flight per VM
    /// (a probe already in flight for `name` is left to finish), so a slow guest `top` can't pile up a
    /// new probe on every 2 s poll. Mirrors `refreshControlUsageInBackground` for the container.
    private func refreshMacVMUsageInBackground(name: String) {
        guard !macVMUsageProbes.contains(name), let macVMManager else { return }
        macVMUsageProbes.insert(name)
        Task { [weak self] in
            let sample = await macVMManager.sampleResourceUsage(name: name)
            guard let self else { return }
            if let sample { self.macVMUsageSamples[name] = sample }
            self.macVMUsageProbes.remove(name)
        }
    }

    /// Refresh the cached container resource sample off the snapshot's critical path. Single-flight:
    /// a probe already in flight is left to finish (so a slow/stuck `statistics()` can't pile up a new
    /// probe on every 2 s poll). The result lands in `controlUsageSample` for the next snapshot; the
    /// snapshot itself never awaits this, so a stalled guest agent degrades the meter, not the panel.
    private func refreshControlUsageInBackground() {
        guard controlUsageProbe == nil, let containerManager else { return }
        controlUsageProbe = Task { [weak self] in
            let sample = await containerManager.controlContainerUsage()
            guard let self else { return }
            self.controlUsageSample = sample
            self.controlUsageProbe = nil
        }
    }

    /// Refresh `containerDownload` from the engine. Cheap — a single actor hop reading an in-memory
    /// value, with no I/O — so it's safe to poll on a short cadence (the Control panel while it's
    /// open, and a busy chat while its turn waits on the container). Clears the value when the
    /// container service is off or no manager is wired.
    public func refreshContainerDownload() async {
        guard ContainerServiceSettings.serviceEnabled, let containerManager else {
            if containerDownload != nil { containerDownload = nil }
            return
        }
        let progress = await containerManager.controlDownloadProgress()
        if containerDownload != progress { containerDownload = progress }
    }

    /// A point-in-time snapshot of which sessions hold file locks and which are queued
    /// ("Wait for Access") behind them, for the queue viewer. Holders come from each
    /// active session's unmerged-file footprint (the same implicit lock the detector
    /// reasons over); waiters come from `conflictWaiters`, re-checked live against the
    /// current holders so the "blocked by" links reflect the present, not prompt time.
    public func lockQueueSnapshot() async -> LockQueueSnapshot {
        let raw = await lockManager.rawState()

        // Build a participant (title + objective) for every involved session, once.
        var ids = Set(raw.holds.map(\.session))
        for w in raw.waits { ids.insert(w.session) }
        var participants: [SessionID: LockParticipant] = [:]
        for id in ids {
            let s = await controllers[id]?.snapshot.session
            let task = [s?.title, s?.summary]
                .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
                .filter { !$0.isEmpty }.joined(separator: " — ")
            participants[id] = LockParticipant(sessionID: id, title: s?.title ?? id.rawValue, task: task)
        }

        // Holders per path.
        var fileHolders: [String: [LockParticipant]] = [:]
        var orderedPaths: [String] = []
        for hold in raw.holds.sorted(by: { $0.path < $1.path }) {
            if fileHolders[hold.path] == nil { orderedPaths.append(hold.path) }
            if let p = participants[hold.session] { fileHolders[hold.path, default: []].append(p) }
        }

        // Waiters mapped onto the held paths they overlap, plus who blocks them.
        var fileWaiters: [String: [LockParticipant]] = [:]
        var waitingSessions: [WaitingSession] = []
        for w in raw.waits {
            guard let p = participants[w.session] else { continue }
            for path in orderedPaths
            where w.delta.contains(where: { ConflictDetector.pathsOverlap($0, path) }) {
                fileWaiters[path, default: []].append(p)
            }
            let blockedBy = raw.holds
                .filter { hold in
                    hold.domain == w.domain && hold.session != w.session
                        && w.delta.contains { ConflictDetector.pathsOverlap($0, hold.path) }
                }
                .compactMap { participants[$0.session] }
            waitingSessions.append(WaitingSession(
                sessionID: w.session, title: p.title, task: p.task,
                wantedFiles: w.delta, blockedBy: dedupedParticipants(blockedBy)))
        }

        let files = orderedPaths.map { path in
            FileLock(
                path: path,
                holders: dedupedParticipants(fileHolders[path] ?? []),
                waiters: dedupedParticipants(fileWaiters[path] ?? []))
        }
        return LockQueueSnapshot(files: files, waiters: waitingSessions)
    }

    /// Drop repeat sessions (a session can hold/want a path via more than one entry),
    /// keeping first-seen order.
    private func dedupedParticipants(_ items: [LockParticipant]) -> [LockParticipant] {
        var seen = Set<SessionID>()
        return items.filter { seen.insert($0.sessionID).inserted }
    }

    /// Reduce an agent-supplied path to repo-relative by dropping its worktree prefix if
    /// present (agents report absolute paths inside their own worktree). Tries both the raw
    /// stored path and its canonical form, since a sandboxed agent's cwd is the canonicalized
    /// (symlink-resolved) container path, not the raw host path.
    private func stripWorktreePrefix(_ path: String, _ worktree: String?) -> String {
        guard let worktree, !worktree.isEmpty else { return path }
        for prefix in [worktree, GitWorktreeManager.canonical(worktree)] where path.hasPrefix(prefix) {
            return String(path.dropFirst(prefix.count))
        }
        return path
    }

    /// The trunk-relative path for an agent-reported edit, or `nil` when the edit lands *outside* the
    /// trunk worktree and so isn't a trunk file at all (NVRSION §3). Agents report absolute paths; a
    /// session that writes a plan, memory, or transcript under the app-support dir (claude-home)
    /// yields an absolute path that isn't under the trunk. Unlike `stripWorktreePrefix` — which
    /// returns such a path unchanged, so a downstream `normalize` would just drop its leading "/" and
    /// hand git an unresolvable pathspec — this distinguishes "not a trunk file" (→ `nil`, skip) from
    /// a real repo-relative path. A relative path is taken as already worktree-relative (kept for
    /// decoders that report cwd-relative paths). Matches the worktree on its raw and canonical
    /// (symlink-resolved) forms, since a sandboxed agent's cwd is the canonicalized container path.
    private func trunkRelativePath(_ path: String, in worktree: String) -> String? {
        guard !worktree.isEmpty else { return nil }
        guard path.hasPrefix("/") else {
            let rel = ConflictDetector.normalize(path)  // already worktree-relative
            return rel.isEmpty ? nil : rel
        }
        for base in [worktree, GitWorktreeManager.canonical(worktree)] {
            let prefix = base.hasSuffix("/") ? base : base + "/"
            guard path.hasPrefix(prefix) else { continue }
            let rel = ConflictDetector.normalize(String(path.dropFirst(prefix.count)))
            return rel.isEmpty ? nil : rel
        }
        return nil  // absolute, but not under the trunk worktree — not a trunk file
    }

    // MARK: - Dashboard

    /// Aggregate stats + per-day activity for the home dashboard. Reads each
    /// session's transcript to tally user turns by day (small JSONL files).
    public func loadDashboard() async {
        let projects = (try? await database.loadProjects()) ?? []
        let sessions = (try? await database.loadAllSessions()) ?? []
        // Serve the message count + activity grid from the persisted per-session cache so
        // opening Home never re-scans every transcript on the main thread (the old beachball).
        let cache = (try? await database.loadActivityCache()) ?? [:]

        let calendar = Calendar.current
        var activity: [Date: Int] = [:]
        var tokensByDay: [Date: Int] = [:]
        var messages = 0
        var tokens = 0
        // A cache entry is fresh only when its cursor exactly matches the session's live
        // lastSeq — so a grown OR truncated/reverted transcript both invalidate it. A missing
        // or stale entry is recomputed off-main below.
        var stale: [(id: SessionID, path: String)] = []
        var lastSeqByID: [SessionID: UInt64] = [:]
        for session in sessions {
            lastSeqByID[session.id] = session.lastSeq
            if let entry = cache[session.id], entry.lastSeq == session.lastSeq {
                messages += entry.messageCount
                tokens += entry.tokenCount
                for (day, count) in entry.activityByDay { activity[day, default: 0] += count }
                for (day, used) in entry.tokensByDay { tokensByDay[day, default: 0] += used }
            } else {
                stale.append((id: session.id, path: session.transcriptPath))
            }
        }
        publishDashboard(
            projects: projects, sessions: sessions, activity: activity, messages: messages,
            tokensByDay: tokensByDay, tokens: tokens)

        // Refresh any cold/stale sessions off the main actor, persist them, and republish so
        // the grid fills in without ever blocking the UI.
        guard !stale.isEmpty else { return }
        let prepared = await Self.prepareSessions(stale, calendar: calendar)
        for (id, prep) in prepared {
            messages += prep.messageCount
            tokens += prep.tokenCount
            for (day, count) in prep.activityByDay { activity[day, default: 0] += count }
            for (day, used) in prep.tokensByDay { tokensByDay[day, default: 0] += used }
            try? await database.saveActivityCache(id, ActivityCacheEntry(
                lastSeq: lastSeqByID[id] ?? 0,
                messageCount: prep.messageCount, activityByDay: prep.activityByDay,
                tokenCount: prep.tokenCount, tokensByDay: prep.tokensByDay))
        }
        publishDashboard(
            projects: projects, sessions: sessions, activity: activity, messages: messages,
            tokensByDay: tokensByDay, tokens: tokens)
    }

    /// Publish the dashboard rollup to the observable state. The status-derived counts
    /// (chats/active) come straight from the cheap session list; the message count and
    /// activity grid are supplied by the caller (cache-served, refined as stale entries land).
    private func publishDashboard(
        projects: [Project], sessions: [Session], activity: [Date: Int], messages: Int,
        tokensByDay: [Date: Int], tokens: Int
    ) {
        let visible = sessions.filter { !$0.archived }
        let active = visible.filter { !$0.status.isTerminal }.count
        dashboard = DashboardStats(
            projects: projects.count,
            chats: visible.count,
            activeChats: active,
            messages: messages,
            activeDays: activity.count,
            tokens: tokens)
        activityByDay = activity
        self.tokensByDay = tokensByDay
    }

    /// Non-archived chats for a local project, favorites first, then by most recent user
    /// message (newest first) so the chats you're actively talking to rise to the top.
    /// Chats that have never received a user message fall back to their creation time.
    public func summaries(for projectID: ProjectID) -> [SessionSummary] {
        Self.sidebarOrder(
            summaries.filter { $0.projectID == projectID && !$0.archived && !$0.isSubagent })
    }

    /// The origin-aware variant the unified sidebar renders through (mesh session sync): a
    /// local project answers from this Mac's records, a peer Mac's project from that peer's
    /// mirrored list — keyed by the summary's own origin, never the bare `ProjectID`, so two
    /// Macs holding the same id (a migrated app database) can't cross their session lists.
    /// Same shape, same comparator, either origin.
    public func summaries(for project: ProjectSummary) -> [SessionSummary] {
        Self.sidebarOrder(sessionPool(of: project).filter { !$0.archived && !$0.isSubagent })
    }

    /// Favorites pinned on top, then newest user message (falling back to creation) first.
    private static func sidebarOrder(_ list: [SessionSummary]) -> [SessionSummary] {
        list.sorted {
            let lhs = ($0.favorite ? 0 : 1, $0.lastUserMessageAt ?? $0.createdAt)
            let rhs = ($1.favorite ? 0 : 1, $1.lastUserMessageAt ?? $1.createdAt)
            if lhs.0 != rhs.0 { return lhs.0 < rhs.0 }   // favorites pinned on top
            return lhs.1 > rhs.1                          // then newest message first
        }
    }

    /// Every summary belonging to `project`, drawn from its own origin: this Mac's records
    /// for a local project, the owning peer's mirrored session list for a remote one (empty
    /// when that peer is gone — its rows leave with it).
    private func sessionPool(of project: ProjectSummary) -> [SessionSummary] {
        guard let hostID = project.hostID else {
            return summaries.filter { $0.projectID == project.id }
        }
        guard let peer = meshPeers.first(where: { $0.connected && $0.deviceID == hostID }) else {
            return []
        }
        return peer.sessions
            .filter { $0.projectID == project.id.rawValue }
            .map { SessionSummary(wire: $0, hostID: hostID) }
    }

    /// Whether any non-archived chat in this project is mid-turn (provisioning, running, or
    /// awaiting approval). Integration squashes the shared nvrsion trunk into the real branch,
    /// so it must wait until no chat in the project is actively landing work — otherwise it'd
    /// promote a half-written trunk out from under a running agent.
    public func isAnySessionRunning(in projectID: ProjectID) -> Bool {
        summaries.contains {
            $0.projectID == projectID && !$0.archived && $0.status.hasTurnInFlight
        }
    }

    public func archivedSummaries(for projectID: ProjectID) -> [SessionSummary] {
        summaries
            .filter { $0.projectID == projectID && $0.archived && !$0.isSubagent }
            .sorted { $0.updatedAt > $1.updatedAt }
    }

    /// Origin-aware variant for the unified sidebar — see `summaries(for project:)`.
    public func archivedSummaries(for project: ProjectSummary) -> [SessionSummary] {
        sessionPool(of: project)
            .filter { $0.archived && !$0.isSubagent }
            .sorted { $0.updatedAt > $1.updatedAt }
    }

    /// The most-recently-active non-archived chats across every project — this Mac's and
    /// every connected peer's (mesh session sync), so Recents reads as one coherent surface
    /// over the whole mesh — newest first, capped at `limit`. Powers the sidebar's "Recents"
    /// section, which shows the session title with its project name as subtext.
    public func recentSummaries(limit: Int = 8) -> [SessionSummary] {
        (summaries + remoteSummaries())
            .filter { !$0.archived && !$0.isSubagent }
            .sorted { $0.updatedAt > $1.updatedAt }
            .prefix(limit)
            .map { $0 }
    }

    /// Every connected peer Mac's *live* mirrored sessions, mapped into the local summary
    /// shape — the cross-mesh feed behind Recents (both call sites drop archived rows, so
    /// they're filtered at the source). Deduped two ways so the one session never renders
    /// twice in a cross-mesh list: a live LOCAL row shadows a peer's copy of the same id, and
    /// the first peer's live copy shadows another peer's (a moved session's old owner mirrors
    /// only an archived tombstone, which the archived filter already drops — so an archived
    /// local tombstone must NOT shadow the live twin its new owner mirrors back).
    private func remoteSummaries() -> [SessionSummary] {
        var seen = Set(summaries.lazy.filter { !$0.archived }.map(\.id))
        var result: [SessionSummary] = []
        for peer in meshPeers where peer.connected {
            for wire in peer.sessions where !wire.archived && !seen.contains(wire.sessionID) {
                seen.insert(wire.sessionID)
                result.append(SessionSummary(wire: wire, hostID: peer.deviceID))
            }
        }
        return result
    }

    /// How many non-archived chats were active within the last `window` (default 24h),
    /// measured by `updatedAt`, across this Mac and every connected peer. Drives the Recents
    /// header's "N recent" rollup — unlike `recentSummaries` it isn't capped at eight, so it
    /// reflects the true count of the day's chats rather than just the rows on screen.
    public func recentCount(within window: TimeInterval = 24 * 3600, now: Date = Date()) -> Int {
        (summaries + remoteSummaries())
            .filter { !$0.archived && !$0.isSubagent && now.timeIntervalSince($0.updatedAt) < window }
            .count
    }

    /// The subagents under `sessionID` (Orchestra workers), oldest first — the list behind a chat's
    /// Subagents panel. These are hidden from the sidebar (`isSubagent`) but stay in `summaries`, so
    /// this reads straight off it and updates live as workers spawn and finish.
    ///
    /// Rolls the whole spawn tree up under its root: a worker spawned by another worker carries the
    /// root chat's id in `rootSpawnedBySessionID`, so querying the root returns the *entire*
    /// descendant tree — not just its direct workers. Querying an intermediate worker still returns
    /// its own direct children (via `spawnedBySessionID`), so each node's panel stays meaningful.
    /// The two clauses coincide for a root's direct workers; `filter` yields each match once.
    public func subagentSummaries(for sessionID: SessionID) -> [SessionSummary] {
        summaries
            .filter { $0.rootSpawnedBySessionID == sessionID || $0.spawnedBySessionID == sessionID }
            .sorted { $0.createdAt < $1.createdAt }
    }

    // MARK: - Session lifecycle

    /// Create a randomly-named chat under a project and open it. The session's
    /// worktree is created immediately; the agent doesn't run until the user sends
    /// the first message in the chat composer.
    @discardableResult
    public func newSession(in project: Project) async -> SessionID? {
        do {
            let id = try await createSession(in: project, title: RandomName.generate(), prompt: "")
            openSessionID = id
            return id
        } catch {
            lastError = "New session failed: \(error)"
            return nil
        }
    }

    /// The live session whose own branch is `branch`, if any — used at creation to detect a
    /// nested child (its base is a parent session's branch) and inherit the parent's `rootRef`
    /// (LOCKING §3.2). Scans controllers; trees are shallow and creation is infrequent.
    private func liveSession(whoseBranchIs branch: String) async -> Session? {
        for (_, controller) in controllers {
            let s = await controller.snapshot.session
            if s.branch == branch, !s.archived { return s }
        }
        return nil
    }

    /// Create a worktree, open a transcript, spawn a (conversational) controller, and
    /// persist. If `prompt` is non-empty the first turn starts immediately; otherwise
    /// the session waits for the user's first message (PLAN M1).
    @discardableResult
    public func createSession(
        in project: Project, title: String, prompt: String = "", base: GitRef? = nil,
        model: String? = nil, effort: String? = nil, useWorktree: Bool = true, auto: Bool? = nil,
        autoShip: Bool? = nil, attachments: [PendingAttachment] = [], ephemeral: Bool = false,
        spawnedBy: SessionID? = nil, startRun: Bool = true
    ) async throws -> SessionID {
        let sessionID = SessionID.generate()
        // Orchestra: the parent chat is the *supervisor*, so it runs the configured supervisor
        // model rather than the composer's pick (its workers run the worker model — see
        // `spawnOrchestraSubagent`). Both are fixed here, at creation, which is why Orchestra is a
        // start-of-chat-only mode: the supervisor model can't be applied to a chat already under
        // way on a different model. Gated to Control projects, where Orchestra actually runs
        // (`orchestraActive`); the supervisor default falls back to the ordinary model if unset.
        let resolvedModel: String?
        if OrchestrationMode.isOrchestra(effort ?? defaultEffort), project.isNucleicControlled {
            resolvedModel = defaultSupervisorModel ?? model ?? defaultModel
        } else {
            resolvedModel = model ?? defaultModel
        }
        // The chosen model drives the backend (the picker doubles as the backend selector);
        // an unrecognized SKU falls back to the project default, then Claude.
        let backendID = BackendID.forModel(resolvedModel) ?? project.defaultBackend ?? .claudeCode
        let resolvedBase = base ?? project.defaultBranch

        // nvrsion (NVRSION §2): the whole project shares one `nucleic/trunk` checkout — the session
        // makes NO isolated worktree; it edits the shared trunk directly and lands each edit into it.
        // So it behaves like a worktree-less session whose working dir is the trunk.
        let nvrsionTrunkPath: String? = project.nvrsionActive ? project.resolvedTrunkPath : nil
        if let nvrsionTrunkPath, let nvrsion = project.nvrsion {
            try await nvrsionTrunk.ensureTrunk(
                root: project.rootPath, trunkPath: nvrsionTrunkPath,
                trunkBranch: nvrsion.trunkBranch, base: project.defaultBranch.value)
        }

        // An ephemeral, out-of-project chat runs in its own disposable, git-init'd scratch dir
        // (`<support>/scratch/<sessionID>`) — never a project worktree. No branch, no ship.
        let ephemeralScratchPath: String? = ephemeral
            ? try await EphemeralScratch.create(base: ephemeralScratchBase, session: sessionID)
            : nil

        // With a worktree (the default), the chat runs on its own isolated branch.
        // Without one, it runs directly in the project's main checkout on the chosen
        // branch — no isolation, so merges/discards have nothing to remove.
        let worktree: Worktree? = (useWorktree && nvrsionTrunkPath == nil && !ephemeral)
            ? try await worktrees.create(for: sessionID, in: project, base: resolvedBase, slug: title)
            : nil
        let worktreePath = ephemeralScratchPath ?? nvrsionTrunkPath ?? worktree?.path ?? project.rootPath

        let transcriptURL = transcriptsDir
            .appendingPathComponent(sessionID.rawValue)
            .appendingPathComponent("transcript.jsonl")
        let header = SessionHeader(
            sessionID: sessionID, backend: backendID, worktree: worktreePath, createdAt: now())
        let writer = try TranscriptWriter(url: transcriptURL, header: header)

        // Drop any composer attachments into the new working tree and fold their paths into the
        // first prompt — done before the turn starts so the opening message references them. A
        // prompt that was empty becomes non-empty here, so an attachment-only first message still
        // kicks off a turn (status below keys off `trimmedPrompt`).
        // The opening message starts a turn immediately (never queued), so only the augmented
        // text is needed here — the per-file references matter only for a queued message.
        let (trimmedPrompt, _) = await augmentWithAttachments(
            prompt.trimmingCharacters(in: .whitespacesAndNewlines), attachments, worktree: worktreePath)
        // Ship implies auto: a session that ships must run autonomously.
        // Autoship is gated on Nucleic Control — a non-controlled project never births a
        // chat with autoship on, even when the app-wide "Autoship new chats" default is set.
        let resolvedAutoShip = (autoShip ?? defaultAutoShip) && project.isNucleicControlled
        // Auto is locked on for every Nucleic Control chat — they run autonomously by design and
        // nvrsion can't function without it — so force it on there regardless of the app default.
        // Off-control it follows the per-call value / app default, or rides along with autoship.
        let resolvedAuto = (auto ?? defaultAuto) || resolvedAutoShip || project.isNucleicControlled
        // Lock-domain wiring (LOCKING §3.2): the base we fork from is the release target
        // (`parentRef`). If that base is another live session's branch this is a nested child —
        // inherit that session's `rootRef` (the tree's top-level branch) and link to it; else
        // this base *is* the root of a new tree. Ephemeral chats never nest: they all sit on their
        // own scratch `main`, so skip the scan (it would otherwise match a sibling scratch chat).
        let parentSession = ephemeral ? nil : await liveSession(whoseBranchIs: resolvedBase.value)
        // nvrsion sessions all live on the shared trunk branch (their lock domain, NVRSION §10);
        // they have no fork point (`baseSHA`) and no nesting — the trunk is the one shared base.
        let nvrsionBranch = nvrsionTrunkPath != nil ? project.nvrsion?.trunkBranch : nil
        // Root of the spawn lineage (Orchestra nesting): a subagent inherits its spawner's root, so
        // a worker spawned by another worker still rolls up under the one top-level chat. When the
        // spawner is itself a top-level chat it has no root of its own, so it *is* the root. Read off
        // the spawner's live summary; a direct caller that names a `spawnedBy` we don't know falls
        // back to that spawner. `nil` when this isn't a spawned worker.
        let spawnedByRoot = spawnedBy.map { spawner in
            summaries.first { $0.id == spawner }?.rootSpawnedBySessionID ?? spawner
        }
        let session = Session(
            id: sessionID, projectID: project.id, backend: backendID,
            title: title, status: trimmedPrompt.isEmpty ? .awaitingInput : .running,
            worktreePath: worktreePath, branch: nvrsionBranch ?? worktree?.branch ?? resolvedBase.value,
            baseSHA: worktree?.baseSHA,
            parentRef: nvrsionBranch ?? resolvedBase.value,
            rootRef: nvrsionBranch ?? parentSession?.rootRef ?? resolvedBase.value,
            parentSessionID: nvrsionBranch != nil ? nil : parentSession?.id,
            spawnedBySessionID: spawnedBy,
            rootSpawnedBySessionID: spawnedByRoot,
            model: resolvedModel, effort: effort ?? defaultEffort,
            transcriptPath: transcriptURL.path, auto: resolvedAuto,
            autoShip: resolvedAutoShip,
            createdAt: now(), updatedAt: now())

        // Supervisor-side Orchestra channel: spawn workers, then drive them with supervise/reply.
        // Injected into every session's controller, but only exposed as tools while the session is
        // an active Orchestra supervisor (`SessionController.orchestraActive`).
        let subagentSpawner: OrchestraSubagentSpawner = { [weak self] request in
            guard let self else {
                return .denied(message: "Nucleic is no longer available to spawn a worker.")
            }
            return await self.spawnOrchestraSubagent(request, parent: session, project: project)
        }
        let superviseHandler: OrchestraSuperviseHandler = { [weak self] in
            guard let self else { return OrchestraSuperviseResult(events: [], remainingRunning: 0) }
            return await self.superviseOrchestra(supervisor: sessionID)
        }
        let replyToWorkerHandler: OrchestraReplyToWorkerHandler = { [weak self] request in
            guard let self else {
                return .unknown(message: "Nucleic is no longer available.")
            }
            return await self.replyToOrchestraWorker(supervisor: sessionID, request: request)
        }
        // Worker-side Orchestra channel: reach back up to the supervisor. Injected ONLY for a
        // machine-spawned worker (`spawnedBy != nil`); its presence is what marks this run as a
        // worker (drives the worker briefing + the `nucleic_ask_supervisor` tool).
        let askSupervisorHandler: OrchestraAskSupervisorHandler?
        if let supervisorID = spawnedBy {
            askSupervisorHandler = { [weak self] question in
                guard let self else {
                    return .unavailable(message: "No supervisor is available; proceed autonomously.")
                }
                return await self.askOrchestraSupervisor(
                    worker: sessionID, supervisor: supervisorID, question: question)
            }
        } else {
            askSupervisorHandler = nil
        }
        let controller = SessionController(
            session: session, backend: backendFactory(session), transcript: writer,
            worktreeManager: worktrees, project: project, worktree: worktree,
            metadataStore: database, conversational: true,
            orchestraSubagentSpawner: subagentSpawner,
            orchestraSuperviseHandler: superviseHandler,
            orchestraReplyToWorkerHandler: replyToWorkerHandler,
            orchestraAskSupervisorHandler: askSupervisorHandler, now: now)
        controllers[sessionID] = controller
        sessionsCreatedThisRun.insert(sessionID)  // all its tool groups are live, never "history"
        try? await database.saveSession(session)
        upsertSummary(SessionSummary(session, pendingApprovalCount: 0))
        if ephemeral {
            // Seed the per-session persistence choice from the app default; the in-window
            // Ephemeral/Saved switch flips it later. Saved chats stay in the DB under the reserved
            // id; truly-ephemeral ones (absent from the set) are discarded when their window closes.
            if EphemeralChatPolicy.savedByDefault { ephemeralPersistedSessions.insert(sessionID) }
        } else {
            // Push the new row to connected viewers (phones, peer Macs mirroring this sidebar).
            // An *idle* creation (empty prompt — Cmd-N, or a peer's remote "New chat") emits no
            // transcript event, so without this it stays invisible to them until the next
            // reconnect or unrelated broadcast. Ephemeral chats are local-only and never broadcast.
            broadcast(.sessionUpdated(wireSummary(for: session)))
        }

        observe(controller, sessionID)
        // `startRun: false` creates the session and its controller but leaves the opening turn
        // unstarted — the caller drives `controller.start` itself later. Orchestra worker spawns use
        // this so `nucleic_subagent` can return a `worker_id` immediately while the worker's run is
        // deferred until it acquires a concurrency slot (see `spawnOrchestraSubagent`).
        if startRun, !trimmedPrompt.isEmpty {
            await controller.start(prompt: AgentInput(text: trimmedPrompt))
            maybeNameSession(sessionID, firstMessage: trimmedPrompt)
        }
        return sessionID
    }


    /// Live count of Orchestra workers currently occupying a concurrency slot, and the FIFO queue
    /// of `nucleic_subagent` calls parked waiting for one. Both are `@MainActor`-isolated, so their
    /// mutations never race. See `defaultOrchestraMaxConcurrentWorkers`. Internal (not private)
    /// only so tests can observe the gate.
    private(set) var orchestraWorkersRunning = 0
    /// Per-supervisor live worker channel: the actor that bridges `nucleic_subagent` /
    /// `nucleic_supervise` / `nucleic_reply_to_worker` (supervisor side) and `nucleic_ask_supervisor`
    /// (worker side). Keyed by SUPERVISOR session id, created lazily on the supervisor's first spawn,
    /// and torn down when the supervisor's turn ends (`teardownOrchestra`). Only supervisors that have
    /// actually spawned a worker appear here.
    private var orchestraCoordinators: [SessionID: OrchestraWorkerCoordinator] = [:]
    /// The detached run task for each live worker, keyed by WORKER session id. Cancelling one unwinds
    /// its worker — unparking a still-queued slot acquire, or interrupting a running turn (see
    /// `runOrchestraWorker`). Removed when the worker's run task finishes.
    private var orchestraWorkerRunTasks: [SessionID: Task<Void, Never>] = [:]
    /// One parked `nucleic_subagent` spawn. Identified so a cancelled supervisor can pull ITS
    /// waiter back out of the queue — a queued spawn must die with its supervisor, not go on to
    /// create and run a worker whose result nobody will read.
    private struct OrchestraWorkerWaiter {
        let id: UUID
        let continuation: CheckedContinuation<Void, Never>
    }
    private var orchestraWorkerWaiters: [OrchestraWorkerWaiter] = []
    /// Test-only visibility into the parked-spawn queue.
    var orchestraWorkerWaiterCount: Int { orchestraWorkerWaiters.count }

    /// Park until a worker slot is free, honoring the configured cap. `<= 0` means unlimited, so it
    /// returns immediately. Waiters wake in FIFO order as slots are released. Returns `false` —
    /// WITHOUT acquiring — when the calling task is cancelled (its supervisor's run ended while the
    /// spawn was queued); the waiter is removed so it can't be woken into a dead spawn later. On
    /// `true`, always pair with `releaseOrchestraWorkerSlot()` (via `defer`) so a thrown/early-
    /// returning spawn frees its slot. Internal (not private) only for tests.
    func acquireOrchestraWorkerSlot() async -> Bool {
        let limit = defaultOrchestraMaxConcurrentWorkers
        if limit > 0 {
            while orchestraWorkersRunning >= limit {
                if Task.isCancelled { return false }
                let id = UUID()
                await withTaskCancellationHandler {
                    await withCheckedContinuation { continuation in
                        // Runs synchronously on the main actor before this task suspends, so even a
                        // cancellation that fired before the append (the handler's hop below queues
                        // behind this) finds the waiter and resumes it — never a stranded park.
                        orchestraWorkerWaiters.append(
                            OrchestraWorkerWaiter(id: id, continuation: continuation))
                    }
                } onCancel: {
                    Task { @MainActor [weak self] in self?.cancelOrchestraWorkerWaiter(id) }
                }
            }
        }
        if Task.isCancelled {
            // The wake-up that got us here may have been release() offering a freed slot; we
            // can't use it, so pass it along instead of stranding the remaining waiters.
            wakeNextOrchestraWorkerWaiter()
            return false
        }
        orchestraWorkersRunning += 1
        return true
    }

    /// Remove + resume a cancelled spawn's waiter; the woken task sees its cancellation at the
    /// top of the acquire loop and bails out. A waiter already woken by `release()` (and so no
    /// longer queued) is left alone — the post-loop cancellation check declines the slot instead.
    private func cancelOrchestraWorkerWaiter(_ id: UUID) {
        guard let index = orchestraWorkerWaiters.firstIndex(where: { $0.id == id }) else { return }
        orchestraWorkerWaiters.remove(at: index).continuation.resume()
    }

    private func wakeNextOrchestraWorkerWaiter() {
        if !orchestraWorkerWaiters.isEmpty {
            orchestraWorkerWaiters.removeFirst().continuation.resume()
        }
    }

    /// Internal (not private) only for tests.
    func releaseOrchestraWorkerSlot() {
        orchestraWorkersRunning -= 1
        wakeNextOrchestraWorkerWaiter()
    }

    /// The live worker channel for `supervisor`, created on first use. One coordinator per supervisor
    /// session bridges that supervisor's `nucleic_subagent` / `nucleic_supervise` /
    /// `nucleic_reply_to_worker` tools and its workers' `nucleic_ask_supervisor` calls.
    private func orchestraCoordinator(for supervisor: SessionID) -> OrchestraWorkerCoordinator {
        if let existing = orchestraCoordinators[supervisor] { return existing }
        let created = OrchestraWorkerCoordinator()
        orchestraCoordinators[supervisor] = created
        return created
    }

    /// Spawn one Orchestra worker as a first-class Nucleic session — **non-blocking**. It creates the
    /// worker session (so the supervisor gets a `worker_id` to track immediately), registers it with
    /// the supervisor's coordinator, and hands the actual run to a detached task; it does NOT wait for
    /// the worker to finish. The worker's questions and its terminal result reach the supervisor later
    /// as `nucleic_supervise` events. The app retains the full child lifecycle: sidebar row,
    /// transcript, approvals, sandbox/container command observation, and model.
    ///
    /// Concurrency: the supervisor may fan out several `nucleic_subagent` calls in one turn; each
    /// returns at once, and its worker parks on `acquireOrchestraWorkerSlot` *inside* its detached run
    /// task — so the cap (`defaultOrchestraMaxConcurrentWorkers`) bounds how many run at once without
    /// ever blocking the spawn call itself (blocking it would deadlock a fan-out wider than the cap).
    func spawnOrchestraSubagent(
        _ request: OrchestraSubagentRequest, parent: Session, project: Project
    ) async -> OrchestraSubagentResult {
        let prompt = request.prompt.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !prompt.isEmpty else {
            return .denied(message: "Worker prompt is required.")
        }
        let workerModel = defaultOrchestraWorkerModel ?? parent.model
        let title = Self.orchestraWorkerTitle(for: request.task)
        let coordinator = orchestraCoordinator(for: parent.id)
        let workerID: SessionID
        do {
            // Create the worker session but DON'T start its run yet — its turn begins in the detached
            // task below, only once it holds a concurrency slot. This is what lets the spawn return
            // immediately with a real `worker_id`.
            workerID = try await createSession(
                in: project, title: title, prompt: prompt,
                model: workerModel, effort: OrchestrationMode.resolvedOrchestraEffort,
                useWorktree: !project.nvrsionActive, auto: true, autoShip: false,
                spawnedBy: parent.id, startRun: false)
        } catch {
            return .denied(message: "Worker session could not be created: \(error)")
        }
        guard let controller = controllers[workerID] else {
            return .denied(message: "Worker session was created but its controller is unavailable.")
        }
        await coordinator.register(workerID: workerID, task: request.task)
        // Drive the worker on its own task so this spawn call returns now. Cancelling this task (at
        // supervisor teardown) unparks a still-queued slot acquire or interrupts a running worker.
        let runTask = Task { [weak self] in
            guard let self else { return }
            await self.runOrchestraWorker(
                workerID: workerID, controller: controller, coordinator: coordinator,
                task: request.task, prompt: prompt)
        }
        orchestraWorkerRunTasks[workerID] = runTask
        return .spawned(workerID: workerID, title: title, model: workerModel)
    }

    /// Run a spawned Orchestra worker to completion and report its outcome to `coordinator`. Owns the
    /// worker's concurrency slot for the lifetime of its run: it parks on `acquireOrchestraWorkerSlot`
    /// (so a fan-out wider than the cap queues here, not in the spawn call), starts the turn, then
    /// waits — interrupting the worker if this task is cancelled because the supervisor's turn ended.
    private func runOrchestraWorker(
        workerID: SessionID, controller: SessionController,
        coordinator: OrchestraWorkerCoordinator, task: String, prompt: String
    ) async {
        defer { orchestraWorkerRunTasks[workerID] = nil }
        // Park for a slot. Cancelled while queued (supervisor's run ended) → never start the worker.
        guard await acquireOrchestraWorkerSlot() else {
            await coordinator.fail(
                workerID: workerID,
                message: "Worker cancelled: the supervisor's run ended before a slot freed.")
            if workerID != openSessionID { await setSessionArchived(workerID, true) }
            return
        }
        defer { releaseOrchestraWorkerSlot() }
        // Now that we hold a slot, begin the worker's turn (createSession left it unstarted).
        await controller.start(prompt: AgentInput(text: prompt))
        // If the supervisor's run ends mid-turn, interrupt the worker: its result has nowhere to go,
        // so running on only burns tokens and the slot. The worker session survives (interrupted,
        // visible in the sidebar) for the user to inspect or resume.
        await withTaskCancellationHandler {
            await controller.join()
        } onCancel: {
            Task { await controller.interrupt() }
        }
        let snapshot = await controller.snapshot
        let events = await controller.transcriptSoFar()
        // Nothing ever resumes a worker session (each spawn is fresh), so archive it now — a finished
        // worker otherwise rests at `.awaitingInput` and counts as an active chat forever, holding its
        // sandbox container. Spared only when it's the chat the user has open (peeking via the
        // Subagents panel, which lists archived workers all the same).
        if workerID != openSessionID { await setSessionArchived(workerID, true) }
        if Task.isCancelled {
            await coordinator.fail(
                workerID: workerID,
                message: "The supervisor's run ended; the worker was interrupted.")
            return
        }
        let lastError = events.reversed().compactMap { event -> AgentError? in
            if case .error(let error) = event.kind { return error }
            return nil
        }.first
        if let lastError {
            await coordinator.fail(workerID: workerID, message: lastError.message)
            return
        }
        let output = Self.lastReply(in: events)?.trimmingCharacters(in: .whitespacesAndNewlines)
        await coordinator.complete(
            workerID: workerID, model: snapshot.session.model,
            output: output?.isEmpty == false ? output! : "Worker completed without a final reply.")
    }

    /// Supervisor side (`nucleic_supervise`): block until this supervisor's workers report events,
    /// then drain them. No coordinator (no worker spawned yet) → nothing to wait on.
    func superviseOrchestra(supervisor: SessionID) async -> OrchestraSuperviseResult {
        guard let coordinator = orchestraCoordinators[supervisor] else {
            return OrchestraSuperviseResult(events: [], remainingRunning: 0)
        }
        return await coordinator.supervise()
    }

    /// Supervisor side (`nucleic_reply_to_worker`): deliver an answer to a worker waiting in
    /// `nucleic_ask_supervisor`.
    func replyToOrchestraWorker(
        supervisor: SessionID, request: OrchestraReplyRequest
    ) async -> OrchestraReplyResult {
        guard let coordinator = orchestraCoordinators[supervisor] else {
            return .unknown(message: "No active supervision for this session.")
        }
        return await coordinator.reply(workerID: request.workerID, reply: request.reply)
    }

    /// Worker side (`nucleic_ask_supervisor`): route a worker's question to its supervisor's
    /// coordinator and block until the supervisor replies (or its run ends).
    func askOrchestraSupervisor(
        worker: SessionID, supervisor: SessionID, question: String
    ) async -> OrchestraAskSupervisorResult {
        guard let coordinator = orchestraCoordinators[supervisor] else {
            return .unavailable(
                message: "No supervisor is available; proceed autonomously on your best judgment.")
        }
        return await coordinator.ask(workerID: worker, question: question)
    }

    /// Dissolve a supervisor's live worker channel at the end of its turn. Force-drains the
    /// coordinator (answers any parked `nucleic_ask_supervisor` with "supervisor unavailable", wakes
    /// any `nucleic_supervise` waiter), then cancels every still-live worker's run task — which
    /// interrupts a running worker or unparks a queued slot acquire. Deterministic: no worker is left
    /// running for, or blocked on, a supervisor whose turn is over, whether or not the model finished
    /// its supervise loop. Internal (not private) so tests can drive teardown without running a full
    /// supervisor turn to `.runFinished`.
    func teardownOrchestra(supervisor: SessionID) async {
        guard let coordinator = orchestraCoordinators.removeValue(forKey: supervisor) else { return }
        let liveWorkers = await coordinator.teardown()
        for workerID in liveWorkers {
            orchestraWorkerRunTasks[workerID]?.cancel()
        }
    }

    private static func orchestraWorkerTitle(for task: String) -> String {
        let trimmed = task.trimmingCharacters(in: .whitespacesAndNewlines)
        let base = trimmed.isEmpty ? "Orchestra worker" : trimmed
        let collapsed = base.split(whereSeparator: { $0.isWhitespace }).joined(separator: " ")
        return collapsed.count <= 64 ? collapsed : String(collapsed.prefix(61)) + "..."
    }

    public func respondToOpenApproval(_ id: ApprovalID, _ decision: Decision) async {
        // A remote (peer Mac) session's approval (mesh session sync): answer it on the owner
        // over the mesh, optimistically dropping it from the open strip (the owner confirms
        // via its approvalResolved broadcast). Mirrors `respondToApproval`'s remote branch —
        // without this the approval bar silently no-ops for a remote session.
        if let hostID = openHostID {
            await peerClient?.respondRemoteApproval(id, decision, on: hostID)
            openApprovals.removeAll { $0.id == id }
            return
        }
        guard let sessionID = openSessionID, let controller = controllers[sessionID] else { return }
        do { try await controller.respondToApproval(id, decision, by: localResponderID) }
        catch { lastError = "Approval failed: \(error)" }
    }

    public func sendToOpenSession(_ text: String, attachments: [PendingAttachment] = []) async {
        // A remote (peer Mac) session (mesh session sync): route the message to its owner over the
        // mesh — it runs there. Attachments aren't carried over the mesh viewer yet.
        if let hostID = openHostID, let sessionID = openSessionID {
            let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
            guard !trimmed.isEmpty else { return }
            await peerClient?.sendRemoteInput(AgentInput(text: trimmed), to: sessionID, on: hostID)
            return
        }
        guard let sessionID = openSessionID, let controller = controllers[sessionID] else { return }
        // Drop any attached files into the session's working tree and fold their paths into the
        // message, so the agent (which reads that tree, even from inside the sandbox) can open them.
        // Keep the original text and per-file references separately so a message that ends up
        // *queued* can show and restore its attachments as chips rather than raw path text.
        let (augmented, queuedAttachments) = await augmentWithAttachments(
            text, attachments, worktree: openSession?.worktreePath)
        let display = QueuedMessage(text: text, attachments: queuedAttachments)
        do {
            try await controller.sendInput(AgentInput(text: augmented), display: display)
            maybeNameSession(sessionID, firstMessage: augmented)  // first send names the session
            // A message queued mid-turn produces no event, so the event observer won't
            // refresh the open snapshot — pull it directly so the "queued" indicator appears
            // right away. (When the send started a turn instead, the snapshot still reflects
            // it; the observer's later refreshes stay consistent.)
            if sessionID == openSessionID {
                let snapshot = await controller.snapshot
                openSession = snapshot.session
                broadcast(.sessionUpdated(wireSummary(for: snapshot.session, pendingApprovals: snapshot.pendingApprovals)))
            }
        } catch {
            lastError = "Send failed: \(error)"
        }
    }

    /// Write `attachments` into `worktree` (off the main actor — they may be large media) and
    /// append their reference block to `text`, returning both the augmented text (what the agent
    /// reads) and a per-file reference list (so a queued message can show and restore them as
    /// chips). Best-effort: on any write failure the message is sent without the references
    /// rather than dropped, with the reason surfaced in `lastError`. A no-op (returns `text`
    /// unchanged, no references) when there are no attachments or no working tree.
    private func augmentWithAttachments(
        _ text: String, _ attachments: [PendingAttachment], worktree: String?
    ) async -> (text: String, attachments: [QueuedAttachment]) {
        guard !attachments.isEmpty, let worktree else { return (text, []) }
        do {
            let paths = try await Task.detached(priority: .userInitiated) {
                try AttachmentMaterializer.materialize(attachments, intoWorktree: worktree)
            }.value
            let augmented = AttachmentMaterializer.augment(message: text, withReferencePaths: paths)
            // `materialize` returns paths in attachment order, so zip pairs each file's original
            // name with where it landed.
            let refs = zip(attachments, paths).map {
                QueuedAttachment(filename: $0.0.filename, relativePath: $0.1)
            }
            return (augmented, refs)
        } catch {
            lastError = "Attaching files failed: \(error.localizedDescription)"
            return (text, [])
        }
    }

    /// Materialize the attachment parts a remote (phone) turn carried into `id`'s working tree and
    /// fold their paths into the message, returning the rewritten turn (text-only, ready for the
    /// backend) and a `QueuedMessage` for display should the turn end up queued mid-flight. A turn
    /// with no attachment parts comes back unchanged with a `nil` display (the controller then
    /// derives its own from the plain text), so the ordinary text-only path is untouched.
    private func materializeRemoteAttachments(
        _ input: AgentInput, forSession id: SessionID
    ) async -> (AgentInput, QueuedMessage?) {
        let wireAttachments = input.attachments
        guard !wireAttachments.isEmpty else { return (input, nil) }
        let text = input.plainText ?? ""
        let worktree = await controllers[id]?.snapshot.session.worktreePath
        let pending = wireAttachments.map { PendingAttachment(filename: $0.filename, data: $0.data) }
        let (augmented, refs) = await augmentWithAttachments(text, pending, worktree: worktree)
        return (AgentInput(text: augmented), QueuedMessage(text: text, attachments: refs))
    }

    /// Cancel one of the open chat's queued (not-yet-sent) follow-ups by id, returning the
    /// removed message so the composer can refill its text and restore its attachment chips.
    /// Returns `nil` if nothing matched.
    @discardableResult
    public func cancelOpenQueuedMessage(id: QueuedMessage.ID) async -> QueuedMessage? {
        guard let sessionID = openSessionID, let controller = controllers[sessionID] else { return nil }
        let removed = await controller.cancelQueuedMessage(id: id)
        let snapshot = await controller.snapshot
        if sessionID == openSessionID { openSession = snapshot.session }
        broadcast(.sessionUpdated(wireSummary(for: snapshot.session, pendingApprovals: snapshot.pendingApprovals)))
        return removed
    }

    /// Revert the open chat to just before the user message at `seq`: wipe the transcript
    /// after it (and rewind the agent's memory, best-effort — see `SessionController.revert`)
    /// and return the reverted message's text so the composer can be refilled for editing.
    /// Returns `nil` if it isn't revertable (not a user message, or a turn is in flight).
    @discardableResult
    public func revertOpenSession(toSeq seq: UInt64) async -> String? {
        guard let sessionID = openSessionID, let controller = controllers[sessionID] else { return nil }
        guard let text = await controller.revert(toSeq: seq) else { return nil }
        // The transcript just *shrank*; `applyOpenTranscript` only ever grows, so set the
        // authoritative history directly (this is a deliberate, user-initiated rewrite).
        let history = await controller.transcriptSoFar()
        let snapshot = await controller.snapshot
        openTranscript = history
        openTranscriptVersion &+= 1
        openSession = snapshot.session
        openApprovals = snapshot.pendingApprovals
        lastIngestedSeq[sessionID] = snapshot.session.lastSeq
        upsertSummary(SessionSummary(snapshot.session, pendingApprovals: snapshot.pendingApprovals))
        return text
    }

    /// Refresh `sessionID`'s live diffstat so its sidebar change-count is current. A worktree session
    /// diffs its worktree against its base (`SessionController.refreshStatus`); an nvrsion session has
    /// no worktree (it shares the trunk, NVRSION §2), so instead the trunk is measured for the files
    /// this chat is the latest author of — the same set per-session promotion would ship (NVRSION §6).
    /// Best-effort: a transient git failure leaves the prior value in place; the next turn (or a
    /// manual refresh) recomputes it.
    private func refreshDiffStat(_ sessionID: SessionID, _ controller: SessionController) async {
        let session = await controller.snapshot.session
        if let project = projectsByID[session.projectID], isNvrsionSession(session, in: project),
           let nvrsion = project.nvrsion {
            let stat = await nvrsionTrunk.sessionDiffStat(
                root: project.rootPath, trunkBranch: nvrsion.trunkBranch,
                base: project.defaultBranch.value, session: sessionID)
            await controller.setLiveDiffStat(stat)
        } else {
            _ = try? await controller.refreshStatus()
        }
    }

    /// Recompute the open session's worktree status/diffstat on demand (a UI
    /// "refresh" affordance, and a deterministic settle point once a run is idle).
    public func refreshOpenStatus() async {
        guard let sessionID = openSessionID, let controller = controllers[sessionID] else { return }
        await refreshDiffStat(sessionID, controller)
        let snapshot = await controller.snapshot
        upsertSummary(SessionSummary(snapshot.session, pendingApprovals: snapshot.pendingApprovals))
    }

    public func interruptOpenSession() async {
        // A remote (peer Mac) session runs on its owner — interrupt it there (mesh session sync).
        if let hostID = openHostID, let sessionID = openSessionID {
            await peerClient?.sendCommand(.interrupt(sessionID), to: hostID)
            return
        }
        guard let sessionID = openSessionID, let controller = controllers[sessionID] else { return }
        await controller.interrupt()
    }

    public func renameOpenSession(to title: String) async {
        if await sendToOpenSessionOwner({ .renameSession($0, title) },
            onSent: { if var s = openSession { s.title = title; openSession = s } }) { return }
        await mutateOpenSession { await $0.rename(title) }
    }

    /// Set the model (nil = default) for the open session's next turn.
    public func setOpenSessionModel(_ model: String?) async {
        if await sendToOpenSessionOwner({ .setSessionModel($0, model) },
            onSent: { if var s = openSession { s.model = model; openSession = s } }) { return }
        await mutateOpenSession { await $0.setModel(model) }
    }

    /// Set the reasoning effort (nil = default) for the open session's next turn.
    public func setOpenSessionEffort(_ effort: String?) async {
        if await sendToOpenSessionOwner({ .setSessionEffort($0, effort) },
            onSent: { if var s = openSession { s.effort = effort; openSession = s } }) { return }
        await mutateOpenSession { await $0.setEffort(effort) }
    }

    /// Toggle the selected backend's auto-approval mode for the open session's next turn.
    public func setOpenSessionAuto(_ auto: Bool) async {
        if await sendToOpenSessionOwner({ .setSessionAuto($0, auto) },
            onSent: { if var s = openSession { s.auto = auto; openSession = s } }) { return }
        await mutateOpenSession { await $0.setAuto(auto) }
    }

    /// Toggle autoship for the open session. Enabling it also enables auto-approval
    /// (shipping requires an autonomous run); the controller couples the two.
    public func setOpenSessionAutoShip(_ autoShip: Bool) async {
        if await sendToOpenSessionOwner({ .setSessionAutoShip($0, autoShip) },
            onSent: { if var s = openSession { s.autoShip = autoShip; openSession = s } }) { return }
        await mutateOpenSession { await $0.setAutoShip(autoShip) }
    }

    /// Set the autoship destination branch for the open session. `nil` clears the override so
    /// the session inherits the project's autoship branch (the project root repo by default).
    public func setOpenSessionShipBranch(_ branch: String?) async {
        if await sendToOpenSessionOwner({ .setSessionShipBranch($0, branch) },
            onSent: { if var s = openSession { s.shipBranch = branch; openSession = s } }) { return }
        await mutateOpenSession { await $0.setShipBranch(branch) }
    }

    /// Archive/unarchive the *open* session wherever it lives — the detail view's Unarchive
    /// button routes here so it works for a peer Mac's chat exactly like a local one.
    public func setOpenSessionArchived(_ archived: Bool) async {
        if await sendToOpenSessionOwner({ .setArchived($0, archived) },
            onSent: { if var s = openSession { s.archived = archived; openSession = s } }) { return }
        guard let sessionID = openSessionID else { return }
        await setSessionArchived(sessionID, archived)
    }

    /// When the open session lives on a peer Mac (mesh session sync), route the verb `make`
    /// builds to its owner and return true; false means the open session is local (or none
    /// is open) and the caller should mutate its controller as ever. On delivery, `onSent`
    /// applies the caller's optimistic `openSession` touch-up (immediate header feedback; the
    /// owner's `sessionUpdated` broadcast confirms) and a `listSessions` pull converges the
    /// sidebar mirror even against an owner that predates the mutation broadcasts. When the
    /// peer is unreachable the failure is surfaced instead — no optimistic lie.
    private func sendToOpenSessionOwner(
        _ make: (SessionID) -> ClientMsg, onSent: () -> Void
    ) async -> Bool {
        guard let hostID = openHostID, let sessionID = openSessionID else { return false }
        if await peerClient?.sendCommand(make(sessionID), to: hostID) == true {
            onSent()
            await peerClient?.sendCommand(.listSessions, to: hostID)
        } else {
            lastError = "\(openSession?.title ?? "That chat")’s Mac isn’t reachable right now — nothing was changed."
        }
        return true
    }

    public func setSessionFavorite(_ id: SessionID, _ favorite: Bool) async {
        await mutateSession(id) { await $0.setFavorite(favorite) }
    }

    // MARK: - Origin-aware sidebar verbs (mesh session sync)
    // The sidebar's rows act through these summary-shaped overloads so the same context menu
    // and swipe actions work identically on local and remote sessions: a summary carrying a
    // `hostID` routes its verb to the owning Mac over the mesh (the same wire verbs a phone
    // uses; mesh Macs grant each other control scope), and the owner's `sessionUpdated` /
    // `sessionList` broadcasts refresh our mirrored copy. A local summary falls through to
    // the id-addressed local verb.

    public func setSessionFavorite(_ summary: SessionSummary, _ favorite: Bool) async {
        if let hostID = summary.hostID {
            await sendToPeer(.setFavorite(summary.id, favorite), host: hostID, about: summary.title)
            return
        }
        await setSessionFavorite(summary.id, favorite)
    }

    public func setSessionArchived(_ summary: SessionSummary, _ archived: Bool) async {
        if let hostID = summary.hostID {
            await sendToPeer(.setArchived(summary.id, archived), host: hostID, about: summary.title)
            // Archiving puts the chat away on its owner; match the local behavior of closing
            // it here if it's the one open. Origin-qualified: the raw id alone would also
            // close this Mac's open tombstone of a moved session (same `SessionID`).
            if archived, openSessionID == summary.id, openHostID == hostID { openSessionID = nil }
            return
        }
        await setSessionArchived(summary.id, archived)
    }

    public func deleteSession(_ summary: SessionSummary) async {
        if let hostID = summary.hostID {
            if openSessionID == summary.id, openHostID == hostID { openSessionID = nil }
            await sendToPeer(.deleteSession(summary.id), host: hostID, about: summary.title)
            return
        }
        await deleteSession(summary.id)
    }

    /// Send a session verb to its owning Mac, then pull a fresh session list from it so this
    /// Mac's mirror converges even against an owner that predates the mutation broadcasts
    /// (`listSessions` is a v1 verb whose reply replaces the mirrored list). Surfaces the
    /// failure when the peer isn't reachable instead of pretending the verb landed.
    private func sendToPeer(_ msg: ClientMsg, host hostID: String, about title: String) async {
        guard await peerClient?.sendCommand(msg, to: hostID) == true else {
            lastError = "“\(title)”’s Mac isn’t reachable right now — nothing was changed."
            return
        }
        await peerClient?.sendCommand(.listSessions, to: hostID)
    }

    /// Start a new chat in a project that lives on a peer Mac (mesh session sync) — the same
    /// wire verb a phone's composer uses, so the chat runs on the project's owner and appears
    /// in everyone's sidebar through the owner's broadcasts (with a list/dashboard pull as
    /// the convergence backstop for owners that don't broadcast creations).
    public func startRemoteChat(
        in project: ProjectSummary, message: String,
        model: String? = nil, effort: String? = nil, auto: Bool = false
    ) async {
        guard let hostID = project.hostID else { return }
        guard await peerClient?.sendCommand(.startChat(StartChatRequest(
            projectID: project.id, message: message, model: model, effort: effort,
            auto: auto)), to: hostID) == true else {
            lastError = "\(project.hostLabel ?? "That Mac") isn’t reachable right now — the chat wasn’t started."
            return
        }
        await peerClient?.sendCommand(.listSessions, to: hostID)
        await peerClient?.sendCommand(.listDashboard, to: hostID)
    }

    /// Manually mark a chat "Done" from the sidebar — the recovery action for a chat stuck
    /// reading "Awaiting Input" whose work is actually finished (e.g. it ended its last turn
    /// with a question the user has since handled out-of-band, or the classifier read it as
    /// awaiting when it wasn't). Flips the last turn's disposition to `.completed`, exactly as
    /// the agent's own completion classification would, so the row reads "Done" and drops out of
    /// "needs attention". A no-op unless the chat is idle at `.awaitingInput` — the only state the
    /// disposition refines (`setDisposition` enforces this) — so it can never override a running,
    /// blocked, or terminal chat. Deliberately does *not* autoship or release the chat's locks:
    /// beyond correcting the displayed state, it only applies the normal Done VM suspension policy.
    public func markSessionDone(_ id: SessionID) async {
        await mutateSession(id) { await $0.setDisposition(.completed) }
        await suspendVMIfCompleted(id)
    }

    public func setSessionArchived(_ id: SessionID, _ archived: Bool) async {
        await mutateSession(id) { await $0.setArchived(archived) }
        if archived {
            if openSessionID == id { openSessionID = nil }
            // Archiving puts the chat away — free its sandbox container immediately.
            if let name = await containerManager?.teardown(id, waitForActive: false) {
                reportSandboxCleanupFailures([name])
            }
            // …and its guest VM(s): a macOS/Linux VM holds far more host RAM (and a big on-disk
            // clone) than the container, so tear it down now rather than leaking it until launch.
            if let vmName = await macVMManager?.teardown(id, waitForActive: false) {
                reportVMCleanupFailure(vmName)
            }
            await reapSharedControlContainerIfUnused()
            // Orchestra cascade: putting a supervisor away puts its (still-active) workers away
            // too — they're reachable only through its Subagents panel, so left active they'd
            // keep counting toward active-chat rollups and holding containers. Recursive for a
            // worker that itself supervised. Deliberately not mirrored on unarchive: workers
            // are one-shot and done; restoring the supervisor shouldn't resurrect their
            // containers (the Subagents panel lists archived workers regardless).
            let workerIDs = summaries.filter { $0.spawnedBySessionID == id && !$0.archived }.map(\.id)
            for workerID in workerIDs {
                await setSessionArchived(workerID, true)
            }
        } else {
            // Unarchiving: if the cleanup sweep reclaimed this chat's worktree while it was
            // archived, re-create it from the preserved branch so the chat is workable again.
            await restoreReclaimedWorktree(id)
        }
    }

    /// Re-create a worktree the cleanup sweep reclaimed, when its chat is unarchived. A no-op
    /// unless the checkout is actually missing (so a normally-archived chat that never hit the
    /// cleanup interval reopens untouched). Because `restore` reuses the same path, branch, and
    /// `baseSHA`, the session's stored worktree fields and its controller's worktree stay valid
    /// — nothing else needs updating.
    private func restoreReclaimedWorktree(_ id: SessionID) async {
        guard let controller = controllers[id] else { return }
        let session = await controller.snapshot.session
        guard let path = session.worktreePath, let branch = session.branch,
              let baseSHA = session.baseSHA, let project = project(session.projectID),
              !FileManager.default.fileExists(atPath: path)
        else { return }
        let worktree = Worktree(
            sessionID: id, path: path, branch: branch, baseSHA: baseSHA, createdAt: session.createdAt)
        do { try await worktrees.restore(worktree, in: project) }
        catch { lastError = "Couldn't re-create the worktree for this chat: \(error)" }
    }

    /// Cascade re-target (LOCKING §5.2): when a parent session is removed, re-point each child's
    /// parent to the grandparent (the removed parent's own parent). `rootRef` — and thus the lock
    /// domain — is unchanged, so no locks move; the child releases against and updates from the
    /// grandparent from now on, and the reconcile loop re-checks it.
    private func cascadeChildren(of parentID: SessionID) async {
        guard let parent = await controllers[parentID]?.snapshot.session else { return }
        let grandRef = parent.parentRef
        let grandSessionID = parent.parentSessionID
        for (sid, controller) in controllers where sid != parentID {
            let child = await controller.snapshot.session
            guard child.parentSessionID == parentID else { continue }
            await mutateSession(sid) { await $0.setParent(ref: grandRef, sessionID: grandSessionID) }
            await controller.note(
                "Parent “\(parent.title)” was removed — now based on \(grandRef ?? "the project default").",
                icon: "arrow.triangle.branch")
        }
    }

    /// Delete a chat (any row, not just the open one): stop it, remove its worktree +
    /// branch + records.
    public func deleteSession(_ id: SessionID) async {
        // Never delete a session that's mid-transfer or a still-pending moved tombstone (mesh P5):
        // deleting the worktree/branch/row out from under the coordinator would race it, and
        // destroying the `.tombstoned` recovery state would strand a moved-but-unconfirmed session.
        guard !transferringSessions.contains(id) else {
            lastError = "This session is being moved to another Mac — try again once that finishes."
            return
        }
        // Claim the delete SYNCHRONOUSLY (before any await) so a `moveSession` for the same id can't
        // slip in during the async checks below and start a transfer we then delete out from under.
        deletingSessions.insert(id)
        defer { deletingSessions.remove(id) }
        if (try? await database.activeTransfer(sessionID: id)) != nil {
            lastError = "This session is being moved to another Mac — try again once that finishes."
            return
        }
        // Orchestra cascade: a supervisor's workers are reachable only through its Subagents
        // panel (the sidebar hides them — `isSubagent`), so they'd be stranded invisible if the
        // supervisor went away alone. Delete them with it, recursively covering a worker that
        // itself supervised. Ids are snapshotted first because each delete mutates `summaries`.
        let workerIDs = summaries.filter { $0.spawnedBySessionID == id }.map(\.id)
        for workerID in workerIDs {
            await deleteSession(workerID)
        }
        await cascadeChildren(of: id)  // re-target nested children before this parent goes away
        if let controller = controllers[id] {
            do { try await controller.discard(force: true) }
            catch { lastError = "Delete failed: \(error)" }
        }
        if let name = await containerManager?.teardown(id, waitForActive: false) {
            reportSandboxCleanupFailures([name])
        }
        // Delete is permanent — remove the chat's guest VM(s) and their on-disk clones too.
        if let vmName = await macVMManager?.teardown(id, waitForActive: false) {
            reportVMCleanupFailure(vmName)
        }
        observers[id]?.cancel()
        observers[id] = nil
        controllers[id] = nil
        forgetShipState(id)
        try? await database.deleteSession(id: id)
        summaries.removeAll { $0.id == id }
        updateAlarm(clearing: id)  // a deleted chat can't be returned to — don't keep ringing for it
        if openSessionID == id { openSessionID = nil }
        broadcastSessionListChanged()
        await reapSharedControlContainerIfUnused()
    }

    /// Push the whole refreshed session list to connected viewers (phones, peer Macs
    /// mirroring this sidebar). Removals have no per-session "updated" shape — a deletion, a
    /// project archive/delete, a transfer hiding or restoring a session — so the list itself
    /// is the broadcast; clients replace their mirror wholesale (the same handling they do on
    /// connect). Skipped (cheaply) when no sync server is up.
    private func broadcastSessionListChanged() {
        guard syncBroadcastContinuation != nil else { return }
        Task { broadcast(.sessionList(await sessionSummaries())) }
    }

    private func mutateOpenSession(_ body: (SessionController) async -> Void) async {
        guard let sessionID = openSessionID else { return }
        await mutateSession(sessionID, body)
    }

    private func mutateSession(_ id: SessionID, _ body: (SessionController) async -> Void) async {
        guard let controller = controllers[id] else { return }
        await body(controller)
        let snapshot = await controller.snapshot
        if id == openSessionID { openSession = snapshot.session }
        upsertSummary(SessionSummary(snapshot.session, pendingApprovals: snapshot.pendingApprovals))
        // These mutations (favorite/archive/rename/disposition) emit no transcript event, so
        // push the refreshed summary explicitly — connected viewers (phones, peer Macs
        // mirroring this sidebar) would otherwise not see the change until the next event.
        broadcast(.sessionUpdated(
            wireSummary(for: snapshot.session, pendingApprovals: snapshot.pendingApprovals)))
    }

    /// Append a system log line to the open session's chat (e.g. header git actions).
    /// No-op if there's no open session.
    public func noteToOpenSession(_ text: String, icon: String? = nil) async {
        guard let sessionID = openSessionID, let controller = controllers[sessionID] else { return }
        await controller.note(text, icon: icon)
    }

    @discardableResult
    public func integrateOpenSession(strategy: IntegrationStrategy) async -> IntegrationResult? {
        guard let sessionID = openSessionID, let controller = controllers[sessionID] else { return nil }
        do {
            let result = try await controller.integrate(strategy: strategy)
            if case .clean = result { await lockManager.releaseAll(sessionID) }  // mediated release
            return result
        } catch { lastError = "Integrate failed: \(error)"; return nil }
    }

    /// Full Markdown export of the open session (metadata, backend/debug fields,
    /// conversation, and raw event JSONL) plus a suggested filename.
    public func exportOpenSession() async -> (filename: String, contents: String)? {
        guard let sessionID = openSessionID, let controller = controllers[sessionID] else { return nil }
        let snapshot = await controller.snapshot
        let events = await controller.transcriptSoFar()
        let project = projectsByID[snapshot.session.projectID]
        return (
            ConversationExport.suggestedFileName(for: snapshot.session),
            ConversationExport.markdown(session: snapshot.session, project: project, events: events))
    }

    public func discardOpenSession() async {
        guard let sessionID = openSessionID else { return }
        // A remote (peer Mac) session is deleted on its owner (mesh session sync) — the local
        // path would close the view while deleting nothing. The owner's sessionList broadcast
        // (or the listSessions pull, for older owners) drops the mirrored row.
        if let hostID = openHostID {
            let title = openSession?.title ?? "That chat"
            openSessionID = nil
            await sendToPeer(.deleteSession(sessionID), host: hostID, about: title)
            return
        }
        await deleteSession(sessionID)
    }

    // MARK: - Subscription quota

    /// Begin polling the Pro/Max subscription usage endpoint into `subscriptionUsage`
    /// (idempotent — the app calls it once at launch). Fetches immediately, then every
    /// 2 minutes; turn completions also trigger a (throttled) out-of-band refresh (see
    /// `ingestUI`). The window only moves slowly, so a gentle cadence is plenty and
    /// avoids rate-limiting the endpoint.
    public func startQuotaPolling() {
        guard quotaPollTask == nil else { return }
        quotaPollTask = Task { [weak self] in
            while !Task.isCancelled {
                await self?.refreshSubscriptionUsage()
                try? await Task.sleep(for: .seconds(120))
            }
        }
    }

    // MARK: - Service status feeds

    /// Begin polling the watched providers' status feeds into `statusFeeds` (idempotent —
    /// the app calls it once at launch). Fetches immediately, then every 30 seconds so a new
    /// incident — or its resolution — surfaces in the indicator with minimal latency. The
    /// feeds are small CDN-served Atom/RSS files built for polling, so a tight cadence over a
    /// handful of providers is cheap; a provider that fails keeps its last good value.
    public func startStatusPolling() {
        guard statusPollTask == nil else { return }
        statusPollTask = Task { [weak self] in
            while !Task.isCancelled {
                await self?.refreshStatusFeeds()
                try? await Task.sleep(for: .seconds(30))
            }
        }
    }

    /// Fetch every provider's feed concurrently and replace `statusFeeds`. Providers are
    /// independent: one that fails (offline, feed moved, 5xx) keeps its last good value
    /// rather than dropping out, so a single flaky page never blanks the other.
    public func refreshStatusFeeds() async {
        // Poll only enabled providers; keep `allCases` order so the indicator lists them
        // consistently (Claude · OpenAI · xAI).
        let providers = StatusProvider.allCases.filter(enabledStatusProviders.contains)
        var fetched: [StatusProvider: StatusFeed] = [:]
        await withTaskGroup(of: StatusFeed?.self) { group in
            for provider in providers {
                group.addTask { try? await StatusFeedFetcher.fetch(provider) }
            }
            for await feed in group {
                if let feed { fetched[feed.provider] = feed }
            }
        }
        let before = statusFeeds
        statusFeeds = providers.compactMap { provider in
            fetched[provider] ?? statusFeeds.first { $0.provider == provider }
        }
        revertModelFailoverIfRecovered()
        // Paired phones mirror the status pill off the dashboard; re-push on change only,
        // so the 30s poll doesn't re-send an identical snapshot each tick.
        if statusFeeds != before { broadcast(.dashboard(await dashboardSnapshot())) }
    }

    /// Force-refresh a single provider's feed because one of its agents just errored — an agent
    /// error (an `api_retry`/overload storm, a failed run) is often the first sign of a provider
    /// incident, and it can beat the 30s poll, so we pull that provider's status immediately. The
    /// window can only *narrow* an incident's latency, never hide one: on fetch failure the last
    /// good feed is kept, exactly like the poll. Coalesced per provider via `lastErrorStatusRefresh`
    /// so an error storm can't hammer the page. No-op until polling has started (so a launch-time
    /// error doesn't fire a premature fetch) or if the provider isn't enabled.
    public func refreshStatusFeedOnError(for provider: StatusProvider) async {
        guard statusPollTask != nil, enabledStatusProviders.contains(provider) else { return }
        let at = now()
        if let last = lastErrorStatusRefresh[provider],
            at.timeIntervalSince(last) < Self.errorStatusRefreshInterval
        {
            return
        }
        lastErrorStatusRefresh[provider] = at
        guard let feed = try? await StatusFeedFetcher.fetch(provider) else { return }
        // Splice the fresh feed in, keeping the other providers' last-good values and `allCases`
        // order so the indicator stays stable.
        statusFeeds = StatusProvider.allCases
            .filter(enabledStatusProviders.contains)
            .compactMap { p in
                p == provider ? feed : statusFeeds.first { $0.provider == p }
            }
        revertModelFailoverIfRecovered()
    }

    /// Set which providers' status is polled and surfaced (from Settings). Drops any
    /// now-disabled provider's feed immediately so the indicator updates without waiting for
    /// the next poll, then refreshes to pick up any newly enabled ones (only once polling has
    /// started, so configuring this at launch doesn't fire a premature fetch).
    public func setEnabledStatusProviders(_ providers: Set<StatusProvider>) {
        guard providers != enabledStatusProviders else { return }
        enabledStatusProviders = providers
        statusFeeds = statusFeeds.filter { providers.contains($0.provider) }
        if statusPollTask != nil {
            Task { [weak self] in await self?.refreshStatusFeeds() }
        }
    }

    /// The watched-component status for a provider, if it's been fetched yet.
    public func statusFeed(for provider: StatusProvider) -> StatusFeed? {
        statusFeeds.first { $0.provider == provider }
    }

    /// Whether a provider currently has an unresolved incident on one of its watched
    /// components. False when its feed hasn't been fetched, so we never cry wolf on no data.
    public func providerHasActiveIncident(_ provider: StatusProvider) -> Bool {
        statusFeed(for: provider)?.hasActiveIncident ?? false
    }

    /// Temporarily switch the default model to `alternative` (on a healthy provider) while
    /// `original`'s provider is down, remembering `original` so it can be restored. Called by
    /// the UI when the user accepts the failover offer (see `ModelFailoverBanner`).
    public func engageModelFailover(original: String, alternative: String) {
        modelFailover = ModelFailover(original: original, alternative: alternative)
        defaultModel = alternative
    }

    /// Undo an in-effect failover immediately, restoring the original default model. Used by
    /// the "switch back now" action; recovery otherwise reverts automatically.
    public func cancelModelFailover() {
        if let failover = modelFailover, defaultModel == failover.alternative {
            defaultModel = failover.original
        }
        modelFailover = nil
    }

    /// Restore the original default model once its provider has recovered — the "…until the
    /// provider resolves its issues" half of failover. Only reverts while our switch is still
    /// in place (the user hasn't since picked a different default); otherwise it just clears
    /// the stale failover record. A no-op when no failover is active.
    private func revertModelFailoverIfRecovered() {
        guard let failover = modelFailover else { return }
        guard let provider = StatusProvider.forModel(failover.original),
              !providerHasActiveIncident(provider) else { return }
        if defaultModel == failover.alternative { defaultModel = failover.original }
        modelFailover = nil
    }

    // MARK: - Auto-archive

    /// The running auto-archive sweep loop; nil until `startAutoArchivePolling()`.
    private var autoArchiveTask: Task<Void, Never>?

    /// How often the auto-archive sweep runs. The idle threshold is far coarser than
    /// this (30 minutes at the shortest), so a minute's granularity keeps archiving
    /// prompt without busy-work.
    private let autoArchiveSweepInterval: Duration = .seconds(60)

    /// Begin the periodic auto-archive sweep (idempotent — the app calls it once at
    /// launch). The loop runs for the app's lifetime regardless of the current setting,
    /// so changing the threshold (or turning it back on) in Settings takes effect
    /// without a relaunch; when `autoArchiveIdleInterval` is nil ("Never") each pass is
    /// a no-op.
    public func startAutoArchivePolling() {
        guard autoArchiveTask == nil else { return }
        autoArchiveTask = Task { [weak self] in
            while !Task.isCancelled {
                await self?.sweepAutoArchive()
                // Reclaiming worktrees of long-archived chats rides the same loop: its
                // threshold is far coarser (an hour at the shortest), so a minute's
                // granularity is plenty and avoids a second timer.
                await self?.sweepArchivedWorktrees()
                try? await Task.sleep(for: self?.autoArchiveSweepInterval ?? .seconds(60))
            }
        }
    }

    /// One auto-archive pass. Archives every chat that is *done* — a finished run, or
    /// an interactive chat whose last turn was classified `.completed` — and that has
    /// sat untouched longer than `autoArchiveIdleInterval`. Favorites and the chat
    /// you currently have open are spared, and anything that still needs you (a turn
    /// that ended asking a question, a pending approval, or a turn not yet classified)
    /// is deliberately left alone so nothing requiring a reply gets buried.
    func sweepAutoArchive() async {
        guard let interval = autoArchiveIdleInterval else { return }
        let cutoff = now().addingTimeInterval(-interval)
        let stale = summaries.filter { summary in
            summary.isCompleted && !summary.archived && !summary.favorite
                && summary.id != openSessionID && summary.updatedAt <= cutoff
                // Ephemeral (Scratch) chats have their own discard-on-close lifecycle — never
                // auto-archive them (they don't live in the sidebar this sweep tidies).
                && summary.projectID != Self.ephemeralProjectID
        }
        for summary in stale {
            await setSessionArchived(summary.id, true)
        }
    }

    /// Reclaim ephemeral scratch directories with no live session — orphans left by a crash or by a
    /// truly-ephemeral chat whose window never got a clean close. Run at launch, after `loadSessions`
    /// has reconstructed every persisted (Saved) ephemeral chat into a controller, so any scratch dir
    /// not backed by a controller is genuinely dead. Best-effort; failures are retried next launch.
    public func sweepOrphanedScratchDirs() {
        let fm = FileManager.default
        guard let dirs = try? fm.contentsOfDirectory(
            at: ephemeralScratchBase, includingPropertiesForKeys: nil) else { return }
        let live = Set(controllers.keys.map(\.rawValue))
        for dir in dirs where !live.contains(dir.lastPathComponent) {
            try? fm.removeItem(at: dir)
        }
    }

    /// One archived-worktree cleanup pass. Reclaims the worktree checkout of every chat that
    /// has stayed archived longer than `archivedWorktreeCleanupInterval`, freeing the disk a
    /// full checkout occupies. The branch (and any uncommitted work, committed as WIP first)
    /// is kept, so unarchiving the chat re-creates the worktree exactly — see
    /// `setSessionArchived(_:false)`. Worktree-less chats (no `baseSHA` — they ran in the main
    /// checkout) have nothing to remove and are skipped, as is anything whose directory is
    /// already gone. Each reclaim is best-effort; a transient git failure is retried next pass.
    func sweepArchivedWorktrees() async {
        guard let interval = archivedWorktreeCleanupInterval else { return }
        let cutoff = now().addingTimeInterval(-interval)
        // A session mid-transfer must never have its worktree reclaimed/committed-WIP under it
        // (mesh P5) — the transfer owns the worktree until it discards it post-commit.
        let transferring = Set((try? await database.allActiveTransfers())?.map(\.sessionID) ?? [])
        for (id, controller) in controllers {
            let session = await controller.snapshot.session
            guard !transferring.contains(session.id),
                  session.archived, let archivedAt = session.archivedAt, archivedAt <= cutoff,
                  let path = session.worktreePath, let branch = session.branch,
                  let baseSHA = session.baseSHA,
                  FileManager.default.fileExists(atPath: path),
                  let project = project(session.projectID)
            else { continue }
            let worktree = Worktree(
                sessionID: id, path: path, branch: branch, baseSHA: baseSHA,
                createdAt: session.createdAt)
            do { try await worktrees.reclaim(worktree, in: project) }
            catch { /* best-effort — leave it for the next sweep */ }
        }
    }

    /// Fetch subscription usage once, subject to throttling. Skips if a fetch ran within
    /// `minInterval` (coalesces the per-turn refreshes that parallel sessions fire) or if
    /// we're in a post-429 backoff. On a 429 it backs off for 5 minutes; any other
    /// failure (logged out, offline, token expired, endpoint change) just leaves the last
    /// good value in place — the next `claude` run refreshes the keychain token and the
    /// next poll recovers.
    public func refreshSubscriptionUsage(minInterval: TimeInterval = 90) async {
        let now = Date()
        if let backoff = usageBackoffUntil, now < backoff { return }
        if let last = lastUsageFetchAt, now.timeIntervalSince(last) < minInterval { return }
        lastUsageFetchAt = now
        do {
            let before = subscriptionUsage
            subscriptionUsage = try await SubscriptionUsageFetcher.fetch()
            usageBackoffUntil = nil
            // Paired phones mirror these gauges off the dashboard; re-push on change only,
            // so the steady-state poll doesn't re-send an identical snapshot every 2 min.
            if subscriptionUsage != before { broadcast(.dashboard(await dashboardSnapshot())) }
        } catch {
            if case SubscriptionUsageError.http(429) = error {
                usageBackoffUntil = now.addingTimeInterval(300)
            }
        }
    }

    // MARK: - Observation bridge (controller → @Observable state)

    private func observe(_ controller: SessionController, _ sessionID: SessionID) {
        observers[sessionID]?.cancel()
        observers[sessionID] = Task { [weak self] in
            let stream = await controller.subscribe()
            for await event in stream {
                guard let self else { return }
                await self.ingestUI(sessionID, event)
            }
        }
    }

    private func ingestUI(_ sessionID: SessionID, _ event: AgentEvent) async {
        guard let controller = controllers[sessionID] else { return }
        await handleNvrsionEvent(sessionID, controller, event)
        // The account-wide rate-limit window the CLI reports out of band: mirror the
        // latest one as the coarse fallback for the global quota indicator.
        if case .rateLimit(let rl) = event.kind { latestRateLimit = rl }
        // Codex pushes its subscription windows the same out-of-band way (there's no endpoint
        // to poll): keep the freshest snapshot for the Codex quota card, and re-push the
        // dashboard to paired phones only when it actually changed.
        if case .codexUsage(let usage) = event.kind, usage != codexUsage {
            codexUsage = usage
            broadcast(.dashboard(await dashboardSnapshot()))
        }
        // A finished turn just consumed quota; refresh the precise subscription usage
        // so the indicator reflects it promptly rather than waiting for the next poll.
        if case .runFinished = event.kind, quotaPollTask != nil {
            Task { await refreshSubscriptionUsage() }
        }
        // Recompute the live diff at turn boundaries so the summary's diffstat is fresh — for an
        // nvrsion session (no worktree) this measures its per-session trunk contribution instead.
        switch event.kind {
        case .turnCompleted, .runFinished:
            await refreshDiffStat(sessionID, controller)
        default:
            break
        }
        // A finished turn left the session idle at .awaitingInput; classify (for every
        // session, not just the open one) whether the agent is actually waiting on the
        // user or has finished, so the sidebar's "Awaiting input"/"Done" is accurate.
        if case .runFinished(let finished) = event.kind {
            classifyDisposition(sessionID, controller, outcome: finished.outcome)
        }
        // A finished turn is the first point where the agent's actions can sharpen a
        // weak first-message title; attempt the one-time post-run rename.
        if case .runFinished = event.kind { maybeRenameSession(sessionID, controller) }
        // Orchestra supervisor turn ended: dissolve this turn's live worker channel. A supervisor's
        // `nucleic_supervise`/`nucleic_reply_to_worker` tools live only for the turn (the CLI
        // re-applies `--allowedTools` each turn), so the coordinator that backs them must end with
        // the turn too. `teardownOrchestra` force-drains — it answers any worker still parked in
        // `nucleic_ask_supervisor` with "supervisor unavailable" and interrupts any worker still
        // running — so no worker outlives, or hangs waiting on, a supervisor whose turn is over,
        // regardless of whether the model finished its supervise loop. A clean completion (workers
        // already done) is a cheap no-op. Only supervisors own a coordinator, so this is inert for
        // ordinary sessions and for workers. A follow-up supervisor turn builds a fresh coordinator.
        if case .runFinished = event.kind, orchestraCoordinators[sessionID] != nil {
            await teardownOrchestra(supervisor: sessionID)
        }
        let snapshot = await controller.snapshot
        // An agent error is often the first sign of a provider-side incident (an api_retry/overload
        // storm during an Anthropic outage, a failed run) and can beat the 30s status poll. Pull
        // that provider's status feed now so the incident indicator lights up promptly; the call
        // coalesces storms and no-ops when polling is off or the provider is disabled.
        if case .error = event.kind, let provider = StatusProvider.forBackend(snapshot.session.backend) {
            Task { [weak self] in await self?.refreshStatusFeedOnError(for: provider) }
        }
        upsertSummary(SessionSummary(snapshot.session, pendingApprovals: snapshot.pendingApprovals))
        // This event may have flipped whether any chat in the project is mid-turn — the gate the
        // auto-integrate countdown waits on. Re-evaluate it: a turn ending in the last working chat
        // arms the countdown; a turn starting cancels it.
        reconsiderNvrsionAutoIntegration(snapshot.session.projectID)
        // A finished run (or a fresh turn) flips whether the chat is completed; refresh the
        // unread dot now. Disposition-classified completion is handled in classifyDisposition.
        await reconcileUnseenCompletion(sessionID, snapshot)

        // Project to any connected iPhone (SYNC §5): the event itself, the dedicated
        // approval push for approval kinds, and the updated session summary. Skipped
        // entirely (including the wire-summary construction) when no sync server is up —
        // the common case — so a streaming burst doesn't build wire records nobody reads.
        if syncBroadcastContinuation != nil {
            broadcast(.events(sessionID, [event]))
            switch event.kind {
            case .approvalRequested(let req):
                broadcast(.approvalRequested(req))
            case .approvalResolved(let res): broadcast(.approvalResolved(res))
            default: break
            }
            broadcast(.sessionUpdated(wireSummary(for: snapshot.session, pendingApprovals: snapshot.pendingApprovals)))
        }
        // A finished turn shifts the aggregate counts (active chats, messages); refresh the
        // dashboard caches and push so the phone's Home/Projects stay live without polling.
        if case .runFinished = event.kind, syncBroadcastContinuation != nil {
            await loadDashboard()
            broadcast(.dashboard(await dashboardSnapshot()))
        }

        // A `toolCallInputDelta` streams a tool call's argument JSON one fragment at a time.
        // The transcript projection deliberately ignores these (the tool name shows on start,
        // the authoritative full input lands on completion — see TranscriptProjection.flatItems),
        // so a delta changes nothing on screen. But each one still grows `history`, and the open
        // session block below would, per fragment, re-pull the whole history, re-run the
        // O(transcript) tool-group summary pass, and bump `openTranscriptVersion` — which forces
        // the (deliberately eager, non-lazy) transcript VStack to re-lay-out *every* row. A large
        // or streaming tool input (a big Write/MultiEdit, a long heredoc, a fat Task prompt) emits
        // hundreds of these fragments in a tight burst on the main actor, so on a long chat that
        // burst wedges the main thread for seconds — the beach ball, while Core Animation keeps the
        // status spinner turning. Skip the display work for input deltas; the next event that can
        // actually change the view pulls the full history (these fragments included) in one pass.
        let eventChangesDisplay: Bool = {
            if case .toolCallInputDelta = event.kind { return false }
            return true
        }()
        if sessionID == openSessionID, eventChangesDisplay {
            // A turn end must regenerate the summary card even if its refresh is folded
            // into a coalesced window — record it, keyed to the session so a chat switch
            // mid-window can't leak the regen into the newly opened chat.
            if case .runFinished = event.kind { openDisplayPendingTurnEnd = sessionID }
            await refreshOpenDisplayThrottled(sessionID, controller)
        }
        // Record how far the observer has drained this session's event stream — any
        // work this event spawned (e.g. classification on runFinished) has been issued
        // by now, so awaitOpenSessionSettled can tell when it's safe to drain tasks.
        lastIngestedSeq[sessionID] = max(lastIngestedSeq[sessionID] ?? 0, event.seq)
    }

    /// Refresh the open session's display state at most once per
    /// `openDisplayRefreshInterval`. The refresh is the expensive tail of ingestion — a
    /// full-history pull, the whole-transcript reassign (which forces the eager transcript
    /// VStack to re-lay-out every row), and the O(transcript) tool-group summary pass — and
    /// a streaming turn emits events far faster than any of it is worth repeating. The first
    /// event after a quiet spell refreshes immediately (a lone approval or user message
    /// appears with no added latency); events landing inside the window coalesce into one
    /// trailing catch-up flush, so a burst costs a handful of O(n) passes instead of one per
    /// event while the transcript still visibly streams.
    private func refreshOpenDisplayThrottled(_ sessionID: SessionID, _ controller: SessionController) async {
        if let last = lastOpenDisplayRefreshAt,
           ContinuousClock.now - last < Self.openDisplayRefreshInterval {
            // Inside the window — one pending flush covers every event that lands here.
            guard openDisplayCatchUpTask == nil else { return }
            openDisplayCatchUpTask = Task { [weak self] in
                try? await Task.sleep(for: Self.openDisplayRefreshInterval)
                guard let self else { return }
                self.openDisplayCatchUpTask = nil
                guard let controller = self.controllers[sessionID] else { return }
                await self.refreshOpenDisplayNow(sessionID, controller)
            }
            return
        }
        await refreshOpenDisplayNow(sessionID, controller)
    }

    /// The display refresh itself. Pulls the full authoritative history *before* mutating
    /// any @Observable state (rather than appending single events): combined with
    /// `applyOpenTranscript`'s monotonic guard, this is robust against interleaving with
    /// reloadOpen (both only ever grow the list). Reading everything up front also keeps
    /// `openSession` (which drives the "Working…" card) and `openTranscript` in the same
    /// synchronous main-actor turn — an await sandwiched between the two writes let other
    /// work interleave there under main-actor contention, rendering a running session over
    /// an empty transcript until ingest resumed.
    private func refreshOpenDisplayNow(_ sessionID: SessionID, _ controller: SessionController) async {
        guard sessionID == openSessionID else { return }
        // Stamp the window at the *start* so events arriving while the pulls below are in
        // flight coalesce into the trailing flush instead of piling up their own refreshes.
        lastOpenDisplayRefreshAt = .now
        let turnEnded = openDisplayPendingTurnEnd == sessionID
        openDisplayPendingTurnEnd = nil
        let snapshot = await controller.snapshot
        let history = await controller.transcriptSoFar()
        guard sessionID == openSessionID else { return }
        // Equality-gated writes: with @Observable, assigning an identical value still
        // invalidates every view that read the property — and most events change the
        // transcript without changing the session record or the approval queue.
        if openSession != snapshot.session { openSession = snapshot.session }
        applyOpenTranscript(history, for: sessionID)
        if openApprovals != snapshot.pendingApprovals { openApprovals = snapshot.pendingApprovals }
        regenerateToolGroupSummaries()  // summarize any newly-settled tool-call groups
        if turnEnded { regenerateSummary() }
    }

    private func reloadOpen() async {
        // A remote (peer Mac) session streams via `streamRemoteSession`, not a local controller.
        guard openHostID == nil else { return }
        guard let sessionID = openSessionID, let controller = controllers[sessionID] else { return }
        // Opening a chat means the user sees its autoship failure note (if any), so retire
        // the sidebar's sticky "autoship failed" icon before we snapshot for display.
        await clearAutoShipFailedSeen(sessionID)
        let snapshot = await controller.snapshot
        // Opening a chat marks it seen: clear any "completed but unopened" unread dot.
        await reconcileUnseenCompletion(sessionID, snapshot)
        let events = await controller.transcriptSoFar()
        applyOpenTranscript(events, for: sessionID)
        if sessionID == openSessionID {
            // Baseline the tool groups present at open as "history" for a chat carried
            // over from a previous run, so opening it doesn't retroactively AI-summarize
            // its whole transcript. A chat created this run has no history — leave it empty
            // so its (live) groups summarize normally.
            historicalToolGroupSignatures = sessionsCreatedThisRun.contains(sessionID)
                ? []
                : Set(TranscriptProjection.summarizableUnits(events).map(\.signature))
            openSession = snapshot.session
            openApprovals = snapshot.pendingApprovals
            // History is never AFM-summarized, so give its eligible families their final
            // heuristic line up front — otherwise the view's loading placeholder would spin
            // forever waiting on an AI line that's never generated.
            seedHistoricalToolGroupLines(events, root: snapshot.session.worktreePath)
            regenerateToolGroupSummaries()  // summarize the reopened transcript's tool-call groups
            // Show the cached summary immediately; only regenerate if we've never
            // produced one for this chat (otherwise it refreshes on each turn end).
            if let cached = snapshot.session.summary, !cached.isEmpty {
                openSummary = cached
            } else {
                regenerateSummary()
            }
        }
    }

    // MARK: - Open a remote (peer Mac) session (mesh session sync)

    /// Open any session the sidebar shows — local or on a peer Mac — from its summary row.
    /// The summary's `hostID` is the row's own origin, so this stays correct even when the
    /// same `SessionID` exists both as a local moved-away tombstone and as the live session
    /// mirrored back from its new owner (the ID-only `openSessionID` path prefers the local
    /// record in that collision).
    public func openSession(_ summary: SessionSummary) {
        guard let hostID = summary.hostID else {
            openSessionID = summary.id
            return
        }
        if let wire = meshPeers.first(where: { $0.deviceID == hostID })?
            .sessions.first(where: { $0.sessionID == summary.id }) {
            openRemoteSession(wire, on: hostID)
        }
    }

    /// Open a session that lives on a peer Mac: stream its transcript + approvals here so the
    /// detail view renders it, and route interaction to the owner. Assigning `openSessionID`
    /// routes remotely on its own now (see its `didSet`); the explicit wiring below covers
    /// what the roster lookup can't: a summary that hasn't landed in `meshPeers` yet, a live
    /// twin shadowed by its local tombstone, the lookup binding a moved session to the wrong
    /// peer (the caller's `deviceID` is authoritative — it's the row the user clicked), and a
    /// same-session re-click after the owning Mac dropped and came back (the didSet no-ops on
    /// a same-value assignment, so the re-click is the user's re-subscribe action).
    public func openRemoteSession(_ summary: WireSessionSummary, on deviceID: String) {
        let sameSelection = openSessionID == summary.sessionID
        openSessionID = summary.sessionID       // didSet resets state + routes if resolvable
        // A changed selection the didSet already routed to this same host must not
        // double-subscribe; everything else re-wires to the caller's host.
        if !sameSelection, openHostID == deviceID { return }
        openHostID = deviceID
        openSession = Self.remoteSession(from: summary)
        let sessionID = summary.sessionID
        remoteSessionTask?.cancel()
        remoteSessionTask = Task { [weak self] in await self?.streamRemoteSession(sessionID, on: deviceID) }
    }

    private func streamRemoteSession(_ sessionID: SessionID, on deviceID: String) async {
        guard let pc = peerClient else { return }
        // Cancelled before ever running (openRemoteSession re-wired to a different host in
        // the same main-actor turn the didSet spawned this) — don't subscribe at all.
        guard !Task.isCancelled else { return }
        // Full-transcript mesh sync: seed the view from the local mirror (full history, instant)
        // before the live stream lands, and resume the subscription from what we already hold so
        // the host replays only the gap — no 200-event cap, no hole. With no mirror, `sinceSeq: 0`
        // pulls the whole transcript on demand (what the user opening a peer session expects).
        var seededSeq: UInt64 = 0
        if let url = await mirrorStore.url(sessionID),
           let mirrored = try? TranscriptReader(url: url).read().events, !mirrored.isEmpty {
            applyOpenTranscript(mirrored, for: sessionID)
            seededSeq = mirrored.last?.seq ?? 0
        }
        let stream = await pc.openRemoteSession(sessionID, on: deviceID, sinceSeq: seededSeq)
        for await update in stream {
            guard openSessionID == sessionID, openHostID == deviceID else { break }
            switch update {
            case .snapshot(let snap):
                // Merge (not replace) so the mirror-seeded history survives — the host's replay
                // covers only events after our cursor.
                let known = Set(openTranscript.map(\.seq))
                let merged = openTranscript + snap.recentEvents.filter { !known.contains($0.seq) }
                applyOpenTranscript(merged, for: sessionID)
                openApprovals = snap.pendingApprovals
                // Seed the owner's already-computed Bash summary lines (mesh session sync) so a
                // card collapsed on the owner reads the same here, not a spinning placeholder.
                applyRemoteToolSummaries(snap.toolSummaries, for: sessionID)
                if let s = meshPeers.first(where: { $0.deviceID == deviceID })?
                    .sessions.first(where: { $0.sessionID == sessionID }) {
                    openSession = Self.remoteSession(from: s)
                }
            case .events(let batch):
                let known = Set(openTranscript.map(\.seq))
                let merged = openTranscript + batch.filter { !known.contains($0.seq) }
                applyOpenTranscript(merged, for: sessionID)
            case .approvalRequested(let req):
                if !openApprovals.contains(where: { $0.id == req.id }) { openApprovals.append(req) }
            case .approvalResolved(let res):
                openApprovals.removeAll { $0.id == res.id }
            case .toolSummaries(let lines):
                // Live update as the owner (re)computes a line for this session.
                applyRemoteToolSummaries(lines, for: sessionID)
            case .disconnected:
                lastError = "\(openSession?.title ?? "That chat")'s Mac went offline."
                return
            }
        }
    }

    /// A display-only `Session` reconstructed from a peer's mirrored summary (mesh session sync) — it
    /// drives the detail header; the transcript streams into `openTranscript`. No local worktree or
    /// transcript file exist for it, so those are nil/empty.
    static func remoteSession(from s: WireSessionSummary) -> Session {
        Session(
            id: s.sessionID, projectID: ProjectID(rawValue: s.projectID), backend: s.backend,
            title: s.title, status: s.status, worktreePath: nil, branch: s.branch,
            model: s.model, effort: s.effort, lastSeq: s.lastSeq, transcriptPath: "",
            diffStat: s.diffStat,
            // Carry the owner's automation truth — the header renders these, and a default
            // `auto: false` here made the detail view's control-project reconcile fire a
            // spurious setSessionAuto(true) at the owner on every open.
            auto: s.auto, autoShip: s.autoShip, shipBranch: s.shipBranch,
            favorite: s.favorite, archived: s.archived,
            queuedMessage: s.queuedMessage, queuedMessages: s.queuedMessages,
            createdAt: s.updatedAt, updatedAt: s.updatedAt)
    }

    // MARK: - Intelligence: summary + auto-naming

    /// Regenerate the open session's summary card (called on open, on turn end, or
    /// from the card's refresh button).
    public func regenerateOpenSummary() { regenerateSummary() }

    /// Await the open session's latest run to be fully observed and all the async work
    /// it feeds — transcript ingestion, turn classification, and summary generation —
    /// to quiesce. A test hook so assertions on the open transcript, summary state, or
    /// `intelligence` call counts don't race the async pipelines behind them.
    ///
    /// This is fully deterministic, not a sleep: it first joins the open session's run
    /// so every event has been emitted, then waits for the event observer to actually
    /// ingest up through that run's terminal seq (the observer lags the controller —
    /// its `runFinished` ingestion is what fills `openTranscript` and spawns
    /// classification). Once the observer has caught up, every follow-on task has been
    /// *issued*; the final loop drains them, re-checking `intelligenceWorkSeq` because
    /// they chain (a reload or a classification each spawns a summary) until none remain.
    func awaitOpenSessionSettled() async {
        if let sessionID = openSessionID, let controller = controllers[sessionID] {
            await controller.join()  // run done at the source: all events fanned out
            let finalSeq = await controller.snapshot.session.lastSeq
            // The observer drains a buffered stream; yield until it has ingested the
            // terminal event (finite work, so this completes — it is not a timed wait).
            while (lastIngestedSeq[sessionID] ?? 0) < finalSeq { await Task.yield() }
        }
        var seq = intelligenceWorkSeq &- 1
        while seq != intelligenceWorkSeq {
            seq = intelligenceWorkSeq
            // The coalesced display flush first: it's what fills `openTranscript` and issues
            // the tool-group/summary work the tasks below drain (an event that landed inside
            // the throttle window is applied by this trailing task, not its own ingest).
            await openDisplayCatchUpTask?.value
            await reloadTask?.value
            await classifyTask?.value
            await summaryTask?.value
            await toolGroupTask?.value
        }
    }

    /// The live merged Bash line for a block, keyed by the block's stable id (its first
    /// Bash call's `toolCallID`), or nil while the first merge is pending — the view shows a
    /// loading placeholder until then. Updates in place as the block grows.
    public func bashBlockLine(_ blockID: String) -> String? { bashBlockLines[blockID] }

    /// The AFM-merged lines for a non-Bash eligible family (keyed by its content signature),
    /// or nil while pending — the view shows a loading placeholder until then.
    public func toolFamilyLines(_ signature: Int) -> [String]? { familySummaryLines[signature] }

    // MARK: Bash summary mesh sync (owner → viewer)

    /// The open session's collapsed Bash summary lines, as wire records keyed by block id — what
    /// the owner ships to peers viewing the same session (mesh session sync). Bash blocks only:
    /// their block id is a `toolCallID`, stable across Macs, unlike a family's per-process hash.
    private func openToolSummaryLines() -> [ToolSummaryLine] {
        bashBlockLines.map { ToolSummaryLine(toolCallID: $0.key, line: $0.value) }
    }

    /// The current Bash summary lines to seed a subscriber with (mesh session sync). Non-empty
    /// only for the session this Mac has open — the only one it summarizes — so a peer viewing a
    /// session the owner isn't looking at simply gets none (its cards stay on the placeholder,
    /// exactly as before this sync existed).
    func toolSummaryLinesForSnapshot(_ id: SessionID) -> [ToolSummaryLine] {
        id == openSessionID ? openToolSummaryLines() : []
    }

    /// Push the open session's current Bash summary lines to any peer viewing it (mesh session
    /// sync). Cheap and idempotent: a no-op when the sync server is off or nothing is open, and a
    /// subscriber only re-applies lines it may already hold. Called whenever a line changes.
    private func broadcastOpenToolSummaries() {
        guard syncBroadcastContinuation != nil, let id = openSessionID else { return }
        broadcast(.toolSummaries(id, openToolSummaryLines()))
    }

    /// Apply Bash summary lines a peer owner pushed for the session being viewed (mesh session
    /// sync), dropping each onto its matching card by `toolCallID`. Guarded to the open remote
    /// session so a late frame from a session we've navigated away from is ignored. On a viewer
    /// the local summary engine never runs for a remote session, so these are the only writer of
    /// `bashBlockLines` — no race with `regenerateToolGroupSummaries`.
    private func applyRemoteToolSummaries(_ lines: [ToolSummaryLine], for sessionID: SessionID) {
        guard sessionID == openSessionID else { return }
        for line in lines { bashBlockLines[line.toolCallID] = line.line }
    }

    /// Drops every cached tool-call summary (and in-flight bookkeeping). Used when the open
    /// session changes, the scope changes, or summarization is turned off.
    private func clearToolGroupSummaries() {
        bashCallSummaries = [:]; bashCallSummariesInFlight = []
        bashBlockLines = [:]; bashBlockInputs = [:]; bashBlocksInFlight = []
        familySummaryLines = [:]; familyLinesInFlight = []
    }

    /// Seed the deterministic heuristic line for every tool group baselined as history at
    /// open. The engine deliberately never AFM-summarizes a carried-over chat's pre-existing
    /// groups (see `historicalToolGroupSignatures`) — so without this, the view's loading
    /// placeholder for an eligible Bash/family line would spin forever, waiting on an AI line
    /// that is never generated. Seeding gives those lines their final value immediately (no
    /// model work — it's purely local). If a resumed chat later grows a group, its signature
    /// leaves the historical set and it summarizes normally, upgrading the seeded line.
    private func seedHistoricalToolGroupLines(_ events: [AgentEvent], root: String?) {
        guard summarizeToolCalls else { return }  // off → the view renders heuristics directly
        let scope = toolSummaryScope
        for unit in TranscriptProjection.summarizableUnits(events)
        where historicalToolGroupSignatures.contains(unit.signature) {
            for family in HeuristicSummary.families(unit.calls) where family.aiEligible(scope: scope) {
                let line = HeuristicSummary.line(for: family, relativeTo: root)
                if family.isBash {
                    if let first = family.calls.first { bashBlockLines[first.toolCallID] = line }
                } else {
                    familySummaryLines[TranscriptProjection.toolGroupSignature(family.calls)] = [line]
                }
            }
        }
        // Seeded history now has its final lines — push them to any peer viewing this session.
        broadcastOpenToolSummaries()
    }

    /// Summarize the open transcript's tool-call groups family-by-family. Bash blocks are
    /// summarized live (map each new command, re-merge the running gist list) so the
    /// collapsed line tracks the block as it grows; every other eligible family is merged
    /// once it settles. Content-addressed throughout, so each command/family is summarized
    /// exactly once and stale entries are dropped. All model work lands off the main run loop.
    private func regenerateToolGroupSummaries() {
        // Off → the transcript renders the deterministic merge directly; skip the AFM work
        // and clear any stale cache.
        guard summarizeToolCalls else {
            if !bashBlockLines.isEmpty || !familySummaryLines.isEmpty || !bashCallSummaries.isEmpty {
                clearToolGroupSummaries()
            }
            return
        }
        let groups = TranscriptProjection.summarizableUnits(openTranscript)
        let scope = toolSummaryScope
        let root = openSession?.worktreePath

        // Collect the live keys (per-command, per-block, per-family) to GC stale caches.
        var liveCallSigs = Set<Int>(), liveBlockIDs = Set<String>(), liveFamilySigs = Set<Int>()
        for group in groups {
            for family in HeuristicSummary.families(group.calls) where family.aiEligible(scope: scope) {
                if family.isBash {
                    if let first = family.calls.first { liveBlockIDs.insert(first.toolCallID) }
                    for call in family.calls { liveCallSigs.insert(TranscriptProjection.toolGroupSignature([call])) }
                } else {
                    liveFamilySigs.insert(TranscriptProjection.toolGroupSignature(family.calls))
                }
            }
        }
        bashCallSummaries = bashCallSummaries.filter { liveCallSigs.contains($0.key) }
        bashCallSummariesInFlight = bashCallSummariesInFlight.intersection(liveCallSigs)
        bashBlockLines = bashBlockLines.filter { liveBlockIDs.contains($0.key) }
        bashBlockInputs = bashBlockInputs.filter { liveBlockIDs.contains($0.key) }
        bashBlocksInFlight = bashBlocksInFlight.intersection(liveBlockIDs)
        familySummaryLines = familySummaryLines.filter { liveFamilySigs.contains($0.key) }
        familyLinesInFlight = familyLinesInFlight.intersection(liveFamilySigs)

        let intelligence = self.intelligence
        for group in groups {
            // Pre-open history (a chat carried over from a previous run) → heuristic only;
            // opening it must not fire a burst of model calls over its whole transcript.
            if historicalToolGroupSignatures.contains(group.signature) { continue }
            for family in HeuristicSummary.families(group.calls) where family.aiEligible(scope: scope) {
                if family.isBash {
                    scheduleBashBlock(family, root: root, intelligence: intelligence)
                } else if group.allFinished {
                    scheduleToolFamily(family, root: root, intelligence: intelligence)
                }
            }
        }
    }

    /// Map+reduce one Bash block: gist each not-yet-summarized command, then condense the
    /// block's running gist list into its one live line. Re-runs additively whenever the
    /// gist list changes (a new command landed), guarded so a block is worked once per change.
    private func scheduleBashBlock(_ family: HeuristicSummary.ToolFamily,
                                   root: String?, intelligence: any IntelligenceProviding) {
        guard let blockID = family.calls.first?.toolCallID else { return }
        if bashBlocksInFlight.contains(blockID) { return }
        // The gists already cached for this block's commands; if every command is gisted and
        // the line was built from this exact list, there's nothing to do.
        let callSigs = family.calls.map { TranscriptProjection.toolGroupSignature([$0]) }
        let cachedGists = callSigs.compactMap { bashCallSummaries[$0] }
        if cachedGists.count == family.calls.count, bashBlockInputs[blockID] == hashStrings(cachedGists) {
            return
        }
        let calls = family.calls
        intelligenceWorkSeq += 1
        bashBlocksInFlight.insert(blockID)
        toolGroupTask = Task {
            // Map: gist each command (cached ones are reused), in run order.
            var gists: [String] = []
            for call in calls {
                let sig = TranscriptProjection.toolGroupSignature([call])
                if let cached = self.bashCallSummaries[sig] { gists.append(cached); continue }
                let command = call.input["command"]?.stringValue ?? ""
                let relativized = HeuristicSummary.relativizePaths(command, relativeTo: root)
                let gist = await intelligence.summarizeBashCommand(relativized)
                guard self.currentBashBlockIDs().contains(blockID) else {  // block went away
                    self.bashBlocksInFlight.remove(blockID); return
                }
                self.bashCallSummaries[sig] = gist
                gists.append(gist)
            }
            // Reduce: condense the running gist list into the block's one live line.
            let line = await intelligence.mergeBashSummaries(gists)
            guard self.currentBashBlockIDs().contains(blockID) else {
                self.bashBlocksInFlight.remove(blockID); return
            }
            self.bashBlockLines[blockID] = line
            self.bashBlockInputs[blockID] = self.hashStrings(gists)
            // The line just changed — push it to any peer viewing this session (mesh session sync).
            self.broadcastOpenToolSummaries()
            // Clear in-flight BEFORE re-checking so a command that landed while we were
            // working gets picked up now (a fresh task), rather than waiting for the next
            // transcript event. Converges: the input-signature guard makes this a no-op once
            // the gist list is stable.
            self.bashBlocksInFlight.remove(blockID)
            self.regenerateToolGroupSummaries()
        }
    }

    /// Merge one settled non-Bash family (Grep/Glob/… under the "all" scope) into its line(s),
    /// once, keyed by content signature.
    private func scheduleToolFamily(_ family: HeuristicSummary.ToolFamily,
                                    root: String?, intelligence: any IntelligenceProviding) {
        let signature = TranscriptProjection.toolGroupSignature(family.calls)
        if familySummaryLines[signature] != nil { return }  // already summarized this exact family
        if familyLinesInFlight.contains(signature) { return }  // a task is already on it — don't race
        let calls = family.calls
        intelligenceWorkSeq += 1
        familyLinesInFlight.insert(signature)
        toolGroupTask = Task {
            defer { self.familyLinesInFlight.remove(signature) }
            let lines = await intelligence.summarizeToolFamily(calls, worktreeRoot: root)
            guard self.currentFamilySignatures().contains(signature) else { return }
            self.familySummaryLines[signature] = lines
        }
    }

    /// Stable ids of the open transcript's live Bash blocks right now — the staleness guard
    /// for an in-flight map/reduce (the open session may have changed while the model ran).
    private func currentBashBlockIDs() -> Set<String> {
        guard summarizeToolCalls else { return [] }
        let scope = toolSummaryScope
        var ids = Set<String>()
        for group in TranscriptProjection.summarizableUnits(openTranscript)
        where !historicalToolGroupSignatures.contains(group.signature) {
            for family in HeuristicSummary.families(group.calls)
            where family.isBash && family.aiEligible(scope: scope) {
                if let first = family.calls.first { ids.insert(first.toolCallID) }
            }
        }
        return ids
    }

    /// Content signatures of the open transcript's live non-Bash eligible families right now
    /// — the staleness guard for an in-flight family summary.
    private func currentFamilySignatures() -> Set<Int> {
        guard summarizeToolCalls else { return [] }
        let scope = toolSummaryScope
        var sigs = Set<Int>()
        for group in TranscriptProjection.summarizableUnits(openTranscript)
        where !historicalToolGroupSignatures.contains(group.signature) {
            for family in HeuristicSummary.families(group.calls)
            where !family.isBash && family.aiEligible(scope: scope) {
                sigs.insert(TranscriptProjection.toolGroupSignature(family.calls))
            }
        }
        return sigs
    }

    /// A stable hash of an ordered list of strings (the block's gist list), so a re-merge
    /// fires exactly when the list changes.
    private func hashStrings(_ strings: [String]) -> Int {
        var hasher = Hasher()
        for string in strings { hasher.combine(string) }
        return hasher.finalize()
    }

    private func regenerateSummary() {
        guard let sessionID = openSessionID, let controller = controllers[sessionID] else { return }
        summaryToken += 1
        let token = summaryToken
        summarizing = true
        intelligenceWorkSeq += 1
        let intelligence = self.intelligence
        summaryTask = Task {
            let snapshot = await controller.snapshot
            let events = await controller.transcriptSoFar()
            let text = await intelligence.summarize(session: snapshot.session, events: events)
            await controller.setSummary(text)  // cache so reopen doesn't regenerate
            guard token == self.summaryToken, sessionID == self.openSessionID else { return }
            self.openSummary = text
            self.summarizing = false
            self.openSession = await controller.snapshot.session  // reflect the cached summary
        }
    }

    /// The cue-state the sidebar would show a chat in, for sound edge-detection. Mirrors
    /// `SessionSummary.isCompleted` / `.needsAttention` exactly, so the audible cue always
    /// matches the visible status — across *every* blocked state (approval, AskUserQuestion,
    /// the agent awaiting a reply, an autoship failure/conflict), not just a hand-picked few.
    private enum ChatCueState: Equatable {
        /// The chat's work is done (a finished run, or a turn classified `.completed`).
        case done
        /// The chat is blocked on the user (approval, a question, or an awaited reply).
        case blocked
        /// Nothing to announce (idle, provisioning, running, errored).
        case quiet
        /// A turn just ended awaiting input but isn't classified yet — defer until the
        /// classifier settles it into done/blocked, so a turn that turns out *done* never
        /// first rings "blocked" in the gap between turn-end and classification.
        case pending
    }

    private func chatCueState(_ s: SessionSummary) -> ChatCueState {
        if s.isCompleted { return .done }
        guard s.needsAttention else { return .quiet }
        // Awaiting input with no classification yet is the one unsettled "needs you": the
        // run just finished and `classifyDisposition` will resolve it momentarily. Any other
        // needs-attention reason (approval/question/autoship) is settled now → blocked.
        if s.status == .awaitingInput, s.disposition == nil,
           s.pendingApprovalCount == 0, !s.autoShipFailed, !s.autoShipConflict {
            return .pending
        }
        return .blocked
    }

    /// Last announced cue-state per session — the edge-detection baseline so a cue fires
    /// only when a chat *transitions* into done/blocked, never repeatedly while it sits there.
    private var lastChatCueState: [SessionID: ChatCueState] = [:]

    /// Sessions that are blocked-and-unviewed — the escalating alarm rings while this set is
    /// non-empty. A chat joins when it transitions into "needs you" away from the user's view;
    /// it leaves when the user opens it (`openSessionID`'s `didSet`), when it settles out of
    /// blocked (`announceChatCue`), or when it's deleted.
    private var alarmingSessions: Set<SessionID> = []

    /// Add and/or remove a session from the alarm set and, when that flips the set between
    /// empty and non-empty, tell the app layer to start or stop ringing. The app layer owns
    /// the repeating sound and the off-by-default switch, so a `true` here is a no-op when the
    /// user hasn't opted in. Clearing a session that wasn't alarming is a harmless no-op.
    private func updateAlarm(adding: SessionID? = nil, clearing: SessionID? = nil) {
        let wasRinging = !alarmingSessions.isEmpty
        if let adding { alarmingSessions.insert(adding) }
        if let clearing { alarmingSessions.remove(clearing) }
        let nowRinging = !alarmingSessions.isEmpty
        if nowRinging != wasRinging {
            chatAlarmHook?(nowRinging)
            // Same edge feeds the Dock bounce; the app layer holds it for a minute before
            // acting, and stops it early on this `false`.
            chatDockBounceHook?(nowRinging)
        }
    }

    /// Ring the matching system sound when a chat's displayed status transitions into "Done"
    /// (Funk) or "needs you" (Pop). Called for every summary the sidebar shows, so it covers
    /// all blocked states uniformly. The first time a session is seen its state is seeded
    /// silently — launch/resume restore already-done/blocked chats without a fanfare; only
    /// live transitions ring. `pending` never fires and never overwrites the baseline.
    private func announceChatCue(_ s: SessionSummary) {
        let new = chatCueState(s)
        if new == .pending { return }  // unsettled — leave the baseline (and the alarm) untouched
        let old = lastChatCueState.updateValue(new, forKey: s.id)
        guard let old, old != new else { return }  // unseen-before → seed silently; unchanged → ignore

        // Escalating alarm: a live transition *into* blocked starts it — unless the user is
        // already looking at that chat, in which case there's nothing to call them back to.
        // Any transition *out* of blocked clears it; opening the chat clears it separately
        // (`openSessionID`'s `didSet`).
        if new == .blocked {
            if s.id != openSessionID { updateAlarm(adding: s.id) }
        } else if old == .blocked {
            updateAlarm(clearing: s.id)
        }

        switch new {
        case .done: chatStatusSoundHook?(.done)
        case .blocked: chatStatusSoundHook?(.blocked)
        case .quiet, .pending: break
        }
    }

    /// Classify the just-finished turn (asking vs. done) from the agent's last reply
    /// and stamp it on the session, so the sidebar can distinguish "the agent needs
    /// you" from "the agent finished" and the open session's summary card can switch to
    /// a done recap. Runs off the main loop; the controller ignores a stale
    /// classification if a new turn has since started.
    private func classifyDisposition(
        _ sessionID: SessionID, _ controller: SessionController, outcome: RunFinished.Outcome
    ) {
        let intelligence = self.intelligence
        intelligenceWorkSeq += 1
        classifyTask = Task {
            let events = await controller.transcriptSoFar()
            // No assistant prose this turn → leave it as plain "awaiting input".
            guard let reply = HeuristicSummary.lastTurn(events).reply,
                  !reply.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
            else { return }
            let disposition = await intelligence.classifyTurn(lastReply: reply)
            await controller.setDisposition(disposition)
            let snapshot = await controller.snapshot
            self.upsertSummary(SessionSummary(
                snapshot.session, pendingApprovals: snapshot.pendingApprovals))
            if sessionID == self.openSessionID { self.openSession = snapshot.session }
            // Project the disposition flip to connected remotes (SYNC §5). Unlike a status
            // change, this classification is applied here — outside the ingest pipeline that
            // broadcasts at AppStore §ingestUI — so without this the host shows "Done" while
            // remote viewers stay stuck on the "Awaiting input" they last received.
            self.broadcast(.sessionUpdated(self.wireSummary(
                for: snapshot.session, pendingApprovals: snapshot.pendingApprovals)))
            // The turn was just classified done/awaiting — refresh the unread dot, which
            // for conversational chats is driven by this disposition rather than `.finished`.
            await self.reconcileUnseenCompletion(sessionID, snapshot)
            // Suspension is reversible and belongs to the displayed Done transition itself. Keep it
            // outside the clean-outcome gate below: an interrupted/max-turn run can still settle as
            // Done, and a follow-up will transparently thaw its VM.
            if disposition == .completed {
                await self.suspendVMIfCompleted(sessionID)
            }
            // Finalize the chat (autoship + free its sandbox/locks) only when the agent both
            // classified the turn done *and* the run actually completed cleanly. A run that
            // errored, was interrupted, or hit the turn cap can still leave done-looking prose
            // the classifier reads as `.completed`; shipping that half-finished work — or
            // tearing down its sandbox/locks — is exactly the bug this guards against.
            if disposition == .completed, outcome == .completed {
                await self.releaseSandboxIfCompleted(sessionID)
                await self.shipIfCompleted(sessionID)
                // Release any locks whose work has already landed now that the chat is done, so a
                // completed chat never sits on a stale lock waiting for the background poll or an
                // archive sweep (LOCKING §4.4). Unmerged work pending the autoship enqueued just
                // above stays locked and releases via its merge.
                await self.releaseCompletedSessionLocks(sessionID)
            }
        }
    }

    /// Autoship entry point: the agent's last turn was classified `.completed`, so if this
    /// session has autoship on, enqueue its branch to merge into the project's default
    /// branch. The merge queue dedupes (a re-fired `.completed` won't double-enqueue) and
    /// serializes per target; eligibility is re-checked at merge time in `performShip`.
    private func shipIfCompleted(_ sessionID: SessionID) async {
        guard let controller = controllers[sessionID] else { return }
        let session = await controller.snapshot.session
        // Autoship requires Nucleic Control (defense-in-depth: a session whose flag was
        // persisted true under an older build, or whose project later left the control dir,
        // must never enqueue a merge here).
        guard session.autoShip, !session.archived,
              let project = projectsByID[session.projectID], project.isNucleicControlled
        else { return }
        // A worktree-less session runs directly in the project's main checkout on the chosen
        // branch (typically the default branch) — its edits are already on that branch, so
        // there is no isolated branch to merge (a branch can't merge into itself, and
        // `integrate()` has no git context). Skip autoship explicitly: enqueuing would emit a
        // spurious "merging into <default branch>…" note before the integrator could bail. The
        // footprint guard below already happens to suppress this (a worktree-less session's
        // `unmergedFiles` is empty), but that is incidental — this states the invariant directly.
        guard await controller.hasWorktree else { return }
        // Only ship a turn that actually changed files. A conversational turn (no edits), or a
        // follow-up after the work already landed, has an empty footprint; enqueuing it would
        // emit a spurious "merging…" note — the queue emits `.merging` before the integrator
        // can skip — followed by a no-op merge. Re-checked at merge time in `performShip`.
        // (No shipped-marker backstop here: an empty footprint can't distinguish "the agent
        // merged its work" from a plain conversational turn, so lighting the marker would
        // false-positive. The interceptor's direct path is the only signal that a merge ran.)
        guard await hasShippableChanges(controller, session) else { return }
        await mergeQueue.enqueue(ShipRequest(
            sessionID: sessionID, projectID: project.id,
            target: session.shipDestination(in: project), strategy: .squash))
    }

    /// A session classified `.completed` has finished its work, so free its sandbox
    /// container now rather than holding it until the idle timer — a later follow-up
    /// message transparently starts a fresh one. Re-checks the live state so a follow-up
    /// turn that began in the meantime isn't torn down, and drains the just-finished turn
    /// (waitForActive) before removing.
    private func releaseSandboxIfCompleted(_ sessionID: SessionID) async {
        guard let containerManager, let controller = controllers[sessionID] else { return }
        let session = await controller.snapshot.session
        guard session.lastTurnDisposition == .completed, session.status == .awaitingInput,
              projectsByID[session.projectID]?.sandbox?.enabled == true
        else { return }
        if let name = await containerManager.teardown(sessionID, waitForActive: true) {
            reportSandboxCleanupFailures([name])
        }
    }

    /// A completed chat no longer needs guest CPU, so freeze either per-session macOS/Linux VM now.
    /// The VM manager deliberately leaves its already-running idle TTL intact: a follow-up thaws the
    /// guest transparently, while an untouched Done chat still reaches the existing stop path on time.
    /// Re-check the live controller because classification and a new user turn can race.
    private func suspendVMIfCompleted(_ sessionID: SessionID) async {
        guard let macVMManager, let controller = controllers[sessionID] else { return }
        let session = await controller.snapshot.session
        guard session.lastTurnDisposition == .completed, session.status == .awaitingInput else {
            return
        }
        await macVMManager.suspendIfIdle(sessionID)
    }

    /// On a session's first user message, derive a concise title and rename. Runs at
    /// most once per session. Uses the intelligence provider when it returns a name,
    /// else a deterministic heuristic from the message — so a chat always gets named,
    /// even with Intelligence off or the model unavailable.
    private func maybeNameSession(_ id: SessionID, firstMessage: String) {
        guard !namedSessions.contains(id) else { return }
        namedSessions.insert(id)
        firstMessages[id] = firstMessage
        let intelligence = self.intelligence
        Task {
            guard let controller = self.controllers[id] else { return }
            // Only replace an auto-generated random name; never clobber a title the
            // caller set explicitly.
            guard RandomName.isGenerated(await controller.snapshot.session.title) else { return }
            let generated = await intelligence.sessionName(fromFirstMessage: firstMessage)
            let name = generated.flatMap { $0.isEmpty ? nil : $0 }
                ?? HeuristicTitle.fromMessage(firstMessage)
            await controller.rename(name)
            // Remember the provisional title so the post-run rename can tell it apart
            // from one the user has since edited.
            self.autoTitles[id] = name
            let snapshot = await controller.snapshot
            self.upsertSummary(SessionSummary(snapshot.session, pendingApprovals: snapshot.pendingApprovals))
            if id == self.openSessionID { self.openSession = snapshot.session }
        }
    }

    /// Once the first turn has run, re-derive the title from what the agent actually did
    /// — the cure for a name the first message alone got wrong (a vague "continue",
    /// pasted error text). Runs at most once per session, only while the title is still
    /// the provisional one we set, and only when that provisional name was weak: a thin
    /// opening message or a heuristic fallback the model declined to name. A good
    /// model-chosen name is left untouched, so re-naming upgrades the misses without
    /// churning the hits.
    private func maybeRenameSession(_ id: SessionID, _ controller: SessionController) {
        guard !renamedSessions.contains(id), namedSessions.contains(id),
              let firstMessage = firstMessages[id], let autoTitle = autoTitles[id]
        else { return }
        let wasHeuristic = autoTitle == HeuristicTitle.fromMessage(firstMessage)
        guard wasHeuristic || HeuristicTitle.isWeakFirstMessage(firstMessage) else { return }
        renamedSessions.insert(id)
        let intelligence = self.intelligence
        Task {
            // The user may have edited the title since; if so, leave it alone.
            guard await controller.snapshot.session.title == autoTitle else { return }
            let digest = HeuristicSummary.sessionDigest(await controller.transcriptSoFar())
            guard let better = await intelligence.sessionName(fromFirstMessage: firstMessage, context: digest),
                  !better.isEmpty, better != autoTitle
            else { return }
            // Re-check the title didn't change while the model ran.
            guard await controller.snapshot.session.title == autoTitle else { return }
            await controller.rename(better)
            self.autoTitles[id] = better
            let snapshot = await controller.snapshot
            self.upsertSummary(SessionSummary(snapshot.session, pendingApprovals: snapshot.pendingApprovals))
            if id == self.openSessionID { self.openSession = snapshot.session }
        }
    }

    /// Single guarded writer for `openTranscript`: only applies if it's still the
    /// open session and the snapshot is at least as complete as what's shown, so a
    /// late-arriving stale read can never shorten the transcript.
    private func applyOpenTranscript(_ events: [AgentEvent], for sessionID: SessionID) {
        guard sessionID == openSessionID, events.count >= openTranscript.count else { return }
        openTranscript = events
        openTranscriptVersion &+= 1
    }

    /// Reconcile a session's "completed but not yet opened" flag so the sidebar shows an
    /// unread dot on a chat that finished its work while the user was elsewhere. The flag
    /// is true exactly when the chat is completed and isn't the one currently open; opening
    /// it (it becomes `openSessionID`) or starting a new turn (no longer completed) clears it.
    /// A no-op when nothing changed, so it's cheap to call on every ingested event.
    private func reconcileUnseenCompletion(_ sessionID: SessionID, _ snapshot: SessionController.Snapshot) async {
        guard let controller = controllers[sessionID] else { return }
        let summary = SessionSummary(snapshot.session, pendingApprovals: snapshot.pendingApprovals)
        let unseen = summary.isCompleted && sessionID != openSessionID
        guard snapshot.session.unseenCompletion != unseen else { return }
        await controller.setUnseenCompletion(unseen)
        let updated = await controller.snapshot
        upsertSummary(SessionSummary(updated.session, pendingApprovals: updated.pendingApprovals))
        if sessionID == openSessionID { openSession = updated.session }
    }

    /// Retire a session's sticky "autoship failed" marker once the user has the chat open —
    /// the failure note is in the transcript, so they've seen it and the sidebar's "needs
    /// attention" icon has done its job. Only acts on the currently-open chat; a no-op when
    /// the flag isn't set. Mirrors `reconcileUnseenCompletion`: refreshes the sidebar summary
    /// and the open session without reordering the sidebar (the controller skips `updatedAt`).
    private func clearAutoShipFailedSeen(_ sessionID: SessionID) async {
        guard sessionID == openSessionID,
              let controller = controllers[sessionID],
              await controller.snapshot.session.autoShipFailed
        else { return }
        await controller.clearAutoShipFailed()
        let updated = await controller.snapshot
        upsertSummary(SessionSummary(updated.session, pendingApprovals: updated.pendingApprovals))
        if sessionID == openSessionID { openSession = updated.session }
    }

    private func upsertSummary(_ summary: SessionSummary) {
        if let index = summaries.firstIndex(where: { $0.id == summary.id }) {
            // Skip the no-op write: with @Observable, re-assigning an identical row still
            // invalidates every view reading `summaries` (the whole sidebar, Recents, the
            // dashboard), and this is called for *every* ingested event — most of which
            // change the transcript without changing the summary row at all.
            if summaries[index] != summary { summaries[index] = summary }
        } else {
            summaries.append(summary)
        }
        announceChatCue(summary)
    }

    /// Upsert many summaries in a single observable update (one re-render), so a batch
    /// publish — e.g. the whole sidebar at launch — paints all at once rather than
    /// row-by-row, and merges in O(n) instead of O(n²) per-call `firstIndex` scans.
    private func upsertSummaries(_ incoming: [SessionSummary]) {
        guard !incoming.isEmpty else { return }
        var merged = summaries
        var indexByID = Dictionary(
            merged.enumerated().map { ($0.element.id, $0.offset) }, uniquingKeysWith: { a, _ in a })
        for summary in incoming {
            if let index = indexByID[summary.id] {
                merged[index] = summary
            } else {
                indexByID[summary.id] = merged.count
                merged.append(summary)
            }
            announceChatCue(summary)
        }
        summaries = merged
    }

    // MARK: - Sync server lifecycle (iPhone remote access)

    /// UserDefaults key recording whether the user has remote access ("Allow paired devices to
    /// monitor and approve") switched on, so it can be restored after the process relaunches —
    /// notably a Sparkle auto-update, which quits and respawns the app. `syncRunning` itself is
    /// in-memory only; without this the toggle came back off after every update and paired devices
    /// never reconnected. Written from the user's toggle action, not the internal `restartIfRunning`
    /// bounces, so a transport/relay change never clears the intent.
    public static let remoteEnabledDefaultsKey = "nucleic.remote.enabled"

    /// Restore remote access on launch when the user last had it on. Called once from the app's
    /// startup task; a no-op when it was off. Safe to await off the main startup path — the tailnet
    /// branch can block on a browser login for minutes, and `startSyncServer` is single-flight.
    public func resumeRemoteAccessIfEnabled() async {
        guard UserDefaults.standard.bool(forKey: Self.remoteEnabledDefaultsKey) else { return }
        await startSyncServer()
    }

    /// Start advertising + listening for remote clients (SYNC §3.1) on **every** connection
    /// method the Settings checkboxes enable — LAN (Bonjour + TCP) and/or the user's
    /// tailnet — merged by `CompositeSyncListener`. A method that fails to start degrades
    /// to a per-method status row instead of taking the server down; only when nothing can
    /// listen does the start fail. Idempotent; single-flight (the tailnet path awaits a
    /// node login for seconds, and a toggle-off or method change during that window is
    /// honored at the commit point).
    public func startSyncServer() async {
        guard syncHost == nil, !syncStartInFlight else { return }
        syncStartInFlight = true
        defer {
            syncStartInFlight = false
            syncStopRequested = false
        }
        if syncHostName == "Mac" { syncHostName = Host.current().localizedName ?? "Mac" }
        let identity = HostIdentityStore.loadOrCreate()
        syncHostID = identity.hostID
        // Persist paired-device pins to disk so they survive a sync-server restart (notably a
        // canary auto-update relaunch); an in-memory store forgot every phone on each launch,
        // which broke reconnects with an unrecoverable "handshake closed" (see FilePairedDeviceStore).
        let store = FilePairedDeviceStore(
            url: transcriptsDir.deletingLastPathComponent()
                .appendingPathComponent("paired-devices.json"))
        syncPairedStore = store
        // Out-of-band approval push (docs/PUSH_SETUP.md §4), active only when the relay
        // is configured — a runtime setting (env vars first, then the same-named defaults
        // keys the Settings UI writes). Relay-only by design: the APNS key stays in
        // Worker secrets, never on a user's machine.
        let pushRelay = PushRelayConfig.resolve().map { PushRelayClient(config: $0) }
        let host = SyncHost(
            identity: identity, bridge: self, store: store, pushRelay: pushRelay,
            relayMembership: { [weak self] deviceID in
                await self?.mintRelayMembership(for: deviceID)
            })

        while true {
            let enabled = SyncTransportSetting.resolveEnabled()
            var health: [SyncTransportHint: String] = [:]
            var children: [(transport: SyncTransportHint, listener: any SyncListener)] = []

            if enabled.contains(.tailnet) {
                do {
                    let tailnetListener = try await startTailnetListener()
                    syncTailnetListener = tailnetListener
                    children.append((.tailnet, tailnetListener))
                } catch {
                    tailnetStatus = await TailnetNode.shared.status.label
                    // A half-started node must not outlive its failed start — nothing
                    // else would own (or ever stop) it.
                    await TailnetNode.shared.stop()
                    health[.tailnet] = "Tailscale: \(error.localizedDescription)"
                }
            }
            #if canImport(Network)
            if enabled.contains(.lan) {
                // Reuse last launch's LAN port so a sibling Mac's stored `lanHint` survives our
                // relaunch/update. The OS otherwise assigns a fresh ephemeral port each launch, which
                // stranded peers dialing our now-dead old port (they only relearn a port over a live
                // connection — and after a coordinated update, none exists to bootstrap from).
                let lanListener = LANListener(
                    preferredPort: Self.preferredLANPort(),
                    identityFingerprint: identity.fingerprint)
                syncLANListener = lanListener
                children.append((.lan, lanListener))
            }
            #endif
            if enabled.contains(.relay) {
                do {
                    let (access, relayListener) = try await startRelayListener(identity: identity)
                    relayAccess = access
                    syncRelayListener = relayListener
                    children.append((.relay, relayListener))
                } catch {
                    relayAccess = nil
                    health[.relay] = "Relay: \(error.localizedDescription)"
                }
            }
            guard !children.isEmpty else {
                syncTransportHealth = health
                lastError = "Couldn't start remote access: "
                    + (health.values.first ?? "no connection methods are enabled.")
                return
            }

            // Child start failures inside the composite (e.g. the LAN port) degrade to a
            // status row, matching the tailnet handling above. The callback fires on the
            // sync host's executor mid-`start`, so it records into a locked box the commit
            // point below reads — not directly into observable state (ordering race).
            let transports = children.map(\.transport)
            let startFailures = TransportFailureBox()
            let listener: any SyncListener = children.count == 1
                ? children[0].listener
                : CompositeSyncListener(children.map(\.listener)) { index, error in
                    startFailures.record(transports[index], "\(error.localizedDescription)")
                }

            // Release whatever this pass brought up without committing it.
            func abandon() async {
                listener.stop()
                syncRelayListener?.stop()
                #if canImport(Network)
                syncLANListener = nil
                #endif
                syncTailnetListener = nil
                syncRelayListener = nil
                relayAccess = nil
                if children.contains(where: { $0.transport == .tailnet }) {
                    await TailnetNode.shared.stop()
                    tailnetStatus = nil
                }
            }
            if syncStopRequested {  // toggled off while the start was in flight
                await abandon()
                return
            }
            if SyncTransportSetting.resolveEnabled() != enabled {  // methods changed mid-start
                await abandon()
                continue
            }
            do {
                try await host.start(listener: listener)
                // A stop may also have landed during the host.start suspension — honoring
                // it only before that await would commit a server the user turned off
                // (with its child-listener refs already cleared by stopSyncServer).
                if syncStopRequested {
                    await host.stop()
                    await abandon()
                    return
                }
                syncHost = host
                syncListener = listener
                let failed = startFailures.snapshot()
                for (transport, message) in failed {
                    health[transport] = "\(transport.label): \(message)"
                }
                syncActiveTransports = Set(transports).subtracting(failed.keys)
                syncTransportHealth = health
                syncRunning = true
                await refreshPairedDevices()
                await startPeerClient(identity: identity, store: store, host: host)
            } catch {
                await abandon()
                syncTransportHealth = health
                lastError = "Couldn't start remote access: \(error)"
            }
            return
        }
    }

    /// Enroll with the Nucleic Private Relay (X25519 proof-of-possession, mesh P2) and open
    /// this Mac's room socket. Throwing here — relay unreachable, enrollment rejected —
    /// degrades the method to a Settings status row; the returned listener redials on later
    /// drops by itself.
    private func startRelayListener(identity: DeviceIdentity) async throws -> (RelayAccess, RelayListener) {
        let access = RelayAccess(identity: identity)
        let hostDeviceID = identity.hostID
        let listener = RelayListener(dial: {
            let membership = try await access.mintMembership(deviceID: hostDeviceID, role: "host")
            let base = RelayAPI.baseURL(membership.url)
            let token = try await RelayAPI.connectionToken(base: base, membershipToken: membership.token)
            return try await RelayWebSocket.connect(
                url: RelayAPI.webSocketURL(base: base, connectionToken: token))
        })
        try await listener.connect()
        return (access, listener)
    }

    /// Mint a device's relay membership (the `SyncHost` post-hello push and the pairing QR).
    /// nil while the relay method is off or the relay can't be reached — best-effort by
    /// design; the device just keeps whatever credential it already holds.
    private func mintRelayMembership(for deviceID: String) async -> WireRelayMembership? {
        guard let relayAccess else { return nil }
        return try? await relayAccess.mintMembership(deviceID: deviceID)
    }

    /// The Settings→pool client (docs/ANTIMATTER_RUNNER.md §0.2 item 4): PoP-enrolls this Mac's
    /// runner pool and drives provision / knob pushes / the status list. Lazily built, and
    /// rebuilt when the dev control-URL override changes so the Settings field takes effect
    /// without a relaunch.
    private var runnerPool: RunnerPoolClient?
    public func runnerPoolClient() -> RunnerPoolClient {
        let base = RunnerPoolClient.resolveBaseURL()
        if let runnerPool, runnerPool.baseURL == base { return runnerPool }
        let client = RunnerPoolClient(identity: HostIdentityStore.loadOrCreate(), baseURL: base)
        runnerPool = client
        return client
    }

    /// Bring the embedded Tailscale node up and bind the sync listener to it on the fixed
    /// tailnet port. The node may already be registered — the user can turn the Tailnet
    /// method on before remote access, which brings it up early — so `bringUpTailnetNode`
    /// is idempotent for an unchanged config.
    private func startTailnetListener() async throws -> TailnetListener {
        guard TailnetSupport.isBuiltIn else { throw TailnetError.notBuiltIn }
        try await bringUpTailnetNode()
        return try await TailnetNode.shared.makeListener(port: SyncTransportSetting.tailnetPort)
    }

    /// Register this Mac's embedded Tailscale node and mirror its status here. The node has
    /// no auth key, so a first start with no existing node state always goes through the
    /// interactive browser login — the node's status stream carries the login URL, surfaced
    /// on `tailnetLoginURL` so Settings can open it. Idempotent: an already-running node for
    /// an unchanged config returns at once.
    private func bringUpTailnetNode() async throws {
        if syncHostName == "Mac" { syncHostName = Host.current().localizedName ?? "Mac" }
        let config = SyncTransportSetting.nodeConfig(hostName: syncHostName)
        tailnetStatus = "Starting…"
        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 {
                    self.tailnetLoginURL = URL(string: url)
                } else {
                    self.tailnetLoginURL = nil
                }
            }
        }
        defer {
            watcher.cancel()
            tailnetLoginURL = nil
        }
        try await TailnetNode.shared.ensureRunning(config: config)
        tailnetStatus = await TailnetNode.shared.status.label
    }

    /// Bring the tailnet node online on its own, the moment the user enables the Tailnet
    /// connection method — so the browser login opens immediately instead of waiting for
    /// remote access to be turned on. When remote access later starts, `startTailnetListener`
    /// finds the node already registered and just binds its listener. A no-op while a sync
    /// start is already in flight (that path brings the node up itself).
    public func startTailnetNode() async {
        guard TailnetSupport.isBuiltIn, !syncStartInFlight else { return }
        syncTransportHealth[.tailnet] = nil
        do {
            try await bringUpTailnetNode()
        } catch {
            tailnetStatus = await TailnetNode.shared.status.label
            // A half-started node must not linger — nothing else owns it while remote
            // access is off.
            await TailnetNode.shared.stop()
            syncTransportHealth[.tailnet] = "Tailscale: \(error.localizedDescription)"
        }
    }

    /// Spin the standalone tailnet node down when the user disables the Tailnet method while
    /// remote access is off. A no-op once remote access is running — the sync server owns the
    /// node's lifecycle then, and `stopSyncServer` tears it down.
    public func stopTailnetNode() async {
        guard !syncRunning, syncTailnetListener == nil else { return }
        await TailnetNode.shared.stop()
        tailnetStatus = nil
        tailnetLoginURL = nil
        syncTransportHealth[.tailnet] = nil
    }

    public func stopSyncServer() async {
        // A start may be mid-flight (node login); it checks this at its commit point and
        // abandons what it brought up.
        if syncStartInFlight { syncStopRequested = true }
        let hadTailnet = syncTailnetListener != nil
        resolveMacPairing(false)  // a confirm can't outlive the server it arrived on
        peerPresenceTask?.cancel()
        peerPresenceTask = nil
        tailnetAddressTask?.cancel()
        tailnetAddressTask = nil
        transcriptSyncTicker?.cancel()
        transcriptSyncTicker = nil
        transcriptSyncCoordinator = nil
        await peerClient?.stop()
        peerClient = nil
        meshPeers = []
        #if canImport(Network)
        syncLANBrowser?.stop()
        syncLANBrowser = nil
        meshPathMonitor?.stop()
        meshPathMonitor = nil
        #endif
        await syncHost?.stop()
        syncHost = nil
        syncPairedStore = nil
        lastGossipedRoster = nil
        syncListener = nil
        #if canImport(Network)
        syncLANListener = nil
        #endif
        syncTailnetListener = nil
        syncRelayListener = nil
        relayAccess = nil
        syncActiveTransports = []
        syncTransportHealth = [:]
        syncRunning = false
        pairingQR = nil
        if hadTailnet {
            await TailnetNode.shared.stop()
            tailnetStatus = nil
        }
    }

    /// Bring up the mesh dialer alongside the sync server (mesh P4): this Mac is a host to
    /// its phones *and* a client of its paired sibling Macs, sharing one identity and one
    /// paired-device store across both roles. `host` is the just-committed `SyncHost` — the
    /// commit token: if a `stopSyncServer` raced our post-commit awaits (`syncHost` is no longer
    /// this one), the client we built is orphaned, so we stop it instead of publishing it.
    private func startPeerClient(
        identity: DeviceIdentity, store: any PairedDeviceStore, host: SyncHost
    ) async {
        // Bonjour rediscovery for sibling Macs (mesh LAN reconnect), live only while the LAN method
        // is up. Lets the dialer find a peer that relaunched onto a new OS-assigned port before any
        // stored hint refreshes — the direct-LAN counterpart to the relay's off-LAN healing.
        var discovery: (any PeerLANBrowsing)?
        #if canImport(Network)
        if syncActiveTransports.contains(.lan) {
            let browser = LANBrowser()
            browser.start()
            syncLANBrowser = browser
            discovery = browser
        }
        #endif
        let client = PeerClient(
            identity: identity, store: store, dialer: MacPeerDialer(),
            config: PeerClient.Config(
                deviceID: identity.hostID, deviceLabel: syncHostName,
                releaseChannel: releaseChannel,
                // Advertise this device's mesh kind on outbound dials — `.cloud` for a runner
                // (nucleicd sets `meshSelfKind`), `.mac` otherwise (docs/ANTIMATTER_RUNNER.md §3).
                selfKind: meshSelfKind),
            discovery: discovery)
        // Route a peer Mac's gossiped roster into the app's single merge path (mesh "join").
        await client.setMeshRosterHandler { [weak self] push, deviceID in
            await self?.mergeMeshRoster(push, from: deviceID)
        }
        // Do all the actor hops on the *local* client first, before publishing it, so a stop
        // landing in these awaits can't observe (or race) a half-installed peerClient.
        await client.setLocalAddresses(await currentLocalAddresses())
        let presenceStream = await client.start()
        guard syncHost === host, !syncStopRequested else {
            await client.stop()
            return
        }
        // Commit synchronously — no await between the guard and here.
        peerClient = client
        // Full-transcript mesh sync: proactively mirror peers' active-session transcripts so they
        // open with full history (not a 200-event tail) and can be re-served to a third Mac. The
        // fetch closure dials the peer through the same client. Mac-only by construction (this is
        // the Mac authority; phones fetch on demand).
        let coordinator = TranscriptSyncCoordinator(mirrorStore: mirrorStore) { [weak client] sessionID, afterSeq, deviceID in
            guard let client else {
                return AsyncStream<PeerClient.TranscriptFetchUpdate> { $0.finish() }
            }
            return await client.fetchTranscript(sessionID, afterSeq: afterSeq, from: deviceID)
        }
        transcriptSyncCoordinator = coordinator
        peerPresenceTask = Task { [weak self] in
            for await peers in presenceStream {
                guard let self, !Task.isCancelled else { break }
                self.meshPeers = peers
                await self.reconcileTranscriptSync()
            }
        }
        // Periodic re-scan catches gaps missed between broadcasts / after a transient failure.
        transcriptSyncTicker = Task { [weak self] in
            while !Task.isCancelled {
                try? await Task.sleep(for: .seconds(90))
                guard let self, !Task.isCancelled else { break }
                await self.reconcileTranscriptSync()
            }
        }
        // The LAN listener binds its port (`.any` → OS-assigned) asynchronously, so the address
        // snapshot pushed above omits our LAN hint. Once it lands, re-push: our reconnect hellos
        // then carry the port, and every connected peer gets an `addressUpdate`. Without this a
        // sibling Mac can never relearn our address after we quit and relaunch onto a fresh
        // ephemeral port — it keeps dialing our now-dead old port and never reconnects.
        #if canImport(Network)
        if syncActiveTransports.contains(.lan) {
            Task { [weak self] in
                for _ in 0..<50 {
                    guard let self, self.syncHost === host else { return }
                    if self.syncLANListener?.assignedPort != nil { break }
                    try? await Task.sleep(for: .milliseconds(100))
                }
                guard let self, self.syncHost === host else { return }
                // Persist the live port so the next launch prefers it (stable-hint reconnect).
                Self.persistLANPort(self.syncLANListener?.assignedPort)
                await self.peerClient?.setLocalAddresses(await self.currentLocalAddresses())
            }
        }
        #endif
        // The tailnet counterpart to the LAN re-push above: the tsnet node registers/comes up
        // asynchronously (a first-run browser login can take a while), so the initial address
        // snapshot carries no tailnet IP. Watch the node's status and re-publish our addresses each
        // time it reaches `.running`, so every connected peer's `addressUpdate` finally learns our
        // tailnet endpoint — without which an off-LAN reconnect has nothing to dial.
        if syncActiveTransports.contains(.tailnet) {
            tailnetAddressTask?.cancel()
            tailnetAddressTask = Task { [weak self] in
                for await status in await TailnetNode.shared.statusStream() {
                    guard let self, self.syncHost === host, !Task.isCancelled else { return }
                    if case .running = status {
                        await self.peerClient?.setLocalAddresses(await self.currentLocalAddresses())
                    }
                }
            }
        }
        // Proactively re-plan the mesh on a network path change (Fix for "connection doesn't re-form
        // over tailnet after LAN → cellular"): drop connections orphaned by the switch so they redial
        // over a still-valid path, and re-publish our own addresses (our LAN IP just changed / went
        // away). Runs off the monitor's queue → bounce onto the main actor to touch `AppStore`.
        #if canImport(Network)
        meshPathMonitor?.stop()
        let monitor = MeshPathMonitor { [weak self] in
            Task { @MainActor in
                guard let self, self.syncHost === host else { return }
                await self.peerClient?.networkPathChanged()
                await self.peerClient?.setLocalAddresses(await self.currentLocalAddresses())
            }
        }
        meshPathMonitor = monitor
        monitor.start()
        #endif
    }

    /// UserDefaults key holding the last LAN listener port, so a relaunch can reuse it and keep a
    /// sibling Mac's stored `lanHint` dialable across the restart. `0`/absent ⇒ let the OS pick.
    private static let lanPortDefaultsKey = "nucleic.sync.lanPort"

    /// The LAN port to prefer at startup — the one this Mac bound last launch, or `nil` to let the
    /// OS assign a fresh one (first launch, or after a `.any` fallback that never got persisted).
    static func preferredLANPort() -> UInt16? {
        let raw = UserDefaults.standard.integer(forKey: lanPortDefaultsKey)
        return (raw > 0 && raw <= 65_535) ? UInt16(raw) : nil
    }

    /// Remember the port the LAN listener actually bound, for next launch to reuse.
    static func persistLANPort(_ port: UInt16?) {
        guard let port else { return }
        UserDefaults.standard.set(Int(port), forKey: lanPortDefaultsKey)
    }

    /// Drive one transcript-sync reconcile pass from the current mesh presence. Proactive backfill
    /// is restricted to LAN/Tailnet holders — the relay stays on-demand only (bandwidth/cost).
    private func reconcileTranscriptSync() async {
        guard let coordinator = transcriptSyncCoordinator else { return }
        let candidates = meshPeers
            .filter { $0.connected && $0.transport != nil && $0.transport != .relay }
            .map(\.deviceID)
        var refs: [TranscriptSyncCoordinator.SessionRef] = []
        for peer in meshPeers where peer.connected {
            for summary in peer.sessions {
                refs.append(TranscriptSyncCoordinator.SessionRef(
                    sessionID: summary.sessionID, lastSeq: summary.lastSeq,
                    archived: summary.archived, ownerDeviceID: peer.deviceID))
            }
        }
        guard !refs.isEmpty else { return }
        await coordinator.sync(sessions: refs, candidates: candidates)
    }

    /// Where this Mac can be dialed right now — the LAN listener's live endpoint plus the
    /// tailnet IP when that method is up. Sent in `Welcome.addresses` and in the peer
    /// dialer's hellos so sibling Macs keep fresh reconnect hints.
    private func currentLocalAddresses() async -> PeerAddresses? {
        var lanHint: String?
        #if canImport(Network)
        if syncActiveTransports.contains(.lan),
           let host = LANAddress.primaryIPv4(), let port = syncLANListener?.assignedPort {
            lanHint = "\(host):\(port)"
        }
        #endif
        var tailnetHost: String?
        if syncActiveTransports.contains(.tailnet), syncTailnetListener != nil {
            let addrs = await TailnetNode.shared.addresses
            tailnetHost = addrs?.ip4 ?? addrs?.ip6
        }
        // The relay room is this host's identity, dialable whenever the method is up.
        let relayRoomID = syncActiveTransports.contains(.relay) && syncRelayListener != nil
            ? syncHostID : nil
        let addresses = PeerAddresses(
            lanHint: lanHint, tailnet: tailnetHost, relayRoomID: relayRoomID, updatedAt: Date())
        return addresses.isEmpty ? nil : addresses
    }

    /// Answer (or clear) a pending Mac-pairing confirm. `approved: false` also runs when the
    /// pairing window closes or the server stops, so the waiting handshake fails closed.
    public func resolveMacPairing(_ approved: Bool) {
        macPairTimeoutTask?.cancel()
        macPairTimeoutTask = nil
        // Withdraw the forwarded prompt on any phone showing it (harmless if none is).
        if let pending = pendingMacPairRequest {
            broadcast(.macPairResolved(deviceID: pending.deviceID, approved: approved))
        }
        pendingMacPairRequest = nil
        macPairContinuation?.resume(returning: approved)
        macPairContinuation = nil
    }

    /// Answer a Mac-pair confirm relayed from a phone (`ClientMsg.respondMacPair`). Guards on the
    /// pending request's `deviceID` so a stale answer (the request already resolved/withdrawn, or a
    /// different Mac is now pending) is ignored.
    public func respondToMacPairing(deviceID: String, approve: Bool) async {
        guard pendingMacPairRequest?.deviceID == deviceID else { return }
        resolveMacPairing(approve)
    }

    /// Pair this Mac to a sibling Mac by its pasted `nucleic://pair` link (mesh P4). Throws
    /// `PeerPairError` with user-facing text; success starts a managed connection.
    /// Join this Mac to a mesh group by a scanned/pasted member's code (mesh "join"). Scanning any
    /// member joins you to the whole group — the roster gossip that follows the handshake pins and
    /// connects every other member. A thin rename of `pairPeerMac` (kept as the internal name so
    /// the whole sync stack doesn't churn).
    public func joinMesh(with code: String) async throws {
        try await pairPeerMac(code: code)
    }

    public func pairPeerMac(code: String) async throws {
        guard let peerClient else { throw PeerPairError.meshOffline }
        // Refresh our own dialable addresses before the pairing hello goes out — the LAN port
        // isn't bound the instant the server starts, so the snapshot from `startPeerClient` may
        // be stale; by the time the user pastes a link it's live.
        await peerClient.setLocalAddresses(await currentLocalAddresses())
        try await peerClient.pair(with: code)
        await refreshPairedDevices()
    }

    /// Begin a pairing window and publish the QR string for the Settings sheet to render.
    /// The QR carries every active method's connection hint: the Mac's LAN IP + port when
    /// LAN is up (Bonjour is only a fallback), plus the tailnet IP + fixed port when the
    /// tailnet is on — the phone tries LAN first and falls back to the tailnet.
    public func beginPairing() async {
        guard let syncHost else { return }
        let tailnetActive = syncActiveTransports.contains(.tailnet) && syncTailnetListener != nil
        let addrs = tailnetActive ? await TailnetNode.shared.addresses : nil
        // Relay bootstrap (mesh P2): a membership token minted for a fresh pairing identity,
        // so the very first connection can ride the relay when no direct path exists. After
        // the hello, the host re-issues a token bound to the device's real id
        // (`SyncHost.register` → `relayMembership`). Best-effort — a relay hiccup shouldn't
        // block LAN pairing.
        var relay: WireRelayMembership?
        if syncActiveTransports.contains(.relay), syncRelayListener != nil {
            let pairingDeviceID = "pair-" + UUID().uuidString.prefix(8).lowercased()
            relay = await mintRelayMembership(for: pairingDeviceID)
        }
        #if canImport(Network)
        let pairingLANHost = LANAddress.primaryIPv4()
        let pairingLANPort = syncLANListener?.assignedPort
        #else
        let pairingLANHost: String? = nil
        let pairingLANPort: UInt16? = nil
        #endif
        let payload = await syncHost.beginPairing(
            lanHost: pairingLANHost,
            lanPort: pairingLANPort,
            transport: tailnetActive ? .tailnet : .lan,
            tailnetHost: addrs?.ip4 ?? addrs?.ip6,
            tailnetPort: tailnetActive ? syncTailnetListener?.port : nil,
            relay: relay)
        pairingQR = try? payload.qrString()
    }

    public func endPairing() async {
        await syncHost?.cancelPairing()
        pairingQR = nil
        resolveMacPairing(false)  // closing the window answers a pending confirm with "no"
        await refreshPairedDevices()
    }

    public func revokeDevice(_ deviceID: String) async {
        // Mesh "join" group-wide revoke: tombstone (not just remove) so the death rides gossip and
        // other members drop the device too — and can't re-introduce it. The pin is gone, so its
        // next handshake to us fails `.unauthorized`; it also stops being dialed.
        await syncHost?.tombstone(deviceID: deviceID)
        await peerClient?.reconcile()
        // Drop every transcript mirror synced from the revoked device (full-transcript mesh sync)
        // — a single directory removal; we no longer trust or serve its history.
        await mirrorStore.removeSource(deviceID)
        await meshPeersChanged()  // refresh + gossip the tombstone to connected members
    }

    /// Leave the mesh: forget every paired Mac in one action. Each is tombstoned (mesh "join"), so
    /// the group-wide revocation propagates to still-connected members before their links drop —
    /// this Mac's sessions vanish from the others (their connection to us fails its pin check) and
    /// theirs vanish from us. Paired phones are untouched.
    public func leaveMesh() async {
        for mac in pairedDevices where mac.kind.canOwnSessions {
            await syncHost?.tombstone(deviceID: mac.deviceID)
            await mirrorStore.removeSource(mac.deviceID)  // drop its mirrored transcripts too
        }
        await peerClient?.reconcile()
        await meshPeersChanged()
    }

    private func refreshPairedDevices() async {
        pairedDevices = (await syncHost?.pairedDevices() ?? []).sorted { $0.pairedAt < $1.pairedAt }
        inboundConnectedDeviceIDs = await syncHost?.connectedDeviceIDs() ?? []
    }

    /// Collects per-transport start failures from `CompositeSyncListener`, which reports on
    /// the sync host's executor mid-`start` — the commit point reads the snapshot back on
    /// the main actor instead of racing observable state.
    private final class TransportFailureBox: @unchecked Sendable {
        private let lock = NSLock()
        private var failures: [SyncTransportHint: String] = [:]

        func record(_ transport: SyncTransportHint, _ message: String) {
            lock.lock()
            defer { lock.unlock() }
            failures[transport] = message
        }

        func snapshot() -> [SyncTransportHint: String] {
            lock.lock()
            defer { lock.unlock() }
            return failures
        }
    }

    // MARK: - Sync host bridge support

    private func broadcast(_ event: HostBroadcast) {
        syncBroadcastContinuation?.yield(event)
    }

    /// The model/effort catalog projected to paired phones. Injected by the app layer at
    /// startup because `ModelCatalog` lives in NucleicApp; `.empty` until then (a phone that
    /// receives an empty catalog falls back to its built-in effort list).
    private var injectedModelCatalog: WireModelCatalog = .empty

    /// Hand the sync host the wire model catalog the app built from its `ModelCatalog`.
    public func setSyncModelCatalog(_ catalog: WireModelCatalog) {
        injectedModelCatalog = catalog
    }

    /// Where a moved session (mesh P5) went, resolved to a friendly name for the sidebar / wire.
    /// `nil` unless the session was tombstoned by a transfer. The name comes from the paired-device
    /// store (still present at move time); a session moved to a since-unpaired Mac falls back to a
    /// generic label rather than exposing the raw 64-char host id.
    func movedDestination(for session: Session) -> MovedDestination? {
        guard let deviceID = session.movedToDeviceID else { return nil }
        let name = pairedDevices.first { $0.deviceID == deviceID }?.label ?? "another Mac"
        return MovedDestination(deviceID: deviceID, deviceName: name)
    }

    /// Where a session arrived from (mesh P5), resolved to a friendly name — the mirror of
    /// `movedDestination(for:)`. `nil` unless the session was transferred in. A session that arrived
    /// from a since-unpaired Mac falls back to a generic label rather than a raw host id.
    func arrivedSource(for session: Session) -> ArrivedFrom? {
        guard let deviceID = session.arrivedFromDeviceID else { return nil }
        let name = pairedDevices.first { $0.deviceID == deviceID }?.label ?? "another Mac"
        return ArrivedFrom(deviceID: deviceID, deviceName: name)
    }

    /// Map a host `Session` to the wire `SessionSummary` the phone renders. Takes the session's
    /// pending approvals (not just their count) so it can carry `pendingQuestionCount` for an
    /// `AskUserQuestion` block, letting remotes name it "Question(s) Asked" like the host does.
    fileprivate func wireSummary(for session: Session, pendingApprovals: [ApprovalRequest] = []) -> WireSessionSummary {
        WireSessionSummary(
            sessionID: session.id,
            projectID: session.projectID.rawValue,
            projectName: projectsByID[session.projectID]?.name ?? "",
            backend: session.backend,
            status: session.status,
            disposition: session.lastTurnDisposition,
            title: session.title,
            branch: session.branch ?? "",
            lastSeq: session.lastSeq,
            diffStat: session.diffStat,
            pendingApprovalCount: pendingApprovals.count,
            pendingQuestionCount: SessionSummary.questionCount(of: pendingApprovals.first),
            // The oldest pending approval's id + risk, so the phone's Live Activity can resolve it
            // inline (Allow/Deny on the glance) without opening the transcript. Just id/risk — the
            // tool input never rides the summary.
            firstApprovalID: pendingApprovals.first?.id,
            firstApprovalIsHighRisk: pendingApprovals.first.map(\.risk.isHigh),
            favorite: session.favorite,
            archived: session.archived,
            queuedMessage: session.queuedMessage,
            queuedMessages: session.queuedMessages,
            model: session.model,
            effort: session.effort,
            auto: session.auto,
            autoShip: session.autoShip,
            shipBranch: session.shipBranch,
            updatedAt: session.updatedAt,
            lastUserMessageAt: session.lastUserMessageAt,
            movedTo: movedDestination(for: session),
            arrivedFrom: arrivedSource(for: session))
    }
}

/// A session that arrived from a peer Mac but was stranded at `.ready` when this Mac relaunched
/// mid-transfer (mesh P5) — what the "Interrupted arrivals" recovery UI renders per stranded chat.
public struct ArrivedTransferPrompt: Sendable, Equatable, Identifiable {
    public var id: String { transferID }
    public let transferID: String
    /// The staged chat's title, for the recovery prompt.
    public let title: String
    /// The source Mac's `HostID`, resolved to a friendly name by the view (live from paired Macs).
    public let sourceDeviceID: String

    public init(transferID: String, title: String, sourceDeviceID: String) {
        self.transferID = transferID
        self.title = title
        self.sourceDeviceID = sourceDeviceID
    }
}

/// A Mac mid-pairing awaiting the user's confirm (mesh P4) — what the accepting Mac's
/// pairing sheet renders in its allow/deny dialog.
public struct MacPairRequest: Sendable, Equatable, Identifiable {
    public var id: String { deviceID }
    public let deviceID: String
    public let label: String

    public init(deviceID: String, label: String) {
        self.deviceID = deviceID
        self.label = label
    }
}

/// `AppStore` is the host authority the iPhone projects (SYNC_PROTOCOL §1). Conformance lives
/// in this file so it can reach `controllers` and the broadcast continuation; everything is
/// `@MainActor`, satisfying the protocol's `async` requirements via cross-actor hops.
extension AppStore: SyncHostBridge {
    public var hostInfo: HostInfo {
        // hostID is the key hash (rename-proof, collision-free) — never the display name.
        // `startSyncServer` sets it before any connection can ask; the name fallback covers
        // only tests that drive the bridge without the server.
        HostInfo(hostID: syncHostID ?? syncHostName, hostName: syncHostName)
    }

    /// This host's mesh kind, stamped into `Welcome.hostKind` so a device pairing directly with
    /// it pins the right kind (docs/ANTIMATTER_RUNNER.md §3). `.mac` for the cockpit; nucleicd
    /// sets `meshSelfKind = .cloud`.
    public var hostKind: PeerKind { meshSelfKind }

    public var capabilities: WireCapabilities {
        // Mirrors ClaudeCodeBackend (BACKEND_PROTOCOL §2.1): modify-and-allow + the
        // always-rule scopes the Mac offers. `canFetchDiff` advertises the on-demand
        // full-diff fetch (the phone's Diff tab).
        WireCapabilities(
            canModifyToolInput: true,
            allowAlwaysScopes: [.session, .toolName, .toolNameWithPattern],
            canFetchDiff: true,
            canListPeers: true,
            canUpdateAddresses: true,
            canReceiveSessionTransfer: true,
            canSyncRoster: true,
            canSyncTranscripts: true,
            canPushLiveActivity: true,
            canPushToStartLiveActivity: true,
            canMintPairingCode: true,
            canReceiveForegroundState: true,
            // Clone-and-register (CLOUD_RUNTIME §4.3): the same `addClonedProject` the Mac's
            // own "clone" sheet uses, so any AppStore-backed host — cockpit or runner — can be
            // seeded with a project from a device.
            canCreateProjects: true)
    }

    /// Mint a join code for a phone relaying "add a device to this mesh" (`ClientMsg
    /// .requestPairingCode`): open a pairing window here — arming the one-time secret and
    /// producing the QR, exactly as the Mac's own "Add device…" button does — and return its
    /// join string. Nil when the sync server isn't running (no host to pair against).
    public func mintPairingCode() async -> String? {
        guard syncHost != nil else { return nil }
        await beginPairing()
        return pairingQR
    }

    /// Close the pairing window a `mintPairingCode` opened (the relaying phone dismissed its
    /// sheet), so the one-time secret doesn't linger armed.
    public func cancelPairingCode() async {
        await endPairing()
    }

    /// Mesh peers this host knows (mesh P4): the paired devices, marked online when they have a
    /// live connection — inbound (they dialed us) or outbound (our peer dialer holds one).
    /// Feeds the phone's mesh device list and the transfer destination picker.
    public func peerSummaries() async -> [PeerSummary] {
        let inbound = await syncHost?.connectedDeviceIDs() ?? []
        let outbound = Set(meshPeers.filter(\.connected).map(\.deviceID))
        return pairedDevices.map { device in
            PeerSummary(
                deviceID: device.deviceID, label: device.label, kind: device.kind,
                capabilities: device.capabilities,
                online: inbound.contains(device.deviceID) || outbound.contains(device.deviceID),
                lastSeenAt: device.lastSeenAt)
        }
    }

    /// Where this host can be dialed, for `Welcome.addresses` (mesh P4).
    public var localAddresses: PeerAddresses? {
        get async { await currentLocalAddresses() }
    }

    /// A Mac completed the pairing handshake and wants in (mesh P4 locked decision: control
    /// scope needs an explicit confirm on the accepting Mac, not just QR possession). Publishes
    /// the request for the pairing sheet's dialog and waits for `resolveMacPairing` — or fails
    /// closed after a timeout, when another confirm is already pending, or when it can't be
    /// shown (no pairing window open).
    public func approveMacPairing(deviceID: String, label: String) async -> Bool {
        guard macPairContinuation == nil, pairingQR != nil else { return false }
        pendingMacPairRequest = MacPairRequest(deviceID: deviceID, label: label)
        // Forward the confirm to control-scope clients too, so the phone that shared the join code
        // can approve the join (first responder wins with this Mac's own dialog). Crucial for a
        // phone-relayed mint, where this Mac's pairing sheet isn't even open to show the dialog.
        broadcast(.macPairRequested(WireMacPairRequest(deviceID: deviceID, label: label)))
        // The timer is stored and cancelled by `resolveMacPairing` the moment this confirm is
        // answered, so it can only ever resolve the request it was armed for — no value-equality
        // guard that a later, identical attempt could trip.
        macPairTimeoutTask = Task { [weak self] in
            try? await Task.sleep(for: .seconds(120))
            guard !Task.isCancelled else { return }
            self?.resolveMacPairing(false)
        }
        return await withCheckedContinuation { continuation in
            macPairContinuation = continuation
        }
    }

    /// The peer table or presence changed (a device paired, connected, disconnected, or moved).
    /// Refresh the published list and let the dialer pick up new/removed peer Macs. A no-op once
    /// the server is down — a stray change from a closing handler must not wipe `pairedDevices`
    /// (which the Settings banners read even while remote access is off).
    public func meshPeersChanged() async {
        guard syncHost != nil else { return }
        await refreshPairedDevices()
        await peerClient?.reconcile()
        // Mesh "join": membership/addresses changed → gossip the current roster to every connected
        // member so they converge without waiting for a reconnect. Deduped so an unchanged roster
        // doesn't re-push (breaks a re-gossip ping-pong).
        await gossipRosterIfChanged()
        // A peer (re)connecting is our chance to finish a transfer a crash left tombstoned (mesh P5).
        // Fire-and-forget + single-flight, so it doesn't stall the mesh refresh or double up.
        Task { [weak self] in await self?.recoverInterruptedTransfers() }
    }

    // MARK: Mesh "join" — roster gossip

    /// This host's current group view (live members + revocation tombstones), built from the shared
    /// paired-device store. Self is naturally excluded (a host isn't in its own store); every peer
    /// we're connected to already has us pinned, so omitting self loses nothing.
    public func meshRoster() async -> MeshRosterPush {
        guard let store = syncPairedStore else { return MeshRosterPush() }
        return MeshRosterPush(
            members: await store.all().map(MeshMember.init(pinned:)),
            tombstones: await store.tombstones())
    }

    /// Merge a roster a member pushed (mesh "join"): the one path both the accepting-host side
    /// (`ConnectionHandler`) and the Mac-dialer side (`PeerClient`) route into. On a real change it
    /// reconciles dial loops (picking up newly-pinned Macs) and re-gossips onward; the `changed`
    /// guard makes the gossip converge instead of looping.
    public func mergeMeshRoster(_ push: MeshRosterPush, from deviceID: String) async {
        guard let store = syncPairedStore, let hostID = syncHostID else { return }
        let changed = await MeshRosterMerge.apply(push, into: store, selfDeviceID: hostID)
        if changed { await meshPeersChanged() }
    }

    /// Push the roster to all connected members (inbound clients + outbound peer Macs), but only
    /// when it differs from the last one we sent.
    private func gossipRosterIfChanged() async {
        guard let syncHost else { return }
        let roster = await meshRoster()
        guard roster != lastGossipedRoster else { return }
        lastGossipedRoster = roster
        await syncHost.broadcastMeshRoster(roster)
        await peerClient?.pushRoster(roster)
    }

    // MARK: Session transfer — destination side (mesh P5)

    /// The lazily-built importer. One per host, sharing this Mac's store + worktree manager, with
    /// this `AppStore` as its `TransferImportEnvironment`.
    private func transferImporter() -> SessionTransferImporter {
        if let existing = _transferImporter { return existing }
        let environment = StandardTransferImportEnvironment(
            transcriptsDir: transcriptsDir,
            resolveProject: { [weak self] descriptor in
                await self?.resolveTransferProject(descriptor) ?? nil
            },
            didActivate: { [weak self] session in
                await self?.activateTransferredSession(session)
            })
        let importer = SessionTransferImporter(
            store: database, worktrees: worktrees, environment: environment,
            stagingRoot: transcriptsDir.deletingLastPathComponent()
                .appendingPathComponent("transfers", isDirectory: true),
            now: { [now] in now() })
        _transferImporter = importer
        return importer
    }

    /// Reconcile transfers left mid-flight by a crash/quit (mesh P5 relaunch recovery). Called once
    /// at launch (after `loadSessions`) and again whenever a peer (re)connects, so a durably-moved
    /// source finishes its 2-phase commit the moment the destination is reachable. Single-flight and
    /// cheap when there's nothing to reconcile.
    ///
    /// - outbound pre-tombstone (`.offering`/`.streaming`/`.awaitingReady`) — abandoned before the
    ///   commit point, so the source session lives here again (loadSessions rebuilt it because
    ///   `movedToDeviceID` is nil); free the stale lock so it can be moved again.
    /// - outbound `.tombstoned` — durably moved but unconfirmed: re-send the idempotent commit to
    ///   the destination peer if reachable, discarding the now-stale source worktree once it acks.
    /// - inbound `.staging` — an orphaned partial receive: drop its staging dir + lock.
    ///
    /// A stranded inbound `.ready` (staged, verified, but the source vanished before commit) is left
    /// alone here — recovering it needs a destination-side "Activate anyway" that reconstructs the
    /// staged session from disk, which isn't built yet.
    public func recoverInterruptedTransfers() async {
        guard !isRecoveringTransfers else { return }
        isRecoveringTransfers = true
        defer { isRecoveringTransfers = false }
        guard let locks = try? await database.allActiveTransfers(), !locks.isEmpty else { return }
        var hadOrphanedInboundStaging = false
        for lock in locks {
            switch (lock.direction, lock.state) {
            case (.outbound, .offering), (.outbound, .streaming), (.outbound, .awaitingReady):
                try? await database.clearTransfer(transferID: lock.transferID)
            case (.outbound, .tombstoned):
                await completeTombstonedTransfer(lock)
            case (.inbound, .staging):
                hadOrphanedInboundStaging = true
            default:
                break   // inbound .ready (stranded, awaits activate-anyway) + terminal states
            }
        }
        if hadOrphanedInboundStaging { await transferImporter().discardStaleInboundTransfers() }

        // Surface any transfer stranded at `.ready` (staged here, source vanished before commit) so
        // the user can activate it anyway or discard it. `recoverableInboundTransfers` also drops any
        // whose on-disk manifest is gone (unrecoverable).
        let recoverable = await transferImporter().recoverableInboundTransfers()
        pendingArrivedTransfers = recoverable.map {
            ArrivedTransferPrompt(
                transferID: $0.transferID, title: $0.session.title, sourceDeviceID: $0.sourceDeviceID)
        }
    }

    /// Activate a stranded arrival the user chose to keep (mesh P5 "Activate anyway"): the importer
    /// inserts the staged session + the environment rebuilds its controller and broadcasts, so it
    /// joins the live list. Clears the prompt either way.
    public func activateArrivedSession(_ transferID: String) async {
        do {
            _ = try await transferImporter().activateRecoveredTransfer(transferID)
        } catch {
            lastError = "Couldn't activate the arrived chat: \(error.localizedDescription)"
        }
        pendingArrivedTransfers.removeAll { $0.transferID == transferID }
    }

    /// Discard a stranded arrival the user doesn't want (mesh P5): tear down the imported worktree +
    /// branch and the placed transcript (the app owns those), then clear the importer's staging + the
    /// lock. Clears the prompt.
    public func discardArrivedSession(_ transferID: String) async {
        let recoverable = await transferImporter().recoverableInboundTransfers()
        if let target = recoverable.first(where: { $0.transferID == transferID }) {
            let s = target.session
            if let project = projectsByID[s.projectID], let path = s.worktreePath,
               let branch = s.branch, let baseSHA = s.baseSHA {
                let worktree = Worktree(
                    sessionID: s.id, path: path, branch: branch, baseSHA: baseSHA, createdAt: s.createdAt)
                try? await worktrees.discardAfterTransfer(worktree, in: project)
            }
            // Remove the transcript we placed for the staged (never-activated) session.
            try? FileManager.default.removeItem(
                at: URL(fileURLWithPath: s.transcriptPath).deletingLastPathComponent())
        }
        await transferImporter().clearInboundStaging(transferID)
        pendingArrivedTransfers.removeAll { $0.transferID == transferID }
    }

    /// Re-send the idempotent commit for one `.tombstoned` outbound transfer to its destination peer,
    /// if that peer is connected and advertising the capability. A no-op when unreachable — the lock
    /// stays `.tombstoned` (and the source keeps reading "Moved to <Mac>") for a later attempt.
    private func completeTombstonedTransfer(_ lock: SessionTransferLock) async {
        guard let peerClient, await peerClient.canTransfer(to: lock.peerDeviceID),
              let channel = await peerClient.openTransferChannel(to: lock.peerDeviceID) else { return }
        let session = try? await database.loadSession(id: lock.sessionID)
        let project = session.flatMap { projectsByID[$0.projectID] }
        _ = await SessionTransferCoordinator.recoverTombstoned(
            lock: lock, session: session, project: project, channel: channel,
            store: database, worktrees: worktrees, now: now())
        await peerClient.closeTransferChannel(to: lock.peerDeviceID)
    }

    /// Match an incoming project descriptor to a local project (mesh P5): by UUID first, then by
    /// repo identity (root commit / normalized remote), so a project the user added separately on
    /// each Mac still matches. Nil ⇒ the user must add the project here first.
    func resolveTransferProject(_ descriptor: ProjectDescriptor) async -> Project? {
        // Only match an *active* project — importing into an archived one hides the session
        // (the identity fallback below already excludes archived, so keep the UUID path consistent).
        if let byID = projectsByID[descriptor.projectID], byID.archivedAt == nil { return byID }
        let probe = TransferGitProbe()
        for project in projects where project.archivedAt == nil {
            guard let identity = try? await probe.repoIdentity(rootPath: project.rootPath) else { continue }
            let local = ProjectDescriptor(
                projectID: project.id, name: project.name,
                rootCommitSHA: identity.rootCommitSHA, normalizedRemote: identity.normalizedRemote,
                defaultBranch: project.defaultBranch.value)
            if descriptor.matches(local) { return project }
        }
        return nil
    }

    /// A transferred session was committed here (mesh P5): build its controller, register it, and
    /// broadcast its arrival so the sidebar and any connected phones show it. The row is already
    /// persisted by the importer's atomic activate.
    func activateTransferredSession(_ session: Session) async {
        guard controllers[session.id] == nil, let project = projectsByID[session.projectID] else { return }
        let events = (try? TranscriptReader(url: URL(fileURLWithPath: session.transcriptPath)).read().events) ?? []
        guard let controller = reconstructController(for: session, in: project, events: events) else { return }
        controllers[session.id] = controller
        namedSessions.insert(session.id)
        observe(controller, session.id)
        let summary = SessionSummary(session, pendingApprovalCount: 0)
        upsertSummary(summary)
        broadcast(.sessionUpdated(wireSummary(for: session)))
    }

    public func receiveTransferOffer(_ offer: TransferOffer, from deviceID: String) async -> [HostMsg] {
        await transferImporter().receiveOffer(offer, from: deviceID)
    }
    public func receiveTransferChunk(_ chunk: TransferChunk) async -> [HostMsg] {
        await transferImporter().receiveChunk(chunk)
    }
    public func receiveTransferCommit(_ transferID: String) async -> [HostMsg] {
        await transferImporter().receiveCommit(transferID)
    }
    public func receiveTransferCancel(_ transferID: String) async {
        await transferImporter().receiveCancel(transferID)
    }

    // MARK: Session transfer — source side (mesh P5)

    /// Peers this session could be moved to right now: connected session-owning peers (a sibling
    /// Mac or a cloud runner) that advertised the transfer capability. Feeds the "Move to …"
    /// picker. `canOwnSessions` (not `== .mac`) so a runner is a valid destination
    /// (docs/ANTIMATTER_RUNNER.md §3).
    public func transferDestinations() -> [PeerClient.PeerPresence] {
        meshPeers.filter { $0.connected && $0.canReceiveSessionTransfer && $0.kind.canOwnSessions }
    }

    /// Move an idle session to a paired peer Mac (mesh P5). Classifies transferability, quiesces
    /// (auto-commits uncommitted work), packages the branch + transcript (+ best-effort native
    /// memory), and runs the 2-phase-commit coordinator over the peer connection. On success the
    /// session is tombstoned here and leaves the live list. Throws `SessionTransferError`
    /// (including a typed classification reject) on failure, leaving the session untouched.
    public func moveSession(_ id: SessionID, to peerDeviceID: String) async throws {
        guard !transferringSessions.contains(id), !deletingSessions.contains(id),
              let controller = controllers[id] else {
            throw SessionTransferError.rejected(.internalError, message: "Session not found or already moving.")
        }
        // Detach the controller SYNCHRONOUSLY, up front, before any `await`. This is the single
        // chokepoint that makes the session inert on EVERY path — local composer, approval resume,
        // remote sendInput/interrupt/integrate/setSession*/delete — because they all resolve
        // `controllers[id]` and now find nothing. No new turn can start while it's being moved, so
        // it can't run in two places. If we bail before the tombstone, `restore()` reattaches it.
        transferringSessions.insert(id)
        let removedSummary = summaries.first { $0.id == id }
        let wasOpen = openSessionID == id
        observers[id]?.cancel(); observers[id] = nil
        controllers[id] = nil
        summaries.removeAll { $0.id == id }
        if wasOpen { openSessionID = nil }
        func restore() {
            transferringSessions.remove(id)
            controllers[id] = controller
            observe(controller, id)
            if let removedSummary { upsertSummary(removedSummary) }
            if wasOpen { openSessionID = id }  // a failed move returns the user to the open chat
            // Mid-transfer the session is hidden from the wire list; a viewer that refreshed
            // in that window dropped the row — push the restored list so it comes back.
            broadcastSessionListChanged()
        }

        let session = await controller.snapshot.session
        guard let project = projectsByID[session.projectID] else {
            restore(); throw SessionTransferError.rejected(.internalError, message: "Project not found.")
        }

        // Classify: session-level first, then repo shape (a git probe). An in-flight turn started
        // before the detach is caught here (notQuiescent) and the session is restored.
        let isParent = await isParentOfLiveChild(id)
        if let reason = TransferClassifier.sessionReject(
            session: session, project: project, isParentOfLiveChild: isParent) {
            restore(); throw SessionTransferError.rejected(reason, message: reason.message)
        }
        let probe = TransferGitProbe()
        if let reason = (try? await probe.repoShape(rootPath: project.rootPath))?.rejectReason {
            restore(); throw SessionTransferError.rejected(reason, message: reason.message)
        }

        // Only send transfer verbs to a peer that advertised it can receive them (the locked
        // capability-gating invariant) and is a connected mac.
        guard let peerClient, await peerClient.canTransfer(to: peerDeviceID) else {
            restore(); throw SessionTransferError.disconnected("The other Mac isn't connected or can't receive transfers.")
        }
        guard let channel = await peerClient.openTransferChannel(to: peerDeviceID) else {
            restore(); throw SessionTransferError.disconnected("The other Mac isn't connected.")
        }
        func close() async { await peerClient.closeTransferChannel(to: peerDeviceID) }

        guard let path = session.worktreePath, let branch = session.branch, let baseSHA = session.baseSHA else {
            await close(); restore()
            throw SessionTransferError.rejected(.noWorktree, message: TransferRejectReason.noWorktree.message)
        }
        let worktree = Worktree(sessionID: id, path: path, branch: branch, baseSHA: baseSHA, createdAt: session.createdAt)
        // Quiesce: capture any uncommitted work into the branch so the bundle is complete.
        try? await worktrees.finalize(worktree, commit: .auto(message: "nucleic: transfer snapshot"), onCommit: nil)

        let tip = (try? await GitRunner().checked(["rev-parse", "HEAD"], in: path)) ?? baseSHA
        let identity = try? await probe.repoIdentity(rootPath: project.rootPath)
        let descriptor = ProjectDescriptor(
            projectID: project.id, name: project.name,
            rootCommitSHA: identity?.rootCommitSHA, normalizedRemote: identity?.normalizedRemote,
            defaultBranch: project.defaultBranch.value)
        let nativeURL = await controller.nativeTranscriptURL()
        let stagingRoot = transcriptsDir.deletingLastPathComponent()
            .appendingPathComponent("transfers", isDirectory: true)

        let package: SessionTransferPackage
        do {
            package = try SessionTransferPackage.prepare(
                transferID: UUID().uuidString, session: session, project: project,
                projectDescriptor: descriptor, worktree: worktree,
                transcriptURL: URL(fileURLWithPath: session.transcriptPath),
                nativeTranscriptURL: nativeURL, branchTipSHA: tip,
                transcriptHeaderVersion: SessionHeader.currentVersion,
                stagingRoot: stagingRoot, worktrees: worktrees)
        } catch {
            await close(); restore()  // pre-tombstone failure — the session lives here again
            throw SessionTransferError.rejected(.internalError, message: "Couldn't package the session: \(error)")
        }

        let coordinator = SessionTransferCoordinator(
            session: session, project: project, worktree: worktree,
            destinationDeviceID: peerDeviceID, package: package, channel: channel,
            store: database, worktrees: worktrees)
        do {
            try await coordinator.run(now: now())
        } catch SessionTransferError.committedButUnconfirmed {
            // Past the tombstone: the session is durably moved. Shut the (already-detached) source
            // controller down for good, regardless of the missing ack — the destination will
            // activate on the redelivered commit. The DB tombstone + transfer lock survive.
            await close()
            await finalizeMovedTeardown(id, controller: controller)
            throw SessionTransferError.committedButUnconfirmed
        } catch {
            await close(); restore()  // pre-tombstone failure — the session lives here again
            throw error
        }
        await close()
        await finalizeMovedTeardown(id, controller: controller)
    }

    /// Finish tearing down an already-detached moved session: broadcast the archived departure,
    /// shut the controller down (backend + transcript handle), reap its container, clear the flag.
    private func finalizeMovedTeardown(_ id: SessionID, controller: SessionController) async {
        if let moved = try? await database.loadSession(id: id) {
            // Re-surface the tombstone as a read-only "Moved to <Mac>" row (mesh P5). At move start
            // the row was removed from `summaries` to make it inert; now it returns, archived, under
            // the sidebar's Archived section — and the same marker rides the wire to phones.
            upsertSummary(SessionSummary(moved, pendingApprovalCount: 0, movedTo: movedDestination(for: moved)))
            broadcast(.sessionUpdated(wireSummary(for: moved)))
        }
        await controller.shutdown()
        if let name = await containerManager?.teardown(id, waitForActive: false) {
            reportSandboxCleanupFailures([name])
        }
        transferringSessions.remove(id)
        if openSessionID == id { openSessionID = nil }
    }

    /// UI-friendly `moveSession`: reports failures via `lastError` instead of throwing, so the
    /// sidebar's "Move to <Mac>…" menu can fire it in a plain `Task`.
    public func moveSessionToPeer(_ id: SessionID, to peerDeviceID: String, label: String) async {
        do {
            try await moveSession(id, to: peerDeviceID)
        } catch {
            lastError = Self.transferErrorMessage(error, label: label)
        }
    }

    /// The transfer error → user-facing message mapping (mesh P5), shared by the single-session menu
    /// and the bulk hand-off flow. `committedButUnconfirmed` is a *moved-but-unconfirmed* outcome the
    /// bulk path counts as a success, so it's messaged as one here.
    private static func transferErrorMessage(_ error: Error, label: String) -> String {
        switch error {
        case SessionTransferError.rejected(_, let message): return message
        case SessionTransferError.disconnected(let why): return "Couldn't move to \(label): \(why)"
        case SessionTransferError.alreadyTransferring: return "That session is already being moved."
        case SessionTransferError.committedButUnconfirmed:
            return "The session moved to \(label), but it hasn't confirmed yet."
        case SessionTransferError.unexpectedReply:
            return "\(label) sent an unexpected response; the move was cancelled."
        default: return "Move to \(label) failed: \(error.localizedDescription)"
        }
    }

    /// The sessions eligible to hand off to a peer Mac right now (mesh P5 bulk hand-off): live,
    /// non-archived, quiescent, standalone (not a nested child or a parent of a live child), and not
    /// already moving. Repo-shape rejects (shallow/LFS/…) are only known after a git probe, so a
    /// listed session can still fail at move time — surfaced in the batch's rollup.
    public func transferableSessions() async -> [SessionSummary] {
        var out: [SessionSummary] = []
        for (id, controller) in controllers where !transferringSessions.contains(id) {
            let session = await controller.snapshot.session
            guard !session.archived, let project = projectsByID[session.projectID] else { continue }
            let isParent = await isParentOfLiveChild(id)
            if TransferClassifier.sessionReject(
                session: session, project: project, isParentOfLiveChild: isParent) == nil {
                out.append(SessionSummary(session, pendingApprovalCount: 0))
            }
        }
        return out.sorted { $0.updatedAt > $1.updatedAt }
    }

    /// Hand several sessions off to one peer Mac in sequence (mesh P5). Each rides the single-session
    /// path; failures are aggregated into one `lastError` rollup rather than a burst of alerts.
    /// Sequential so the per-peer transfer channel is never opened concurrently. Returns the counts.
    @discardableResult
    public func moveSessionsToPeer(
        _ ids: [SessionID], to peerDeviceID: String, label: String
    ) async -> (moved: Int, failed: Int) {
        var moved = 0, failed = 0
        var firstFailure: String?
        for id in ids {
            do {
                try await moveSession(id, to: peerDeviceID)
                moved += 1
            } catch SessionTransferError.committedButUnconfirmed {
                moved += 1   // durably moved; only the ack is missing (recovery finishes it)
            } catch {
                failed += 1
                if firstFailure == nil { firstFailure = Self.transferErrorMessage(error, label: label) }
            }
        }
        if failed > 0 {
            let total = moved + failed
            lastError = moved == 0
                ? "Couldn't hand off to \(label). \(firstFailure ?? "")"
                : "Handed \(moved) of \(total) off to \(label). \(firstFailure ?? "")"
        }
        return (moved, failed)
    }

    /// Whether any live session has `id` as its parent (a stack member can't move alone).
    private func isParentOfLiveChild(_ id: SessionID) async -> Bool {
        for (cid, controller) in controllers where cid != id {
            if await controller.snapshot.session.parentSessionID == id { return true }
        }
        return false
    }


    public var modelCatalog: WireModelCatalog { injectedModelCatalog }

    /// This build's release channel, from the same compile-time `NUCLEIC_*` define the build
    /// banner and container-naming use (`Package.swift` sets it on `NucleicCore`). Sent in
    /// `Welcome` and gated against the remote's channel in `ConnectionHandler`.
    public var releaseChannel: ReleaseChannel? {
        #if NUCLEIC_STABLE
        return .release
        #elseif NUCLEIC_RC
        return .releaseCandidate
        #elseif NUCLEIC_BETA
        return .beta
        #elseif NUCLEIC_CANARY
        return .canary
        #else
        return .local
        #endif
    }

    public func sessionSummaries() async -> [WireSessionSummary] {
        var result: [WireSessionSummary] = []
        for (id, controller) in controllers where !transferringSessions.contains(id) {
            let snap = await controller.snapshot
            // Ephemeral (Scratch) chats are local-only — never surface them to phones/peers.
            if isEphemeral(snap.session) { continue }
            result.append(wireSummary(for: snap.session, pendingApprovals: snap.pendingApprovals))
        }
        // Moved-away sessions (mesh P5) have no controller but still ship as read-only "Moved to
        // <Mac>" tombstones, so a phone that (re)subscribes after the move sees where the session
        // went rather than it silently vanishing. Their project must be active (matches the sidebar).
        let activeProjectIDs = Set(projects.map(\.id))
        let moved = (try? await database.loadAllSessions())?.filter {
            $0.movedToDeviceID != nil && controllers[$0.id] == nil
                && activeProjectIDs.contains($0.projectID)
        } ?? []
        result += moved.map { wireSummary(for: $0) }
        return result.sorted { $0.updatedAt > $1.updatedAt }
    }

    public func snapshot(_ id: SessionID, sinceSeq: UInt64?) async -> SessionSnapshot? {
        // A session mid-transfer is inert here (mesh P5) — don't hand out a subscribable snapshot.
        guard !transferringSessions.contains(id), let controller = controllers[id] else { return nil }
        let snap = await controller.snapshot
        // Ephemeral (Scratch) chats are local-only — a peer should never be able to subscribe to one
        // (belt-and-suspenders: they're already absent from `sessionSummaries`, so undiscoverable).
        guard !isEphemeral(snap.session) else { return nil }
        let all = await controller.transcriptSoFar()
        let events: [AgentEvent]
        if let sinceSeq {
            events = all.filter { $0.seq > sinceSeq }   // warm resubscribe (SYNC §5.3)
        } else {
            events = Array(all.suffix(200))             // cold subscribe: a tail window
        }
        let cursor = all.last?.seq ?? snap.session.lastSeq
        return SessionSnapshot(
            summary: wireSummary(for: snap.session, pendingApprovals: snap.pendingApprovals),
            recentEvents: events,
            pendingApprovals: snap.pendingApprovals,
            cursor: cursor,
            // Seed the peer with any Bash summary lines already computed for this session (mesh
            // session sync) — non-empty only when this Mac has it open, the only one it summarizes.
            toolSummaries: toolSummaryLinesForSnapshot(id))
    }

    /// Serve a session's transcript events after `afterSeq` for full-transcript mesh sync
    /// (`ClientMsg.fetchTranscript`). Unlike `snapshot` (which is capped at a 200-event tail and
    /// requires a live controller), this returns the *whole* history and falls back through three
    /// sources so a peer that doesn't run the session can still serve it:
    ///   1. a live controller (owned + running here) — the freshest,
    ///   2. an owned on-disk transcript (owned but not currently controllered),
    ///   3. a mirror synced from another device.
    /// `afterSeq == 0` returns everything plus the header (so the requester can build a mirror
    /// file); a positive cursor returns only `seq > afterSeq` and no header (a top-up). Returns
    /// nil when this device holds no transcript for `id` (⇒ `transcriptUnavailable(notHeld)`).
    public func transcriptEvents(_ id: SessionID, afterSeq: UInt64) async
        -> (header: SessionHeader?, events: [AgentEvent])? {
        func slice(_ all: [AgentEvent]) -> [AgentEvent] {
            afterSeq == 0 ? all : all.filter { $0.seq > afterSeq }
        }

        // 1. Live controller.
        if !transferringSessions.contains(id), let controller = controllers[id] {
            let all = await controller.transcriptSoFar()
            var header: SessionHeader?
            if afterSeq == 0 {
                let path = await controller.snapshot.session.transcriptPath
                header = try? TranscriptReader(url: URL(fileURLWithPath: path)).read().header
            }
            return (header, slice(all))
        }

        // 2. Owned on-disk transcript (session exists here but no live controller).
        if let session = try? await database.loadSession(id: id) {
            let url = URL(fileURLWithPath: session.transcriptPath)
            if FileManager.default.fileExists(atPath: url.path),
               let (header, all) = try? TranscriptReader(url: url).read() {
                return (afterSeq == 0 ? header : nil, slice(all))
            }
        }

        // 3. Mirror synced from another device.
        if let url = await mirrorStore.url(id),
           let (header, all) = try? TranscriptReader(url: url).read() {
            return (afterSeq == 0 ? header : nil, slice(all))
        }

        return nil
    }

    // MARK: Dashboard + control actions (control scope)

    public func dashboardSnapshot() async -> DashboardSnapshot {
        let counts = DashboardCounts(
            projects: dashboard.projects, chats: dashboard.chats, activeChats: dashboard.activeChats,
            messages: dashboard.messages, activeDays: dashboard.activeDays, tokens: dashboard.tokens)
        // Union the message and token day sets so a day that recorded only one of the two still
        // ships; each `ActivityDay` carries both (tokens 0 ⇒ the grid shades it by message count).
        let activity = Set(activityByDay.keys).union(tokensByDay.keys)
            .map { ActivityDay(day: $0, count: activityByDay[$0] ?? 0, tokens: tokensByDay[$0] ?? 0) }
            .sorted { $0.day < $1.day }
        let projects = self.projects.map { project -> WireProject in
            let sessions = summaries.filter { $0.projectID == project.id && !$0.archived }
            let active = sessions.filter { !$0.status.isTerminal && $0.status != .idle }.count
            return WireProject(
                id: project.id, name: project.name, defaultBranch: project.defaultBranch.value,
                sessionCount: sessions.count, activeCount: active,
                isNucleicControlled: project.isNucleicControlled)
        }
        let todos = self.todos.map { todo in
            WireTodo(
                id: todo.id, text: todo.text, summary: todo.summary,
                projectID: todo.projectID,
                projectName: todo.projectID.flatMap { projectsByID[$0]?.name },
                status: todo.status, dispatchedSessionID: todo.dispatchedSessionID,
                triage: todo.triageLevel?.rawValue,
                updatedAt: todo.updatedAt)
        }
        return DashboardSnapshot(
            counts: counts, activity: activity, projects: projects, todos: todos,
            usage: wireUsage(), codexUsage: codexUsage, statusFeeds: wireStatusFeeds())
    }

    /// The account quota windows for the phone's gauges — the same `subscriptionUsage`
    /// the Mac's `QuotaIndicator` reads, already polled by `startQuotaPolling`.
    private func wireUsage() -> WireSubscriptionUsage? {
        guard let usage = subscriptionUsage else { return nil }
        func window(_ w: UsageWindow?) -> WireUsageWindow? {
            w.map { WireUsageWindow(utilization: $0.utilization, resetsAt: $0.resetsAt) }
        }
        return WireSubscriptionUsage(
            fiveHour: window(usage.fiveHour), sevenDay: window(usage.sevenDay),
            sevenDayOpus: window(usage.sevenDayOpus), sevenDaySonnet: window(usage.sevenDaySonnet))
    }

    /// The provider status feeds for the phone's status pill — the same `statusFeeds` the
    /// Mac's `StatusFeedIndicator` reads. Only active (unresolved) incidents ride the wire;
    /// resolved history is noise at phone scale.
    private func wireStatusFeeds() -> [WireStatusFeed] {
        statusFeeds.map { feed in
            WireStatusFeed(
                provider: feed.provider.rawValue,
                providerName: feed.provider.displayName,
                incidents: feed.activeIncidents.map { incident in
                    WireStatusIncident(
                        id: incident.id, title: incident.title, url: incident.url,
                        updatedAt: incident.updatedAt, state: incident.state,
                        isResolved: incident.isResolved, components: incident.components)
                })
        }
    }

    /// The phone Diff tab's on-demand patch. Caps the patch text to protect the encrypted
    /// LAN channel — the file list and stat always arrive complete.
    public func sessionDiff(_ id: SessionID) async -> WireSessionDiff? {
        guard let controller = controllers[id] else { return nil }
        guard let diff = try? await controller.diff() else { return nil }
        let cap = 512_000  // ~0.5 MB of patch text; plenty for phone review
        var patch = diff.patch
        var truncated = false
        if patch.count > cap {
            patch = String(patch.prefix(cap))
            truncated = true
        }
        return WireSessionDiff(
            sessionID: id,
            stat: diff.stat,
            files: diff.files.map {
                WireFileDiff(path: $0.path, oldPath: $0.oldPath, status: $0.status.rawValue,
                             added: $0.added, removed: $0.removed)
            },
            patch: patch,
            truncated: truncated)
    }

    public func startChat(_ request: StartChatRequest) async throws {
        guard let project = projectsByID[request.projectID] else {
            throw WireError(code: .unknownSession, message: "no such project")
        }
        // reveal: false — a chat started from another device (phone composer, a peer Mac's
        // unified sidebar) must not yank THIS Mac's open view over to it; the initiator sees
        // the new row through the creation broadcast instead.
        // Attachment bytes the phone shipped along become host-side `PendingAttachment`s, which
        // `createSession` materializes into the new working tree and folds into the first prompt.
        let attachments = request.attachments.map {
            PendingAttachment(filename: $0.filename, data: $0.data)
        }
        _ = await startChat(
            in: project, message: request.message, model: request.model, effort: request.effort,
            base: request.baseBranch.map { GitRef($0) }, useWorktree: request.useWorktree,
            auto: request.auto, attachments: attachments, reveal: false)
        await pushDashboard()
    }

    public func captureTodo(_ request: CaptureTodoRequest) async throws {
        _ = await addTodo(request.text, projectID: request.projectID)
        await pushDashboard()
    }

    public func dispatchTodo(_ id: TodoID, in projectID: ProjectID) async throws {
        guard let project = projectsByID[projectID] else {
            throw WireError(code: .unknownSession, message: "no such project")
        }
        _ = await dispatchTodo(id, in: project)
        await pushDashboard()
    }

    public func remoteSetTodoStatus(_ id: TodoID, _ status: TodoStatus) async throws {
        await setTodoStatus(id, status)
        await pushDashboard()
    }

    public func remoteDeleteTodo(_ id: TodoID) async throws {
        await deleteTodo(id)
        await pushDashboard()
    }

    public func renameSession(_ id: SessionID, to title: String) async throws {
        try await mutateSessionForRemote(id) { await $0.rename(title) }
    }
    public func setFavorite(_ id: SessionID, _ favorite: Bool) async throws {
        try await mutateSessionForRemote(id) { await $0.setFavorite(favorite) }
    }
    public func setArchived(_ id: SessionID, _ archived: Bool) async throws {
        try await mutateSessionForRemote(id) { await $0.setArchived(archived) }
    }
    public func deleteSessionRemote(_ id: SessionID) async throws {
        await deleteSession(id)
        await pushDashboard()
    }
    public func integrate(_ id: SessionID, _ mode: IntegrationMode) async throws {
        guard let controller = controllers[id] else {
            throw WireError(code: .unknownSession, message: "no such session", sessionID: id)
        }
        let strategy: IntegrationStrategy
        switch mode {
        case .merge: strategy = .merge
        case .squash: strategy = .squash
        case .rebase: strategy = .rebase
        }
        do {
            let result = try await controller.integrate(strategy: strategy)
            if case .clean = result { await lockManager.releaseAll(id) }  // mediated release
        } catch { throw WireError(code: .backpressure, message: "integrate failed: \(error)", sessionID: id) }
        await pushDashboard()
    }
    public func discard(_ id: SessionID) async throws {
        await deleteSession(id)
        await pushDashboard()
    }

    /// Re-broadcast the dashboard so subscribed phones refresh Home/Projects/To-Dos.
    private func pushDashboard() async {
        broadcast(.dashboard(await dashboardSnapshot()))
    }

    /// Remote project creation (`ClientMsg.createProject`, CLOUD_RUNTIME §4.3): clone the URL
    /// as a Nucleic-controlled repo and register it — the verb that seeds work onto a fresh
    /// runner. Idempotent for a retrying device: a project whose `origin` already matches the
    /// URL is success (answered with the existing project), not a second clone.
    public func createProject(_ request: WireCreateProjectRequest) async throws -> WireProjectCreated {
        let url = request.gitURL.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !url.isEmpty else {
            throw WireError(code: .malformed, message: "createProject: empty git URL")
        }
        if let existing = await projectMatching(remote: url) {
            await pushDashboard()  // the retrying client still gets the project it asked for
            return WireProjectCreated(
                requestID: request.requestID, projectID: existing.id, name: existing.name)
        }
        guard let created = await addClonedProject(
            name: request.name ?? "", gitURL: url, nucleicControl: true,
            destinationParent: nil, branch: request.branch.map { GitRef($0) })
        else {
            throw WireError(
                code: .backpressure, message: lastError ?? "createProject: clone failed")
        }
        await pushDashboard()
        return WireProjectCreated(
            requestID: request.requestID, projectID: created.id, name: created.name)
    }

    /// The registered project whose `origin` remote matches `gitURL` (normalized, so the
    /// https/ssh spellings of one repo compare equal) — the idempotency probe for remote- and
    /// boot-seeded clones.
    public func projectMatching(remote gitURL: String) async -> Project? {
        let want = TransferGitProbe.normalizeRemote(gitURL)
        guard !want.isEmpty else { return nil }
        let probe = TransferGitProbe()
        for project in projects {
            if let identity = try? await probe.repoIdentity(rootPath: project.rootPath),
               identity.normalizedRemote == want {
                return project
            }
        }
        return nil
    }

    public func respondToApproval(_ id: ApprovalID, _ decision: Decision, by responder: String) async throws {
        // A remote (peer Mac) session's approval (mesh session sync): answer it on the owner over the
        // mesh, and optimistically drop it from the open strip (the owner confirms via broadcast).
        if let hostID = openHostID {
            await peerClient?.respondRemoteApproval(id, decision, on: hostID)
            openApprovals.removeAll { $0.id == id }
            return
        }
        // Route by finding the controller whose pending set holds this approval. If none
        // do, it was already resolved (likely answered on the Mac) — first-responder-wins.
        for (_, controller) in controllers {
            let pending = await controller.snapshot.pendingApprovals
            if pending.contains(where: { $0.id == id }) {
                try await controller.respondToApproval(id, decision, by: responder)
                return
            }
        }
        throw WireError(code: .alreadyResolved, message: "approval already resolved")
    }

    public func sendInput(_ id: SessionID, _ input: AgentInput) async throws {
        // A session mid-transfer (or already moved) is inert here — it must not accept a turn
        // while the destination owns it (mesh P5 "never runs in two places").
        guard !transferringSessions.contains(id) else {
            throw WireError(code: .unsupported, message: "session is being moved to another Mac", sessionID: id)
        }
        guard let controller = controllers[id] else {
            throw WireError(code: .unknownSession, message: "no such session", sessionID: id)
        }
        // A remote (phone) turn may carry attachment bytes it couldn't materialize itself — it can't
        // reach this Mac's working tree. Drop them into the session's tree here and fold their paths
        // into the message (exactly as the Mac composer does in `sendToOpenSession`), so the agent's
        // `Read` tool can open them. A turn with no attachments passes through untouched, so the
        // ordinary text-only path is unaffected.
        let (materialized, display) = await materializeRemoteAttachments(input, forSession: id)
        try await controller.sendInput(materialized, display: display)
        // Queueing a follow-up mid-turn emits no event, so push the refreshed summary
        // (carrying `queuedMessage`) so the phone's composer reflects the queued state.
        let snapshot = await controller.snapshot
        broadcast(.sessionUpdated(wireSummary(for: snapshot.session, pendingApprovals: snapshot.pendingApprovals)))
        if id == openSessionID { openSession = snapshot.session }
    }

    public func interrupt(_ id: SessionID) async throws {
        guard let controller = controllers[id] else {
            throw WireError(code: .unknownSession, message: "no such session", sessionID: id)
        }
        await controller.interrupt()
    }

    public func cancelQueuedMessage(_ id: SessionID, _ messageID: UUID) async throws {
        guard let controller = controllers[id] else {
            throw WireError(code: .unknownSession, message: "no such session", sessionID: id)
        }
        // The phone already has the message text locally (it's showing the pill), so the removed
        // item isn't returned over the wire — just drop it and push the refreshed summary (this
        // emits no transcript event) so the phone's queued list updates.
        _ = await controller.cancelQueuedMessage(id: messageID)
        let snapshot = await controller.snapshot
        if id == openSessionID { openSession = snapshot.session }
        broadcast(.sessionUpdated(wireSummary(for: snapshot.session, pendingApprovals: snapshot.pendingApprovals)))
    }

    // MARK: Mid-session model / reasoning / automation (control scope)

    public func setSessionModel(_ id: SessionID, _ model: String?) async throws {
        try await mutateSessionForRemote(id) { await $0.setModel(model) }
    }
    public func setSessionEffort(_ id: SessionID, _ effort: String?) async throws {
        try await mutateSessionForRemote(id) { await $0.setEffort(effort) }
    }
    public func setSessionAuto(_ id: SessionID, _ auto: Bool) async throws {
        try await mutateSessionForRemote(id) { await $0.setAuto(auto) }
    }
    public func setSessionAutoShip(_ id: SessionID, _ autoShip: Bool) async throws {
        try await mutateSessionForRemote(id) { await $0.setAutoShip(autoShip) }
    }
    public func setSessionShipBranch(_ id: SessionID, _ branch: String?) async throws {
        try await mutateSessionForRemote(id) { await $0.setShipBranch(branch) }
    }

    /// Apply a setter to a session addressed by id, then push the refreshed summary so the
    /// phone's header reflects the change immediately (these mutations emit no transcript
    /// event). Mirrors `sendInput`'s explicit `sessionUpdated` broadcast. Throws
    /// `.unknownSession` if the session is gone (so the phone surfaces it).
    private func mutateSessionForRemote(
        _ id: SessionID, _ body: (SessionController) async -> Void
    ) async throws {
        guard let controller = controllers[id] else {
            throw WireError(code: .unknownSession, message: "no such session", sessionID: id)
        }
        await body(controller)
        let snapshot = await controller.snapshot
        if id == openSessionID { openSession = snapshot.session }
        // Refresh this Mac's own sidebar too — a favorite/archive/rename arriving over the
        // wire is the same mutation the local UI makes, and must render the same way.
        upsertSummary(SessionSummary(snapshot.session, pendingApprovals: snapshot.pendingApprovals))
        broadcast(.sessionUpdated(
            wireSummary(for: snapshot.session, pendingApprovals: snapshot.pendingApprovals)))
    }

    public func broadcasts() async -> AsyncStream<HostBroadcast> {
        AsyncStream { continuation in syncBroadcastContinuation = continuation }
    }
}
