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()
}

/// One request to present the global new-chat composer. A request is deliberately identifiable
/// rather than a plain `Bool`: if SwiftUI ever tears down or rejects a sheet presentation while
/// leaving its binding set, the next keyboard shortcut still installs a new identity and therefore
/// produces an observable presentation event instead of silently assigning `true` to `true`.
public struct NewChatComposerRequest: Identifiable, Sendable, Equatable {
    public let id: UUID
    public let background: Bool
    public let ephemeral: Bool

    public init(id: UUID = UUID(), background: Bool, ephemeral: Bool) {
        self.id = id
        self.background = background
        self.ephemeral = ephemeral
    }
}

/// 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 parsed sidebar search query — the raw text split into lowercased tokens, matched
/// AND-substring (every token must appear somewhere in the haystack). Drives the sidebar's
/// omnisearch over the session index (title + keywords) and the to-do index (text + summary).
/// An empty query matches everything, so callers can treat "no search" as a no-op filter.
public struct SearchQuery: Equatable, Sendable {
    /// The lowercased, whitespace-split search terms; empty when the raw text is blank.
    public let tokens: [String]

    public init(_ raw: String) {
        tokens = raw.lowercased()
            .split(whereSeparator: { $0 == " " || $0 == "\t" || $0.isNewline })
            .map(String.init)
    }

    /// True when there's nothing to search for — every session/to-do matches.
    public var isEmpty: Bool { tokens.isEmpty }

    /// Whether `haystack` satisfies the query: every token appears in it as a substring
    /// (case-insensitive; `haystack` is lowercased by the caller-facing overload). An empty
    /// query matches anything.
    public func matches(_ haystack: String) -> Bool {
        guard !tokens.isEmpty else { return true }
        let hay = haystack.lowercased()
        return tokens.allSatisfy { hay.contains($0) }
    }
}

/// 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
    /// Cached search keywords (comma-joined) for the sidebar's session index, so a search
    /// matches a chat on more than its literal title. `nil` until the intelligence provider
    /// extracts them (see `Session.keywords`). Not carried on the wire — a remote summary
    /// reads `nil` and matches on its title alone.
    public var keywords: 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?
    /// Set when this chat runs on another device but is folded under *this* Mac's project section —
    /// a peer Mac's chat or a Covalence Cloud runner's (docs/COVALENCE_RUNNER.md §3): the owning
    /// device's display label. Every device holds the same projects, so a project is one sidebar row
    /// and the *rows under it* carry the origin: non-nil ⇒ the row draws an internet globe beside the
    /// auto/orchestra/autoship markers. `nil` for a chat that runs here. See
    /// `AppStore.sessionPool(of:)`.
    public var foldedHostLabel: String? = nil

    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.keywords = session.keywords
        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.keywords = nil  // keywords aren't carried on the wire; a remote matches on 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" }
        // Waiting on an armed background watch (`nucleic_monitor`) — not done, not blocked on you.
        if status == .awaitingInput, disposition == .waitingBackground { return "Monitoring…" }
        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
        // `.waitingBackground` is waiting on a monitor, not on the user — it carries no attention
        // pulse (and, via `chatCueState`, no sound/alarm), just like a `.completed` turn.
        case .awaitingInput: return disposition != .completed && disposition != .waitingBackground
        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
    }
}

/// Holds a block-based NotificationCenter observer token and removes it when released — a
/// `@MainActor` class can't remove the token from its nonisolated `deinit` (the token type
/// isn't Sendable), so ownership of the removal lives here instead.
private final class NotificationObserverToken: @unchecked Sendable {
    private let token: any NSObjectProtocol
    init(_ token: any NSObjectProtocol) { self.token = token }
    deinit { NotificationCenter.default.removeObserver(token) }
}

/// 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>?

    /// The in-flight VM base build/provision (macOS or Linux — only one runs at a time), retained so
    /// ``forceCancelMacVMBaseWork()`` can cancel a wedged pass instead of waiting out its timeouts.
    @ObservationIgnored private var macVMBaseWorkTask: Task<Void, Error>?

    public private(set) var summaries: [SessionSummary] = []

    /// Every local turn that must keep executing continuously, including provisioning, approval
    /// waits, subagents, and Scratch chats. The app's Smart Sleep policy observes this count and
    /// leases the closed-lid override while it is nonzero.
    public var localTurnInFlightCount: Int {
        summaries.lazy.filter { $0.hostID == nil && $0.status.hasTurnInFlight }.count
    }

    /// 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 (⌘N / ⌘⇧N). Each invocation gets a fresh
    /// identity so a stale, non-presented request can never make a later shortcut a no-op.
    public var newChatComposerRequest: NewChatComposerRequest?
    /// 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> = []
    /// Per-command gists read back from the metadata store for the open chat, keyed by the
    /// command's own `toolCallID`. The in-memory `bashCallSummaries` is keyed by content
    /// signature — a per-process `Hasher` value, meaningless across launches — so the durable
    /// cache keys on the backend-assigned call id instead, and this map is folded into
    /// `bashCallSummaries` as each block is scheduled.
    private var persistedBashGists: [String: String] = [:]
    /// The input cursor each persisted block line was merged from (block id → the newline-joined
    /// ordered `toolCallID`s of its commands). A cached line is adopted only when the block's
    /// current command list still matches, so a line merged from a shorter prefix — the run was
    /// interrupted mid-block — is re-merged rather than shown as if it covered the whole block.
    private var persistedBlockInputs: [String: String] = [:]
    /// 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 }
            // A reload can outlive a quick A → Home → A round-trip. Cancel it before resetting
            // readiness so that stale work for the same raw SessionID cannot raise the new
            // selection's gate early; `reloadOpen` checks cancellation again at its commit edge.
            reloadTask?.cancel()
            // Opening a real session supersedes any pending-Covalence provisioning view (item 5).
            if openSessionID != nil { openPendingCovalence = nil }
            // 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) }
            // Retarget projector cadences: the newly-opened chat commits at UI cadence,
            // the one left behind drops to background cadence (SessionUIProjector).
            let previousProjector = oldValue.flatMap { projectors[$0] }
            let currentProjector = openSessionID.flatMap { projectors[$0] }
            if previousProjector != nil || currentProjector != nil {
                Task {
                    await previousProjector?.setCadence(.background)
                    await currentProjector?.setCadence(.open)
                }
            }
            // 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
            openTranscriptReadySessionID = nil
            openTranscriptPreviewSessionID = nil
            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)
                }
            }
            if let id = openSessionID, controllers[id] == nil,
               let preparation = startupSessionPreparation
            {
                // The row can be clicked milliseconds after Phase 1 publishes the sidebar, before
                // its controller exists. Promote that transcript out of the background queue,
                // install it as soon as its own decode completes, then run the ordinary open path.
                // This is also the missing wake-up: `reloadOpen` used to return on the absent
                // controller and nothing retried it when the all-sessions barrier finally lifted.
                let previewSession = startupHydrationSessions[id]
                reloadTask = Task { [weak self] in
                    let previewTask = Task { [weak self] in
                        guard let previewSession else { return }
                        await self?.streamStartupTranscriptPreview(previewSession)
                    }
                    defer { previewTask.cancel() }
                    guard let prepared = await preparation.value(for: id, prioritizing: true),
                          !Task.isCancelled else { return }
                    await self?.installStartupPreparedSession(prepared, from: preparation)
                }
            } else {
                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] = []
    /// The selected session whose authoritative initial history has been installed in
    /// `openTranscript`. Reset synchronously on every real selection change, then set by the
    /// local reload (or remote catch-up) only after it has pulled the transcript through the
    /// session's known tail. The macOS view gates the ScrollView's *first appearance* on this,
    /// rather than on `openTranscript.isEmpty == false`: an early message-only snapshot must not
    /// establish the initial bottom before trailing autoship/system notes arrive.
    public private(set) var openTranscriptReadySessionID: SessionID?
    /// A tail-first startup preview may make canonical events displayable before the controller's
    /// full history reconstruction completes. This remains separate from the authoritative-ready
    /// edge so summary/unread bookkeeping still waits for the complete history.
    private var openTranscriptPreviewSessionID: SessionID?
    /// The selected session with enough canonical history to construct the transcript surface.
    /// During cold startup this can precede `openTranscriptReadySessionID` by many full-file decode
    /// batches; remote and already-reconstructed sessions normally raise both together.
    public var openTranscriptAvailableSessionID: SessionID? {
        openTranscriptReadySessionID ?? openTranscriptPreviewSessionID
    }
    /// 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] = []
    /// Approvals the user has answered here but whose authoritative `approvalResolved` hasn't come
    /// back yet. Answering travels a long way — main actor → controller → backend → the approval
    /// coordinator → the blocked tool handler → back through the run stream → ingest → projector →
    /// main actor — and until it lands the controller still reports the request as pending. Without
    /// this set the card the user just clicked stays on screen for the whole round-trip (and any
    /// snapshot-driven refresh landing in that window would re-add it even after an optimistic
    /// removal), which reads as a dead button. `visibleApprovals` filters these out; the matching
    /// `approvalResolved` — or the request simply leaving the pending set — clears them.
    private var answeredApprovalIDs: Set<ApprovalID> = []
    /// 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> = []

    /// Sessions that ended a turn in a dead "I'll wait for an external event" posture and have
    /// already received their one automatic continuation this streak (AppStore §classifyDisposition).
    /// Nucleic's single-shot turn model means such a wait never resumes on its own; we nudge the
    /// agent back to a *foreground* wait exactly once, then let the chat surface as awaiting-input
    /// if it insists on waiting again. Cleared when the agent next makes progress (a non-waiting
    /// turn end) or the user sends a fresh message, so a *later* distinct wait earns its own nudge.
    private var autoContinuedWaiting: 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>?
    /// Cold-launch transcript hydration. Sidebar rows are published before their controllers are
    /// rebuilt, so a user can select a chat while its JSONL is still queued. The pool keeps the
    /// ordinary work bounded and lets that selection claim one foreground slot immediately.
    @ObservationIgnored private var startupSessionPreparation: SessionPreparationPool?
    /// Session rows awaiting their prepared transcript. Removal is the atomic "installed" claim:
    /// both the background result stream and the foreground open waiter may receive the same
    /// prepared value, but only one can rebuild/install its controller.
    @ObservationIgnored private var startupHydrationSessions: [SessionID: Session] = [:]
    @ObservationIgnored private var startupHydrationNormalized: Set<SessionID> = []
    @ObservationIgnored private var startupHydrationGeneration: UUID?
    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?
    /// The effort an Orchestra supervisor runs at — the "(Deep)" / "(Max)" half of the supervisor
    /// choice in Settings. Stored on the session as a qualified sentinel
    /// (`OrchestrationMode.qualifiedEffortSentinel`) so the mode stays Orchestra while the
    /// underlying API effort varies. `nil` keeps the historical fixed `xhigh`.
    public var defaultSupervisorEffort: String?
    /// The worker model for Orchestra fan-out — a SKU, or
    /// `OrchestrationMode.intelligentWorkerModel` for **Intelligent**, which classifies each
    /// worker's task and routes it (see `orchestraWorkerSelection`).
    public var defaultOrchestraWorkerModel: String?
    /// The Intelligence level routed workers are resolved at (set by the app from Settings). Level
    /// is the ambition/budget knob the classifier's purpose is combined with — Orchestra's own
    /// thoroughness lives in the supervisor and the fan-out, so workers route at the user's
    /// ordinary default rather than being forced to the top of the ladder.
    public var defaultIntelligenceLevel: IntelligenceLevel = .fallback
    /// 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

    /// Backends the user can actually run right now (installed + authenticated — see
    /// `ProviderAvailability.isConnected`). Refreshed by `refreshConnectedProviders()` at
    /// launch and when a composer appears; consumed by the Intelligence router so it never
    /// offers a model on a provider the user lacks. Empty until the first probe completes —
    /// the router treats that as "route to the stored default", which self-corrects within
    /// seconds of launch.
    public private(set) var connectedProviders: Set<BackendID> = []

    /// Re-probe provider availability into `connectedProviders` (see above).
    public func refreshConnectedProviders() async {
        connectedProviders = Set(
            await ProviderAvailability.probeAll().filter(\.isConnected).map(\.backend))
    }

    /// 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?

    /// Default grace period between archiving a chat and reclaiming its worktree checkout.
    /// This lives in Core so headless hosts get the same cleanup behavior as the macOS app.
    public static let defaultArchivedWorktreeCleanupInterval: TimeInterval = 24 * 60 * 60

    /// How long a chat may stay archived before its worktree checkout is reclaimed to free
    /// disk, in seconds. The branch is kept and any WIP is committed first, so unarchiving
    /// the chat re-creates the worktree exactly. `nil` — the macOS Settings "Never" choice —
    /// disables cleanup. Defaults to one day for every host; the app may override it from
    /// Settings at launch.
    public var archivedWorktreeCleanupInterval: TimeInterval? =
        AppStore.defaultArchivedWorktreeCleanupInterval

    /// 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 {
            guard summarizeToolCalls != oldValue else { return }
            refreshToolGroupSummaries()
        }
    }

    /// 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()
            refreshToolGroupSummaries()
        }
    }

    /// 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?
    /// Serialized owner of Claude subscription refresh and browser login.
    private let claudeCredentialBroker: ClaudeCredentialBroker?
    private let codexCredentialBroker: CodexCredentialBroker?
    /// Runs remote sign-in attempts a device begins over the wire (remote agent sign-in,
    /// docs/REMOTE_AGENT_LOGIN.md) — the brokers do the OAuth; this correlates challenge ↔
    /// callback per requestID and enforces one flow per provider. Ignored by observation
    /// (it's plumbing, not UI state — and `@Observable` rejects `lazy` otherwise).
    @ObservationIgnored private lazy var agentLoginCoordinator = AgentLoginCoordinator(
        claude: claudeCredentialBroker, codex: codexCredentialBroker)
    /// 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>] = [:]
    /// The per-session event reducers behind `observers` (docs/MAIN_THREAD_PERFORMANCE_PLAN.md
    /// item 2). Kept alongside the observer task so the open-chat switch can retarget a
    /// session's flush cadence; entries live and die with their `observers` entry.
    private var projectors: [SessionID: SessionUIProjector] = [:]

    // 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>?

    /// The Carbon data layer (docs/CARBON_SHARDING.md) — mandatory, always-on (D17): shards
    /// every enrolled project's session state into the local store on a cadence, and
    /// replicates to the mesh when peers exist. A sibling of the transcript mirror root.
    /// Keychain escrow is bound on Darwin only (Linux runners receive keys via the credential
    /// mesh, phase 5); tests construct their own `CarbonService` with nil escrow.
    @ObservationIgnored lazy var carbonService: CarbonService = {
        let root = transcriptsDir.deletingLastPathComponent()
            .appendingPathComponent("carbon", isDirectory: true)
        #if canImport(Security)
        return CarbonService(
            root: root,
            escrow: { key, uuid in try CarbonKeyEscrow.escrow(key, projectUUID: uuid) },
            recover: { uuid in try CarbonKeyEscrow.recover(projectUUID: uuid) },
            ownerDeviceID: { [weak self] in self?.syncHostID ?? "local" })
        #else
        return CarbonService(
            root: root, ownerDeviceID: { [weak self] in self?.syncHostID ?? "local" })
        #endif
    }()
    /// The producer's drive loop (§6.4) — started once on launch (`startCarbonProducer`),
    /// independent of the mesh: a mesh of one still snapshots locally.
    private var carbonTicker: 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>] = [:]
    /// Per-session: the held paths whose edit has actually **materialized** — observed to differ
    /// from the parent while held (LOCKING §4.4). The detected-landing poll may release a lock only
    /// once its work has both materialized *and* since matched the parent again (an actual merge);
    /// before the agent writes its change (the edit gate acquires the lock *before* the write,
    /// §4.5) — or while it is re-grounding an edit it hasn't re-issued — the held file still matches
    /// the parent though nothing landed, and releasing then would hand a sibling a file mid-edit and
    /// cause the very divergence the lock exists to prevent (the non-nvrsion "released right after an
    /// edit began, not after autoship" leak). Pruned to the live held set each pass; cleared when the
    /// session releases everything or is forgotten. Turn-end still reaps never-written declarations
    /// via `releaseCompletedSessionLocks`, which is a deliberate lifecycle sweep, not this poll.
    ///
    /// The value is the **parent-side blob sha** of that path at the moment the divergence was
    /// observed (`Self.absentFromParent` when the parent didn't have the file). "No longer differs
    /// from the parent" alone does not mean the work landed — `git reset --hard`, `git restore`,
    /// `git checkout <parent> -- <path>` and `git stash` all reach that state by *discarding or
    /// parking* the work, with the parent untouched. Landing is distinguished by the parent having
    /// **moved**: if the recorded blob still matches the parent's blob, the worktree merely
    /// reverted to content the parent always had, and the lock is kept (LOCKING §4.4).
    private var materializedLockWork: [SessionID: [String: String]] = [:]
    /// `materializedLockWork` value meaning "the parent had no such path". Never a real sha.
    private static let absentFromParent = ""
    /// `reconcileLocks` skip cache: holders whose last pass concluded "everything still held",
    /// keyed to the exact on-disk git state (`SessionController.lockStateFingerprint`) and held
    /// set that verdict was computed from. While both match, the ~5 git spawns of the landed check
    /// are skipped for that holder — the poll's answer cannot have changed (release requires a
    /// ref/index move, which changes the fingerprint). Entries are written only for a still-held
    /// outcome, invalidated by any release (the held set shrinks), and pruned each pass to
    /// sessions actually holding locks so a stale entry can't outlive a full release + re-acquire.
    private var reconcileStillHeld: [SessionID: (fingerprint: String, held: Set<String>)] = [:]
    /// A wire-arbitrated session's origin (CARBON_SHARDING §17.5): the device running it and
    /// the project its request named. This is how the authority attributes ledger events for
    /// a session it has no controller for — and why the waiter-reap check must not cull such
    /// sessions as "gone" (their liveness is their own host's concern).
    struct RemoteLockSession: Sendable, Equatable {
        let deviceID: String
        let projectUUID: UUID
    }
    private var remoteLockSessions: [SessionID: RemoteLockSession] = [:]
    /// 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 weekly limit the `codex` app-server reads when it
    /// starts and pushes when it changes (`account/rateLimits/updated`). The Codex counterpart to
    /// `subscriptionUsage`; 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?

    /// Quota exclusions for Intelligence routing, derived from the same live snapshots the
    /// dashboard gauges display. Reset-aware window access makes every exclusion temporary.
    public var intelligenceRoutingLimits: IntelligenceRouter.Limits {
        IntelligenceRouter.Limits(
            claudeUsage: subscriptionUsage, codexUsage: codexUsage,
            claudeRateLimit: latestRateLimit, now: now())
    }

    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/COVALENCE_RUNNER.md §3): `.mac` for the cockpit app, `.cloud`
    /// when running headless as an Covalence 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

    /// Auto-approve inbound Mac-pair confirms instead of prompting a human (docs/COVALENCE_RUNNER.md
    /// §3). A headless runner (`nucleicd`) mints its own one-shot pairing code, so possession of
    /// that code — proven by the XXpsk0 handshake — *is* the authorization; the interactive "let
    /// this Mac join?" confirm is a cockpit-only control-scope gate with no operator on a runner.
    /// Off on the cockpit app (the user still confirms); `nucleicd` turns it on before the sync
    /// server starts. Without it a headless runner declines every join after the 120 s timeout, so
    /// an auto-provisioned runner could never actually enter the mesh.
    public var meshAutoApprovePairing: Bool = false

    /// 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/COVALENCE_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 Covalence 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?
    /// The merged fleet feed (mesh casting), oldest-first — every origin's activity casts this
    /// host holds (its own and mirrored peers'). Seeded from the durable cast table at launch
    /// (`seedActivityFeed`), appended live from publish/ingest. The bell renders it reversed.
    public internal(set) var activityFeed: [ActivityFeedItem] = []
    /// When the user last opened the feed — rows newer than this count as unread.
    public internal(set) var feedLastSeenAt: Date =
        UserDefaults.standard.object(forKey: "nucleic.feed.lastSeenAt") as? Date ?? .distantPast
    /// Per-session mesh-topic subscriptions (`nucleic_subscribe_topic`) — in-memory and
    /// session-scoped by design: coordination state that dies with the session, not durable
    /// mail. Entries for gone sessions are dropped lazily at delivery.
    var meshTopicSubscriptions: [SessionID: Set<String>] = [:]
    /// Sessions parked in `nucleic_wait_for_message` — a matching delivery resumes the waiter
    /// (returned as the tool result) instead of injecting a user turn. One waiter per session.
    var meshMessageWaiters: [SessionID: (topic: String?, continuation: CheckedContinuation<String, Never>)] = [:]
    /// Mesh-dispatch idempotency (the double-start killer): requestID → the session it
    /// created, TTL'd in `startChatAcknowledged`. A retried dispatch acks the same session.
    var recentDispatches: [String: (sessionID: SessionID, at: Date)] = [:]
    /// The latest runner-presence card per host (the `runner.presence` state channel, all
    /// origins merged) — the mesh dispatcher's candidate pool. Includes this host's own card.
    public internal(set) var runnerPresenceByHost: [String: RunnerPresence] = [:]
    /// Persisted record of the Covalence Cloud runner sessions this device dispatched, so the
    /// sidebar folds them under their origin *local* project (item 1) and renders them the instant
    /// the app launches — before the mesh sync server reconnects and republishes them (item 4).
    /// Keyed by the session's stable id. Loaded once at startup (`loadCovalenceMirrors`) and kept
    /// warm from live presence (`refreshCovalenceMirrors`). Cloud-only by construction.
    let covalenceMirrorStore = CovalenceSessionMirrorStore()
    var covalenceMirrors: [SessionID: CovalenceSessionMirror] = [:]
    private var covalenceMirrorsLoaded = false
    /// Covalence chats the user started that are still waiting for a runner (item 5). Each opens a
    /// session view immediately with live provisioning progress; the entry is cleared and the real
    /// session opened the moment the chat lands. Keyed by the parked `MeshDispatchRecord.id`.
    public internal(set) var pendingCovalenceDispatches: [PendingCovalenceDispatch] = []
    /// The pending Covalence dispatch currently shown in the detail pane (its provisioning view), or
    /// `nil` when an ordinary session (or nothing) is open. Cleared whenever a real session opens.
    public internal(set) var openPendingCovalence: PendingCovalenceDispatch?
    /// Debounce for `publishRunnerPresence` (presence publishes on change, never as a heartbeat).
    var presencePublishTask: Task<Void, Never>?
    /// The live remote typer per session (mesh composer streaming) — folded from
    /// `composer.typing` casts, *other* devices only (this device's own entries are skipped, so
    /// a typer never locks itself). Views lock the session's composer and render the streamed
    /// draft while an entry is present; expiry-guarded locally so a typer that vanished without
    /// its tombstone can't leave the composer locked.
    public internal(set) var composerTypingBySession: [SessionID: ComposerTypingState] = [:]
    /// Composer-streaming publisher bookkeeping, keyed by session: the throttle's in-flight
    /// trailing-edge publish, the last publish time, the newest not-yet-published draft, the
    /// idle-stop timer, and which device the session's current entry was published for (this
    /// Mac itself, or the client it relays — the tombstone must name the same device).
    var composerPublishTasks: [SessionID: Task<Void, Never>] = [:]
    var composerLastPublishAt: [SessionID: Date] = [:]
    var composerPendingText: [SessionID: String] = [:]
    var composerIdleTasks: [SessionID: Task<Void, Never>] = [:]
    var composerPublishedDevice: [SessionID: (deviceID: String, deviceName: String)] = [:]
    /// Watcher-side expiry timers for `composerTypingBySession` (the crash guard).
    var composerRemoteExpiryTasks: [SessionID: Task<Void, Never>] = [:]
    /// Cached repo-identity descriptors (root commit / normalized remote are stable; probing is
    /// a git round-trip we don't want on every presence publish).
    var projectDescriptorCache: [ProjectID: ProjectDescriptor] = [:]
    /// In-flight mesh dispatches awaiting their `chatStarted` ack, keyed by requestID.
    var chatStartedWaiters: [String: CheckedContinuation<WireChatStarted?, Never>] = [:]
    /// In-flight dispatch-time clones awaiting their `projectCreated` outcome, keyed by requestID.
    var projectCreatedWaiters: [String: CheckedContinuation<WireProjectCreated?, Never>] = [:]
    /// The count of chats parked in the durable mesh-dispatch queue (mesh dispatch), for a
    /// Settings/UI "N queued" affordance. Refreshed after enqueue/reconcile.
    public internal(set) var queuedMeshDispatchCount = 0
    /// Single-flight guard for `retryQueuedMeshDispatches`.
    var reconcilingDispatches = false
    /// Covalence rebalancing (mesh work queue) — per-session cooldown stamps (a session that just
    /// moved, or just declined to, isn't re-evaluated for `covalenceRebalanceCooldown` seconds),
    /// the in-flight guard, and the debounced sweep task armed by turn ends + presence changes.
    var covalenceRebalanceLastAttempt: [SessionID: Date] = [:]
    var covalenceRebalancingSessions: Set<SessionID> = []
    var covalenceRebalanceSweepTask: Task<Void, Never>?
    /// 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] = [] {
        didSet { refreshOpenRemoteSessionStatus() }
    }
    /// The Covalence mesh diagnostics event log (MeshDiagnostics.swift): a capped in-memory ring
    /// of structural mesh events (peer churn, presence folds, dispatch/rebalance decisions,
    /// transfer phases) the Covalence Mesh Viewer window renders live. Fed by `meshDiag` at the
    /// mesh choke points; every record also lands on the "mesh" unified-logging category.
    public let meshDiagnostics = MeshDiagnosticsLog()

    /// Everything the nash agent shell emitted, per session (NashEventLog.swift) — the Nash Viewer
    /// panel's source. Fed at the top of `observeShellEvent`, so it holds *every* kind, including
    /// the data-flow ones (redirect/cmdsub/pipe) that the command feed drops on the floor. Backed
    /// by the `nash_event` table when the metadata store persists them, so a chat's shell history
    /// outlives the app run that produced it (`hydrateNashEvents`).
    public let nashEvents: NashEventLog

    /// 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
            // Every device in the mesh holds the same projects, so a project is ONE row: a peer's
            // copy of a project we also hold never becomes a second header — its chats fold under
            // ours (`sessionPool(of:)`) wearing a globe. Only a project we genuinely don't hold
            // yet (a repo added on that device and not here) gets a header of its own, so its
            // chats aren't lost.
            var seen: Set<ProjectID> = []
            for wire in peer.dashboard?.projects ?? [] {
                seen.insert(wire.id)
                guard localProjectID(forPeerProject: wire.id, on: peer.deviceID, named: wire.name) == nil
                else { continue }
                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 {
                // A chat this device dispatched to (or moved onto) a runner is folded by its mirror
                // record even when the repo identity doesn't line up — the runner's clone may carry
                // neither our root commit nor our remote.
                if let mirror = covalenceMirrors[s.sessionID],
                    projectsByID[mirror.originProjectID] != nil { continue }
                let pid = ProjectID(rawValue: s.projectID)
                guard !seen.contains(pid) else { continue }
                seen.insert(pid)
                guard localProjectID(forPeerProject: pid, on: peer.deviceID, named: s.projectName) == nil
                else { continue }
                items.append(ProjectSummary(
                    id: pid, name: s.projectName, hostID: peer.deviceID, hostLabel: label))
            }
        }
        return items.sorted { $0.name < $1.name }
    }

    /// This Mac's id for a project as `hostID` knows it, or `nil` if we don't hold that repo.
    ///
    /// The mesh guarantees every device holds the same projects — but each device *registers* them
    /// independently, so the same repo carries a different `ProjectID` per device. The real identity
    /// is `ProjectDescriptor.matches`: UUID, then root-commit SHA, then normalized remote, read from
    /// the peer's presence card. Name is the last resort, for the window before either device has
    /// published a presence card (it's cast-backed, so a LAN-only pair may have none): the name is
    /// what the user sees under both rows, and a missed match is the visible bug — the project
    /// showing up twice.
    func localProjectID(
        forPeerProject peerID: ProjectID, on hostID: String, named name: String
    ) -> ProjectID? {
        if projectsByID[peerID] != nil { return peerID }
        if let remote = runnerPresenceByHost[hostID]?.projects.first(where: { $0.projectID == peerID }) {
            for project in projects where descriptor(for: project).matches(remote) { return project.id }
        }
        let folded = name.lowercased()
        return projects.first { $0.name.lowercased() == folded }?.id
    }

    /// A project's repo identity for matching, from the cache the presence publisher warms. Falls
    /// back to a UUID-only descriptor until that first publish, so matching degrades rather than
    /// crashing into a git probe on the main actor.
    private func descriptor(for project: Project) -> ProjectDescriptor {
        projectDescriptorCache[project.id] ?? ProjectDescriptor(
            projectID: project.id, name: project.name, rootCommitSHA: nil,
            normalizedRemote: nil, defaultBranch: project.defaultBranch.value)
    }

    /// 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> = []
    /// Single-flight guard for `reconcileSupersededMacIdentities` — it tombstones + reconciles,
    /// which re-enters the mesh-refresh path; the guard keeps two overlapping calls from racing.
    private var reconcilingSupersededIdentities = false
    /// 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> = []
    /// What a session parked in the lock queue is waiting for — the contended files plus who holds
    /// them — captured when `arbitrate` parks and cleared when the wait resolves. Drives the open
    /// chat's in-transcript contention banner (files, holder, a release affordance), so a blocked
    /// chat explains itself instead of silently freezing at its last status line.
    public struct LockWaitInfo: Sendable, Equatable {
        public struct Holder: Sendable, Equatable, Identifiable {
            public let sessionID: SessionID
            public let title: String
            public var id: String { sessionID.rawValue }
        }
        public let files: [String]
        public let holders: [Holder]
        public let since: Date
    }
    public private(set) var lockWaits: [SessionID: LockWaitInfo] = [:]
    /// Signature of the last visible contention note per session, so a re-park on the same files
    /// against the same holders (the agent retrying a denied edit) doesn't spam the transcript.
    /// Cleared when the wait resolves in a grant, so a *later* identical contention notes again.
    private var contentionNoteSignatures: [SessionID: String] = [:]

    /// 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?
    /// Cached `ContainerServiceSettings.serviceEnabled` for `refreshContainerDownload`, which a
    /// busy chat polls once a second on the main actor — the `UserDefaults` `bool(forKey:)`
    /// behind the live accessor showed up 24 samples deep in a Jul 22 main-thread hang sample.
    /// `nil` means "read it on next use" (so the read still resolves the task-local defaults
    /// tests bind); any defaults change invalidates it via the observer installed in `init`.
    @ObservationIgnored private var containerServiceEnabledCache: Bool?
    /// Token for the `UserDefaults.didChangeNotification` observer that invalidates
    /// `containerServiceEnabledCache` (both the Settings toggle and `enableService()` write
    /// through UserDefaults, so every flip posts it). Boxed so the observer is removed when
    /// the store is released without needing an isolated `deinit`.
    @ObservationIgnored private var defaultsChangeObserver: NotificationObserverToken?
    /// True while Nucleic is driving a Claude browser sign-in.
    public private(set) var claudeLoginInProgress = false
    /// True while Nucleic is driving a Codex (ChatGPT) browser sign-in.
    public private(set) var codexLoginInProgress = false
    /// The ChatGPT plan advertised by the current Codex OAuth login (`plus`, `pro`, …). This is
    /// account metadata decoded from `~/.codex/auth.json`, never inferred from quota size. Unknown
    /// and API-key auth stay nil so plan-gated UI is fail-closed.
    public private(set) var codexSubscriptionPlan: String?
    /// Re-reads the current plan after login and after Codex turns (which may rotate auth.json).
    private let codexSubscriptionPlanProvider: @Sendable () -> String?

    /// Whether the current Codex ChatGPT login is explicitly a Pro subscription.
    public var isCodexPro: Bool { codexSubscriptionPlan == "pro" }

    /// 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,
        claudeCredentialBroker: ClaudeCredentialBroker? = nil,
        codexCredentialBroker: CodexCredentialBroker? = nil,
        macVMManager: MacVMManager? = nil,
        conflictCoordinator: ConflictCoordinator? = nil,
        nashEventLog: NashEventLog? = nil,
        codexSubscriptionPlanProvider: @escaping @Sendable () -> String? = {
            CodexAuthFile.currentSubscriptionPlanType()
        },
        now: @escaping @Sendable () -> Date = { Date() },
        backendFactory: @escaping BackendFactory
    ) {
        self.database = database
        // Same `database as? …` shape the cast store uses: a metadata store that doesn't persist
        // shell events just leaves the Nash Viewer in-memory-only (docs/NASH.md §9.4). The explicit
        // injection keeps the transcript-readiness ordering deterministically testable.
        self.nashEvents = nashEventLog ?? NashEventLog(store: database as? NashEventPersisting)
        self.worktrees = worktrees
        self.transcriptsDir = transcriptsDir
        self.containerManager = containerManager
        self.claudeCredentialBroker = claudeCredentialBroker
        self.codexCredentialBroker = codexCredentialBroker
        self.macVMManager = macVMManager
        self.conflictCoordinator = conflictCoordinator
        self.codexSubscriptionPlanProvider = codexSubscriptionPlanProvider
        self.codexSubscriptionPlan = codexSubscriptionPlanProvider()
        self.now = now
        self.nvrsionGovernor = NvrsionReleaseGovernor(now: now)
        self.backendFactory = backendFactory
        // App-scoped host-shell observation (docs/NASH.md §7.7.3): install the one sink on the
        // shared listener so Build/Run spawns under nash can report. Everything stays lazy — the
        // listener binds no port until a spawn actually registers.
        Task {
            await HostShellEventListener.shared.install { [weak self] origin, calls in
                await self?.observeAppScopedShellEvents(origin: origin, calls: calls)
            }
        }
        defaultsChangeObserver = NotificationObserverToken(NotificationCenter.default.addObserver(
            forName: UserDefaults.didChangeNotification, object: nil, queue: .main
        ) { [weak self] _ in
            MainActor.assumeIsolated { self?.containerServiceEnabledCache = nil }
        })
    }

    // MARK: - Claude authentication

    /// Sign in to Claude through Nucleic's own OAuth flow (PKCE + loopback redirect capture). The
    /// browser stays Anthropic-owned; Nucleic owns the exchange, persistence, progress, and errors —
    /// no external terminal or `claude` CLI login. The stored credential feeds the Covalence mesh.
    public func loginClaude() async {
        guard !claudeLoginInProgress else { return }
        guard let claudeCredentialBroker else {
            lastError = "Claude sign-in is unavailable in this build."
            return
        }
        claudeLoginInProgress = true
        defer { claudeLoginInProgress = false }

        await noteToOpenSession("Opening Claude sign-in in your browser…", icon: "key.fill")
        switch await claudeCredentialBroker.loginInteractively() {
        case .connected:
            await noteToOpenSession(
                "Claude sign-in complete — retry your message to continue.", icon: "checkmark.seal.fill")
            await rebroadcastAgentAuthStatus()
        case .failed(let message):
            lastError = message
            await noteToOpenSession(message, icon: "exclamationmark.triangle.fill")
        }
    }

    /// Sign in to Codex (ChatGPT subscription) through Nucleic's own OAuth flow (PKCE + the fixed
    /// localhost:1455 redirect capture). No external `codex login` terminal step; the resulting
    /// `~/.codex/auth.json` is what every container and the Covalence mesh already reconcile.
    public func loginCodex() async {
        guard !codexLoginInProgress else { return }
        guard let codexCredentialBroker else {
            lastError = "Codex sign-in is unavailable in this build."
            return
        }
        codexLoginInProgress = true
        defer { codexLoginInProgress = false }

        await noteToOpenSession("Opening Codex sign-in in your browser\u{2026}", icon: "key.fill")
        switch await codexCredentialBroker.loginInteractively() {
        case .connected:
            refreshCodexSubscriptionPlan()
            await noteToOpenSession(
                "Codex sign-in complete \u{2014} retry your message to continue.", icon: "checkmark.seal.fill")
            await rebroadcastAgentAuthStatus()
        case .failed(let message):
            lastError = message
            await noteToOpenSession(message, icon: "exclamationmark.triangle.fill")
        }
    }

    /// Refresh account metadata from the same Codex credential every session uses. Public so the
    /// app can refresh after an externally-driven `codex login` without exposing credential data.
    public func refreshCodexSubscriptionPlan() {
        codexSubscriptionPlan = codexSubscriptionPlanProvider()
    }

    /// Sign in for whichever provider backs `backend`, through Nucleic's mediated OAuth. Claude and
    /// Codex are the two subscription providers Nucleic can drive; anything else is a no-op (its CLI
    /// owns its own auth). Used by the in-chat "Log in" rows and Settings › Agents.
    public func login(forBackend backend: BackendID) async {
        switch backend {
        case .codex, .codexExec:
            await loginCodex()
        case .claudeCode:
            await loginClaude()
        default:
            break
        }
    }

    // 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 })
        // A gone project's cached repo identity must not linger (mesh dispatch presence).
        projectDescriptorCache = projectDescriptorCache.filter { projectsByID[$0.key] != nil }
        // Mesh dispatch: the held-projects set changed — refresh the presence card (debounced).
        publishRunnerPresence()
        // 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 through a bounded pool. Results
    ///     install incrementally, so one pathological history cannot hold every controller behind
    ///     a global barrier. A row selected during this phase is promoted into a foreground slot
    ///     and opens as soon as *its own* decode completes; the rest keep hydrating behind it.
    ///
    /// `deferringStartupMaintenance` (docs/MAIN_THREAD_PERFORMANCE_PLAN.md item 6) is the app
    /// launch path's split: when true, the maintenance tail that used to run inline here —
    /// orphaned-Orchestra-worker cleanup, container + VM-disk GC, keyword backfill — is left
    /// for `runDeferredStartupMaintenanceOnce()` (after the first window paint), and the
    /// lock/nvrsion reconciliation starts on its own task that COMMANDS gate on
    /// (`awaitStartupLockReconciliation`) so no turn can run against unreconciled lock state
    /// while unrelated UI hydration proceeds. The default (false) keeps the original inline
    /// tail for every other caller — mid-run rebuilds (revert/undo), nucleicd, and tests all
    /// rely on locks and maintenance being settled when this returns.
    public func loadSessions(deferringStartupMaintenance: Bool = false) async {
        // Fold the persisted Covalence Cloud runner rows in before the first sidebar render, so
        // dispatched cloud sessions show immediately at launch instead of blinking in once the mesh
        // sync server reconnects seconds later (item 4).
        loadCovalenceMirrors()
        // Restore any still-parked Covalence chats as provisioning rows (item 5).
        await rehydratePendingCovalence()
        // 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: bounded, priority-aware transcript hydration off the main actor ---
        let generation = UUID()
        let ordered = pending.sorted { $0.updatedAt > $1.updatedAt }
        let preparation = SessionPreparationPool(
            items: ordered.map {
                .init(id: $0.id, path: $0.transcriptPath, deferred: $0.archived)
            },
            calendar: Calendar.current)
        startupHydrationGeneration = generation
        startupHydrationSessions = Dictionary(uniqueKeysWithValues: pending.map { ($0.id, $0) })
        startupHydrationNormalized = normalized
        startupSessionPreparation = preparation

        // `start` fills only the bounded background slots. The stream yields each finished decode
        // immediately; `openSessionID.didSet` may consume the same value through `value(for:)` and
        // install it first. `startupHydrationSessions.removeValue` arbitrates that harmless race.
        let results = await preparation.start()
        for await prepared in results {
            await installStartupPreparedSession(prepared, from: preparation)
        }
        if startupHydrationGeneration == generation {
            startupSessionPreparation = nil
            startupHydrationSessions = [:]
            startupHydrationNormalized = []
            startupHydrationGeneration = nil
        }

        // 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. Installed before any maintenance/commands can boot a VM (it's an O(1) closure set).
        await macVMManager?.setEvictionPolicy { [weak self] occupying in
            await self?.evictableVMsRanked(among: occupying) ?? []
        }

        if deferringStartupMaintenance {
            // App launch path (plan item 6): the lock/nvrsion reconciliation starts NOW on its
            // own task so it doesn't serialize the rest of hydration; commands gate on it via
            // `awaitStartupLockReconciliation`, so no turn can start against unreconciled lock
            // state. The orphan/GC maintenance tail waits for `runDeferredStartupMaintenanceOnce()`
            // (called by the app after the first window paint + hydration).
            beginStartupLockReconciliation()
        } else {
            // Every other caller (mid-run rebuilds, nucleicd, tests): the original inline tail,
            // in the original order — settled locks and maintenance on return.
            await sweepOrphanedOrchestraWorkers(in: allSessions)
            await reconcileSessionEnvironments()
            // 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()
            // Backfill the omnisearch keyword index for pre-existing chats that don't have one yet
            // — a low-priority pass on the AFM queue, behind all user-facing soft-AI work.
            backfillSessionKeywords()
        }
    }

    /// 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 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 that have a live controller, so the
    /// delete reclaims the worktree and branch; a supervisor moved to another Mac still has a
    /// tombstone row in `allSessions`, so its workers are correctly spared.
    private func sweepOrphanedOrchestraWorkers(in allSessions: [Session]) async {
        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)
        }
    }

    /// GC the two per-session environment stores orphaned by a previous run/crash: sandbox
    /// containers and macOS-VM disk clones (daemonless — no live guest survives the process).
    /// The stores are independent, so the two reconciles run CONCURRENTLY
    /// (docs/MAIN_THREAD_PERFORMANCE_PLAN.md item 6). Must run only after every persisted
    /// session has its controller reconstructed: both take the live-session set as the
    /// keep-list, so running early would GC a still-loading session's container/disks.
    private func reconcileSessionEnvironments() async {
        let active = Array(controllers.keys)
        // A chat that was already Done when the app last quit gets no amnesty from the restart: the
        // in-process Done reap died with the process, so drop those clones from the VM keep-list and let
        // this GC collect them. Uses the same grace as the live reaper, measured from the chat's last
        // update — without it, quitting during the grace window leaked the clone permanently, since every
        // later launch would keep re-adding it to the keep-list.
        let vmKeep = active.filter { !Self.isLongDone($0, summaries: summaries, now: now()) }
        // Local bindings so the structured children capture the (Sendable) actor handles.
        let containerManager = self.containerManager
        let macVMManager = self.macVMManager
        async let containerGC: Void? = containerManager?.reconcile(activeSessions: active)
        async let vmDiskGC: Void? = macVMManager?.reconcile(activeSessions: vmKeep)
        _ = await (containerGC, vmDiskGC)
        // 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()
    }

    // MARK: - Launch split: lock gate + deferred maintenance (plan item 6)

    /// The launch-path lock/nvrsion reconciliation, once per process on its own task so it
    /// never serializes UI hydration (docs/MAIN_THREAD_PERFORMANCE_PLAN.md item 6). Held locks
    /// are rebuilt from authoritative git state (`reconstructLocks`, LOCKING §6) and crash
    /// residue on nvrsion trunks recovered (`reconcileNvrsionTrunks`, NVRSION §8). COMMANDS —
    /// chat creation and message sends — await this gate (`awaitStartupLockReconciliation`) so
    /// no turn can run against unreconciled lock state, while unrelated hydration (activity
    /// feed, todos, provider probes) proceeds freely. Stored (not structured under the root
    /// view's `.task`) so a window close mid-launch can't cancel it half-reconciled.
    @ObservationIgnored private var startupLockReconciliation: Task<Void, Never>?

    private func beginStartupLockReconciliation() {
        guard startupLockReconciliation == nil else { return }  // exactly once per process
        startupLockReconciliation = Task { [weak self] in
            StartupSignposts.begin(.lockReconcile)
            defer { StartupSignposts.end(.lockReconcile) }
            guard let self else { return }
            // Rebuild held locks from authoritative git state now that controllers exist (LOCKING §6).
            await self.reconstructLocks()
            // Commit any uncommitted residue a crash left on an nvrsion trunk (NVRSION §8).
            await self.reconcileNvrsionTrunks()
        }
    }

    /// The command seams' await point. Free once the gate has completed, and always free on
    /// stores that never split the launch (nucleicd, tests, mid-run reloads): the task is nil.
    func awaitStartupLockReconciliation() async {
        await startupLockReconciliation?.value
    }

    /// Startup maintenance deferred past the first window paint (plan item 6): everything the
    /// old launch sequence ran serially before the UI settled, but which only needs to happen
    /// *eventually*. Exactly once per process (nil-check), on a stored task so the root view's
    /// `.task` being cancelled (window closed mid-launch) can't strand it half-run.
    @ObservationIgnored private var startupMaintenance: Task<Void, Never>?

    /// Kick off the deferred maintenance batch. Called by the app after hydration completes
    /// (the sidebar and dashboard are already settled by then). The order inside is deliberate:
    ///   1. await the lock/nvrsion gate — the Orchestra orphan sweep below deletes sessions,
    ///      and a delete racing `reconstructLocks` could restore a lock for a session
    ///      mid-teardown;
    ///   2. crash-recovery for mid-flight mesh transfers (mesh P5) — clears abandoned transfer
    ///      locks; a tombstoned source's commit finishes when its peer reconnects (via
    ///      `meshPeersChanged`);
    ///   3. orphan cleanup — interrupted Orchestra delete-cascades, then ephemeral scratch dirs
    ///      (safe: `loadSessions` has already reconstructed every persisted scratch chat);
    ///   4. container + VM-disk GC, concurrently (independent stores);
    ///   5. the omnisearch keyword backfill (a low-priority AFM pass), and the stale-base
    ///      re-provision check on its own task — it re-runs full provisioning when it fires
    ///      (minutes), and spawning it *after* the VM-disk GC preserves the old launch
    ///      sequence's happens-before so the two never touch the VM store concurrently. (The
    ///      surface host is wired before hydration even starts, so the macOS re-provision can
    ///      drive the guest — the other ordering the old inline sequence guaranteed.)
    public func runDeferredStartupMaintenanceOnce() {
        guard startupMaintenance == nil else { return }  // exactly once per process
        startupMaintenance = Task { [weak self] in
            StartupSignposts.begin(.deferredMaintenance)
            defer { StartupSignposts.end(.deferredMaintenance) }
            guard let self else { return }
            await self.awaitStartupLockReconciliation()
            await self.recoverInterruptedTransfers()
            let allSessions = (try? await self.database.loadAllSessions()) ?? []
            await self.sweepOrphanedOrchestraWorkers(in: allSessions)
            // Reclaim ephemeral scratch dirs left orphaned by a crash / unclean close.
            self.sweepOrphanedScratchDirs()
            await self.reconcileSessionEnvironments()
            // Backfill the omnisearch keyword index for pre-existing chats that don't have one
            // yet — a low-priority pass on the AFM queue, behind all user-facing soft-AI work.
            self.backfillSessionKeywords()
            // Off this task (see the doc comment): a stale base re-provision runs for minutes.
            Task { [weak self] in await self?.reprovisionStaleMacVMBasesIfNeeded() }
        }
    }

    /// 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).
    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]
    }

    /// Bounded cold-launch work queue. Three utility decodes keep SSD throughput high without the
    /// unbounded CPU/RAM spike of one task per transcript. A newly selected queued chat may use one
    /// extra foreground slot, so interaction never waits for an unrelated long decode to finish;
    /// at most one such overflow exists even if the user clicks through several rows rapidly.
    actor SessionPreparationPool {
        struct Item: Sendable {
            let id: SessionID
            let path: String
            /// Archived transcripts remain available for reconstruction, but do not enter the
            /// startup lanes until every ordinary transcript has completed. An explicit open
            /// still promotes one through `prioritize` below.
            let deferred: Bool

            init(id: SessionID, path: String, deferred: Bool = false) {
                self.id = id
                self.path = path
                self.deferred = deferred
            }
        }

        private struct Running: Sendable {
            let foregroundOverflow: Bool
            let deferred: Bool
        }

        private let calendar: Calendar
        private let maxBackground: Int
        private let prepare: @Sendable (Item, Calendar) -> PreparedSession
        private var pending: [Item]
        private var running: [SessionID: Running] = [:]
        private var completed: [SessionID: PreparedSession] = [:]
        private var waiters: [SessionID: [CheckedContinuation<PreparedSession?, Never>]] = [:]
        private var output: AsyncStream<PreparedSession>.Continuation?
        private var started = false

        init(
            items: [Item], calendar: Calendar, maxBackground: Int = 3,
            prepare: @escaping @Sendable (Item, Calendar) -> PreparedSession = {
                AppStore.prepareSession($0, calendar: $1)
            }
        ) {
            // Preserve caller order within each tier, but never let an archived entry placed
            // early by recency block a later active entry from reaching a free startup slot.
            self.pending = items.enumerated().sorted { lhs, rhs in
                if lhs.element.deferred != rhs.element.deferred {
                    return !lhs.element.deferred
                }
                return lhs.offset < rhs.offset
            }.map(\.element)
            self.calendar = calendar
            self.maxBackground = max(1, maxBackground)
            self.prepare = prepare
        }

        func start() -> AsyncStream<PreparedSession> {
            let (stream, continuation) = AsyncStream<PreparedSession>.makeStream()
            output = continuation
            started = true
            scheduleBackground()
            finishIfDrained()
            return stream
        }

        /// Return this session's shared preparation. When it is still queued, move it to the front
        /// and, if every ordinary slot is occupied, start it in the one user-initiated overflow.
        func value(for id: SessionID, prioritizing: Bool) async -> PreparedSession? {
            if let value = completed[id] { return value }
            if prioritizing { prioritize(id) }
            guard running[id] != nil || pending.contains(where: { $0.id == id }) else { return nil }
            return await withCheckedContinuation { continuation in
                waiters[id, default: []].append(continuation)
            }
        }

        /// Release the pool's retained array once a controller owns it. `AsyncStream` and any
        /// already-resumed foreground waiter retain their own copy-on-write references as needed.
        /// A background replacement starts only at this handoff, providing backpressure when the
        /// main actor is briefly busy installing controllers or persisting cache metadata.
        func release(_ id: SessionID) {
            completed[id] = nil
            scheduleBackground()
            finishIfDrained()
        }

        func isRunningForTesting(_ id: SessionID) -> Bool { running[id] != nil }

        private func prioritize(_ id: SessionID) {
            guard running[id] == nil,
                  let index = pending.firstIndex(where: { $0.id == id }) else { return }
            let item = pending.remove(at: index)
            let hasForegroundOverflow = running.values.contains { $0.foregroundOverflow }
            if running.count < maxBackground {
                launch(item, foregroundOverflow: false, priority: .userInitiated)
            } else if !hasForegroundOverflow {
                launch(item, foregroundOverflow: true, priority: .userInitiated)
            } else {
                pending.insert(item, at: 0)
            }
        }

        private func scheduleBackground() {
            guard started else { return }
            while running.values.filter({ !$0.foregroundOverflow }).count < maxBackground,
                  let next = pending.first
            {
                // Do not let speculative archived reconstruction contend with even the tail of
                // active startup hydration. Once no active background item remains, archived
                // work may use the bounded lanes at background QoS.
                if next.deferred,
                   running.values.contains(where: {
                       !$0.foregroundOverflow && !$0.deferred
                   })
                { break }
                let item = pending.removeFirst()
                launch(
                    item, foregroundOverflow: false,
                    priority: item.deferred ? .background : .utility)
            }
        }

        private func launch(
            _ item: Item, foregroundOverflow: Bool, priority: TaskPriority
        ) {
            running[item.id] = Running(
                foregroundOverflow: foregroundOverflow, deferred: item.deferred)
            let calendar = self.calendar
            let prepare = self.prepare
            Task.detached(priority: priority) {
                let prepared = prepare(item, calendar)
                await self.finished(prepared)
            }
        }

        private func finished(_ prepared: PreparedSession) {
            running[prepared.id] = nil
            completed[prepared.id] = prepared
            output?.yield(prepared)
            for waiter in waiters.removeValue(forKey: prepared.id) ?? [] {
                waiter.resume(returning: prepared)
            }
            finishIfDrained()
        }

        private func finishIfDrained() {
            guard started, pending.isEmpty, running.isEmpty else { return }
            output?.finish()
            output = nil
            // A waiter can only exist for a queued/running id, but fail closed if that invariant is
            // ever changed so a launch task cannot remain suspended forever.
            for continuations in waiters.values {
                for continuation in continuations { continuation.resume(returning: nil) }
            }
            waiters = [:]
        }
    }

    nonisolated private static func prepareSession(
        _ item: SessionPreparationPool.Item, calendar: Calendar
    ) -> PreparedSession {
        let transcriptURL = URL(fileURLWithPath: item.path)
        let events = (try? TranscriptReader(url: transcriptURL).read().events) ?? []
        // A missing/stale sidecar must never block controller recovery. Once the canonical decode
        // has paid that unavoidable fallback cost, use the already-decoded array to rebuild the
        // derived render index on this utility/background preparation lane. Future opens can then
        // paint from bounded records without repeating the recursive payload decode.
        let indexReader = TranscriptRenderIndex.Reader(transcriptURL: transcriptURL)
        do {
            _ = try indexReader.validate(expectedLastSequence: events.last?.seq ?? 0)
        } catch {
            try? TranscriptRenderIndex.rebuild(transcriptURL: transcriptURL, events: 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)
    }

    /// Bounded batch helper for non-interactive callers such as a stale dashboard-cache repair.
    /// Startup uses the pool directly so a selection can dynamically promote one queued item.
    nonisolated private static func prepareSessions(
        _ items: [(id: SessionID, path: String)], calendar: Calendar
    ) async -> [SessionID: PreparedSession] {
        let pool = SessionPreparationPool(
            items: items.map { .init(id: $0.id, path: $0.path) }, calendar: calendar)
        let stream = await pool.start()
        var result: [SessionID: PreparedSession] = [:]
        for await prepared in stream {
            result[prepared.id] = prepared
            await pool.release(prepared.id)
        }
        return result
    }

    /// Install one decoded transcript exactly once, whether it arrived through the ordinary
    /// background stream or the selected-chat foreground waiter. Observable state is committed
    /// before persistence awaits, and an open row is explicitly reloaded after its controller is
    /// present — the wake-up the old all-sessions barrier path missed.
    private func installStartupPreparedSession(
        _ prepared: PreparedSession, from preparation: SessionPreparationPool
    ) async {
        guard var session = startupHydrationSessions.removeValue(forKey: prepared.id) else {
            await preparation.release(prepared.id)
            if prepared.id == openSessionID, controllers[prepared.id] != nil,
               openTranscriptReadySessionID != prepared.id
            {
                reloadTask = Task { await reloadOpen() }
            }
            return
        }
        let wasNormalized = startupHydrationNormalized.remove(session.id) != nil
        // `resolvedProject` (not `projectsByID`) keeps saved ephemeral chats resumable.
        guard let project = resolvedProject(session.projectID) else {
            await preparation.release(prepared.id)
            return
        }

        var changed = wasNormalized
        if session.status == .awaitingInput, session.lastTurnDisposition == nil,
           let reply = prepared.lastReply
        {
            session.lastTurnDisposition = HeuristicTurnClassifier.classify(reply)
            changed = true
        }
        guard let controller = reconstructController(
            for: session, in: project, events: prepared.events)
        else {
            summaries.removeAll { $0.id == session.id }
            await preparation.release(prepared.id)
            return
        }

        controllers[session.id] = controller
        namedSessions.insert(session.id)
        if isEphemeral(session) { ephemeralPersistedSessions.insert(session.id) }
        observe(controller, session.id)
        if changed { upsertSummary(SessionSummary(session, pendingApprovalCount: 0)) }

        // Put the selected transcript on screen before activity-cache or normalized-row writes.
        if session.id == openSessionID, openTranscriptReadySessionID != session.id {
            reloadTask = Task { await reloadOpen() }
        }
        await preparation.release(prepared.id)

        if changed { try? await database.saveSession(session) }
        try? await database.saveActivityCache(session.id, ActivityCacheEntry(
            lastSeq: session.lastSeq, revertEpoch: session.revertEpoch,
            messageCount: prepared.messageCount, activityByDay: prepared.activityByDay,
            tokenCount: prepared.tokenCount, tokensByDay: prepared.tokensByDay))
    }

    /// 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,
            meshMessagingHandlers: meshMessagingHandlers(for: session.id),
            monitorEventSink: monitorEventSink(for: session.id),
            claudeCredentialBroker: claudeCredentialBroker,
            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)
    }

    /// Whether this chat has been sitting in **Done** longer than the reap grace — the launch-time
    /// counterpart to the live Done reap (``suspendVMIfCompleted(_:)``), which can't survive a quit.
    /// Such a chat's guest clones are dropped from the VM keep-list so launch reconcile collects them.
    ///
    /// Conservative on both axes, because the cost of a wrong `true` is a discarded guest: "Done" is
    /// ``SessionSummary/isCompleted`` — exactly what paints the row "Done" — and the wait is dated from
    /// `updatedAt`, the last time *anything* moved in the chat. That timestamp is at or after the moment it
    /// went Done, so the measured age only ever under-counts. A chat with no summary yet reads `false`.
    nonisolated static func isLongDone(
        _ sessionID: SessionID, summaries: [SessionSummary], now: Date,
        grace: TimeInterval = TimeInterval(MacVMSettings.doneReapGraceSeconds)
    ) -> Bool {
        guard let summary = summaries.first(where: { $0.id == sessionID }) else { return false }
        guard summary.isCompleted else { return false }
        return now.timeIntervalSince(summary.updatedAt) >= grace
    }

    /// Local, non-archived sessions that currently have a running macOS or Linux guest — the pool the
    /// floating PiP monitor draws from. Shared by the single-featured selector and the ordered
    /// fan-candidate list so both agree on membership.
    private nonisolated static func localPiPVMSessions(
        runningVMNames: [String], summaries: [SessionSummary]
    ) -> [SessionSummary] {
        let running = Set(runningVMNames)
        return summaries.filter { summary in
            summary.hostID == nil && !summary.archived && (
                running.contains(MacVMManager.vmName(for: summary.id)) ||
                running.contains(MacVMManager.linuxVMName(for: summary.id)))
        }
    }

    /// The background sessions whose running guest can occupy the floating PiP monitor, ordered so the
    /// one that should be featured (the "top" card of the stack) is **first**. The open session is
    /// excluded throughout: its guest belongs in the in-chat monitor, while Picture in Picture exists to
    /// keep *other* sessions' guests visible. This is the source list for the PiP stack + hover fan-out.
    ///
    /// Ordering: the user's last hand-picked card (`preferredSessionID`) sorts first while it's still a
    /// running background VM — a transient bias, not a hard pin, that also keeps the currently-floated
    /// owner stable so several background VMs don't make the PiP jump every poll. Otherwise prefer a
    /// working session (a turn in flight), then the most recently updated one.
    public nonisolated static func pictureInPictureVMSessionIDs(
        runningVMNames: [String], openSessionID: SessionID?,
        preferredSessionID: SessionID?, summaries: [SessionSummary]
    ) -> [SessionID] {
        localPiPVMSessions(runningVMNames: runningVMNames, summaries: summaries)
            .filter { $0.id != openSessionID }
            .sorted { lhs, rhs in
                let lhsPreferred = lhs.id == preferredSessionID
                let rhsPreferred = rhs.id == preferredSessionID
                if lhsPreferred != rhsPreferred { return lhsPreferred }
                if lhs.status.hasTurnInFlight != rhs.status.hasTurnInFlight {
                    return lhs.status.hasTurnInFlight
                }
                return lhs.updatedAt > rhs.updatedAt
            }
            .map(\.id)
    }

    /// Pick the single session whose running guest should occupy the featured (top) floating VM monitor
    /// — the head of ``pictureInPictureVMSessionIDs(runningVMNames:openSessionID:preferredSessionID:summaries:)``,
    /// with `currentSessionID` biasing the currently-floated owner to the front. When no background VM is
    /// running but the open session owns the only running guest, return it as a *dormant* selection;
    /// RootView keeps it cached (but hidden) so switching away can float it immediately.
    public nonisolated static func pictureInPictureVMSessionID(
        runningVMNames: [String], openSessionID: SessionID?, currentSessionID: SessionID?,
        summaries: [SessionSummary]
    ) -> SessionID? {
        if let featured = pictureInPictureVMSessionIDs(
            runningVMNames: runningVMNames, openSessionID: openSessionID,
            preferredSessionID: currentSessionID, summaries: summaries).first {
            return featured
        }
        let local = localPiPVMSessions(runningVMNames: runningVMNames, summaries: summaries)
        return local.contains(where: { $0.id == openSessionID }) ? openSessionID : nil
    }

    /// 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()
    }

    /// Which guest OS the in-flight base build is for (`nil` when none is / no manager is wired). Lets
    /// the settings panel re-derive whether a macOS or Linux base is building after the user navigates
    /// out of and back into Settings — which resets the panel's view-local flags but not this state.
    public func macVMBaseBuildGuest() async -> GuestOS? {
        await macVMManager?.baseBuildGuest() ?? nil
    }

    /// 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()
    }

    /// 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()
    }

    /// Change-stream counterpart of ``runningMacVMs()`` — plan item 7's replacement for the UI's
    /// 2-second VM registry polls (docs/MAIN_THREAD_PERFORMANCE_PLAN.md). Primed with the current
    /// snapshot, then one snapshot per registry transition. A host without the VM service yields
    /// one empty snapshot and finishes, so a consumer degrades to its defensive backstop poll.
    public func macVMChanges() async -> AsyncStream<[MacVMEntry]> {
        guard let macVMManager else {
            return AsyncStream { continuation in
                continuation.yield([])
                continuation.finish()
            }
        }
        return await macVMManager.vmChanges()
    }

    /// 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)
    }

    /// Raw JPEG `Data` of a running per-session VM's screen for the live monitor's fast path — skips the
    /// base64 encode/decode round-trip the `String` overload incurs. `nil` when unavailable.
    public func captureMacVMScreenData(name: String) async -> Data? {
        guard let macVMManager else { return nil }
        return await macVMManager.captureScreenData(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)
    }

    /// Whether the named per-session VM should read as **Waiting…** on the live monitor: powered on but
    /// idle, with no agent operation holding it and no recent `mac_vm_computer` action. The linger on the
    /// latter holds the live screen through the think-gaps between an agent's back-to-back computer-use
    /// actions, so the overlay surfaces only when the agent has genuinely stopped driving the guest.
    /// `false` when the service isn't wired.
    public func macVMIsWaiting(name: String) async -> Bool {
        guard let macVMManager else { return false }
        return await macVMManager.isWaiting(name: name)
    }

    /// A rolling tail of the named per-session VM's recent `mac_vm_exec` output — stdout, stderr, and the
    /// `$ command` echoes — newest last. The live monitor renders this in a console under the guest screen
    /// while ``macVMWorkingInBackground(name:)`` holds, so a headless-busy guest shows *what* it's running
    /// rather than just a frozen frame. Empty when the service isn't wired or the VM has run nothing.
    public func macVMBackgroundLog(name: String) async -> [MacVMLogLine] {
        guard let macVMManager else { return [] }
        return await macVMManager.backgroundLog(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")
        }
        // Run through a retained child task so ``forceCancelMacVMBaseWork()`` can actually cancel the
        // build, not just clear the engine's flags. The panel awaits this call, so without a handle the
        // only owner of the running build was the caller's own await and a wedged pass was unkillable.
        let task = Task { try await macVMManager.buildBaseImage(localRestoreImagePath: localRestoreImagePath) }
        macVMBaseWorkTask = task
        defer { if macVMBaseWorkTask == task { macVMBaseWorkTask = nil } }
        try await task.value
    }

    /// Forcibly cancel whatever the macOS VM base is doing — a hung provisioning pass, or a guest held
    /// open after a failed MDM pass (`nucleic.macvm.holdBaseOnMDMFailure`) — and release its claim on the
    /// base image. Nothing is deleted. Returns a summary of what was released, for the panel to show.
    ///
    /// This is the escape hatch for "provisioning is stuck and every other button refuses". It cancels
    /// the in-flight build task *and* clears the engine-side flags, because either one alone leaves the
    /// base pinned: the task keeps running, or its `defer` re-clears state the cancel just fixed.
    @discardableResult
    public func forceCancelMacVMBaseWork() async -> String {
        macVMBaseWorkTask?.cancel()
        macVMBaseWorkTask = nil
        guard let macVMManager else { return "The macOS VM service isn't wired in this build." }
        return await macVMManager.forceCancelBaseWork()
    }

    /// Re-run **only** the mandatory MDM enrollment/policy pass against the already-built base. Minutes
    /// rather than the ~40 a full rebuild costs, and it exercises the identical Mode A code path — the
    /// iteration loop the enrollment repair is worked in. It deliberately does not stamp the
    /// provisioning recipe: the toolchain and readiness passes have not run, so a success here means
    /// "enrollment works now", not "this base is publishable". Throws so the panel can surface it.
    public func revalidateMacVMBaseMDMPolicy() async throws {
        guard let macVMManager else {
            throw MacVMError.unavailable("the macOS VM service isn't wired in this build")
        }
        try await macVMManager.revalidateBaseMDMPolicy()
    }

    /// 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.
    ///
    /// `force` (the panel's default) cancels the in-flight pass and releases every claim on the base
    /// first, so an explicit delete is never refused because a hung build left its flag set. Pass
    /// `force: false` for the polite behaviour — refuse while the base is busy.
    public func deleteMacVMBaseImage(
        includingRestoreImages: Bool, force: Bool = true
    ) async throws {
        guard let macVMManager else {
            throw MacVMError.unavailable("the macOS VM service isn't wired in this build")
        }
        if force {
            macVMBaseWorkTask?.cancel()
            macVMBaseWorkTask = nil
        }
        try await macVMManager.deleteBaseImage(
            includingRestoreImages: includingRestoreImages, force: force)
    }

    /// 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")
        }
        // Retained so a wedged Linux build is cancellable too — see ``buildMacVMBaseImage``.
        let task = Task { try await macVMManager.buildLinuxBaseImage() }
        macVMBaseWorkTask = task
        defer { if macVMBaseWorkTask == task { macVMBaseWorkTask = nil } }
        try await task.value
    }

    /// Delete the built Linux base so the next ``buildLinuxVMBaseImage()`` rebuilds from scratch.
    /// `force` (the panel's default) cancels the in-flight pass first rather than refusing.
    public func deleteLinuxVMBaseImage(force: Bool = true) async throws {
        guard let macVMManager else {
            throw MacVMError.unavailable("the Linux VM service isn't wired in this build")
        }
        if force {
            macVMBaseWorkTask?.cancel()
            macVMBaseWorkTask = nil
        }
        try await macVMManager.deleteLinuxBaseImage(force: force)
    }

    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
                projectors[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)
            // Drop any chats parked in the durable mesh-dispatch queue for this project (mesh
            // dispatch) — the repo they'd run is gone.
            try? await meshDispatchStore?.removeMeshDispatches(projectID: id)
            await refreshQueuedDispatchCount()
            projectDescriptorCache[id] = nil
            // 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
                projectors[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,
        routing: RoutingNote? = nil,
        base: GitRef? = nil, useWorktree: Bool = true, auto: Bool? = nil, autoShip: Bool? = nil,
        attachments: [PendingAttachment] = [], reveal: Bool = true,
        covalenceOriginDeviceID: String? = nil
    ) 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, routing: routing,
                useWorktree: useWorktree, auto: auto, autoShip: autoShip,
                attachments: attachments, covalenceOriginDeviceID: covalenceOriginDeviceID)
            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,
        routing: RoutingNote? = 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, routing: routing,
                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 {
        // Gate on the launch lock/nvrsion reconciliation (plan item 6): a turn must never run
        // against not-yet-rebuilt lock state. Free once the gate completes.
        await awaitStartupLockReconciliation()
        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty, let controller = controllers[id] else { return }
        autoContinuedWaiting.remove(id)  // a fresh user message ends any dead-wait streak
        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 }
        // Same optimistic retirement as `respondToOpenApproval` — the popup can be answering the
        // chat that's also open in the main window, whose bar must not outlive the click.
        markApprovalAnswered(approvalID)
        do { try await controller.respondToApproval(approvalID, decision, by: localResponderID) }
        catch {
            lastError = "Approval failed: \(error)"
            await unmarkApprovalAnswered(approvalID, controller)
        }
    }

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

    /// Stall ids for which a "host command looks hung" alert has been resolved (killed / dismissed /
    /// exited / resumed). Read by the transcript's alert row to hide its Kill/Keep-waiting buttons
    /// once the alert is closed out. Rebuilt from events on session load, so it survives resume.
    public private(set) var resolvedStalls: Set<String> = []

    /// The title + question-ness of each surfaced approval, kept from `approvalRequested` until
    /// `approvalResolved` so the response bookend (mesh cast + diagnostics row) can name what was
    /// answered without re-fetching the (already-dropped) request. Structural metadata only —
    /// the title is the same one-liner already broadcast when the approval surfaced.
    private var approvalMetaByID: [ApprovalID: (title: String, isQuestion: Bool)] = [:]

    /// The user acted on a "host command looks hung" alert in the transcript: `kill` tears down that
    /// command's whole process tree (the running `host_exec` then returns a terminated result),
    /// otherwise the alert is dismissed and the command keeps running. Optimistically marks the alert
    /// resolved so its button retires immediately; the backend then emits the authoritative
    /// resolution note.
    public func resolveProcessStall(_ stallID: String, kill: Bool, inSession id: SessionID) async {
        resolvedStalls.insert(stallID)
        await controllers[id]?.resolveProcessStall(stallID: stallID, kill: kill)
    }

    /// 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) {
        newChatComposerRequest = NewChatComposerRequest(
            background: background, ephemeral: ephemeral)
    }

    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.
    /// `model` overrides the app default for just this dispatch (nil → project/app default),
    /// so a heavier or lighter model can be picked per idea from the Dispatch chevron.
    @discardableResult
    public func dispatchTodo(
        _ id: TodoID, in project: Project, model: String? = nil, 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, model: model) 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)
        }
        // Reap dead waiters (LOCKING invariant 7): a queued session whose chat has been deleted,
        // gone terminal, or been archived can never consume a grant — granting it would strand
        // the lock on a continuation nobody resumes reading. Checked on every queue poll.
        await lockManager.setReapCheck { [weak self] sid in
            guard let self else { return true }
            return await self.lockWaiterIsDead(sid)
        }
        // Carbon locks (CARBON_SHARDING §17): publish every lock-table mutation to the owning
        // project's lock ledger, and fence landing-driven releases on this device holding the
        // landed content (trivially satisfied on the landing host — §17.4).
        await lockManager.setLedgerAppend { [weak self] record in
            await self?.appendLockLedgerRecord(record)
        }
        await lockManager.setFenceCheck { [weak self] heads in
            guard let self else { return true }
            return await self.carbonLockFenceSatisfied(heads)
        }
        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
        // Fleet activity feed: a landed merge, from the one choke point every ship path
        // (autoship queue, agent-performed merge, remote integrate) flows through.
        if case .merged(let commit) = status, let controller = controllers[id] {
            Task { [weak self] in
                guard let self else { return }
                self.recordFeedEvent(
                    .shipped, session: await controller.snapshot.session,
                    detail: String(commit.prefix(7)))
            }
        }
    }

    /// 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
        materializedLockWork[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)
            // Fenced release (§17.4): the base-history head covering the landed parent tip is
            // produced synchronously here, before the release publishes — to the local
            // manager, or over the wire when another host is the authority (§17.5).
            await publishLockRelease(
                update.sessionID, paths: nil,
                requiredHeads: await landedRequiredHeads(for: 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
    }

    // MARK: Carbon lock ledger + release fence (CARBON_SHARDING §17)

    /// `CarbonLockManager.setLedgerAppend` collaborator: publish one lock-table mutation to
    /// the owning project's lock ledger (§17.2). No-op until the Carbon producer runs (the
    /// same `carbonTicker` gate as turn-boundary snapshots), and for domains that don't
    /// resolve to an enrolled control project.
    private func appendLockLedgerRecord(_ record: CarbonLockLedgerRecord) async {
        guard carbonTicker != nil else { return }
        guard let project = await lockLedgerProject(for: record) else { return }
        // A wire-arbitrated session's events carry the device actually running it (§17.2
        // `deviceID`); local sessions default to this host inside `appendLockEvent`.
        let origin = record.session.flatMap { remoteLockSessions[$0]?.deviceID }
        await carbonService.appendLockEvent(record, project: project, sessionDeviceID: origin)
    }

    /// The control project owning a lock domain: resolved via the record's session — a local
    /// controller, or a wire-arbitrated session's recorded origin (§17.5) — else by scanning
    /// live sessions for one sharing the domain.
    private func lockLedgerProject(for record: CarbonLockLedgerRecord) async -> CarbonService.ProjectInput? {
        if let sid = record.session {
            if let controller = controllers[sid] {
                let session = await controller.session
                guard let project = projectsByID[session.projectID], project.isNucleicControlled
                else { return nil }
                return carbonProjectInput(for: project)
            }
            if let remote = remoteLockSessions[sid] {
                return projectInput(forCarbonUUID: remote.projectUUID)
            }
        }
        for (_, controller) in controllers {
            let session = await controller.session
            guard lockDomain(for: session)?.ref == record.domain.ref,
                let project = projectsByID[session.projectID]
            else { continue }
            return carbonProjectInput(for: project)
        }
        return nil
    }

    /// The local project whose Carbon identity is `uuid` (the reverse of
    /// `carbonProjectInput`), restricted to control projects — the only ones with a ledger.
    private func projectInput(forCarbonUUID uuid: UUID) -> CarbonService.ProjectInput? {
        for project in projects where project.isNucleicControlled {
            if let input = carbonProjectInput(for: project), input.uuid == uuid { return input }
        }
        return nil
    }

    /// The lock authority for a project's domains when it is ANOTHER mesh host (§17.3):
    /// `Project` carries no owner field by design — the mesh-visible owner-of-record is the
    /// device stamped on the project's replicated lock-ledger head (the highest-generation
    /// writer). nil ⇒ this host arbitrates locally: it authored the ledger, or none exists
    /// yet (bootstrap makes the first host to lock the authority, §17.3).
    private func remoteLockAuthority(projectUUID: UUID) async -> String? {
        let key = CarbonStreamKey(projectUUID: projectUUID, stream: .lock, sessionID: nil)
        guard let head = await carbonService.store.heads(projectUUID: projectUUID)[key],
            let manifest = try? await carbonService.store.manifest(
                id: head.manifestID, projectUUID: projectUUID),
            manifest.ownerDeviceID != syncHostID
        else { return nil }
        return manifest.ownerDeviceID
    }

    /// Route a lock release to wherever the session's locks actually live: the remote
    /// authority (§17.5 — the acquires were proxied there too) or the local manager. The
    /// landing-driven callers hand in the fence heads they produced (§17.4); lifecycle/manual
    /// callers pass none. `paths == nil` = release everything.
    ///
    /// An unreachable authority leaves its lock held — correct (fail closed, §17.3); the next
    /// landing-driven publish (turn-end, poll) retries, and the authority's manual
    /// force-release remains the escape hatch.
    private func publishLockRelease(
        _ sessionID: SessionID, paths: [String]?, requiredHeads: [CarbonHead]
    ) async {
        if let controller = controllers[sessionID] {
            let session = await controller.session
            if let project = projectsByID[session.projectID],
                let input = carbonProjectInput(for: project),
                let domain = lockDomain(for: session),
                let authority = await remoteLockAuthority(projectUUID: input.uuid)
            {
                if let peer = peerClient,
                    await peer.carbonLockRelease(
                        projectUUID: input.uuid, domainRef: domain.ref,
                        sessionID: sessionID.rawValue, paths: paths,
                        requiredHeads: requiredHeads, from: authority)
                {
                    return
                }
                lockLog.notice("LOCKTRACE proxy-release unreachable authority=\(authority, privacy: .public) session=\(sessionID.rawValue, privacy: .public)")
                // Fall through: the local manager holds nothing for a proxied session, so
                // the local release is a harmless no-op — but a session whose project only
                // *became* remote-authored mid-flight still frees its local locks.
            }
        }
        if let paths {
            await lockManager.release(sessionID, paths: paths, requiredHeads: requiredHeads)
        } else {
            await lockManager.releaseAll(sessionID, requiredHeads: requiredHeads)
        }
    }

    /// `CarbonLockManager.setFenceCheck` collaborator (§17.4 stage 2): does this device hold
    /// the landed content — chains persisted, packs applied, refs fast-forwarded? The repo
    /// map is snapshotted here (main actor) so the fence itself runs unisolated.
    private func carbonLockFenceSatisfied(_ heads: [CarbonHead]) async -> Bool {
        var rootsByUUID: [UUID: String] = [:]
        for project in projects {
            guard let input = carbonProjectInput(for: project) else { continue }
            rootsByUUID[input.uuid] = input.rootPath
        }
        let roots = rootsByUUID
        let fence = carbonService.makeLockFence(repoPath: { roots[$0] })
        return await fence.satisfied(heads)
    }

    /// §17.4 stage-1 publication input: produce — synchronously, at the landing site, before
    /// the release publishes — the Carbon history head(s) carrying the content a
    /// landing-driven release fences on. Empty (⇒ unfenced release, today's local behavior)
    /// when the producer isn't running or the project doesn't enroll.
    ///
    /// The landed ref is the session's release target: the nvrsion trunk for a trunk
    /// session, else its `parentRef` (a project branch — the per-project base chain — or
    /// another live session's branch, whose own `history` chain then carries the content:
    /// the nested-child row of the §17.4 table).
    func landedRequiredHeads(for sessionID: SessionID) async -> [CarbonHead] {
        guard carbonTicker != nil, let controller = controllers[sessionID] else { return [] }
        let session = await controller.session
        guard let project = projectsByID[session.projectID], project.isNucleicControlled,
            let input = carbonProjectInput(for: project)
        else { return [] }
        let landingRef: String
        if isNvrsionSession(session, in: project) {
            landingRef = project.nvrsion?.trunkBranch ?? ProjectNvrsion.defaultTrunkBranch
        } else {
            landingRef = session.parentRef ?? project.defaultBranch.value
        }
        if let parent = await liveWorktreeSessionInput(
            branch: landingRef, project: input, excluding: sessionID),
            let head = await carbonService.produceSessionHistoryNow(parent)
        {
            return [head]
        }
        if let head = await carbonService.produceBaseHistoryNow(input, branch: landingRef) {
            return [head]
        }
        return []
    }

    /// A live, worktree-backed session of `project` whose branch is `branch` — the nested-
    /// child parent lookup for `landedRequiredHeads`.
    private func liveWorktreeSessionInput(
        branch: String, project: CarbonService.ProjectInput, excluding: SessionID
    ) async -> CarbonService.SessionInput? {
        for (id, controller) in controllers
        where id != excluding && !transferringSessions.contains(id) {
            let s = await controller.session
            guard s.branch == branch, !s.status.isTerminal, !s.archived,
                let worktreePath = s.worktreePath, !worktreePath.isEmpty,
                let p = projectsByID[s.projectID],
                carbonProjectInput(for: p)?.uuid == project.uuid
            else { continue }
            return CarbonService.SessionInput(
                sessionID: id.rawValue, worktreePath: worktreePath, branch: branch,
                baseSHA: s.baseSHA, project: project)
        }
        return nil
    }

    /// The replicated lock-ledger fold for a project (§17.2) — the mesh-side lock view a
    /// non-authority member (or a test) reads.
    public func carbonLockFold(projectID: ProjectID) async -> CarbonLockFold? {
        guard let project = projectsByID[projectID],
            let input = carbonProjectInput(for: project)
        else { return nil }
        return await carbonService.lockFold(projectUUID: input.uuid)
    }

    /// 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 {
        // A release — by ANY path (mediated merge, completion, poll, manual, lifecycle) — drops the
        // freed paths from the materialized-work set (LOCKING §4.4), so if the session later
        // re-acquires the same file its pre-write window re-arms from scratch and isn't misread as
        // "already landed". Done before the controller guard so it still fires during teardown.
        if case .released(let paths) = event, var m = materializedLockWork[sessionID] {
            for p in paths { m[p] = nil }
            materializedLockWork[sessionID] = m.isEmpty ? nil : m
        }
        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 }
            // A remotely-authored domain's locks live at its authority (§17.3) — never seed
            // them into the local manager; the session re-acquires through the wire proxy on
            // its next edit (the LOCKING §6.2 restart argument, spanning hosts).
            if let project = projectsByID[session.projectID],
                let input = carbonProjectInput(for: project),
                await remoteLockAuthority(projectUUID: input.uuid) != nil
            { 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 }
        }

        // Carbon Locks (§17.3/§17.5): when another host authored this project's lock ledger,
        // it is the domain's authority — arbitration routes there over the wire and the local
        // manager stays out of it. The common case (this host owns the project) falls through
        // to the ordinary local path below.
        if let project = projectsByID[session.projectID],
            let input = carbonProjectInput(for: project),
            let authority = await remoteLockAuthority(projectUUID: input.uuid)
        {
            return await proxyArbitrate(
                sessionID: sessionID, controller: controller, projectUUID: input.uuid,
                domain: domain, files: candidateFiles, authority: authority)
        }

        let willWait = await lockManager.wouldBlock(sessionID, domain: domain, files: candidateFiles)
        if willWait {
            sessionsWaitingForAccess.insert(sessionID)
            deadlockedSessions = await lockManager.deadlockedSessions()
            // Record what this chat is now blocked on (files + holders) for the in-chat
            // contention banner, and say so in the transcript as a VISIBLE line — deliberately
            // not a `lockEvent` note, which the transcript filters out by default. A silent park
            // here is exactly the "chat froze on its last status message with no error" stall.
            let holders = await contendedHolders(domain: domain, files: candidateFiles, excluding: sessionID)
            lockWaits[sessionID] = LockWaitInfo(
                files: candidateFiles.sorted(), holders: holders, since: now())
            let signature = (candidateFiles.sorted() + holders.map(\.sessionID.rawValue))
                .joined(separator: "|")
            if contentionNoteSignatures[sessionID] != signature {
                contentionNoteSignatures[sessionID] = signature
                await controller.note(
                    "Waiting for access to \(Self.lockPathList(candidateFiles)) — held by "
                        + "\(Self.holderList(holders)). Resumes automatically when that work lands; "
                        + "the Control panel's File Locks section can release it manually.",
                    icon: "hourglass")
            }
        }
        startLockReconcileIfNeeded()
        // The wait is unbounded by design — contention lasts as long as the holder's work takes
        // to land, which can be an hour. Liveness comes from the visible note + banner above
        // (the user can force-release the holder) and the dead-waiter reap, not from a clock.
        let outcome = await lockManager.acquire(sessionID, domain: domain, files: candidateFiles)
        sessionsWaitingForAccess.remove(sessionID)
        lockWaits[sessionID] = nil
        deadlockedSessions = await lockManager.deadlockedSessions()
        // Seed the materialized-work set (LOCKING §4.4) for any just-acquired path that ALREADY
        // differs from the parent at grant time. The reconcile poll only releases a held path it has
        // *observed* diverge (so a pre-write edit-gate lock — taken before the CLI writes — isn't
        // misread as "already landed" and freed mid-edit). But it samples on a periodic cadence, so
        // if this edit's work then lands via a merge the in-container interceptor can't see (a user or
        // peer merge "outside Nucleic") *within a single poll interval* — before the poll's first pass
        // ever samples the diff — the path would look "never materialized" forever and the poll could
        // never release it, leaking the lock until a lifecycle/mediated release. Sampling once here,
        // at acquire, arms materialization for work that's already on disk so that later landing is
        // detectable on the very next pass. A genuine pre-write acquisition (the file doesn't yet
        // differ) seeds nothing and is still held until it's written, exactly as before.
        if case .cancelled = outcome {} else {
            await seedMaterializedLockWork(sessionID, controller: controller, candidates: candidateFiles)
        }
        switch outcome {
        case .granted:
            contentionNoteSignatures[sessionID] = nil  // resolved — a later contention notes anew
            return ArbitrationOutcome(.proceed)
        case .grantedNeedsReground(let files, let diff):
            contentionNoteSignatures[sessionID] = nil
            // 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.
            contentionNoteSignatures[sessionID] = nil
            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)
        }
    }

    /// The wire half of `arbitrate` (§17.5): acquire from the remote lock authority, retrying
    /// through unreachability — never granting locally (§17.3: unreachable ≠ dead, fail
    /// closed). On grant, update-on-grant/re-ground run HERE against the session's own
    /// worktree (§17.4 soundness guarantees the merge source is present locally: this device
    /// could not have been granted without holding the landed content).
    private func proxyArbitrate(
        sessionID: SessionID, controller: SessionController, projectUUID: UUID,
        domain: LockDomain, files: [String], authority: String
    ) async -> ArbitrationOutcome {
        var notedUnreachable = false
        defer {
            sessionsWaitingForAccess.remove(sessionID)
            lockWaits[sessionID] = nil
        }
        while true {
            // A dead session stops asking; the authority's own reap/lifecycle paths clear its
            // remote record (its wire waiter, if parked, resolves when this connection drops).
            if await lockWaiterIsDead(sessionID) { return ArbitrationOutcome(.cancelled) }
            let outcome = await peerClient?.carbonLockAcquire(
                projectUUID: projectUUID, domainRef: domain.ref,
                sessionID: sessionID.rawValue, paths: files, from: authority,
                onQueued: { [weak self] in
                    Task { @MainActor [weak self] in
                        guard let self else { return }
                        self.sessionsWaitingForAccess.insert(sessionID)
                        self.lockWaits[sessionID] = LockWaitInfo(
                            files: files.sorted(), holders: [], since: self.now())
                    }
                })
            switch outcome {
            case .granted:
                switch await runUpdateOnGrant(sessionID, files: files) {
                case .clean:
                    return ArbitrationOutcome(.proceed)
                case .changed(let changed, let diff):
                    await controller.note(
                        "Re-grounding on \(Self.lockPathList(changed)): 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: changed, diff: diff))
                case .conflicted(let paths):
                    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)
                }
            case .cancelled:
                return ArbitrationOutcome(.cancelled)
            case .queued, nil:
                // `queued` never arrives as the final answer; nil = the authority is
                // unreachable / predates the verb / the wire dropped mid-wait. Never fail
                // open: surface once, then keep retrying until connectivity returns or the
                // session dies (§17.8 row 1's acquire-side analogue).
                if !notedUnreachable {
                    notedUnreachable = true
                    await controller.note(
                        "Waiting for the lock authority (\(authority)) — it is currently "
                            + "unreachable. Edits to locked files resume when it reconnects.",
                        icon: "hourglass")
                }
                try? await Task.sleep(for: .seconds(3))
            }
        }
    }

    /// The sessions holding locks that overlap `files` in `domain`, with their sidebar titles —
    /// what a parked chat's contention note and banner name as the blockers.
    private func contendedHolders(
        domain: LockDomain, files: [String], excluding: SessionID
    ) async -> [LockWaitInfo.Holder] {
        let state = await lockManager.state(in: domain)
        return state.holders.compactMap { holder in
            guard holder.session != excluding,
                holder.paths.contains(where: { held in
                    files.contains { ConflictDetector.pathsOverlap(held, $0) }
                })
            else { return nil }
            let title = summaries.first { $0.id == holder.session }?.title ?? ""
            return LockWaitInfo.Holder(
                sessionID: holder.session, title: title.isEmpty ? "another chat" : title)
        }
    }

    /// Human phrasing for the holders of a contended lock: `“Fix login”, “Refactor sync”`, or a
    /// generic fallback when the holder can't be named (already gone, or a stale record).
    private static func holderList(_ holders: [LockWaitInfo.Holder]) -> String {
        guard !holders.isEmpty else { return "another active chat" }
        return holders.map { "“\($0.title)”" }.joined(separator: ", ")
    }

    /// Arm `materializedLockWork` (LOCKING §4.4) for the just-acquired `candidates` that already
    /// differ from the parent — i.e. work that's on disk at grant time. See the call site in
    /// `arbitrate` for why this is needed (a fast external land within one poll interval). Fails
    /// safe: a worktree-less session or a git error that can't compute the unmerged set seeds
    /// nothing — the periodic poll remains the backstop.
    private func seedMaterializedLockWork(
        _ sessionID: SessionID, controller: SessionController, candidates: [String]
    ) async {
        guard await controller.hasWorktree else { return }
        guard let unmerged = try? await controller.unmergedFiles() else { return }
        // Directory-aware, matching `heldPathDisposition` — a declared *area* (or a path inside a
        // submodule) is diverging when git reports anything overlapping it, never by string equality.
        let alreadyDiffering = Set(candidates).filter { candidate in
            unmerged.contains { ConflictDetector.pathsOverlap(candidate, $0) }
        }
        guard !alreadyDiffering.isEmpty else { return }
        // Stamp each with the parent's current blob, so a later "no longer differs" can be told
        // apart from a discard/stash that reverted to that same blob (LOCKING §4.4).
        guard let parentBlobs = try? await controller.parentBlobSHAs(Array(alreadyDiffering))
        else { return }  // fail safe: seed nothing; the poll re-arms on its next pass
        for path in alreadyDiffering {
            materializedLockWork[sessionID, default: [:]][path] =
                parentBlobs[path] ?? Self.absentFromParent
        }
    }

    /// How a holder's currently-held paths split for release purposes (LOCKING §4.4) — the one
    /// place "has this landed?" is decided, shared by the reconcile poll and the turn-end sweep so
    /// the two can't drift apart (they did: the sweep used a bare `held − unmerged`).
    private struct HeldPathDisposition {
        /// Materialized work that no longer differs from the parent **and** whose parent-side blob
        /// moved to get there — a genuine landing (merge, squash, rebase, or the accepted
        /// coincidental-identical-parent case, LOCKING §12.2).
        var landed: Set<String> = []
        /// Materialized work that no longer differs from the parent, but the parent never moved —
        /// so nothing landed. The agent reset/restored/stashed it, or edited and reverted. These
        /// are indistinguishable from content alone, so the two callers bias differently: the poll
        /// **holds** them (a `git stash` mid-turn is routinely popped straight back, and handing a
        /// sibling that file is the divergence the lock exists to prevent), while the turn-end
        /// sweep **reaps** them (the turn is over, so a discarded edit shouldn't pin the file).
        var settledWithoutLanding: Set<String> = []
        /// Held paths never observed to differ from the parent: declarations the edit gate or
        /// `check_conflict` took that the agent never actually wrote. The poll holds these (the
        /// write may still be coming); the turn-end sweep reaps them.
        var neverWritten: Set<String> = []
    }

    /// Split `held` into landed / never-written for `sessionID`, arming and pruning
    /// `materializedLockWork` as it goes.
    ///
    /// **Throws rather than guessing** — every caller must fail CLOSED. A git failure here (an
    /// `index.lock` race with the agent's own git, a parentRef that won't resolve) must never be
    /// coerced into "nothing differs ⇒ everything landed", which would free work that never merged.
    private func heldPathDisposition(
        _ sessionID: SessionID, controller: SessionController, held: Set<String>
    ) async throws -> HeldPathDisposition {
        let unmerged = Set(try await controller.unmergedFiles())
        var materialized = materializedLockWork[sessionID] ?? [:]
        defer {
            materialized = materialized.filter { held.contains($0.key) }  // prune to the live set
            materializedLockWork[sessionID] = materialized.isEmpty ? nil : materialized
        }
        // Does a held path still have unmerged work under (or at) it? Matched by directory-aware
        // OVERLAP, not string equality, because the two sides speak different vocabularies:
        //
        // - A held path can be a DIRECTORY (`check_conflict` invites "files/areas"), and the lock
        //   table blocks siblings on it via the same overlap rule — but `git --name-only` never
        //   emits a directory, so exact subtraction always classified it as nothing-to-protect and
        //   freed it while files under it were unmerged.
        // - Conversely git reports a SUBMODULE as its gitlink (`sub`), never `sub/foo.txt`, so an
        //   exact match never fired for a lock on a file inside one.
        //
        // `pathsOverlap` is componentwise, so `src` still never matches `src2/x` (Conflict.swift).
        //
        // Computed ONCE as a set, not as a `held × unmerged` scan per query: this method runs on
        // the MainActor every reconcile pass (3s cadence while any lock is held) and used to ask
        // the overlap question three times per held path — on a heavily diverged trunk that pinned
        // the main thread for tens of seconds per pass, freezing transcripts and starving the
        // spawn path into its 60s no-output watchdog (hang-reports 2026-07-28).
        let divergent = Self.divergentHeldPaths(held: held, unmerged: unmerged)
        func diverges(_ heldPath: String) -> Bool { divergent.contains(heldPath) }
        // Arm: any held path observed diverging now, stamped with the parent's blob at this moment.
        let newlyDiffering = held.filter { materialized[$0] == nil && diverges($0) }
        if !newlyDiffering.isEmpty {
            let blobs = try await controller.parentBlobSHAs(Array(newlyDiffering))
            for path in newlyDiffering {
                // A directory resolves to its TREE sha, which is exactly the right thing: it moves
                // when anything beneath it moves.
                materialized[path] = blobs[path] ?? Self.absentFromParent
            }
        }
        var result = HeldPathDisposition()
        result.neverWritten = held.filter { materialized[$0] == nil && !diverges($0) }
        // Candidates: materialized, still held, no longer differing. Landed only if the PARENT is
        // what moved — otherwise the worktree was reset/restored/stashed back onto the parent's
        // own unchanged content and nothing landed at all.
        let settled = held.filter { materialized[$0] != nil && !diverges($0) }
        if !settled.isEmpty {
            let parentNow = try await controller.parentBlobSHAs(Array(settled))
            for path in settled {
                if (parentNow[path] ?? Self.absentFromParent) != materialized[path] {
                    result.landed.insert(path)
                } else {
                    result.settledWithoutLanding.insert(path)
                }
            }
        }
        for path in result.landed { materialized[path] = nil }  // released → re-arm fresh on re-acquire
        return result
    }

    /// The subset of `held` overlapping some unmerged path — same componentwise semantics as
    /// `ConflictDetector.pathsOverlap` (a held path diverges when unmerged work sits at it, under
    /// it, or at a path above it, e.g. a submodule gitlink), but computed in
    /// O((held + unmerged) · depth) with two hash sets instead of the `held × unmerged`
    /// cross-product the reconcile poll used to burn the main thread on.
    nonisolated static func divergentHeldPaths(
        held: Set<String>, unmerged: Set<String>
    ) -> Set<String> {
        guard !held.isEmpty, !unmerged.isEmpty else { return [] }
        // The form `pathsOverlap` effectively compares: components joined by single slashes.
        // Paths from git and the lock table are already canonical; only allocate when not.
        func canonical(_ p: String) -> String {
            if !p.hasPrefix("/") && !p.hasSuffix("/") && !p.contains("//") { return p }
            return p.split(separator: "/").joined(separator: "/")
        }
        var unmergedCanon = Set<String>(minimumCapacity: unmerged.count)
        var unmergedAncestors = Set<String>()  // every proper component-prefix of an unmerged path
        // An all-slash path has zero components, which `pathsOverlap` treats as a universal prefix.
        var unmergedMatchesAll = false
        for u in unmerged {
            guard !u.isEmpty else { continue }
            let cu = canonical(u)
            guard !cu.isEmpty else {
                unmergedMatchesAll = true
                continue
            }
            unmergedCanon.insert(cu)
            var searchFrom = cu.startIndex
            while let slashIdx = cu[searchFrom...].firstIndex(of: "/") {
                unmergedAncestors.insert(String(cu[..<slashIdx]))
                searchFrom = cu.index(after: slashIdx)
            }
        }
        var result: Set<String> = []
        for h in held {
            guard !h.isEmpty else { continue }
            let ch = canonical(h)
            if unmergedMatchesAll || ch.isEmpty  // zero-component held path: universal prefix
                || unmergedCanon.contains(ch)  // exact file/gitlink match
                || unmergedAncestors.contains(ch) {  // held DIRECTORY with unmerged work under it
                result.insert(h)
                continue
            }
            // Held path INSIDE a path git reported (a file within a submodule's gitlink).
            var searchFrom = ch.startIndex
            while let slashIdx = ch[searchFrom...].firstIndex(of: "/") {
                if unmergedCanon.contains(String(ch[..<slashIdx])) {
                    result.insert(h)
                    break
                }
                searchFrom = ch.index(after: slashIdx)
            }
        }
        return result
    }

    // 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). Fenced on the trunk history
            // head covering the turn's landed commits (§17.4 — emitted per land, so it always
            // exists before this release fires).
            await publishLockRelease(
                sessionID, paths: nil,
                requiredHeads: await landedRequiredHeads(for: 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
            }
            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 publishLockRelease(
                session, paths: paths,
                requiredHeads: await landedRequiredHeads(for: session))
        }
        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() {
            // Wire-arbitrated sessions (§17.5) hold locks HERE (this host is their authority)
            // but run — and land — on their own host, which detects the landing and proxies
            // the release. Never treat "no local controller" as dead for them.
            guard remoteLockSessions[sid] == nil else { continue }
            // A session mid-transfer has had its controller detached synchronously
            // (`moveSession`) while its worktree still holds unmerged work — the transfer quiesce
            // *commits* that work, it never merges it upward. Treating the missing controller as
            // "gone" would free those files for the whole transfer, and a pre-tombstone failure
            // restores the controller without re-acquiring anything. It isn't dead: skip it, as
            // every other controller sweep here already does.
            guard !transferringSessions.contains(sid) else { continue }
            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)")
                materializedLockWork[sid] = nil
                await lockManager.releaseAll(sid); continue
            }
            let session = await controller.snapshot.session
            // Favorites are never auto-archived (`sweepAutoArchive`), so they never trip the
            // `archived` branch below — a lock on their unmerged/un-landed work would otherwise
            // hold forever with no automatic release path. Mirror the archive sweep's own
            // staleness test (same idle interval) so a favorited-but-done chat still gets the
            // same "give up and force release" backstop, without being archived itself.
            let sessionIsCompleted: Bool = {
                switch session.status {
                case .finished: return true
                case .awaitingInput: return session.lastTurnDisposition == .completed
                default: return false
                }
            }()
            let idleTooLong =
                autoArchiveIdleInterval.map { session.updatedAt <= now().addingTimeInterval(-$0) } ?? false
            let staleFavorite = session.favorite && sessionIsCompleted && idleTooLong
            // `.error` and `.interrupted` are terminal *statuses* but RESUMABLE *chats*: an errored
            // turn (a backend crash, an API failure, a rate-limit abort) leaves the agent's dirty,
            // unmerged edits on disk, and the user retries and keeps working in that same worktree.
            // Dumping every lock here handed a sibling a file mid-edit — the reported integrity
            // failure, and a direct violation of LOCKING invariant 4 ("release only on
            // merge-into-parent"). Such a session falls through to the content-verified landed
            // check below instead, which frees exactly what has landed and holds the rest. The
            // idle backstop still applies, so a genuinely abandoned errored chat can't strand a
            // lock forever — it gives up on the same clock as a stale favorite.
            let resumableTerminal =
                (session.status == .error || session.status == .interrupted) && !idleTooLong
            if (session.status.isTerminal && !resumableTerminal) || session.archived || staleFavorite {
                lockLog.notice("LOCKTRACE reconcile releaseAll(lifecycle terminal=\(session.status.isTerminal) archived=\(session.archived) staleFavorite=\(staleFavorite)) session=\(sid.rawValue, privacy: .public)")
                materializedLockWork[sid] = nil
                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 }
            // A rebase/merge/cherry-pick/revert in flight checks the target out into the worktree
            // before replaying, so mid-operation EVERY path the session changed transiently matches
            // the parent. Releasing on that snapshot would dump the holder's entire lock set on a
            // state that is about to be undone. The tree isn't authoritative until it settles —
            // skip this holder and retry next pass. (Also fail closed if we can't even tell.)
            let gitOp: String?
            do { gitOp = try await controller.worktreeOperationInProgress() } catch {
                lockLog.notice("LOCKTRACE reconcile gitop-check-failed keep session=\(sid.rawValue, privacy: .public) error=\(String(describing: error), privacy: .public)")
                continue
            }
            if let gitOp {
                lockLog.notice("LOCKTRACE reconcile skip(\(gitOp, privacy: .public)-in-progress) session=\(sid.rawValue, privacy: .public) held=\(held.sorted().joined(separator: ","), privacy: .public)")
                continue
            }
            // Cheap skip before the expensive landed check: that check costs ~5 git spawns per
            // holder (three `unmergedFiles` diffs + `ls-files`, plus `parentBlobSHAs`), and with a
            // few stopped-but-holding sessions the 3s poll alone sustained a permanent git storm
            // (the 2026-07-28 hang reports). The fingerprint pins everything the check reads —
            // worktree tip, index, parent tip, and the held working files themselves (an
            // uncommitted write to a held file must recompute: it's what arms
            // `materializedLockWork`, and a skipped arming would later misread the landing as
            // never-written) — so while it and the held set match the last pass, that pass's
            // "still held" verdict is reused whole. Taken BEFORE the check runs: state that moves
            // mid-check makes the *next* pass's fingerprint differ, so a torn read can only cost
            // one extra recompute, never a stale skip.
            let fingerprint = await controller.lockStateFingerprint(held: Array(held))
            if let fingerprint, let cached = reconcileStillHeld[sid],
                cached.fingerprint == fingerprint, cached.held == held {
                continue
            }
            // Fail CLOSED: a git failure deciding what landed (an `index.lock` race with the
            // agent's own git in the shared worktree, an unresolvable parentRef mid-operation) must
            // NOT release a lock — that 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 disposition: HeldPathDisposition
            do {
                disposition = try await heldPathDisposition(sid, controller: controller, held: held)
            } catch {
                lockLog.notice("LOCKTRACE reconcile landed-check-failed keep session=\(sid.rawValue, privacy: .public) held=\(held.sorted().joined(separator: ","), privacy: .public) error=\(String(describing: error), privacy: .public)")
                continue
            }
            // Only the genuinely-landed set. Never-written declarations stay held here (the write
            // may still be coming — the lock is taken *before* the CLI writes, LOCKING §4.5);
            // turn-end (`releaseCompletedSessionLocks`) is what reaps those.
            if !disposition.landed.isEmpty {
                reconcileStillHeld[sid] = nil
                await lockManager.release(
                    sid, paths: Array(disposition.landed),
                    requiredHeads: await landedRequiredHeads(for: sid))
            } else {
                // Nothing released, so this exact verdict holds until the fingerprinted state
                // (or the held set) moves — cache it so quiet passes skip the git spawns above.
                if let fingerprint { reconcileStillHeld[sid] = (fingerprint, held) }
                // Still held. This line reveals *why*: the files still differ from the parent, or
                // their edit hasn't materialized yet (held pre-write / mid-re-ground), or they
                // stopped differing without the parent moving (a discard/stash, not a landing).
                lockLog.notice("LOCKTRACE reconcile still-held session=\(sid.rawValue, privacy: .public) parentRef=\(session.parentRef ?? "nil", privacy: .public) held=\(held.sorted().joined(separator: ","), privacy: .public) neverWritten=\(disposition.neverWritten.sorted().joined(separator: ","), privacy: .public)")
            }
        }
        let raw = await lockManager.rawState()
        // Backstop: prune the materialized-work map to what's actually still held, so an entry for a
        // session that released everything outside this loop (a mediated ship, completion, manual)
        // can't linger and taint a future re-acquire. The per-release event clear is the fast path;
        // this catches anything it missed.
        if !materializedLockWork.isEmpty {
            var heldBySession: [SessionID: Set<String>] = [:]
            for h in raw.holds { heldBySession[h.session, default: []].insert(h.path) }
            materializedLockWork = materializedLockWork.reduce(into: [:]) { acc, entry in
                let stillHeld = heldBySession[entry.key] ?? []
                let live = entry.value.filter { stillHeld.contains($0.key) }
                if !live.isEmpty { acc[entry.key] = live }
            }
        }
        // Same prune for the still-held skip cache: an entry for a session that released
        // everything outside this loop (mediated ship, turn-end sweep, manual) must not survive
        // to skip a pass for a later re-acquire with state the poll never actually examined.
        if !reconcileStillHeld.isEmpty {
            let holders = Set(raw.holds.map(\.session))
            reconcileStillHeld = reconcileStillHeld.filter { holders.contains($0.key) }
        }
        sessionsWaitingForAccess = Set(raw.waits.map(\.session))
        deadlockedSessions = await lockManager.deadlockedSessions()
        // Keep the banner truthful: drop wait-info for anything no longer actually queued (its
        // arbitrate resolved on another path), so a stale banner can't outlive its wait.
        lockWaits = lockWaits.filter { sessionsWaitingForAccess.contains($0.key) }
    }

    /// `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)")
    }

    /// `ConflictArbiter`: an event the nash agent shell observed for `sessionID` (docs/NASH.md §9).
    /// nash is the agent's own shell, so its exec gate is the ground truth for every simple command
    /// — the convergence path that supersedes the git/gh shims and the bash tracer. Routing:
    ///
    ///   * `exec` of `git`/`gh` → the existing `observeGitOp`/`observeGhOp` (drives locks + autoship
    ///     + the git/gh feed), so a shell exec of git is recorded exactly once, on the git feed.
    ///   * any other `exec` → the existing `observeCommand` path (classify → command feed + NVRSION
    ///     residue backstop + `CMDTRACE`), tagged `source: .nash`.
    ///   * `cd` / `export` (names only) / `fallback` / `dropped` → `CMDTRACE`-logged for now; the
    ///     data-flow feed rows that render them are M2 (docs/NASH.md §9.5).
    ///
    /// While the shims and nash coexist, exec events dedupe naturally by feed. Purely observational
    /// for the non-git kinds.
    ///
    /// Delivered one nash batch at a time (docs/NASH_STREAM_PERF_PLAN.md §P1): the viewer's feed
    /// takes the whole batch in a single append, and only then is each event routed onward. Order
    /// within the batch is nash's own, which is what keeps an `exec-start` ahead of the `exec` that
    /// retires it.
    public func observeShellEvents(
        sessionID: SessionID, calls: [MCPApprovalServer.ShellReportCall]
    ) async {
        // The Nash Viewer's feed, recorded first and for *every* kind — the routing below is lossy
        // by design (it forwards exec onto the git/command feeds and reduces the rest to a log
        // line), whereas the viewer's promise is the complete emission stream for this session.
        nashEvents.record(sessionID: sessionID, calls: calls, at: now())
        for call in calls {
            await routeShellEvent(sessionID: sessionID, call: call)
        }
    }

    /// The per-event half of ``observeShellEvents(sessionID:calls:)``: everything downstream of the
    /// viewer's feed, for one event. Split out so the feed can take the batch whole.
    ///
    /// The `CMDTRACE` lines here are at `.debug` rather than `.notice`
    /// (docs/NASH_STREAM_PERF_PLAN.md §P1.3): nash emits one per pipe link, per redirect and per
    /// `cd`, so a build storm writes thousands of them a minute per agent — a volume at which
    /// os_log's public-interpolation formatting is itself measurable, and at which the trail is
    /// unreadable anyway. The durable feed (`nash_event`) is the record that survives; these are the
    /// live tail, and `log stream --level debug` still shows them. `.policy` keeps `.error`: it is
    /// rare and never routine.
    private func routeShellEvent(
        sessionID: SessionID, call: MCPApprovalServer.ShellReportCall
    ) async {
        switch call.kind {
        case .execStart(let argv, let cwd, _, _):
            // Announcement only (docs/NASH.md §5.1) — the command hasn't returned, so there is no
            // outcome to route: no git/gh op to record, nothing to classify for the command feed,
            // no residue to capture. The Nash Viewer's in-flight row above is the whole point of
            // it; here it is a trace line, and the `exec` that follows does the real work.
            commandLog.debug(
                "CMDTRACE session=\(sessionID.rawValue, privacy: .public) src=nash kind=exec-start argv=\(argv.joined(separator: " "), privacy: .public) cwd=\(cwd, privacy: .public)")
        case .exec(let argv, let cwd, let exitCode, let durationMs, _):
            guard let head = argv.first else { return }
            if head == "git" {
                await observeGitOp(sessionID: sessionID, argv: Array(argv.dropFirst()), exitCode: exitCode)
                return
            }
            if head == "gh" {
                await observeGhOp(sessionID: sessionID, argv: Array(argv.dropFirst()), exitCode: exitCode)
                return
            }
            await observeCommand(
                sessionID: sessionID,
                call: MCPApprovalServer.CommandReportCall(
                    command: nil, argv: argv, commandLine: nil, cwd: cwd, exitCode: exitCode,
                    durationMs: durationMs, stdout: "", stderr: "", truncated: false,
                    source: .nash, sessionID: sessionID.rawValue))
        case .cd(let from, let to):
            commandLog.debug(
                "CMDTRACE session=\(sessionID.rawValue, privacy: .public) src=nash kind=cd from=\(from, privacy: .public) to=\(to, privacy: .public)")
        case .export(let op, let names):
            // Names only — values are never reported by nash (docs/NASH.md §5.4).
            commandLog.debug(
                "CMDTRACE session=\(sessionID.rawValue, privacy: .public) src=nash kind=\(op, privacy: .public) names=\(names.joined(separator: ","), privacy: .public)")
        case .redirect(let op, _, let target, let bytes, _, _, _):
            // Data-flow observation (docs/NASH.md §5.2). Feed rows that render the preview are a
            // follow-up (§9.5); for now the operator/target/size is logged.
            commandLog.debug(
                "CMDTRACE session=\(sessionID.rawValue, privacy: .public) src=nash kind=redirect op=\(op, privacy: .public) target=\(target ?? "-", privacy: .public) bytes=\(bytes, privacy: .public)")
            // A write redirection is a file edit the agent made with raw shell. The approval gate
            // already locked the targets it could name statically; this is the ground-truth half —
            // nash reports the path the shell actually opened — so it holds the lock for anything
            // that parse missed and lands the bytes in the trunk (docs/NASH.md §5.2, LOCKING §4).
            if let target, Self.isWriteRedirect(op) {
                await claimShellWrite(sessionID: sessionID, path: target)
            }
        case .cmdsub(let bytes, _, _, _):
            commandLog.debug(
                "CMDTRACE session=\(sessionID.rawValue, privacy: .public) src=nash kind=cmdsub bytes=\(bytes, privacy: .public)")
        case .pipe(_, let fromIndex, let toIndex, _, _, let bytes, _, _, _):
            commandLog.debug(
                "CMDTRACE session=\(sessionID.rawValue, privacy: .public) src=nash kind=pipe link=\(fromIndex, privacy: .public)->\(toIndex, privacy: .public) bytes=\(bytes, privacy: .public)")
        case .fallback(let reason, _):
            // Rare and structural — nash gave up on an input and re-execed bash. Stays at `.notice`
            // for the same reason `.policy` stays at `.error`: it is never part of the flood.
            commandLog.notice(
                "CMDTRACE session=\(sessionID.rawValue, privacy: .public) src=nash kind=fallback reason=\(reason, privacy: .public)")
        case .dropped(let count):
            // Back-pressure: the stream lost events. Also rare, and worth seeing without a debug
            // filter, since it bounds how much the rest of this trail can be trusted.
            commandLog.notice(
                "CMDTRACE session=\(sessionID.rawValue, privacy: .public) src=nash kind=dropped count=\(count, privacy: .public)")
        case .policy(let lever, let value, let action, _):
            // A refused lever is an agent trying to switch off the layer recording it (docs/NASH.md
            // §4.3), so this logs at `error` — it is the one kind here that is never routine.
            commandLog.error(
                "CMDTRACE session=\(sessionID.rawValue, privacy: .public) src=nash kind=policy action=\(action, privacy: .public) lever=\(lever, privacy: .public) value=\(value, privacy: .public)")
        }
    }

    /// The app-scoped twin of ``observeShellEvents(sessionID:calls:)`` (docs/NASH.md §7.7.3): shell
    /// events from host spawns with no session — Build/Run today — delivered through
    /// ``HostShellEventListener`` and attributed by a **synthetic origin id**
    /// (`buildrun:<projectID>`) rather than a fake session. They join the Nash Viewer's feed under
    /// that id; none of the session-only semantics apply (git/gh convergence feeds a session's
    /// locks/autoship, residue capture targets a session's worktree — neither exists here), so the
    /// rest is the CMDTRACE log, same as the session path's non-exec kinds.
    ///
    /// Batch-shaped for the same reason as the session path (docs/NASH_STREAM_PERF_PLAN.md §P1): a
    /// Build & Run posts one body per 200 events, and the feed takes it in one append.
    public func observeAppScopedShellEvents(
        origin: String, calls: [MCPApprovalServer.ShellReportCall]
    ) async {
        nashEvents.record(
            sessionID: SessionID(rawValue: origin), calls: calls,
            environment: .app(
                origin: origin, label: origin.hasPrefix("buildrun:") ? "Build & Run" : "App"),
            at: now())
        for call in calls {
            if case .exec(let argv, let cwd, let exitCode, _, _) = call.kind {
                commandLog.debug(
                    "CMDTRACE origin=\(origin, privacy: .public) src=nash kind=exec argv=\(argv.joined(separator: " "), privacy: .public) cwd=\(cwd, privacy: .public) exit=\(exitCode, privacy: .public)"
                )
            }
        }
    }

    /// Whether a nash redirection operator wrote to its target. `<` and `<<`/`<<<` only read, and
    /// `<>` is a read-write open the shell uses for coprocess-style plumbing rather than authoring.
    static func isWriteRedirect(_ op: String) -> Bool {
        guard !op.hasPrefix("<") else { return false }  // "<", "<<", "<<<", "<>"
        return op.contains(">")  // ">", ">>", ">|", "2>", "&>"
    }

    /// A raw-shell write nash observed, brought into the lock system after the fact (docs/NASH.md
    /// §5.2 → LOCKING §4).
    ///
    /// The approval gate is the *primary* half: it names a command's write targets before the
    /// command runs and arbitrates them, so the common case arrives here already held and this is a
    /// no-op fast path. This is the backstop for what a static parse cannot see — a path built at
    /// runtime, a write from inside a script the agent invoked, a shell nested below the tool call.
    /// For those the write has already happened, so the lock is claimed *non-blocking*: if another
    /// session holds the file we do not queue (there is nothing left to gate), we leave their lock
    /// alone and let the residue sweep skip the path, which is exactly what keeps one session's
    /// commit from swallowing another's in-flight file.
    private func claimShellWrite(sessionID: SessionID, path: String) async {
        guard let controller = controllers[sessionID] else { return }
        let session = await controller.snapshot.session
        guard let project = projectsByID[session.projectID], project.isNucleicControlled else { return }
        guard let worktree = session.worktreePath,
            let rel = trunkRelativePath(path, in: worktree), !rel.isEmpty,
            !WorktreeMutationGuard.isEphemeral(rel)
        else { return }

        if let domain = lockDomain(for: session),
            !(await lockManager.wouldBlock(sessionID, domain: domain, files: [rel]))
        {
            _ = await lockManager.acquire(sessionID, domain: domain, files: [rel])
        }
        // Land it the way a structured edit lands: `captureCommandResidue` commits the dirty paths
        // this session owns as their own trunk commits and already excludes files other sessions
        // hold. Reused rather than duplicated so a shell write and a crash-recovered one take the
        // identical path. It matters here specifically because the commands this feature unblocks —
        // `echo`, `cat`, `sed`, `tee` — all classify as non-mutating, so the exec-driven residue
        // sweep never fired for them; the redirection is the mutation, and this is where it's seen.
        // A command with many redirections sweeps more than once, which is cheap by construction:
        // the sweep is a `git diff`/`ls-files` pair, and `land` is idempotent (a path a previous
        // sweep already committed comes back `.noop`).
        guard isNvrsionSession(session, in: project), let trunkPath = session.worktreePath else { return }
        await captureCommandResidue(
            sessionID: sessionID, controller: controller, session: session, trunkPath: trunkPath)
    }

    /// 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 finished turn
    /// doesn't keep a lock until the background poll or an archive sweep clears it. Runs on
    /// EVERY classified turn end — completed, interrupted, or errored — because it frees only
    /// paths with no unmerged divergence: work already landed, or files the edit gate locked
    /// that were never actually written (an interrupted turn's pre-write locks otherwise leak,
    /// blocking sibling chats until relaunch). An idle chat's declared-but-unwritten locks and
    /// already-landed work otherwise lingered, blocking other agents, until a manual release:
    ///
    /// - **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.
    ///
    /// Skipped when a NEW turn is already in flight (fail closed — see the guard below).
    ///
    /// 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 !transferringSessions.contains(sessionID) else { return }  // not gone — see reconcileLocks
        guard let controller = controllers[sessionID] else {
            await lockManager.releaseAll(sessionID); return
        }
        // Fail CLOSED on a STALE sweep. This is driven from a detached classify task, and a user
        // follow-up queued mid-turn starts the instant the previous turn ends — so the chat can
        // already be in a *new* turn by the time this runs. That turn's edit gate acquires a path
        // BEFORE the CLI writes it (LOCKING §4.5), so the path is legitimately held and
        // legitimately not-yet-divergent: indistinguishable here from the ended turn's
        // never-written declaration, and reaping it hands a sibling a file mid-write — the very
        // divergence the lock exists to prevent. The ended turn's stale declarations are not worth
        // that; skip, and let the new turn's own end sweep reap both. (`setDisposition` refuses a
        // stale classification on exactly this signal.) Recoverable by construction: the landed
        // poll, lifecycle, and the manual button all still release.
        // Cheap check first: this runs at the head of EVERY turn end, and the overwhelming majority
        // of turns hold no locks at all (a conversational turn, a non-Control project). Bail before
        // any controller hop, so the common case costs one actor read and nothing else.
        let held = await lockManager.heldPaths(sessionID)
        guard !held.isEmpty else { return }
        guard !(await controller.snapshot.session.status.hasTurnInFlight) else {
            lockLog.notice("LOCKTRACE completion skip(turn-in-flight) session=\(sessionID.rawValue, privacy: .public)")
            return
        }
        if await controller.hasWorktree {
            // Mid-rebase/merge/cherry-pick the working tree is transient and matches the parent for
            // everything — never sweep on that (see the poll's identical guard). Fail closed.
            let gitOp: String?
            do { gitOp = try await controller.worktreeOperationInProgress() } catch {
                lockLog.notice("LOCKTRACE completion gitop-check-failed keep-all session=\(sessionID.rawValue, privacy: .public) error=\(String(describing: error), privacy: .public)")
                return
            }
            if let gitOp {
                lockLog.notice("LOCKTRACE completion skip(\(gitOp, privacy: .public)-in-progress) session=\(sessionID.rawValue, privacy: .public) held=\(held.joined(separator: ","), privacy: .public)")
                return
            }
            // Fail CLOSED: if we can't decide what landed we must NOT release — a bare
            // `try? … ?? []` coerces a git failure (an `index.lock` race with the agent's own git,
            // a parentRef that won't resolve) 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 disposition: HeldPathDisposition
            do {
                disposition = try await heldPathDisposition(
                    sessionID, controller: controller, held: Set(held))
            } catch {
                lockLog.notice("LOCKTRACE completion landed-check-failed keep-all session=\(sessionID.rawValue, privacy: .public) held=\(held.joined(separator: ","), privacy: .public) error=\(String(describing: error), privacy: .public)")
                return
            }
            // The sweep's remit is broader than the poll's: the turn is over, so nothing is
            // mid-edit. Free what landed, the declarations the agent never wrote, AND the edits it
            // discarded/reverted — all of which would otherwise pin a file until relaunch. (The
            // poll deliberately holds the last two *during* a turn; see `HeldPathDisposition`.)
            let landed = disposition.landed
                .union(disposition.neverWritten)
                .union(disposition.settledWithoutLanding)
            guard !landed.isEmpty else {
                lockLog.notice("LOCKTRACE completion still-unmerged session=\(sessionID.rawValue, privacy: .public) held=\(held.joined(separator: ","), privacy: .public)")
                return
            }
            lockLog.notice("LOCKTRACE completion release-landed session=\(sessionID.rawValue, privacy: .public) landed=\(landed.sorted().joined(separator: ","), privacy: .public)")
            await publishLockRelease(
                sessionID, paths: Array(landed),
                requiredHeads: await landedRequiredHeads(for: sessionID))
        } else {
            // Worktree-less: the branch IS the parent, so finishing the turn is the landing
            // (LOCKING §3.4) — fenced on the branch's own base-chain head.
            lockLog.notice("LOCKTRACE completion release-all(worktree-less) session=\(sessionID.rawValue, privacy: .public) held=\(held.joined(separator: ","), privacy: .public)")
            await publishLockRelease(
                sessionID, paths: nil,
                requiredHeads: await landedRequiredHeads(for: 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)")
        // Unfenced by design (the manual escape hatch), routed to the authority when the
        // session's locks live on another host (§17.5).
        await publishLockRelease(sessionID, paths: nil, requiredHeads: [])
    }

    /// 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)
    }

    /// Whether a queued lock waiter can never consume a grant — its chat is gone, terminal, or
    /// archived — so the `LockManager` reaps it instead of granting a lock nobody will use
    /// (LOCKING invariant 7). Wired as the manager's `reapCheck`.
    private func lockWaiterIsDead(_ sessionID: SessionID) async -> Bool {
        // A remote session (CARBON_SHARDING §17.5) has no local controller by design — its
        // liveness is its own host's concern; never reap it for being unknown here.
        if remoteLockSessions[sessionID] != nil { return false }
        guard let controller = controllers[sessionID] else { return true }
        let session = await controller.snapshot.session
        return session.status.isTerminal || session.archived
    }

    /// 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 the guest's vsock agent is up — NOT until it has a NAT IP, which
        // the vsock control plane may never assign), 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]
                // Suspend/resume take priority over the boot check: a paused (or thawing) guest has an
                // IP but isn't a live, meter-worthy VM. A running guest is one that's up and reachable.
                let status: ControlVMStatus
                if entry.resuming { status = .resuming }
                else if entry.paused { status = .suspended(toDisk: entry.suspendedToDisk) }
                else if entry.booting { status = .booting }
                else { status = .running }
                vms.append(ControlVMEntry(
                    sessionID: mapped?.id, name: entry.name,
                    title: mapped?.title ?? (entry.os == .linux ? "Linux VM" : "macOS VM"),
                    os: entry.os,
                    status: status,
                    // A suspended/resuming guest's CPU is frozen, so its last sample is stale — drop it
                    // so the row shows the paused-state indicator instead of a misleading live meter.
                    usage: status == .running ? macVMUsageSamples[entry.name] : nil))
                // No point probing a frozen guest (its SSH won't answer); only refresh live ones.
                if status == .running { refreshMacVMUsageInBackground(name: entry.name) }
            }
            // Active guests (booting/running/resuming) first, suspended ones sunk below them: a frozen
            // VM holds no CPU and needs no watching, so the rows worth monitoring stay at the top of
            // the list. Alphabetical by title within each group, as before.
            vms.sort { a, b in
                if a.status.isSuspended != b.status.isSuspended { return !a.status.isSuspended }
                return a.title.localizedCaseInsensitiveCompare(b.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,
                    status: m.booting ? .booting : .running,
                    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 {
        let serviceEnabled: Bool
        if let cached = containerServiceEnabledCache {
            serviceEnabled = cached
        } else {
            serviceEnabled = ContainerServiceSettings.serviceEnabled
            containerServiceEnabledCache = serviceEnabled
        }
        guard 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,
            releasing: await lockManager.allReleasingPaths())
    }

    /// 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 — AND its
        // revert epoch matches: a revert resets the seq counter, so a revert-then-regrow can
        // land back on the same lastSeq with different content that seq arithmetic alone would
        // wrongly serve as fresh. A missing or stale entry is recomputed off-main below.
        var stale: [(id: SessionID, path: String)] = []
        var lastSeqByID: [SessionID: UInt64] = [:]
        var revertEpochByID: [SessionID: UInt64] = [:]
        for session in sessions {
            lastSeqByID[session.id] = session.lastSeq
            revertEpochByID[session.id] = session.revertEpoch
            if let entry = cache[session.id], entry.lastSeq == session.lastSeq,
               entry.revertEpoch == session.revertEpoch {
                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, revertEpoch: revertEpochByID[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 {
            // A local project's own records, plus every mesh chat that belongs to this same project
            // on another device — a peer Mac's or a Covalence Cloud runner's. One project, one
            // section: the rows carry the globe instead of the header. Cached runner rows render at
            // launch before the mesh reconnects (item 4) and are superseded by live rows.
            var folded: [SessionID: SessionSummary] = [:]
            for cached in foldedCloudSessions(originProjectID: project.id) { folded[cached.id] = cached }
            for peer in meshPeers where peer.connected {
                for wire in peer.sessions
                where localProjectID(
                    forPeerProject: ProjectID(rawValue: wire.projectID), on: peer.deviceID,
                    named: wire.projectName) == project.id {
                    var row = SessionSummary(wire: wire, hostID: peer.deviceID)
                    row.foldedHostLabel = meshHostLabel(peer.deviceID)
                    folded[row.id] = row
                }
            }
            return summaries.filter { $0.projectID == project.id } + Array(folded.values)
        }
        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) }
    }

    /// The Covalence Cloud runner sessions this device dispatched from `originProjectID`, as sidebar
    /// rows folded under that local project (item 1). Live rows (from a connected runner) supersede
    /// the cached snapshot the mirror persists for an instant cold-start render (item 4). Each row
    /// carries the runner's label so `SessionRow` draws the globe.
    private func foldedCloudSessions(originProjectID: ProjectID) -> [SessionSummary] {
        var rows: [SessionID: SessionSummary] = [:]
        // Cached snapshots first (present even before the mesh connects).
        for mirror in covalenceMirrors.values
        where mirror.originProjectID == originProjectID {
            guard let summary = mirror.summary, !summary.archived else { continue }
            rows[mirror.sessionID] = foldedCloudRow(from: summary, hostID: mirror.hostID)
        }
        // Live rows from connected runners overwrite the cache with current status.
        for peer in meshPeers where peer.connected && isCloudRunnerHost(peer.deviceID) {
            for wire in peer.sessions where !wire.archived {
                guard let mirror = covalenceMirrors[wire.sessionID],
                    mirror.originProjectID == originProjectID else { continue }
                rows[wire.sessionID] = foldedCloudRow(from: wire, hostID: peer.deviceID)
            }
        }
        return Array(rows.values)
    }

    private func foldedCloudRow(from wire: WireSessionSummary, hostID: String) -> SessionSummary {
        var row = SessionSummary(wire: wire, hostID: hostID)
        row.foldedHostLabel = meshHostLabel(hostID)
        return row
    }

    /// Load the persisted Covalence mirror once, so the first sidebar render at launch already
    /// carries the cloud rows (item 4). Idempotent.
    func loadCovalenceMirrors() {
        guard !covalenceMirrorsLoaded else { return }
        covalenceMirrorsLoaded = true
        let now = self.now()
        var live: [SessionID: CovalenceSessionMirror] = [:]
        for record in covalenceMirrorStore.all()
        where now.timeIntervalSince(record.updatedAt) < CovalenceSessionMirrorStore.staleTTL {
            live[record.sessionID] = record
        }
        covalenceMirrors = live
        if live.count != covalenceMirrorStore.all().count {
            covalenceMirrorStore.replaceAll(Array(live.values))  // drop TTL-expired ghosts
        }
    }

    /// Remember a Covalence Cloud runner session this device just dispatched, so the sidebar folds
    /// it under `originProjectID`. Called from the dispatch-landing path once a runner accepts.
    func recordCovalenceDispatch(sessionID: SessionID, hostID: String, originProjectID: ProjectID) {
        loadCovalenceMirrors()
        let summary = meshPeers.first(where: { $0.deviceID == hostID })?
            .sessions.first(where: { $0.sessionID == sessionID })
        let record = CovalenceSessionMirror(
            sessionID: sessionID, originProjectID: originProjectID, hostID: hostID,
            summary: summary, updatedAt: now())
        covalenceMirrors[sessionID] = record
        covalenceMirrorStore.upsert(record)
    }

    /// Keep the Covalence mirror warm from live presence and prune ended sessions. Called whenever
    /// the roster changes. Only sessions this device already tracks are touched — the mirror never
    /// adopts a cloud session it didn't dispatch. A tracked session absent from every connected
    /// runner (with at least one runner up to speak for it) has ended and ages out after a short
    /// grace; a longer TTL and a deleted origin project also drop it.
    func refreshCovalenceMirrors() {
        loadCovalenceMirrors()
        guard !covalenceMirrors.isEmpty else { return }
        let now = self.now()
        let anyRunnerConnected = meshPeers.contains { $0.connected && isCloudRunnerHost($0.deviceID) }
        var live: [SessionID: WireSessionSummary] = [:]
        var liveHost: [SessionID: String] = [:]
        for peer in meshPeers where peer.connected && isCloudRunnerHost(peer.deviceID) {
            for wire in peer.sessions {
                live[wire.sessionID] = wire
                liveHost[wire.sessionID] = peer.deviceID
            }
        }
        var next: [SessionID: CovalenceSessionMirror] = [:]
        for (sid, original) in covalenceMirrors {
            var record = original
            if let wire = live[sid] {
                record.summary = wire
                record.hostID = liveHost[sid] ?? record.hostID
                record.updatedAt = now
                next[sid] = record
                continue
            }
            let projectGone = projectsByID[record.originProjectID] == nil
            let ended = anyRunnerConnected
                && now.timeIntervalSince(record.updatedAt) > Self.covalenceMirrorEndedGrace
            let expired = now.timeIntervalSince(record.updatedAt) > CovalenceSessionMirrorStore.staleTTL
            if !(projectGone || ended || expired) { next[sid] = record }
        }
        guard mirrorProjectionChanged(from: covalenceMirrors, to: next) else {
            covalenceMirrors = next  // absorb timestamp-only churn without a disk write
            return
        }
        covalenceMirrors = next
        covalenceMirrorStore.replaceAll(Array(next.values))
    }

    /// A tracked cloud session unseen on any connected runner this long is treated as ended (long
    /// enough to ride out a between-turn rebalance / transient runner drop).
    static let covalenceMirrorEndedGrace: TimeInterval = 120

    /// Whether two mirror tables differ in a way worth persisting — membership or a rendered summary
    /// field. Pure timestamp/lastSeq drift is ignored so frequent presence broadcasts don't thrash
    /// the defaults store.
    private func mirrorProjectionChanged(
        from a: [SessionID: CovalenceSessionMirror], to b: [SessionID: CovalenceSessionMirror]
    ) -> Bool {
        if Set(a.keys) != Set(b.keys) { return true }
        for (sid, ra) in a {
            guard let rb = b[sid] else { return true }
            if ra.hostID != rb.hostID || ra.originProjectID != rb.originProjectID { return true }
            let sa = ra.summary, sb = rb.summary
            if (sa == nil) != (sb == nil) { return true }
            if let sa, let sb,
                sa.status != sb.status || sa.title != sb.title || sa.archived != sb.archived
                    || sa.favorite != sb.favorite || sa.diffStat != sb.diffStat
                    || sa.pendingApprovalCount != sb.pendingApprovalCount {
                return true
            }
        }
        return false
    }

    /// 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
    }

    // MARK: - Omnisearch index

    /// Whether a session summary matches `query` for the sidebar's omnisearch — its **search
    /// index**: the chat's title, its cached keywords (see `Session.keywords`), and its project
    /// name. An empty query matches every session (so search is a no-op filter when the bar is
    /// blank).
    public func sessionMatches(_ summary: SessionSummary, _ query: SearchQuery) -> Bool {
        guard !query.isEmpty else { return true }
        var hay = summary.title
        if let keywords = summary.keywords, !keywords.isEmpty { hay += " " + keywords }
        if let name = projectSummary(summary.projectID)?.name { hay += " " + name }
        return query.matches(hay)
    }

    /// Whether a session identified by `id` matches `query`, resolved through the current
    /// summaries (local + remote). Used by the Queue/Control panes, whose entries carry a
    /// `SessionID`. A session no longer in the list can't match a non-empty query.
    public func sessionMatches(_ id: SessionID, _ query: SearchQuery) -> Bool {
        guard !query.isEmpty else { return true }
        guard let summary = (summaries + remoteSummaries()).first(where: { $0.id == id }) else {
            return false
        }
        return sessionMatches(summary, query)
    }

    /// Whether a to-do matches `query` for the sidebar's omnisearch and the to-do inbox filter
    /// — the separate **to-do index**: the idea's text and its glanceable summary. An empty
    /// query matches every to-do.
    public func todoMatches(_ todo: Todo, _ query: SearchQuery) -> Bool {
        guard !query.isEmpty else { return true }
        var hay = todo.text
        if let summary = todo.summary, !summary.isEmpty { hay += " " + summary }
        return query.matches(hay)
    }

    /// Open to-dos matching `query`, newest first — the sidebar omnisearch's "To-dos" results
    /// group. Empty query → empty (the group only appears while searching).
    public func searchTodos(_ query: SearchQuery) -> [Todo] {
        guard !query.isEmpty else { return [] }
        return todos
            .filter { $0.status == .open && todoMatches($0, query) }
            .sorted { $0.updatedAt > $1.updatedAt }
    }

    /// Sessions currently marked with unextracted keywords, so `scheduleSessionKeywords` runs a
    /// chat at most once per app run unless its title changes (naming/rename re-schedule it).
    private var keywordedSessions: Set<SessionID> = []

    /// Extract and cache search keywords for a session as background AFM work (see
    /// `Session.keywords`). Coalesced per session by the provider; guarded here so a chat is
    /// worked once per run unless a rename re-schedules it. No-op if the controller is gone.
    func scheduleSessionKeywords(_ id: SessionID) {
        guard let controller = controllers[id] else { return }
        keywordedSessions.insert(id)
        let intelligence = self.intelligence
        let ref = AFMSessionRef(id: id, title: summaries.first { $0.id == id }?.title ?? "")
        _ = AFMWorkContext.$session.withValue(ref) { Task {
            let session = await controller.snapshot.session
            let digest = HeuristicSummary.sessionDigest(await controller.transcriptSoFar())
            let keywords = await intelligence.sessionKeywords(title: session.title, digest: digest)
            await controller.setKeywords(keywords.joined(separator: ", "))
            // Reflect the new keywords in the in-memory index so search can use them at once.
            let snapshot = await controller.snapshot
            self.upsertSummary(SessionSummary(
                snapshot.session, pendingApprovals: snapshot.pendingApprovals))
            if id == self.openSessionID { self.openSession = snapshot.session }
        } }
    }

    /// Backfill keywords for every loaded local session still missing them — one low-priority
    /// pass on the AFM queue after a launch, so pre-existing chats become searchable by more
    /// than their title. Skips sessions already scheduled this run and moved-away tombstones.
    private func backfillSessionKeywords() {
        for summary in summaries where summary.keywords == nil
            && summary.movedTo == nil && !keywordedSessions.contains(summary.id) {
            scheduleSessionKeywords(summary.id)
        }
    }

    /// 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, routing: RoutingNote? = nil,
        useWorktree: Bool = true, auto: Bool? = nil,
        autoShip: Bool? = nil, attachments: [PendingAttachment] = [], ephemeral: Bool = false,
        spawnedBy: SessionID? = nil, startRun: Bool = true, covalenceOriginDeviceID: String? = nil
    ) async throws -> SessionID {
        // Gate on the launch lock/nvrsion reconciliation (plan item 6): a new chat's first turn
        // must never run against not-yet-rebuilt lock state. Free once the gate completes.
        await awaitStartupLockReconciliation()
        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`). This is the creation-time default; when Orchestra is enabled
        // later from the in-session slider, that chat's current Max-routed model becomes the
        // supervisor instead. Gated to Control projects, where Orchestra actually runs
        // (`orchestraActive`); the supervisor default falls back to the ordinary model if unset.
        let resolvedModel: String?
        // The supervisor's *effort* is configurable alongside its model ("Fable 5 (Max)"), so an
        // Orchestra chat is stored with the qualified sentinel rather than the bare one. `xhigh`
        // still stores bare, so the default selection produces the same value it always did.
        var resolvedEffort = effort
        if OrchestrationMode.isOrchestra(effort ?? defaultEffort), project.isNucleicControlled {
            resolvedModel = defaultSupervisorModel ?? model ?? defaultModel
            resolvedEffort = OrchestrationMode.qualifiedEffortSentinel(defaultSupervisorEffort)
        } 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: resolvedEffort ?? defaultEffort,
            routedPurpose: routing?.purpose.rawValue,
            routedLevel: routing?.level.rawValue,
            routedReason: routing?.reason,
            transcriptPath: transcriptURL.path, auto: resolvedAuto,
            autoShip: resolvedAutoShip,
            covalenceOriginDeviceID: covalenceOriginDeviceID,
            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)
        }
        // Blocking subagent spawner: the non-Orchestra counterpart, exposed on Control-project runs
        // that aren't an active supervisor (`SessionController.activeBlockingSubagentSpawner`). Runs
        // the worker to completion and returns its final reply inline — a drop-in for native Task.
        let blockingSubagentSpawner: OrchestraSubagentSpawner = { [weak self] request in
            guard let self else {
                return .denied(message: "Nucleic is no longer available to spawn a subagent.")
            }
            return await self.runBlockingSubagent(request, parent: session, project: project)
        }
        // Plan delegation: the supervisor submits its whole batched task list in one call and Nucleic
        // spawns a worker per task (`nucleic_delegate_plan`).
        let planHandler: OrchestraPlanHandler = { [weak self] request in
            guard let self else {
                return .denied(message: "Nucleic is no longer available to dispatch a plan.")
            }
            return await self.delegateOrchestraPlan(request, parent: session, project: project)
        }
        let superviseHandler: OrchestraSuperviseHandler = { [weak self] batch in
            guard let self else { return OrchestraSuperviseResult(events: [], remainingRunning: 0) }
            return await self.superviseOrchestra(supervisor: sessionID, batch: batch)
        }
        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,
            blockingSubagentSpawner: blockingSubagentSpawner,
            orchestraPlanHandler: planHandler,
            orchestraSuperviseHandler: superviseHandler,
            orchestraReplyToWorkerHandler: replyToWorkerHandler,
            orchestraAskSupervisorHandler: askSupervisorHandler,
            meshMessagingHandlers: meshMessagingHandlers(for: sessionID),
            monitorEventSink: monitorEventSink(for: sessionID),
            claudeCredentialBroker: claudeCredentialBroker, 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))
        // Mesh dispatch: the active-session count changed — refresh the presence card.
        publishRunnerPresence()
        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
    }

    /// The model + effort one worker should run at, and the routing provenance when the Intelligence
    /// router chose them.
    ///
    /// With a SKU pinned in Settings, every worker runs it. With **Intelligent**, the worker's own
    /// task is classified (`HeuristicPurposeClassifier` — deterministic and sub-millisecond, so it's
    /// safe on this synchronous spawn path, unlike the model-backed layers a composer can await)
    /// and `IntelligenceRouter` picks the model and effort that suit
    /// *that* subtask: a typo fix lands on a cheap model, a backend feature on a strong one. That is
    /// the whole point of the fan-out's cost story — the expensive model supervises, and each worker
    /// costs what its own task deserves.
    ///
    /// `fallbackEffort` is what a pinned-SKU worker runs at (Orchestra's `xhigh` for a supervised
    /// worker, the parent's effort for a blocking subagent); a routed worker uses the router's effort
    /// instead. Always returns a concrete SKU — the Intelligent sentinel must never reach a backend.
    func orchestraWorkerSelection(
        task: String, prompt: String, parent: Session, fallbackEffort: String?
    ) -> (model: String?, effort: String?, routing: RoutingNote?) {
        guard OrchestrationMode.isIntelligentWorkerModel(defaultOrchestraWorkerModel) else {
            return (defaultOrchestraWorkerModel ?? parent.model, fallbackEffort, nil)
        }
        // The short label plus the full instructions: the label carries the supervisor's own summary
        // of the subtask, which is often the clearest statement of what the work *is*.
        let verdict = HeuristicPurposeClassifier.classify([task, prompt].joined(separator: "\n"))
        let resolution = IntelligenceRouter.route(
            purpose: verdict.purpose,
            level: defaultIntelligenceLevel,
            connected: connectedProviders,
            limits: intelligenceRoutingLimits,
            codexPro: isCodexPro,
            fallback: (
                parent.model ?? defaultModel ?? OrchestrationMode.intelligentWorkerFallbackModel,
                fallbackEffort ?? EffortLadder.fallbackEffort
            ))
        return (resolution.model, resolution.effort, RoutingNote(resolution))
    }

    /// 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 selection = orchestraWorkerSelection(
            task: request.task, prompt: prompt, parent: parent,
            fallbackEffort: OrchestrationMode.resolvedOrchestraEffort)
        let workerModel = selection.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: selection.effort ?? OrchestrationMode.resolvedOrchestraEffort,
                routing: selection.routing,
                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, batch: request.batch)
        // 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)
    }

    /// Dispatch a whole Orchestra delegation plan (`nucleic_delegate_plan`): spawn one worker session per
    /// task, tagged with its batch so the supervisor can wait on a group with `nucleic_supervise`.
    ///
    /// Every task in every batch is spawned **now**, not batch by batch. Batches exist so the supervisor
    /// can *consume* results early, not to stagger dispatch — and the worker cap
    /// (`defaultOrchestraMaxConcurrentWorkers`) already queues the excess inside each worker's run task,
    /// so a plan wider than the cap runs at the cap rather than dropping work. A task that can't be
    /// spawned is reported in its batch's `rejected` list instead of failing the whole plan, so the
    /// supervisor learns exactly which pieces are missing.
    func delegateOrchestraPlan(
        _ request: OrchestraPlanRequest, parent: Session, project: Project
    ) async -> OrchestraPlanResult {
        guard !request.batches.isEmpty, !request.batches.allSatisfy({ $0.tasks.isEmpty }) else {
            return .denied(
                message: "The plan carried no delegable tasks. Pass `batches` as a non-empty list of "
                    + "{name, tasks:[{task, prompt}]}.")
        }
        var dispatched: [OrchestraDispatchedBatch] = []
        for batch in request.batches {
            var workers: [OrchestraDispatchedWorker] = []
            var rejected: [OrchestraRejectedTask] = []
            for task in batch.tasks {
                let result = await spawnOrchestraSubagent(
                    OrchestraSubagentRequest(
                        task: task.task, prompt: task.prompt, batch: batch.name),
                    parent: parent, project: project)
                switch result {
                case .spawned(let workerID, _, _):
                    workers.append(OrchestraDispatchedWorker(workerID: workerID, task: task.task))
                case .completed(let workerID, _, _, _):
                    // The Orchestra spawner never returns `.completed` (that's the blocking shape), but
                    // record it as dispatched rather than losing the worker if it ever does.
                    workers.append(OrchestraDispatchedWorker(workerID: workerID, task: task.task))
                case .denied(let message):
                    rejected.append(OrchestraRejectedTask(task: task.task, message: message))
                }
            }
            dispatched.append(
                OrchestraDispatchedBatch(name: batch.name, workers: workers, rejected: rejected))
        }
        // Nothing at all could be spawned — report it as a denial so the supervisor doesn't sit in a
        // supervise loop waiting for workers that don't exist.
        guard dispatched.contains(where: { !$0.workers.isEmpty }) else {
            let firstReason = dispatched.flatMap(\.rejected).first?.message
            return .denied(
                message: "No worker could be spawned for this plan."
                    + (firstReason.map { " First error: \($0)" } ?? ""))
        }
        return .dispatched(batches: dispatched)
    }

    /// 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.")
    }

    /// Run one **blocking** managed subagent to completion and return its final reply, the
    /// non-Orchestra counterpart of `spawnOrchestraSubagent` used on Control projects. Unlike the
    /// Orchestra spawn (non-blocking, driven by a supervise loop), this awaits the whole worker turn
    /// so the `nucleic_subagent` tool call returns the worker's report inline — a drop-in for the
    /// provider-native `Task`/`Agent` subagent, which Nucleic blocks so the worker is an observed
    /// session (sidebar-hidden, surfaced in the Subagents panel like an Orchestra worker).
    ///
    /// The worker runs at the configured worker model (`defaultOrchestraWorkerModel ?? parent.model`)
    /// and inherits the parent's effort. It does NOT take an Orchestra concurrency slot: a blocking
    /// parent holds no slot to deadlock against its child, and nested subagents (a worker spawning its
    /// own) must be free to descend. Claude bounds concurrent tool calls, so a parent's parallel fan-out
    /// is naturally capped. This runs on the MCP handler's task, which is cancelled if the parent's turn
    /// ends — that interrupts the worker (its report would have nowhere to go).
    func runBlockingSubagent(
        _ request: OrchestraSubagentRequest, parent: Session, project: Project
    ) async -> OrchestraSubagentResult {
        let prompt = request.prompt.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !prompt.isEmpty else {
            return .denied(message: "Subagent prompt is required.")
        }
        // Same worker-model resolution as the Orchestra spawn, so an Intelligent selection routes a
        // blocking subagent too — and, critically, so the sentinel is never passed through as a SKU.
        let selection = orchestraWorkerSelection(
            task: request.task, prompt: prompt, parent: parent, fallbackEffort: parent.effort)
        let workerModel = selection.model
        let title = Self.orchestraWorkerTitle(for: request.task)
        let workerID: SessionID
        do {
            workerID = try await createSession(
                in: project, title: title, prompt: prompt,
                model: workerModel, effort: selection.effort ?? parent.effort,
                routing: selection.routing,
                useWorktree: !project.nvrsionActive, auto: true, autoShip: false,
                spawnedBy: parent.id, startRun: false)
        } catch {
            return .denied(message: "Subagent session could not be created: \(error)")
        }
        guard let controller = controllers[workerID] else {
            return .denied(message: "Subagent session was created but its controller is unavailable.")
        }
        await controller.start(prompt: AgentInput(text: prompt))
        // Block until the worker's turn ends. If the parent's run ends first, our handler task is
        // cancelled — interrupt the worker rather than run on for a report nobody will read.
        await withTaskCancellationHandler {
            await controller.join()
        } onCancel: {
            Task { await controller.interrupt() }
        }
        let snapshot = await controller.snapshot
        let events = await controller.transcriptSoFar()
        // Nothing resumes a subagent session (each spawn is fresh), so archive it now — it otherwise
        // rests at `.awaitingInput` and counts as an active chat forever, holding its sandbox. Spared
        // only when it's the chat the user has open (peeking via the Subagents panel).
        if workerID != openSessionID { await setSessionArchived(workerID, true) }
        if Task.isCancelled {
            return .denied(message: "The parent run ended; the subagent was interrupted.")
        }
        let lastError = events.reversed().compactMap { event -> AgentError? in
            if case .error(let error) = event.kind { return error }
            return nil
        }.first
        if let lastError {
            return .denied(message: lastError.message)
        }
        let output = Self.lastReply(in: events)?.trimmingCharacters(in: .whitespacesAndNewlines)
        return .completed(
            workerID: workerID, title: title, model: snapshot.session.model,
            output: output?.isEmpty == false ? output! : "Subagent completed without a final reply.")
    }

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

    /// 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)) + "..."
    }

    /// The approvals the open chat should actually show: what the controller still reports as
    /// pending, minus the ones the user has already answered here. A snapshot is authoritative
    /// about what the *backend* is still blocked on, not about what the user has already acted
    /// on — the answer is in flight for the length of the round-trip described on
    /// `answeredApprovalIDs`, and every writer of `openApprovals` goes through this so none of
    /// them can put an answered card back on screen mid-flight.
    public func visibleApprovals(_ pending: [ApprovalRequest]) -> [ApprovalRequest] {
        guard !answeredApprovalIDs.isEmpty else { return pending }
        return pending.filter { !answeredApprovalIDs.contains($0.id) }
    }

    /// Record that the local user just answered `id`, and retire its card now. The authoritative
    /// `approvalResolved` clears the record when it lands (see `ingestUI` / the remote stream).
    private func markApprovalAnswered(_ id: ApprovalID) {
        answeredApprovalIDs.insert(id)
        openApprovals.removeAll { $0.id == id }
    }

    /// Undo `markApprovalAnswered` after the answer failed to reach the backend, so the card
    /// comes back rather than leaving the user believing they answered a still-blocked agent.
    private func unmarkApprovalAnswered(_ id: ApprovalID, _ controller: SessionController) async {
        answeredApprovalIDs.remove(id)
        guard let sessionID = openSessionID, controllers[sessionID] === controller else { return }
        let pending = await controller.snapshot.pendingApprovals
        guard sessionID == openSessionID else { return }
        openApprovals = visibleApprovals(pending)
    }

    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 {
            markApprovalAnswered(id)
            await peerClient?.respondRemoteApproval(id, decision, on: hostID)
            return
        }
        guard let sessionID = openSessionID, let controller = controllers[sessionID] else { return }
        // Retire the card on the click, not on the round-trip: a local answer is just as much a
        // decision as a remote one, and the request stays in the controller's pending set until
        // the blocked tool handler emits its `approvalResolved` back through the run stream.
        markApprovalAnswered(id)
        do { try await controller.respondToApproval(id, decision, by: localResponderID) }
        catch {
            lastError = "Approval failed: \(error)"
            await unmarkApprovalAnswered(id, controller)
        }
    }

    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 }
        // Gate on the launch lock/nvrsion reconciliation (plan item 6): a locally-run turn must
        // never start against not-yet-rebuilt lock state. Free once the gate completes; the
        // remote-peer branch above is exempt — the owning Mac's lock state governs that turn.
        await awaitStartupLockReconciliation()
        autoContinuedWaiting.remove(sessionID)  // a fresh user message ends any dead-wait streak
        // 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 = visibleApprovals(snapshot.pendingApprovals)
        lastIngestedSeq[sessionID] = snapshot.session.lastSeq
        upsertSummary(SessionSummary(snapshot.session, pendingApprovals: snapshot.pendingApprovals))
        // Fan the revert out to the mesh so subscribed devices (phones + peer Macs) shrink their
        // transcript to the new tip, the same way a new message reaches them — instead of leaving
        // them on the pre-revert transcript until a manual refresh. `.events` can only *grow* a
        // mirrored transcript, so the truncate rides its own `.transcriptReverted`; the trailing
        // `.sessionUpdated` carries the now-lower `lastSeq` so the sidebar/cursor converge too.
        broadcast(.transcriptReverted(
            sessionID, throughSeq: snapshot.session.lastSeq,
            revertEpoch: snapshot.session.revertEpoch))
        broadcast(.sessionUpdated(wireSummary(for: 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()
    }

    /// Abandon a single stuck tool call in the open session (the user tapped **Skip** on a running
    /// host/VM exec card) so the agent stops blocking on it and continues the turn. Local sessions
    /// only — the button is not surfaced for a peer-owned session (its exec runs on the owner, and
    /// there's no mesh command for a remote Skip yet), so a remote open session never reaches here.
    public func skipOpenSessionToolCall(_ call: ToolCall) async {
        guard openHostID == nil,
              let sessionID = openSessionID, let controller = controllers[sessionID]
        else { return }
        await controller.skipToolCall(call)
    }

    /// Resolve a "host command looks hung" alert in the open session — the transcript row's Kill /
    /// Keep-waiting buttons. `kill` reaps the command's whole process tree; otherwise the alert is
    /// dismissed and the command runs on.
    public func resolveOpenSessionStall(_ stallID: String, kill: Bool) async {
        guard openHostID == nil, let sessionID = openSessionID else { return }
        await resolveProcessStall(stallID, kill: kill, inSession: sessionID)
    }

    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) }
    }

    /// Record (or clear) the Intelligence router's provenance for the open session's next
    /// turn. Local-only by design: the note is transparency metadata that doesn't ride the
    /// wire (additive constraint), so a peer Mac's mirrored chat keeps manual pickers.
    public func setOpenSessionRoutingNote(_ note: RoutingNote?) async {
        await mutateOpenSession { await $0.setRoutingNote(note) }
    }

    /// 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 renameSession(_ summary: SessionSummary, to title: String) async {
        if let hostID = summary.hostID {
            await sendToPeer(.renameSession(summary.id, title), host: hostID, about: summary.title)
            if openSessionID == summary.id, openHostID == hostID { openSession?.title = title }
            return
        }
        await mutateSession(summary.id) { await $0.rename(title) }
    }

    /// Acknowledge (dismiss) the autoship alarm on a sidebar row. Local-only for now: a peer
    /// Mac owns its own autoship markers and the mesh wire has no acknowledge verb, so a
    /// remote row falls through to a no-op rather than silently doing nothing surprising —
    /// the caller only offers the menu item on local rows (as it does for "Mark Done", whose
    /// wire verb exists but which this sidebar still offers on local rows only).
    public func acknowledgeAutoShipAlarm(_ summary: SessionSummary) async {
        guard summary.hostID == nil else { return }
        await acknowledgeAutoShipAlarm(summary.id)
    }

    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.
    ///
    /// Marking Done is the *same declaration* the classifier makes, so it finalizes the chat the
    /// same way: an autoship-armed chat ships. Skipping the ship here made the sidebar action a
    /// half-measure — the row read "Done" while its work sat unmerged in the worktree, and because
    /// the disposition never flips again there was no later `.completed` to re-fire autoship, so
    /// the branch stayed stranded until someone integrated it by hand. `shipIfCompleted` re-checks
    /// eligibility (armed, Control-managed, has a worktree, non-empty footprint) and the merge
    /// queue re-validates again at merge time, so a mark on an unarmed or empty chat costs nothing.
    /// Still deliberately does *not* release the chat's locks or sandbox — a hand-marked chat is
    /// one the user is often still working with, and a landed autoship frees its locks anyway.
    public func markSessionDone(_ id: SessionID) async {
        await mutateSession(id) { await $0.setDisposition(.completed) }
        await suspendVMIfCompleted(id)
        await shipIfCompleted(id)
    }

    /// Dismiss the sidebar's "autoship needs attention" alarm (the breathing pulse + warning
    /// triangle) for a chat whose autoship hit a hard failure (`autoShipFailed`) or a merge
    /// conflict (`autoShipConflict`). This is the explicit "I've seen it" the user reaches for
    /// from the sidebar's context menu without opening the chat — opening it already retires a
    /// *failure* marker (`clearAutoShipFailedSeen`), but a conflict marker deliberately persists
    /// until the work lands, so this is the only non-destructive way to quiet it by hand. Clears
    /// both markers (neither bumps `updatedAt`, so acknowledging never reorders the sidebar) and
    /// leaves `autoShip` itself untouched — a conflict chat stays armed and re-ships on the next
    /// clean merge. A no-op when neither marker is set.
    public func acknowledgeAutoShipAlarm(_ id: SessionID) async {
        await mutateSession(id) {
            await $0.clearAutoShipFailed()
            await $0.clearAutoShipConflict()
        }
    }

    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
        projectors[id] = nil
        controllers[id] = nil
        nashEvents.forget(sessionID: id)  // the shell feed is keyed by session; nothing can view it now
        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 {  // mediated release, fenced (§17.4)
                await publishLockRelease(
                    sessionID, paths: nil,
                    requiredHeads: await landedRequiredHeads(for: sessionID))
            }
            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`. The chat you currently have
    /// open is 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.
    ///
    /// Favorites are spared the archive itself (they stay pinned and visible), but a
    /// stale favorite can still be sitting on locks for work that never merged — with
    /// no other automatic release path, since `reconcileLocks`'s lifecycle branch
    /// normally waits for `archived == true`. So a stale favorite gets its locks
    /// released quietly here instead, leaving it un-archived. (`reconcileLocks` also
    /// runs this same staleness check independently, so this isn't the only backstop.)
    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.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 {
            if summary.favorite {
                // Lifecycle (unfenced) release, routed to the authority when the session's
                // locks live on another host (§17.5).
                await publishLockRelease(summary.id, paths: nil, requiredHeads: [])
            } else {
                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. Nucleic refreshes the subscription token itself through the credential
    /// broker before each fetch, so usage self-heals across token expiry without waiting on a
    /// session turn or the external CLI.
    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
            // Route through the broker so an access token near/past expiry is refreshed via
            // Nucleic's own OAuth first; fall back to the raw mediated token if no broker is wired.
            if let token = await claudeCredentialBroker?.freshAccessToken() {
                subscriptionUsage = try await SubscriptionUsageFetcher.fetch(token: token)
            } else {
                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()
        // Reduce the raw event stream OFF the main actor (docs/MAIN_THREAD_PERFORMANCE_PLAN.md
        // item 2): the projector actor consumes the controller's stream, delivers semantic
        // events (approvals, errors, turn boundaries…) immediately, and coalesces streaming
        // text/thinking/tool deltas into batched main-actor commits — ~11/s for the open
        // chat, ~3/s for background sessions — so a token burst costs one ingest per window
        // instead of one main-actor hop per event.
        let projector = SessionUIProjector(
            cadence: sessionID == openSessionID ? .open : .background
        ) { [weak self] batch in
            await self?.ingestUI(sessionID, batch)
        }
        projectors[sessionID] = projector
        observers[sessionID] = Task {
            let stream = await controller.subscribe()
            await projector.run(stream)
        }
    }

    /// Apply one ordered batch of canonical events to the observable UI state in a single
    /// main-actor transaction. Per-event semantic handling (nvrsion landing, dispositions,
    /// feed rows, approval bookkeeping) runs for every event in order — exactly as when
    /// events arrived one at a time — while the expensive tail (controller snapshot,
    /// summary upsert, open-transcript refresh, wire broadcasts) runs once per batch
    /// instead of once per event.
    private func ingestUI(_ sessionID: SessionID, _ batch: [AgentEvent]) async {
        guard let controller = controllers[sessionID], !batch.isEmpty else { return }
        var sawRunFinished = false
        for event in batch {
            await handleNvrsionEvent(sessionID, controller, event)
            // A "host command looks hung" alert closing out (killed/dismissed/exited/resumed): record its
            // stall id so the alert row retires its Kill button. Runs on replay too, so a resumed session
            // shows already-resolved alerts as resolved. Live alerts (resolution == nil) need no state —
            // the row shows the button until a resolution arrives.
            if case .note(let note) = event.kind, let stall = note.processStall, stall.resolution != nil {
                resolvedStalls.insert(stall.id)
            }
            // 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 reports its subscription window out of band: 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 Codex turn can refresh/rotate auth.json. Re-read its account claim at the terminal
            // boundary so a plan change made outside Nucleic is reflected without relaunching.
            if case .runFinished = event.kind,
               (event.backend == .codex || event.backend == .codexExec) {
                refreshCodexSubscriptionPlan()
            }
            // 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 {
                sawRunFinished = true
                if finished.waitingOnBackground {
                    // The agent armed a background watch (`nucleic_monitor`) and ended its turn to
                    // wait for it to fire — neither done nor blocked on the user. Stamp the turn
                    // "Monitoring…" synchronously, BEFORE the summary broadcast below, so the phone's
                    // notification gate sees `.waitingBackground` on the first `.awaitingInput`
                    // summary and stays silent. This also skips the AI disposition classifier and the
                    // dead-wait continuation nudge — the agent has a real re-invocation mechanism.
                    await controller.setDisposition(.waitingBackground)
                    // Keep this session's guest resources alive for the wait: the turn ended, so the
                    // backend already released its per-turn container/VM refs, arming the idle timers.
                    // Pin both so neither the sandbox container nor the per-session VM is idle-stopped
                    // or auto-killed out from under a session that's only *waiting* to be re-invoked.
                    await containerManager?.holdForBackgroundWait(sessionID)
                    await macVMManager?.holdForBackgroundWait(sessionID)
                } else {
                    // A non-waiting turn end: drop any background-wait pin (the monitor ended, or the
                    // agent did other work), so the normal idle-stop / auto-kill policy resumes. A
                    // no-op when the session was never pinned.
                    await containerManager?.releaseBackgroundWait(sessionID)
                    await macVMManager?.releaseBackgroundWait(sessionID)
                    classifyDisposition(sessionID, controller, outcome: finished.outcome)
                }
                // Mesh dispatch: the active-session count changed — refresh the presence card.
                publishRunnerPresence()
                // Covalence: a turn just ended — the between-turn window where a managed session may
                // move to a strictly better host. Debounced; no-ops when nothing here is managed.
                scheduleCovalenceRebalanceSweep()
            }
            // 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. A model occasionally returns prose such as "waiting for
            // the workers" instead of keeping its blocking `nucleic_supervise` call alive. Previously we
            // tore the coordinator down here, which cancelled the workers and destroyed the very event
            // the supervisor claimed it was waiting for — a permanent lost wake-up. On a clean turn end,
            // keep the channel and queue an automatic follow-up whenever live workers or undrained
            // terminal events remain. `sendInput` sees the controller's closing `runTask` and queues the
            // continuation; `finishRun` starts it as soon as the stream actually closes, with all
            // Orchestra tools re-applied. Injected backend-only (`echoToTranscript: false`) so this
            // machine nudge steers the supervisor without surfacing in chat as a user message. Stop/
            // error/max-turn endings still tear down immediately, as does a clean turn that really
            // drained its workers.
            if case .runFinished(let finished) = event.kind,
               let coordinator = orchestraCoordinators[sessionID] {
                if finished.outcome == .completed, await coordinator.needsSupervision() {
                    let prompt = "[Nucleic automatic continuation] Worker activity is still pending. "
                        + "Continue the loop now: call `nucleic_supervise` (pass `batch` to wait for a "
                        + "specific group), handle every event, and act on what returns — answer "
                        + "questions with `nucleic_reply_to_worker`, and delegate any follow-on work "
                        + "the results imply with `nucleic_delegate_plan`. Do not finish until it "
                        + "reports that no workers remain running and you have incorporated every "
                        + "result."
                    try? await controller.sendInput(AgentInput(text: prompt), echoToTranscript: false)
                } else {
                    await teardownOrchestra(supervisor: sessionID)
                }
            }
        }
        let snapshot = await controller.snapshot
        // Per-event follow-ups that need the (batch-final) session record. The snapshot is
        // pulled once for the whole batch: a feed row or status-feed nudge recorded against
        // a record at most one coalesce window newer than its event is indistinguishable in
        // practice, and the per-event snapshot pull was the ingest path's dominant cost.
        for event in batch {
            // 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) }
            }
            // Fleet activity feed (mesh casting): the broadcast-wide "what happened" items.
            // Approvals record once per ApprovalID (the event fires once); errors record at turn
            // level only — per-event `.error`s can be transient api_retry noise. The finished/
            // needs-input pair records from `classifyDisposition` (it knows which one this was).
            if case .approvalRequested(let req) = event.kind {
                recordFeedEvent(
                    .needsApproval, session: snapshot.session, detail: req.title, approvalID: req.id)
                // Remember what was asked so the matching `approvalResolved` can name it.
                approvalMetaByID[req.id] = (
                    title: req.title, isQuestion: req.toolName == AskUserQuestion.toolName)
            }
            // The closing bookend to `needsApproval`: a user answered an approval — approved/denied it
            // or answered its question. Cast it onto the mesh (fleet feed, mesh-wide) and log a
            // structural diagnostics row so both halves of the exchange show in the Covalence Mesh
            // Viewer. Sentinel resolvers (session teardown's "system" deny, an always-rule) aren't user
            // responses — skip them so the feed only reflects deliberate human answers.
            if case .approvalResolved(let res) = event.kind {
                // The answer completed its round-trip: the controller has dropped it from
                // `pendingApprovals`, so the in-flight record isn't needed to keep it off screen.
                answeredApprovalIDs.remove(res.id)
                recordApprovalResponse(res, session: snapshot.session)
            }
            if case .runFinished(let finished) = event.kind, finished.outcome == .errored {
                recordFeedEvent(
                    .error, session: snapshot.session,
                    detail: finished.finalText.map { String($0.prefix(200)) })
            }
        }
        upsertSummary(SessionSummary(snapshot.session, pendingApprovals: snapshot.pendingApprovals))
        // This batch 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 whole batch in one events frame,
        // 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, batch))
            for event in batch {
                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 sawRunFinished, syncBroadcastContinuation != nil {
            await loadDashboard()
            broadcast(.dashboard(await dashboardSnapshot()))
        }
        // Carbon (CARBON_SHARDING §6.4, D8): a turn boundary ALWAYS snapshots — capture this
        // session's fresh state now rather than waiting out the cadence, so the mesh-durable
        // copy reflects the turn the user just watched finish. Off the ingest hot path.
        if sawRunFinished, carbonTicker != nil {
            Task { [weak self] in
                guard let self else { return }
                if let input = await self.carbonSessionInputs()
                    .first(where: { $0.sessionID == sessionID.rawValue })
                {
                    await self.carbonService.tickSession(input)
                }
            }
        }

        // 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 batch, 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, so skip the display work for a batch of
        // nothing but input deltas; the next batch that can actually change the view pulls the
        // full history (these fragments included) in one pass.
        let batchChangesDisplay = batch.contains { event in
            if case .toolCallInputDelta = event.kind { return false }
            return true
        }
        if sessionID == openSessionID, batchChangesDisplay {
            // 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 sawRunFinished { openDisplayPendingTurnEnd = sessionID }
            await refreshOpenDisplayThrottled(sessionID, controller)
        }
        // Record how far the observer has drained this session's event stream — any
        // work these events spawned (e.g. classification on runFinished) has been issued
        // by now, so awaitOpenSessionSettled can tell when it's safe to drain tasks.
        // Events are delivered in canonical order, so the batch's last seq is its max.
        lastIngestedSeq[sessionID] = max(lastIngestedSeq[sessionID] ?? 0, batch.last?.seq ?? 0)
    }

    /// 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 }
        let toolStructureMaybeChanged = applyOpenTranscript(history, for: sessionID)
        let approvals = visibleApprovals(snapshot.pendingApprovals)
        if openApprovals != approvals { openApprovals = approvals }
        // Only re-run the O(transcript) tool-summary pass when the events just applied could actually
        // have changed a tool group (a tool started/finished, a note/file/approval landed, …). A refresh
        // that added only prose/thinking deltas — most of a streaming turn — leaves the groups untouched,
        // so skipping the scan there removes an O(n) main-actor pass from the ~12/sec streaming refresh.
        // Force it on turn end as a backstop so a group settling exactly at run end is never missed.
        // Turn end runs the pass directly (a group settling exactly at run end must not
        // wait out a throttle window); mid-turn structural changes go through the trailing
        // throttle — the pass refolds the whole open transcript, which at the display
        // refresher's ~12.5 Hz was a continuous MainActor tax on long tool-heavy sessions.
        if turnEnded {
            regenerateToolGroupSummaries()
        } else if toolStructureMaybeChanged {
            requestToolGroupSummaryRegen()
        }
        if turnEnded { regenerateSummary() }
    }

    /// While startup is still reconstructing this controller, page lightweight render-index
    /// records backward from the transcript tail (or canonical events when no valid index exists).
    /// Each page is prepared on a user-initiated worker and committed in one small main-actor turn,
    /// so the newest conversation becomes useful without an all-file gate. The ordinary
    /// preparation remains authoritative and replaces this preview when complete.
    private func streamStartupTranscriptPreview(_ session: Session) async {
        let sessionID = session.id
        let transcriptURL = URL(fileURLWithPath: session.transcriptPath)
        let indexReader = TranscriptRenderIndex.Reader(transcriptURL: transcriptURL)
        let validation = try? await Task.detached(priority: .userInitiated) {
            try indexReader.validate(expectedLastSequence: session.lastSeq)
        }.value

        if let validation {
            var indexOffset: Int?
            var indexedPageCount = 0
            var indexedReadFailed = false
            repeat {
                guard !Task.isCancelled, openSessionID == sessionID,
                      openTranscriptReadySessionID != sessionID,
                      controllers[sessionID] == nil
                else { return }
                let cursor = indexOffset
                do {
                    let page = try await Task.detached(priority: .userInitiated) {
                        let records = try indexReader.readReverseBatch(
                            beforeByteOffset: cursor, maximumRecords: 48,
                            maximumEncodedBytes: 128 * 1024)
                        let events = try indexReader.previewEvents(
                            for: records.records, validation: validation)
                        return (events, records.nextByteOffset)
                    }.value
                    guard !Task.isCancelled, openSessionID == sessionID,
                          openTranscriptReadySessionID != sessionID,
                          controllers[sessionID] == nil
                    else { return }
                    if !page.0.isEmpty {
                        openTranscript.insert(contentsOf: page.0, at: 0)
                        openTranscriptVersion &+= 1
                        openSession = session
                        openTranscriptPreviewSessionID = sessionID
                    }
                    indexOffset = page.1
                    indexedPageCount += 1
                    await Task.yield()
                } catch {
                    indexedReadFailed = true
                    break
                }
            } while indexOffset != nil && indexedPageCount < 4
            if !indexedReadFailed { return }

            // A record became unreadable after header validation (for example an interrupted
            // sidecar append). Discard the speculative indexed rows before the canonical fallback
            // so the two sources cannot duplicate the same sequence in the visible preview.
            if openTranscriptPreviewSessionID == sessionID {
                openTranscript = []
                openTranscriptVersion &+= 1
                openTranscriptPreviewSessionID = nil
            }
        }

        let reader = TranscriptReader(url: transcriptURL)
        var offset: Int?
        var pageCount = 0
        repeat {
            guard !Task.isCancelled, openSessionID == sessionID,
                  openTranscriptReadySessionID != sessionID,
                  controllers[sessionID] == nil
            else { return }
            let cursor = offset
            let page: TranscriptReader.ReverseBatch
            do {
                page = try await Task.detached(priority: .userInitiated) {
                    try reader.readReverseBatch(
                        beforeByteOffset: cursor,
                        maximumEvents: 48,
                        maximumEncodedBytes: 128 * 1024)
                }.value
            } catch { return }
            guard !Task.isCancelled, openSessionID == sessionID,
                  openTranscriptReadySessionID != sessionID,
                  controllers[sessionID] == nil
            else { return }
            if !page.events.isEmpty {
                // Pages arrive newest first, but each page itself is chronological. Prepending the
                // next page therefore preserves canonical order while keeping the visible bottom
                // stable as older history becomes available above it.
                openTranscript.insert(contentsOf: page.events, at: 0)
                openTranscriptVersion &+= 1
                openSession = session
                openTranscriptPreviewSessionID = sessionID
            }
            offset = page.nextByteOffset
            pageCount += 1
            await Task.yield()
            // The preview exists to remove the first-content barrier, not to build a second full
            // transcript through repeated Array prepends while the authoritative decode is already
            // in flight. Four bounded pages cover a generous visible tail; render segmentation
            // takes over when the complete array is atomically installed.
        } while offset != nil && pageCount < 4
    }

    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 }
        // Start the indexed Bash-headline read while the controller serves its authoritative
        // state. The derived lines must land before summary bookkeeping, but not before transcript
        // readiness: a large cache may refine tool cards after first paint without withholding all
        // prose. Snapshot-before-transcript guarantees history reaches `snapshot.lastSeq`.
        async let toolSummaryHydration: Void = hydrateToolSummaries(sessionID)
        let snapshot = await controller.snapshot
        let events = await controller.transcriptSoFar()
        guard !Task.isCancelled, sessionID == openSessionID else { return }
        if openTranscriptPreviewSessionID == sessionID {
            // The preview is a chronological tail, not a prefix, so the append-only writer cannot
            // merge it by count. Replace it atomically with the authoritative complete history.
            openTranscript = events
            openTranscriptVersion &+= 1
        } else {
            applyOpenTranscript(events, for: sessionID)
        }

        openSession = snapshot.session
        openApprovals = visibleApprovals(snapshot.pendingApprovals)
        // Show a cached summary at the readiness edge; generating a missing one belongs to the
        // derived post-open work below and must not delay transcript creation.
        let hasCachedSummary = snapshot.session.summary?.isEmpty == false
        if let cached = snapshot.session.summary, !cached.isEmpty { openSummary = cached }
        // Raise authoritative readiness before clearing preview availability so the computed
        // display edge never flickers nil between the two phases.
        openTranscriptReadySessionID = sessionID
        openTranscriptPreviewSessionID = nil

        // Readiness is the critical edge: let SwiftUI project, warm the visible Markdown tail,
        // and install the scroll surface before doing whole-history summary bookkeeping or
        // unrelated sidebar/Nash restoration on the main actor.
        await Task.yield()
        await toolSummaryHydration
        guard !Task.isCancelled, sessionID == openSessionID else { return }

        // 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 live groups summarize normally.
        historicalToolGroupSignatures = sessionsCreatedThisRun.contains(sessionID)
            ? []
            : Set(openSummarizableUnits(events).map(\.signature))
        // History is never AFM-summarized, so give its eligible families their final heuristic
        // line up front; persisted AI lines loaded above always win over these fallbacks.
        seedHistoricalToolGroupLines(events, root: snapshot.session.worktreePath)
        regenerateToolGroupSummaries()
        if !hasCachedSummary { regenerateSummary() }

        // These restore auxiliary UI state but do not affect transcript completeness. Keeping
        // them after readiness prevents an unread-marker write or a large Nash event-log read from
        // leaving the transcript canvas blank. Each guard also makes a quick session switch cheap.
        guard !Task.isCancelled, sessionID == openSessionID else { return }
        await clearAutoShipFailedSeen(sessionID)
        guard !Task.isCancelled, sessionID == openSessionID else { return }
        let seenSnapshot = await controller.snapshot
        await reconcileUnseenCompletion(sessionID, seenSnapshot)
        guard !Task.isCancelled, sessionID == openSessionID else { return }
        await nashEvents.hydrate(sessionID: sessionID)
    }

    // 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)
            return
        }
        // The owning device isn't in the live roster. That's the *normal* state for the seconds
        // between launch and the mesh reconnecting — Covalence rows render from the persisted
        // mirror first (`foldedCloudSessions`), so they're on screen and clickable before any peer
        // presence lands — and the permanent state for a runner that never came back. Opening must
        // never be a silent no-op here: selection is origin-qualified on `openHostID`, so returning
        // without touching state leaves the row un-highlighted and the pane unchanged, which reads
        // as a dead app. Open it anyway from whatever summary we hold; `streamRemoteSession` renders
        // the on-disk transcript mirror read-only and heals live if the owner reconnects.
        if let cached = covalenceMirrors[summary.id]?.summary {
            openRemoteSession(cached, on: hostID)
        } else {
            openRemoteSession(Self.remoteSession(from: summary), on: hostID)
        }
        lastError = "\(summary.title) is on a device that's offline — showing the last synced copy."
        lastErrorIsBenign = true
    }

    /// 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) {
        openRemoteSession(Self.remoteSession(from: summary), on: deviceID)
    }

    /// The shared wiring behind both remote-open entries: the live-roster one above, and
    /// `openSession`'s cached-row fallback, which has no wire summary to hand (its row was
    /// restored from the Covalence mirror before the mesh reconnected).
    private func openRemoteSession(_ session: Session, on deviceID: String) {
        let sameSelection = openSessionID == session.id
        openSessionID = session.id              // 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 = session
        let sessionID = session.id
        remoteSessionTask?.cancel()
        remoteSessionTask = Task { [weak self] in await self?.streamRemoteSession(sessionID, on: deviceID) }
    }

    private func streamRemoteSession(_ sessionID: SessionID, on deviceID: String) async {
        // 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).
        //
        // Revert-epoch guard: skip the seed when the mirror was synced at a different revert
        // epoch than the owner now advertises — the mirror may hold pre-revert content at reused
        // seqs, and the grow-only `.snapshot`/`.events` merges below would keep it on screen
        // forever (the host only replays `seq > sinceSeq`). Opening unseeded renders the host's
        // snapshot instead (the no-mirror experience) while the reconcile pass heals the mirror
        // in the background. This closes the "user opens the session before the reconcile tick
        // notices the missed revert" race.
        var seededSeq: UInt64 = 0
        let ownerSummary = meshPeers.first(where: { $0.deviceID == deviceID })?
            .sessions.first(where: { $0.sessionID == sessionID })
        let ownerEpoch = ownerSummary?.revertEpoch

        // An interactive open promotes this transcript ahead of speculative mirror work. If the
        // coordinator is already fetching it, `prioritize` joins that single flight; otherwise it
        // starts a foreground overflow rather than duplicating the whole history through
        // `subscribe(sinceSeq: 0)`. Relay-only opens skip this bulk-mirror path and retain the live
        // subscription fallback below (proactive transcript payloads deliberately avoid relay).
        let directCandidates = meshPeers
            .filter { $0.connected && $0.transport != nil && $0.transport != .relay }
            .map(\.deviceID)
        if let coordinator = transcriptSyncCoordinator, let ownerSummary,
           !directCandidates.isEmpty
        {
            await coordinator.prioritize(.init(
                sessionID: sessionID, lastSeq: ownerSummary.lastSeq,
                revertEpoch: ownerSummary.revertEpoch, archived: ownerSummary.archived,
                ownerDeviceID: deviceID), candidates: directCandidates)
            guard !Task.isCancelled, openSessionID == sessionID, openHostID == deviceID else {
                return
            }
        }
        let mirrorDiverged: Bool
        if let ownerEpoch {
            let heldEpoch = await mirrorStore.heldEpoch(sessionID)
            mirrorDiverged = heldEpoch.map { $0 != ownerEpoch } ?? (ownerEpoch > 0)
        } else {
            mirrorDiverged = false
        }
        if !mirrorDiverged,
           let url = await mirrorStore.url(sessionID),
           let mirrored = try? TranscriptReader(url: url).read().events, !mirrored.isEmpty {
            guard !Task.isCancelled else { return }
            applyOpenTranscript(mirrored, for: sessionID)
            seededSeq = mirrored.last?.seq ?? 0
            markOpenTranscriptReadyIfCaughtUp(sessionID)
        }
        // Seeding happens above this guard on purpose: with no sync server running (or none yet
        // at launch) the mirror is all we have, and returning before the seed left the pane blank
        // for a session whose full history is sitting on disk.
        guard let pc = peerClient else {
            // Offline: the persisted mirror is the authoritative history available now. Reveal
            // it (including an intentionally empty mirror) rather than leave the detail blank.
            if !Task.isCancelled, openSessionID == sessionID {
                openTranscriptReadySessionID = sessionID
            }
            return
        }
        let stream = await pc.openRemoteSession(sessionID, on: deviceID, sinceSeq: seededSeq)
        for await update in stream {
            guard !Task.isCancelled,
                  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 = visibleApprovals(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)
                }
                markOpenTranscriptReadyIfCaughtUp(sessionID)
            case .events(let batch):
                let known = Set(openTranscript.map(\.seq))
                let merged = openTranscript + batch.filter { !known.contains($0.seq) }
                applyOpenTranscript(merged, for: sessionID)
                markOpenTranscriptReadyIfCaughtUp(sessionID)
            case .approvalRequested(let req):
                if !answeredApprovalIDs.contains(req.id),
                   !openApprovals.contains(where: { $0.id == req.id }) {
                    openApprovals.append(req)
                }
            case .approvalResolved(let res):
                answeredApprovalIDs.remove(res.id)
                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 .reverted(let throughSeq):
                // The owner reverted/undid the chat — the transcript shrank. `applyOpenTranscript`
                // only ever grows, so set the shrunk history directly (a deliberate, owner-initiated
                // rewrite, mirroring the local `revertOpenSession` path).
                openTranscript = openTranscript.filter { $0.seq <= throughSeq }
                openTranscriptVersion &+= 1
            case .disconnected:
                // If the owner vanished mid-catch-up, show the best complete prefix we received
                // instead of keeping an otherwise usable mirrored transcript hidden forever.
                if openTranscriptReadySessionID == nil {
                    openTranscriptReadySessionID = sessionID
                }
                lastError = "\(openSession?.title ?? "That chat")'s Mac went offline."
                return
            }
        }
    }

    /// Mark a remote open ready once the transcript reaches the owner's advertised canonical
    /// tail. A zero cursor means the cached row predates `lastSeq`; any fetched/mirrored state is
    /// then the best known history and is safe to reveal. Readiness is one-way until selection
    /// changes, so a live owner advancing `lastSeq` cannot tear an already-visible ScrollView down.
    private func markOpenTranscriptReadyIfCaughtUp(_ sessionID: SessionID) {
        guard sessionID == openSessionID,
              openTranscriptReadySessionID != sessionID else { return }
        let expectedSeq = openSession?.lastSeq ?? 0
        let loadedSeq = openTranscript.last?.seq ?? 0
        if expectedSeq == 0 || loadedSeq >= expectedSeq {
            openTranscriptReadySessionID = sessionID
        }
    }

    /// Apply a peer's transcript revert/undo to our on-disk mirror (full-transcript mesh sync):
    /// truncate the mirror for `sessionID` down to `throughSeq` so a later open doesn't re-seed the
    /// dropped tail, and stamp the owner's post-revert epoch into the mirror's header so the next
    /// reconcile pass reads this (now converged) copy as current instead of drop-and-refetching it.
    /// An actively-open peer session's on-screen view is shrunk separately by
    /// `streamRemoteSession`'s `.reverted` branch; this keeps the persistent mirror in step even for
    /// sessions that aren't being viewed. Best-effort — a rewrite failure leaves the stale mirror,
    /// which the next epoch-mismatch heal or full refetch overwrites.
    private func applyPeerRevert(
        _ sessionID: SessionID, throughSeq: UInt64, revertEpoch: UInt64?, from deviceID: String
    ) async {
        _ = try? await mirrorStore.truncate(
            sessionID, throughSeq: throughSeq, revertEpoch: revertEpoch)
    }

    /// Keep the open *remote* session's header/status in lockstep with the authoritative roster.
    /// Previously a peer session's `openSession` was refreshed only when the transcript stream
    /// delivered a `.snapshot`, so a turn that finished via the `.events`/roster path left the
    /// detail view's `status` stale — the "Thinking…" indicator re-asserted after a cloud runner
    /// had already completed its turn. The roster is the same source the sidebar reads, so mirroring
    /// it here makes the detail status converge with the row the instant the owner broadcasts the
    /// turn's end. Local sessions (`openHostID == nil`) are unaffected — their status is authored here.
    private func refreshOpenRemoteSessionStatus() {
        guard let hostID = openHostID, let sessionID = openSessionID else { return }
        guard let wire = meshPeers.first(where: { $0.connected && $0.deviceID == hostID })?
            .sessions.first(where: { $0.sessionID == sessionID }) else { return }
        let refreshed = Self.remoteSession(from: wire)
        if openSession != refreshed { openSession = refreshed }
    }

    /// 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)
    }

    /// The same remote `Session`, built from a *sidebar row* instead of a wire summary — for
    /// opening a remote chat whose owner isn't in the live roster and whose wire summary we never
    /// cached. The fields the row doesn't carry (backend, branch, model, `lastSeq`) don't affect
    /// the read-only mirror render, and the live subscription replaces this wholesale with the
    /// owner's own summary the moment it reconnects (`streamRemoteSession`'s `.snapshot` branch).
    static func remoteSession(from s: SessionSummary) -> Session {
        Session(
            id: s.id, projectID: s.projectID, backend: .claudeCode,
            title: s.title, status: s.status, worktreePath: nil, branch: nil,
            effort: s.effort, transcriptPath: "", diffStat: s.diffStat,
            auto: s.auto, favorite: s.favorite, archived: s.archived,
            createdAt: s.createdAt, 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 = []
        persistedBashGists = [:]; persistedBlockInputs = [:]
    }

    // MARK: Bash summary persistence (survive close/reopen)

    /// Load the open chat's cached Bash summary lines from the metadata store into the
    /// in-memory caches. Without this, closing and reopening a chat throws away every line
    /// the on-device model wrote for it: a chat created this run re-summarizes its whole
    /// transcript from scratch, and one carried over from a previous run falls back to the
    /// deterministic heuristic forever. Idempotent; a no-op for remote (peer-owned) sessions,
    /// whose lines arrive over the mesh instead.
    private func hydrateToolSummaries(_ sessionID: SessionID) async {
        guard summarizeToolCalls else { return }  // off → the view renders heuristics directly
        let entries = (try? await database.loadToolSummaries(sessionID: sessionID)) ?? []
        // The model may have finished a fresher line for this block while the read was in
        // flight, so a cached row never overwrites one already in memory.
        guard sessionID == openSessionID, openHostID == nil, summarizeToolCalls else { return }
        for entry in entries {
            switch entry.kind {
            case .gist:
                if persistedBashGists[entry.toolCallID] == nil {
                    persistedBashGists[entry.toolCallID] = entry.line
                }
            case .block:
                guard bashBlockLines[entry.toolCallID] == nil else { continue }
                bashBlockLines[entry.toolCallID] = entry.line
                persistedBlockInputs[entry.toolCallID] = entry.inputs
            }
        }
    }

    /// Re-read the persisted lines and then re-run the summary pass. Used when a settings
    /// change invalidated the in-memory caches: they hold exactly what the store holds, so
    /// rebuilding them from disk is what keeps a toggle from costing a fresh round of model
    /// work over the whole transcript.
    private func refreshToolGroupSummaries() {
        guard summarizeToolCalls, openHostID == nil, let sessionID = openSessionID else {
            regenerateToolGroupSummaries()
            return
        }
        toolGroupTask = Task {
            await self.hydrateToolSummaries(sessionID)
            guard sessionID == self.openSessionID else { return }
            self.regenerateToolGroupSummaries()
        }
    }

    /// Write summary lines through to the metadata store so the next open reuses them.
    /// Fire-and-forget: the cache is derived, so a failed write only costs a re-summarize.
    private func persistToolSummaries(
        _ entries: [ToolSummaryCacheEntry], sessionID: SessionID?
    ) {
        guard let sessionID, !entries.isEmpty else { return }
        let database = self.database
        Task.detached { try? await database.saveToolSummaries(entries, sessionID: sessionID) }
    }

    /// The freshness cursor for a block's merged line: its commands' ids, in run order. Stable
    /// across launches and Macs (unlike the in-memory gist hash), so a persisted line can be
    /// matched against the block it's reopened onto. See `persistedBlockInputs`.
    private static func blockInputCursor(_ calls: [ToolCall]) -> String {
        calls.map(\.toolCallID).joined(separator: "\n")
    }

    /// 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 openSummarizableUnits(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 {
                    // Never over-write a line hydrated from the store: a persisted AI headline
                    // is strictly better than the heuristic, and it's the whole point of the
                    // cache that a reopened chat shows it instead of falling back.
                    if let first = family.calls.first, bashBlockLines[first.toolCallID] == nil {
                        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()
    }

    /// Incremental units source for the summary pass. Every entry point below re-derives the
    /// open transcript's summarizable units — the throttled regen, its staleness guards after
    /// each model call, the open-time history baseline — and the one-shot
    /// `TranscriptProjection.summarizableUnits` refolds the whole stream each time: an
    /// O(transcript) MainActor pass per invocation, 10–100ms+ on 30k-event sessions. This
    /// projector seals the stable prefix's units once and refolds only the live tail; a session
    /// switch or revert changes the stream identity (`firstSeq`/count), which it detects and
    /// answers with a self-healing refold, so no explicit reset wiring is needed.
    private let summaryUnitsProjection = IncrementalTranscriptProjection()

    /// The summarizable units of `events` (the open transcript, or the freshly loaded events
    /// about to become it) — identical to `TranscriptProjection.summarizableUnits(events)`,
    /// computed incrementally.
    private func openSummarizableUnits(_ events: [AgentEvent]) -> [TranscriptProjection.SummarizableUnit] {
        summaryUnitsProjection.summarizableUnits(events: events)
    }

    /// Trailing-edge throttle for the mid-turn summary pass: run now if the last pass is
    /// old enough, otherwise schedule exactly one trailing run for when the window expires.
    /// The trailing run (not a plain drop) matters: the last structural change of a burst —
    /// a block's final command settling, with only prose streaming afterward — must still
    /// get summarized without waiting for turn end.
    private var lastToolSummaryRegenAt: Date = .distantPast
    private var toolSummaryRegenScheduled = false
    private static let toolSummaryRegenMinInterval: TimeInterval = 0.4

    private func requestToolGroupSummaryRegen() {
        let elapsed = now().timeIntervalSince(lastToolSummaryRegenAt)
        if elapsed >= Self.toolSummaryRegenMinInterval {
            lastToolSummaryRegenAt = now()
            regenerateToolGroupSummaries()
        } else if !toolSummaryRegenScheduled {
            toolSummaryRegenScheduled = true
            let delay = Self.toolSummaryRegenMinInterval - elapsed
            Task { @MainActor [weak self] in
                try? await Task.sleep(for: .seconds(delay))
                guard let self else { return }
                self.toolSummaryRegenScheduled = false
                self.lastToolSummaryRegenAt = self.now()
                self.regenerateToolGroupSummaries()
            }
        }
    }

    /// 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 = openSummarizableUnits(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>()
        var liveCallIDs = Set<String>()
        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 where Self.bashCommandReady(call) {
                        liveCallSigs.insert(TranscriptProjection.toolGroupSignature([call]))
                        liveCallIDs.insert(call.toolCallID)
                    }
                } 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)
        persistedBlockInputs = persistedBlockInputs.filter { liveBlockIDs.contains($0.key) }
        persistedBashGists = persistedBashGists.filter { liveCallIDs.contains($0.key) }
        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)
                }
            }
        }
    }

    /// A reference to the currently-open session for tagging its background AFM work
    /// (``AFMWorkContext``), so the sidebar's omnisearch can filter the queue/inspector to a
    /// matching chat. nil when nothing is open.
    private var openSessionRef: AFMSessionRef? {
        guard let id = openSessionID else { return nil }
        let title = openSession?.title ?? summaries.first { $0.id == id }?.title ?? ""
        return AFMSessionRef(id: id, title: title)
    }

    /// 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 }
        // Only commands whose text has actually arrived are summarizable: a tool_use is
        // announced with empty input and its command streams in afterward (the projection
        // shows the authoritative input on completion — see `bashCommandReady`). Gisting a
        // still-streaming command summarizes a `""`, then throws that work away the instant the
        // input — and thus the content signature — flips to the real command, deleting and
        // re-summarizing the block. Wait for the text so each command is gisted exactly once.
        let readyCalls = family.calls.filter { Self.bashCommandReady($0) }
        guard !readyCalls.isEmpty else { return }
        let callSigs = readyCalls.map { TranscriptProjection.toolGroupSignature([$0]) }
        // Fold any gist persisted by a previous run into this run's signature-keyed cache. The
        // durable cache keys on `toolCallID` because content signatures don't survive a launch;
        // this is where the two keyings meet, so the loop below sees a reopened chat's gists as
        // already-cached and skips the model entirely.
        for (call, sig) in zip(readyCalls, callSigs) where bashCallSummaries[sig] == nil {
            if let saved = persistedBashGists[call.toolCallID] { bashCallSummaries[sig] = saved }
        }
        // The gists now 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 cachedGists = callSigs.compactMap { bashCallSummaries[$0] }
        if cachedGists.count == readyCalls.count {
            if bashBlockInputs[blockID] == hashStrings(cachedGists) { return }
            // Fully gisted, and the hydrated line was merged from exactly these commands (not
            // from a shorter prefix of a block that kept growing after it was written) — adopt
            // it as this run's line without a merge call.
            if bashBlockLines[blockID] != nil,
                persistedBlockInputs[blockID] == Self.blockInputCursor(readyCalls) {
                bashBlockInputs[blockID] = hashStrings(cachedGists)
                return
            }
        }
        let calls = readyCalls
        // Captured up front so a write lands on the chat that was summarized even if the user
        // navigates away mid-merge. nil for a remote session — a viewer caches nothing.
        let persistTarget = openHostID == nil ? openSessionID : nil
        intelligenceWorkSeq += 1
        bashBlocksInFlight.insert(blockID)
        // Tag the block's model work with the open session (the transcript it's summarizing)
        // so omnisearch can filter the queue to it. The unstructured `Task` inherits this bound
        // task-local at creation.
        toolGroupTask = AFMWorkContext.$session.withValue(openSessionRef) { 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
                self.persistedBashGists[call.toolCallID] = gist
                // Persist per command, not per block: the gists are the expensive half of the
                // map→reduce, and a run interrupted mid-block should not have to redo them.
                self.persistToolSummaries(
                    [ToolSummaryCacheEntry(toolCallID: call.toolCallID, kind: .gist, line: gist)],
                    sessionID: persistTarget)
                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)
            // Cache the merged headline against the exact command list it covers, so reopening
            // this chat shows it straight away instead of re-running the whole map→reduce.
            let cursor = Self.blockInputCursor(calls)
            self.persistedBlockInputs[blockID] = cursor
            self.persistToolSummaries(
                [ToolSummaryCacheEntry(toolCallID: blockID, kind: .block, line: line, inputs: cursor)],
                sessionID: persistTarget)
            // 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 = AFMWorkContext.$session.withValue(openSessionRef) { 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 openSummarizableUnits(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 openSummarizableUnits(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
    }

    /// Whether a Bash call is ready to be summarized — i.e. its command text has arrived.
    /// A backend announces a tool call with empty input (`toolCallStarted`) and streams the
    /// arguments in afterward; the transcript projection ignores those input deltas and only
    /// adopts the authoritative full input on completion (`toolCallCompleted` → the call is
    /// `finished`), so a Bash call's `command` is empty exactly while it's still streaming. The
    /// content-addressed summary cache keys on the input, so gisting the empty phase produces a
    /// `""` summary that is discarded — and the block re-summarized — the moment the real command
    /// lands and the signature changes. Gating on the command being present keeps each command
    /// gisted once, from its final text, instead of once empty and once real.
    private static func bashCommandReady(_ call: ToolCall) -> Bool {
        !(call.input["command"]?.stringValue ?? "").isEmpty
    }

    /// 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
        let ref = openSessionRef
        summaryTask = AFMWorkContext.$session.withValue(ref) { 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.
    /// The one automatic continuation injected when a turn dead-ends in an external-wait posture
    /// (see `classifyDisposition`). Steers the agent to wait *inside* a turn or finish now, because
    /// Nucleic's single-shot turn model (`ClaudeCodeBackend.closeStdinAfterPrompt`) has no
    /// background loop to resume a deferred wait. Injected as a backend-only steering turn
    /// (`echoToTranscript: false`), so it drives the agent without surfacing in chat as if the user
    /// typed it — a machine nudge shouldn't clutter the transcript. Still prefixed `[Nucleic]` so
    /// the agent reads it as a system injection.
    static let deadWaitContinuationPrompt = """
        [Nucleic] Your turn ended while waiting for something to happen on its own, but this \
        environment runs each turn as a single foreground turn — there is no built-in harness loop \
        that will wake you later, so a deferred or scheduled wait (a ScheduleWakeup, the native \
        Monitor tool, or just "I'll check back once it's done") never resumes on its own. You have \
        three real options. (1) If you want to keep being woken as an external event fires, arm \
        `nucleic_monitor` with a shell `command` that emits a line per event — it runs the watch \
        outside your turn and re-invokes you with each batch of output, so you can end your turn \
        now. (2) Otherwise wait *within this turn*: block in the foreground — a shell loop that \
        sleeps and re-checks the condition, or a command that only returns once it's ready — or, if \
        you're waiting for a signal from another Nucleic session, call `nucleic_wait_for_message`, \
        which parks this turn until the message arrives. (3) If waiting isn't actually necessary, \
        finish the remaining work now and give your final result. Do not end your turn expecting to \
        be resumed automatically.
        """

    private func classifyDisposition(
        _ sessionID: SessionID, _ controller: SessionController, outcome: RunFinished.Outcome
    ) {
        let intelligence = self.intelligence
        intelligenceWorkSeq += 1
        classifyTask = Task {
            // Turn-end lock sweep — FIRST, before anything that can suspend for seconds. It frees
            // only paths with no unmerged divergence (already landed, or acquired by the edit gate
            // and never actually written), so an interrupted/errored turn can't leak a pre-write
            // lock that blocks sibling chats until relaunch, while genuinely dirty work stays
            // locked exactly as before (LOCKING invariant 4).
            //
            // It runs here rather than at the end of this task for two reasons. (1) Every ending
            // must sweep, and the paths below `return` early — a turn with no assistant prose (the
            // canonical "interrupted mid-tool-call" case, precisely the leak this sweep exists to
            // reap) and the dead-wait continuation both skipped it entirely. (2) Everything below
            // awaits an on-device classification that takes seconds, during which a queued
            // follow-up turn starts and its edit gate pre-write-acquires paths; sweeping first
            // shrinks that staleness window to ~nothing (and the sweep's own in-flight guard
            // fails closed if it loses the race anyway).
            await self.releaseCompletedSessionLocks(sessionID)
            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 }
            // Single-shot turn model: this environment runs each turn as one foreground process
            // (`ClaudeCodeBackend.closeStdinAfterPrompt`), so the CLI's own harness loop — the one
            // that would fire a ScheduleWakeup/Monitor/background-task notification and re-invoke
            // the agent on a later tick — does not outlive the turn. An agent that ends a clean
            // turn declaring it will wait for some *external* event therefore dead-ends: nothing
            // re-activates it, and worse, a done-looking "…and I'll report back" reads to the
            // classifier as `.completed` and would ship/tear the chat down. Detect that posture and
            // inject one bounded automatic continuation steering it to a foreground wait (mirroring
            // the Orchestra supervisor's own "waiting for the workers" recovery). Gated: a clean
            // turn only; never an Orchestra supervisor (its dedicated continuation handles that in
            // `ingestUI`); at most once per stuck streak (`autoContinuedWaiting`). A session parked
            // in `nucleic_wait_for_message` never reaches here — that wait keeps the turn running.
            if outcome == .completed,
               self.orchestraCoordinators[sessionID] == nil,
               !self.autoContinuedWaiting.contains(sessionID),
               HeuristicTurnClassifier.isWaitingPosture(reply),
               // Genuinely idle: a user follow-up queued mid-turn already flipped the session back
               // to `.running` (it starts the instant this turn ends), so don't inject a nudge
               // behind the real message the user is about to have answered.
               await controller.snapshot.session.status == .awaitingInput {
                self.autoContinuedWaiting.insert(sessionID)
                // Not `.completed`: keep the chat out of the ship/teardown path while the
                // continuation spins a fresh turn back up.
                await controller.setDisposition(.awaitingInput)
                // Backend-only: steers the next turn without echoing into the transcript as a
                // user message.
                try? await controller.sendInput(
                    AgentInput(text: Self.deadWaitContinuationPrompt), echoToTranscript: false)
                return
            }
            // A non-waiting ending is real progress — clear the one-nudge guard so a *later*
            // distinct wait is eligible for its own continuation.
            self.autoContinuedWaiting.remove(sessionID)
            // Tag the classification with its session so omnisearch can filter the queue to it.
            let classifyRef = AFMSessionRef(
                id: sessionID, title: self.summaries.first { $0.id == sessionID }?.title ?? "")
            let disposition = await AFMWorkContext.$session.withValue(classifyRef) {
                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)
            // Fleet activity feed: exactly one of finished/needsInput per cleanly-completed
            // turn (an errored/interrupted run already recorded — or deliberately didn't).
            if outcome == .completed {
                self.recordFeedEvent(
                    disposition == .completed ? .finished : .needsInput,
                    session: snapshot.session)
            }
            // 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. Called unconditionally — it
            // re-reads the live session and no-ops unless the chat actually reads Done, which also
            // catches the `.finished` run that never gets a `.completed` *disposition* but is every
            // bit as Done (and holds a guest clone just the same).
            await self.suspendVMIfCompleted(sessionID)
            // Finalize the chat (autoship + free its sandbox) 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 — is exactly the bug this guards against.
            if disposition == .completed, outcome == .completed {
                await self.releaseSandboxIfCompleted(sessionID)
                await self.shipIfCompleted(sessionID)
            }
        }
    }

    /// Autoship entry point: this session's last turn settled `.completed` — either the agent's
    /// own classification (`classifyDisposition`) or the user's hand-mark from the sidebar
    /// (`markSessionDone`), which are the same declaration — so if the 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
        // Re-read the settled state rather than trusting the caller's. `setDisposition` refines
        // only an idle `.awaitingInput` chat, so a hand-mark on a running or blocked one is a
        // silent no-op — without this it would still enqueue, and the queue emits `.merging`
        // before `performShip` can re-validate and skip, leaving a spurious note on a chat that
        // never went Done. Also covers a follow-up turn that raced in after classification.
        guard session.lastTurnDisposition == .completed, session.status == .awaitingInput
        else { return }
        // 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 — then
    /// arm the **Done reap** that fully reclaims it (stop + delete its on-disk clone) if the chat is still
    /// Done `MacVMSettings.doneReapGraceSeconds` later.
    ///
    /// The two steps are staged deliberately. Suspending is instant and reversible: it frees the guest's
    /// RAM and its concurrency slot immediately, and a follow-up thaws it transparently. Deleting the clone
    /// is neither, so it waits out a grace period — long enough that the ordinary "one more thing" reply
    /// still lands on the original guest, short enough that a day of finished chats can't leave tens of GB
    /// apiece parked on disk forever. Nothing else would collect them: launch reconcile keeps every live
    /// session's clone, and the idle timer skips a disk-suspended guest by design.
    ///
    /// Re-check the live controller because classification and a new user turn can race — and hand the
    /// manager that same check as a closure so the timer re-verifies Done when it *fires*, rather than
    /// trusting a five-minute-old observation (a follow-up turn that never touches a VM would not otherwise
    /// cancel the reap).
    private func suspendVMIfCompleted(_ sessionID: SessionID) async {
        guard let macVMManager, controllers[sessionID] != nil else { return }
        guard await isChatDone(sessionID) else { return }
        await macVMManager.suspendIfIdle(sessionID)
        await macVMManager.scheduleDoneReap(sessionID) { [weak self] in
            guard let self else { return false }
            return await self.isChatDone(sessionID)
        }
    }

    /// Whether the chat currently reads as **Done** — a finished run, or an interactive chat whose last
    /// turn the agent classified `.completed`. Mirrors ``SessionSummary/isCompleted`` (the property behind
    /// the "Done" row label), but reads the live controller rather than a possibly-stale summary. A chat
    /// with no controller — deleted, archived, or moved to another Mac — reads `false`: those paths
    /// reclaim its VMs directly, so the reap has nothing to add.
    func isChatDone(_ sessionID: SessionID) async -> Bool {
        guard let controller = controllers[sessionID] else { return false }
        let session = await controller.snapshot.session
        switch session.status {
        case .finished: return true
        case .awaitingInput: return session.lastTurnDisposition == .completed
        default: return false
        }
    }

    /// 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
        let ref = AFMSessionRef(id: id, title: summaries.first { $0.id == id }?.title ?? "")
        _ = AFMWorkContext.$session.withValue(ref) { 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 }
            // Now that the chat has a real title, seed its search keywords (background AFM work).
            self.scheduleSessionKeywords(id)
        } }
    }

    /// 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
        let ref = AFMSessionRef(id: id, title: summaries.first { $0.id == id }?.title ?? autoTitle)
        _ = AFMWorkContext.$session.withValue(ref) { 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 }
            // The title changed — refresh the search keywords to match the new name.
            self.scheduleSessionKeywords(id)
        } }
    }

    /// 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.
    /// Reassign the open transcript. Returns whether the newly-appended events could have changed the
    /// **summarizable tool-group structure** — so a caller can skip the O(transcript) tool-summary pass
    /// when a refresh added only pure prose/thinking/usage deltas (the bulk of a streaming turn). The
    /// scan is over just the appended suffix (O(delta), tiny during streaming); any tool-group change is
    /// necessarily caused by a tool/note/file/approval/error/run event, which is in that suffix.
    @discardableResult
    private func applyOpenTranscript(_ events: [AgentEvent], for sessionID: SessionID) -> Bool {
        guard sessionID == openSessionID, events.count >= openTranscript.count else { return false }
        let newSuffix = events[openTranscript.count...]
        let toolStructureMaybeChanged = newSuffix.contains { Self.affectsToolGroups($0) }
        openTranscript = events
        openTranscriptVersion &+= 1
        return toolStructureMaybeChanged
    }

    /// Whether an event can alter the summarizable tool-group structure. `false` for the pure display
    /// deltas — prose, thinking, user text, usage tallies, and session/turn markers — that make up most
    /// of a streaming turn and never create, complete, or re-sign a tool group. Everything else (tool
    /// lifecycle, input deltas, results, file changes, approvals, notes, errors, run end) is treated as
    /// possibly-affecting, so the tool-summary pass still runs whenever it genuinely might matter.
    private static func affectsToolGroups(_ event: AgentEvent) -> Bool {
        switch event.kind {
        case .assistantText, .thinking, .userText, .usage, .rateLimit, .codexUsage,
             .sessionStarted, .turnCompleted, .raw:
            return false
        default:
            return true
        }
    }

    /// 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)

    /// Start remote access on launch. Covalence is a mandatory, always-on feature, so this starts
    /// the sync server unconditionally — there is no user toggle to gate it. Called once from the
    /// app's startup task; needed on every launch because the sync server's running state is
    /// in-memory only and the process relaunches on each Sparkle auto-update. 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 resumeRemoteAccess() async {
        // Carbon (D17, mandatory): the producer runs from launch, mesh or no mesh — local
        // snapshots alone bound the crash-loss window and power the restore browser.
        startCarbonProducer()
        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 {
            // A follow-up start while one is already in flight supersedes any stop that raced
            // in between (restartIfRunning's stop→start pair landing mid-start): the in-flight
            // start re-reads the method set at its commit point, so clearing the stop flag
            // lets it commit with the new settings instead of abandoning — which previously
            // left the server off while every toggle said on.
            if syncStartInFlight { syncStopRequested = false }
            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 {
            var enabled = SyncTransportSetting.resolveEnabled()
            // Cloud lock-screen approvals (SyncedSettings.resolveApprovalsViaCloud) require this
            // Mac to be reachable in the relay room, so force the Covalence data-path
            // on whenever the account opted in — the macOS Settings checkbox locks to match, and
            // the phone routes Live-Activity approvals relay-first.
            if UserDefaults.standard.bool(forKey: SyncedSettings.resolveApprovalsViaCloudKey) {
                enabled.insert(.relay)
            }
            var health: [SyncTransportHint: String] = [:]
            var children: [(transport: SyncTransportHint, listener: any SyncListener)] = []

            // Kick the relay bring-up off first so its network legs (enroll + token mints +
            // the room WebSocket, each individually bounded) overlap the tailnet/LAN bring-up
            // below instead of serializing after them — an unreachable relay must cost the
            // start at most its own deadline, never hold LAN/tailnet hostage.
            var relayStart: Task<(RelayAccess, RelayListener, String?), Never>?
            if enabled.contains(.relay) {
                relayStart = Task { [identity] in
                    await self.startRelayListener(identity: identity)
                }
            }

            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 let relayStart {
                // The listener joins the composite even when its first connect failed — it
                // keeps redialing from start() (launch-time churn is transient) and reports
                // through relayStatusChanged; only the status row records the miss.
                let (access, relayListener, initialFailure) = await relayStart.value
                relayAccess = access
                syncRelayListener = relayListener
                children.append((.relay, relayListener))
                if let initialFailure { health[.relay] = initialFailure }
            }
            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)"
                }
                var inactive = Set(failed.keys)
                // The relay's first connect may have lost its launch race (it keeps
                // redialing) — or won it between the miss and this commit, in which case
                // the recovery callback was already superseded by this snapshot.
                if let relayListener = syncRelayListener, health[.relay] != nil {
                    if relayListener.isConnected {
                        health[.relay] = nil
                    } else {
                        inactive.insert(.relay)
                    }
                }
                syncActiveTransports = Set(transports).subtracting(inactive)
                syncTransportHealth = health
                syncRunning = true
                meshDiag(.peer, "mesh service started",
                    detail: syncActiveTransports.map(\.label).sorted().joined(separator: ", "))
                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 Covalence Relay (X25519 proof-of-possession, mesh P2) and open
    /// this Mac's room socket. The first connect is awaited so the healthy case commits
    /// green immediately — but a miss (relay unreachable, launch-time network churn: the
    /// dial deliberately races LAN/tailnet bring-up) comes back as the status-row string
    /// instead of a throw, and the listener still joins the composite and keeps redialing
    /// from `start()`. One launch-time blip used to brand the relay "Not reachable" until
    /// the next restart, with the very next dial attempt never coming.
    private func startRelayListener(identity: DeviceIdentity) async -> (RelayAccess, RelayListener, String?) {
        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))
        })
        listener.statusChanged = { [weak self, weak listener] failure in
            Task { @MainActor [weak self] in
                // Identity-checked so a stopped listener's in-flight dial can't stomp the
                // row of its replacement from a later start pass.
                guard let self, let listener, self.syncRelayListener === listener else { return }
                self.relayStatusChanged(failure)
            }
        }
        do {
            try await listener.connect()
            meshDiag(.peer, "relay room socket connected (first dial)")
            return (access, listener, nil)
        } catch {
            meshDiag(.peer, level: .warning, "relay first connect failed",
                detail: error.localizedDescription)
            return (access, listener, "Relay: \(error.localizedDescription) Retrying…")
        }
    }

    /// Live relay room-socket health from `RelayListener`'s redial loop: each failed dial
    /// refreshes the Settings status row, and a (re)connect clears it and restores the
    /// method to the active set.
    private func relayStatusChanged(_ failure: String?) {
        if let failure {
            syncTransportHealth[.relay] = "Relay: \(failure) Retrying…"
            syncActiveTransports.remove(.relay)
            // Every failed dial lands in the diagnostics log (and unified logging): a relay
            // that "keeps retrying" used to be invisible outside the Settings row, which
            // made a stalled redial loop indistinguishable from a failing relay.
            meshDiag(.peer, level: .warning, "relay dial failed", detail: failure)
        } else {
            syncTransportHealth[.relay] = nil
            syncActiveTransports.insert(.relay)
            meshDiag(.peer, "relay room socket connected")
        }
    }

    /// 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/COVALENCE_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
    }

    /// Progress of the auto-provision → auto-join choreography (`provisionAndJoinRunner`), for the
    /// Settings pool section and any composer affordance to render.
    public enum RunnerProvisionPhase: Equatable, Sendable {
        case idle
        /// Provision sent; waiting for the host container's first heartbeat.
        case booting
        /// Host ready; pulling the one-shot pairing payload and completing the mesh join.
        case pairing
        /// The runner is in the mesh (or already was).
        case joined
        /// Terminal failure with a user-facing reason.
        case failed(String)
    }

    public private(set) var runnerProvisionPhase: RunnerProvisionPhase = .idle
    /// Single-flight guard: concurrent callers (the Settings button and an auto-provision kicked
    /// from the composer / a mesh dispatch) share one in-flight run rather than racing two boots.
    private var runnerProvisionTask: Task<Bool, Never>?

    /// When THIS device first observed each paired `.cloud` peer disconnected (cleared on
    /// reconnect). A runner's identity is minted per boot, so a disconnected cloud peer can
    /// never come back — once the stamp ages past ``cloudRunnerRevokeGrace`` its tombstone is
    /// revoked, which stops the dialer's reconnect loop (and with it the relay-presence wake
    /// nudges against a room no boot will ever serve again). `lastSeenAt` can't drive this: it
    /// only updates on connect, so a long-lived connection that drops would look instantly old.
    private var cloudPeerDisconnectedSince: [String: Date] = [:]
    /// Pending delayed prune of aged cloud tombstones; single-flight, re-armed while any
    /// disconnected cloud peer is still inside its grace window.
    private var cloudTombstonePruneTask: Task<Void, Never>?
    /// How long a disconnected cloud runner identity is kept before its tombstone is revoked —
    /// long enough that a transient relay drop reconnects first (reconnect backoff caps at
    /// 30 s), short enough that dead identities stop being dialed promptly.
    static let cloudRunnerRevokeGrace: TimeInterval = 300

    /// Whether a Covalence Cloud runner is presence-live on the mesh right now (a connected
    /// `.cloud` peer). Drives "is a runner already up?" decisions in the auto-provision paths.
    public var hasLiveCloudRunnerPeer: Bool {
        runnerPresenceByHost.contains { hostID, presence in
            guard hostID != castOriginID, presence.kind == .cloud else { return false }
            return meshPeers.first(where: { $0.deviceID == hostID })?.connected ?? false
        }
    }

    /// Whether `hostID` is a Covalence Cloud runner (a `.cloud` presence peer, never this device).
    /// The sidebar folds only cloud-runner sessions under their origin project; a Covalence chat
    /// that lands on a peer Mac keeps that Mac's own project section.
    func isCloudRunnerHost(_ hostID: String) -> Bool {
        hostID != castOriginID && runnerPresenceByHost[hostID]?.kind == .cloud
    }

    /// How many Covalence Cloud runner peers are presence-live right now. Each runner is its own
    /// `.cloud` mesh peer, so this is the current fleet size — the demand-driven scale-up boots one
    /// more (up to `RunnerSettings.maxRunners`) only while this is below the cap.
    public var liveCloudRunnerCount: Int {
        runnerPresenceByHost.reduce(into: 0) { count, entry in
            let (hostID, presence) = entry
            guard hostID != castOriginID, presence.kind == .cloud else { return }
            if meshPeers.first(where: { $0.deviceID == hostID })?.connected ?? false { count += 1 }
        }
    }

    /// Ensure one more Covalence Cloud runner is in the mesh, hands-free
    /// (docs/COVALENCE_RUNNER.md §3). First choice is **adoption**: a live runner whose mesh
    /// identity nobody has paired (a wake-hook reboot's fresh identity) is joined in place.
    /// Otherwise provision one more runner (demand-driven scale-up, up to
    /// `RunnerSettings.maxRunners`) → wait for that runner's first heartbeat → pull a one-time
    /// pairing payload (retrying while nucleicd's control endpoint comes up) → `joinMesh` with it.
    /// Single-flight, so the Settings "Provision" button and an auto-provision from the composer
    /// collapse into one run. Returns true once the new runner is in the mesh;
    /// `runnerProvisionPhase` carries progress/failure for the UI.
    @discardableResult
    public func provisionAndJoinRunner() async -> Bool {
        if let existing = runnerProvisionTask { return await existing.value }
        let task = Task { [weak self] () -> Bool in
            guard let self else { return false }
            return await self.runProvisionAndJoin()
        }
        runnerProvisionTask = task
        let result = await task.value
        runnerProvisionTask = nil
        return result
    }

    private func runProvisionAndJoin() async -> Bool {
        let client = runnerPoolClient()
        // Adopt before growing: a `ready` runner whose mesh identity this device has never
        // paired is an orphan — a wake-hook reboot rotates the runner's identity and nothing
        // completes the join for it, so it sits "online" in the pool yet unreachable for
        // dispatch, holding a slot. Joining it in place both heals it and avoids booting yet
        // another runner beside it.
        if let orphan = await unjoinedReadyRunner(client) {
            runnerProvisionPhase = .pairing
            return await pairAndJoinRunner(client, instance: orphan)
        }
        runnerProvisionPhase = .booting
        let instanceId: String
        do {
            guard let booted = try await client.provision() else {
                // The pool is already at the `maxRunners` cap — not an error, just no room to grow.
                runnerProvisionPhase = .failed(
                    "The Covalence Cloud runner fleet is at its maximum. Raise “Maximum runners” in "
                    + "Settings to add another.")
                return false
            }
            instanceId = booted
        } catch {
            runnerProvisionPhase = .failed(error.localizedDescription)
            return false
        }
        guard await waitForRunnerReady(client, instance: instanceId) else {
            runnerProvisionPhase = .failed(
                "The runner hasn't reported ready yet — it may still be booting. Try again in a moment.")
            return false
        }
        runnerProvisionPhase = .pairing
        return await pairAndJoinRunner(client, instance: instanceId)
    }

    /// The first live (`ready`, fresh-heartbeat) runner whose reported mesh identity (`roomId`,
    /// carried on its heartbeats) isn't a device this Mac has paired — an orphan to adopt
    /// instead of growing the fleet. Nil when every live runner is already in the mesh, the
    /// status fetch fails, or the Worker predates the roomId field.
    private func unjoinedReadyRunner(_ client: RunnerPoolClient) async -> String? {
        guard let status = try? await client.status() else { return nil }
        let known = Set(pairedDevices.map(\.deviceID))
        return status.instances.first { instance in
            guard instance.state == "ready", !instance.stale,
                  let room = instance.roomId else { return false }
            return room != syncHostID && !known.contains(room)
        }?.instanceId
    }

    /// Poll the pool until the given runner slot's first heartbeat lands (`ready`, not stale). A
    /// cached image boots in seconds; a cold pull can take a couple of minutes.
    private func waitForRunnerReady(_ client: RunnerPoolClient, instance: String) async -> Bool {
        for _ in 0..<40 {
            if let status = try? await client.status(),
               let runner = status.instances.first(where: { $0.instanceId == instance }),
               runner.state == "ready", !runner.stale {
                return true
            }
            try? await Task.sleep(for: .seconds(3))
        }
        return false
    }

    /// Pull a runner's one-shot pairing payload (retrying while nucleicd's control endpoint comes
    /// up behind the first heartbeat) and complete the mesh join with it. Returns true on join.
    private func pairAndJoinRunner(_ client: RunnerPoolClient, instance: String) async -> Bool {
        for _ in 0..<10 {
            do {
                let code = try await client.pairingCode(instance: instance)
                do {
                    try await joinMesh(with: code)
                    await pruneStaleCloudRunnerPeers()
                    runnerProvisionPhase = .joined
                    return true
                } catch {
                    runnerProvisionPhase = .failed(
                        "Got the pairing code, but auto-join failed (\(error.localizedDescription)).")
                    return false
                }
            } catch RunnerPoolClient.Error.pairingUnavailable(let status) where status == 410 {
                // Only a runner on an older image burns its single pairing code (current
                // nucleicd mints a fresh one per pull). Nothing on this Mac consumed it (the
                // choreography is single-flighted), so report the wedge honestly — the old
                // "some cloud runner is live ⇒ joined" shortcut masked exactly this failure,
                // leaving an unjoined runner holding a slot.
                runnerProvisionPhase = .failed(
                    "This runner's pairing code was already used and its image is too old to "
                    + "mint another. Stop runners, then provision again for a fresh boot.")
                return false
            } catch {
                try? await Task.sleep(for: .seconds(2))
            }
        }
        runnerProvisionPhase = .failed(
            "Couldn't fetch a pairing code from the runner — it may still be coming up. "
            + "Provision again in a moment; the fleet adopts the same runner rather than adding another.")
        return false
    }

    /// Per-boot identity rotation (docs/COVALENCE_RUNNER.md §3: the runner's Noise private key
    /// stays inside its ephemeral container) means a runner that stops or is rebooted leaves its
    /// prior identity an offline tombstone that can never reconnect. Revoke every disconnected
    /// `.cloud` peer that has been observed disconnected past ``cloudRunnerRevokeGrace`` — this
    /// is what stops the dialer's reconnect loop against dead rooms. A currently-connected peer
    /// is never revoked, and the grace window (measured from when THIS device first saw the
    /// disconnect, and from `pairedAt` for a just-joined runner whose first dial is still in
    /// flight) protects transient relay drops. The old "spare the newest tombstone" policy kept
    /// exactly one dead identity dialing forever — the perpetual wake-nudge source.
    private func pruneStaleCloudRunnerPeers() async {
        // Drop bookkeeping for identities no longer paired (revoked elsewhere).
        let paired = Set(pairedDevices.map(\.deviceID))
        cloudPeerDisconnectedSince = cloudPeerDisconnectedSince.filter { paired.contains($0.key) }
        let connected = inboundConnectedDeviceIDs
            .union(Set(meshPeers.filter(\.connected).map(\.deviceID)))
        let cutoff = now().addingTimeInterval(-Self.cloudRunnerRevokeGrace)
        for device in pairedDevices where device.kind == .cloud {
            guard !connected.contains(device.deviceID),
                  device.pairedAt < cutoff,
                  let since = cloudPeerDisconnectedSince[device.deviceID], since < cutoff
            else { continue }
            cloudPeerDisconnectedSince.removeValue(forKey: device.deviceID)
            meshDiag(.peer, "revoking rotated cloud runner identity",
                detail: meshHostLabel(device.deviceID))
            await revokeDevice(device.deviceID)
        }
    }

    /// Fold a presence update into the cloud-tombstone bookkeeping: stamp a `.cloud` peer the
    /// first time it's observed disconnected, clear it on reconnect, and arm the delayed prune
    /// while any stamp is outstanding. Called from the peer-presence stream.
    private func trackCloudRunnerDisconnects(_ peers: [PeerClient.PeerPresence]) {
        var outstanding = false
        for peer in peers where peer.kind == .cloud {
            if peer.connected {
                cloudPeerDisconnectedSince.removeValue(forKey: peer.deviceID)
            } else {
                if cloudPeerDisconnectedSince[peer.deviceID] == nil {
                    cloudPeerDisconnectedSince[peer.deviceID] = now()
                }
                outstanding = true
            }
        }
        if outstanding { scheduleCloudRunnerTombstonePrune() }
    }

    /// Arm the delayed tombstone prune (single-flight). Fires just past the grace window so the
    /// prune's age check passes on the stamps that scheduled it; re-arms while any disconnected
    /// cloud peer remains inside its grace (its stamp will be old enough on the next pass).
    private func scheduleCloudRunnerTombstonePrune() {
        guard cloudTombstonePruneTask == nil else { return }
        cloudTombstonePruneTask = Task { [weak self] in
            try? await Task.sleep(for: .seconds(Self.cloudRunnerRevokeGrace + 15))
            guard let self, !Task.isCancelled else { return }
            self.cloudTombstonePruneTask = nil
            await self.pruneStaleCloudRunnerPeers()
            if !self.cloudPeerDisconnectedSince.isEmpty {
                self.scheduleCloudRunnerTombstonePrune()
            }
        }
    }

    /// The runner-pool credential this device holds, for the post-hello mesh push
    /// (`HostMsg.runnerPoolCredential` — COVALENCE_RUNNER item 4).
    public func runnerPoolCredential() async -> WireRunnerPoolCredential? {
        guard let credential = RunnerPoolCredentialStore.load() else { return nil }
        return WireRunnerPoolCredential(
            poolID: credential.poolID, secret: credential.secret,
            url: credential.url, updatedAt: credential.updatedAt)
    }

    /// The runner's credential-mesh receiving half (COVALENCE_RUNNER §6, item 5): set by
    /// nucleicd at boot; nil on a cockpit Mac. Gates `canReceiveSealedCredentials`, the
    /// post-hello `credentialNeeded` push, and the landing of provisioned records.
    public var runnerCredentialVault: RunnerCredentialVault?

    // MARK: Intelligence delegation (COVALENCE_RUNNER §5, item 6)

    /// The mesh-wide AFM queue: connected devices that advertised `canProvideIntelligence`
    /// register as workers, and the mesh-mode provider (`DelegatedIntelligenceProvider.mesh`)
    /// submits jobs here. Idle on a host that never delegates — workers still register, which
    /// costs a dictionary entry and nothing else.
    public let meshIntelligence = MeshIntelligenceQueue()

    /// Whether this host delegates intelligence work to the mesh — set by nucleicd when
    /// `nucleic.runner.intelligenceMode == mesh`. Advertises `canDelegateIntelligence`, the
    /// flag a client checks before ever answering with `intelligenceResult` (an older host
    /// throws on the unknown `ClientMsg` tag).
    public var delegatesIntelligence = false

    /// This device's own intelligence-worker profile, advertised on outbound peer dials
    /// (`WireClientCapabilities.intelligenceProfile`). Set by the macOS app when Apple
    /// Foundation Models exist on this OS; nil (never advertised) elsewhere — including on a
    /// runner, which is exactly the host that *asks*.
    public var meshIntelligenceProfile: IntelligenceWorkerProfile?

    /// What this host asks connected devices for (`HostMsg.credentialNeeded`): the missing
    /// kinds + the sealing key. Nil when there's no vault (a Mac) or nothing is missing.
    public func credentialNeed() async -> WireCredentialNeed? {
        // A runner asks for what it's missing; it now pushes even with nothing missing, because
        // the carried sealing key is ALSO how a phone learns where to seal an API key it wants
        // to set/replace (REMOTE_AGENT_LOGIN §8). A device seals only requested kinds, so an
        // empty `kinds` solicits nothing.
        if let vault = runnerCredentialVault {
            let key = vault.sealingPublicKey
            guard !key.isEmpty else { return nil }
            return WireCredentialNeed(kinds: vault.missingKinds(), sealingPublicKey: key)
        }
        // A cockpit Mac never *asks* (it's the holder — kinds stays empty so no peer answers),
        // but it advertises its device sealing key so a phone can push an API key at it; the
        // sealed box lands through `CredentialProvider.landUpdate` into the Keychain stores.
        let deviceID = syncHostID ?? syncHostName
        let key = await Task.detached(priority: .userInitiated) {
            // Keychain read (`SecItemCopyMatching`) — off the main actor, like every other
            // credential read (the Settings beach-ball lesson).
            CredentialProvider(deviceID: deviceID).sealingPublicKey
        }.value
        guard !key.isEmpty else { return nil }
        return WireCredentialNeed(kinds: [], sealingPublicKey: key)
    }

    /// A connected device offered AFM execution (COVALENCE_RUNNER §5) — enroll it in the mesh
    /// intelligence queue. Harmless on a host that never delegates (nothing ever submits).
    public func intelligenceWorkerConnected(
        deviceID: String, connectionID: String, profile: IntelligenceWorkerProfile,
        send: @escaping @Sendable (WireIntelligenceRequest) async -> Void
    ) async {
        await meshIntelligence.registerWorker(
            deviceID: deviceID, connectionID: connectionID, profile: profile, send: send)
    }

    /// The worker's registering connection closed — withdraw it (matched on connectionID, so a
    /// deduped stale socket can't unregister its live replacement).
    public func intelligenceWorkerDisconnected(deviceID: String, connectionID: String) async {
        await meshIntelligence.unregisterWorker(deviceID: deviceID, connectionID: connectionID)
    }

    /// A worker answered a delegated intelligence job (`ClientMsg.intelligenceResult`) — the
    /// queue correlates by request id and drops stale/unknown ids.
    public func receiveIntelligenceResult(
        _ result: WireIntelligenceResult, from deviceID: String
    ) async {
        await meshIntelligence.receiveResult(result, from: deviceID)
    }

    /// Land credential records a device sealed to this runner's key (COVALENCE_RUNNER §6).
    /// A record that fails to open (sealed to a stale key, replayed AAD) or has no landing
    /// path is skipped — the rest still land; the device re-answers a future `credentialNeeded`.
    public func receiveCredentialProvision(
        _ envelope: SealedCredentialEnvelope, from deviceID: String
    ) async {
        if let vault = runnerCredentialVault {
            var landedAny = false
            for record in envelope.records {
                do {
                    let path = try vault.land(record)
                    credentialMirrorDigests[record.kind] = vault.digest(for: record.kind)  // seed: don't mirror what we just got
                    landedAny = true
                    print("nucleicd: credential \(record.kind.rawValue) landed at \(path) (from \(deviceID.prefix(8)))")
                    if record.kind == .githubToken, vault.adoptGitHubTokenFallback() {
                        print("nucleicd: adopted landed github token as the Managed Git credential (token mode)")
                    }
                } catch {
                    print("nucleicd: credential \(record.kind.rawValue) failed to land: \(error)")
                }
            }
            // The implicit ack for a phone-set key (REMOTE_AGENT_LOGIN §8): the sender (and
            // everyone else) sees the provider row flip via the refreshed status push.
            if landedAny { await rebroadcastAgentAuthStatus() }
            return
        }
        // Cockpit Mac: a phone set an API key (REMOTE_AGENT_LOGIN §8). Boxes are sealed to this
        // device's own sealing key; `landUpdate` opens them and lands into the Keychain stores
        // the Settings UI reads (plus the newest-wins OAuth landings the mirror-back shares).
        // Keychain writes hop off the main actor, like every other credential touch.
        let hostDeviceID = syncHostID ?? syncHostName
        let landed = await Task.detached(priority: .userInitiated) {
            CredentialProvider(deviceID: hostDeviceID).landUpdate(envelope)
        }.value
        guard !landed.isEmpty else { return }
        print("nucleic: credential \(landed.map(\.rawValue).joined(separator: ", ")) set by device \(deviceID.prefix(8))")
        await rebroadcastAgentAuthStatus()
    }

    /// Devices that shared a `CredentialManifest` (COVALENCE_RUNNER §6): their sealing key (so
    /// the runner can mirror a rotation back) and the refresh leases they gossiped. Runner side.
    private struct DeviceCredentialRecord {
        var sealingKey: Data?
        var leases: [CredentialRefreshLease]
    }
    private var deviceCredentialManifests: [String: DeviceCredentialRecord] = [:]
    /// Per-kind digest of the credential last mirrored back, so only an actual rotation pushes
    /// a `credentialUpdate` (a device's Keychain isn't re-written every sweep).
    private var credentialMirrorDigests: [CredentialKind: String] = [:]

    /// A device told the runner what it holds + its sealing key (`ClientMsg.credentialManifest`).
    /// Record it so the runner can mirror a refreshed credential back, and fold its leases into
    /// the group view. The manifest's tombstones merge on EVERY host (cockpit Mac included) —
    /// riding the gossip is what lets a deletion reach a host that was offline when it happened,
    /// and what keeps a stale holder's records from being trusted before the deletion lands.
    public func receiveCredentialManifest(
        _ manifest: CredentialManifest, from deviceID: String
    ) async {
        if runnerCredentialVault != nil {
            deviceCredentialManifests[deviceID] = DeviceCredentialRecord(
                sealingKey: manifest.sealingPublicKey, leases: manifest.leases)
        }
        if !manifest.tombstones.isEmpty {
            await applyCredentialRevocation(manifest.tombstones, excludingDevice: deviceID)
        }
    }

    /// Land mesh-wide credential deletions (`ClientMsg.credentialRevoke`, manifest gossip, or
    /// a peer's `credentialRevoked` push): clear the local copies, record the tombstones, and
    /// propagate — to this host's other connected clients and to the peers it dials. A replay
    /// (nothing fresher than what's on file) is a complete no-op, which is what terminates the
    /// gossip loop.
    public func receiveCredentialRevocation(
        _ tombstones: [CredentialTombstone], from deviceID: String
    ) async {
        await applyCredentialRevocation(tombstones, excludingDevice: deviceID)
    }

    /// The local entry point (Settings ▸ "Delete on all devices"): mint the tombstone here and
    /// run the same landing/propagation path a wire revocation takes.
    public func revokeCredentialMeshWide(_ kind: CredentialKind) async {
        let stone = CredentialTombstone(
            kind: kind, deletedAt: Date(), originDeviceID: syncHostID ?? syncHostName)
        await applyCredentialRevocation([stone], excludingDevice: nil)
    }

    /// Every credential tombstone on file — the post-hello convergence push (SyncHost) and the
    /// wire projection of the local table. Keychain-adjacent reads stay off the main actor.
    public func credentialTombstones() async -> [CredentialTombstone] {
        await Task.detached(priority: .utility) {
            CredentialTombstoneStore().all()
        }.value
    }

    private func applyCredentialRevocation(
        _ incoming: [CredentialTombstone], excludingDevice: String?
    ) async {
        let hostDeviceID = syncHostID ?? syncHostName
        let vault = runnerCredentialVault
        // Record + clear off the main actor (Keychain deletes — the Settings beach-ball
        // lesson). `applyTombstones` returns only the stones that carried NEW information,
        // each absorbing the freshness of the local copy it deleted; a replay returns empty.
        let fresh = await Task.detached(priority: .userInitiated) { () -> [CredentialTombstone] in
            let applied = CredentialProvider(deviceID: hostDeviceID).applyTombstones(incoming)
            // On a runner, also remove the landed 0600 files + env vars the CLIs read
            // (the provider's stores cover the Keychain/`~` hubs; the vault covers the
            // daemon-HOME landings — deleting an absent file is harmless).
            if let vault {
                for stone in applied { vault.remove(stone.kind) }
            }
            return applied
        }.value
        guard !fresh.isEmpty else { return }
        // A deleted kind must not be "mirrored back" from a digest ghost.
        for stone in fresh { credentialMirrorDigests[stone.kind] = nil }
        print("nucleic: credential deleted mesh-wide: "
            + fresh.map(\.kind.rawValue).joined(separator: ", "))
        // Fan out: this host's other connected clients, then the peers this device dials
        // (each applies-then-propagates; replays no-op, so the wave terminates).
        await syncHost?.broadcastCredentialRevocation(fresh, excluding: excludingDevice)
        await peerClient?.pushCredentialRevocation(fresh)
        // The implicit ack: every device sees the provider row flip via the status push.
        await rebroadcastAgentAuthStatus()
    }

    /// The elected refresher for `kind` across the group (COVALENCE_RUNNER §6): arbitrate every
    /// device's gossiped leases with `CredentialRefreshLease.merged` (stable — earliest live
    /// claim wins), so at most one device advances a rotating refresh token. Nil ⇒ no live lease
    /// (the runner's own session CLI is then the de-facto refresher, and newest-wins mirror-back
    /// keeps devices coherent).
    public func credentialRefreshHolder(for kind: CredentialKind, now: Date = Date()) -> String? {
        var winner: CredentialRefreshLease?
        for record in deviceCredentialManifests.values {
            for lease in record.leases where lease.kind == kind {
                winner = CredentialRefreshLease.merged(winner, lease, at: now)
            }
        }
        return winner?.holderDeviceID
    }

    /// Mirror any credential the runner's session CLI *refreshed* this turn back to connected
    /// devices, sealed to each device's key (COVALENCE_RUNNER §6 — the `syncClaudeLoginBack`
    /// analogue). Gated on a digest change per kind, so an unchanged credential never re-writes
    /// a device's Keychain. Runner side; driven by a periodic sweep in nucleicd.
    public func mirrorRefreshedCredentials() async {
        guard let vault = runnerCredentialVault, let host = syncHost else { return }
        // Which kinds changed since the last mirror.
        var changed: [CredentialKind] = []
        for kind in RunnerCredentialVault.mirrorableKinds {
            guard let digest = vault.digest(for: kind) else { continue }
            if digest != credentialMirrorDigests[kind] {
                changed.append(kind)
                credentialMirrorDigests[kind] = digest
            }
        }
        guard !changed.isEmpty else { return }
        let connected = await host.connectedDeviceIDs()
        for deviceID in connected {
            guard let key = deviceCredentialManifests[deviceID]?.sealingKey,
                  let envelope = vault.mirrorBackEnvelope(for: changed, toSealingKey: key)
            else { continue }
            await host.pushCredentialUpdate(to: deviceID, envelope)
            print("nucleicd: mirrored \(changed.map(\.rawValue).joined(separator: ", ")) → device \(deviceID.prefix(8))")
        }
    }

    /// The credential kinds this Mac can currently provide to a runner over the mesh
    /// (COVALENCE_RUNNER §6, item 5) — what a `credentialNeeded` from a runner would be
    /// answered with. Drives the Settings "shared with runners" list.
    ///
    /// Async because it reads the local Keychain: `SecItemCopyMatching` is a synchronous
    /// IPC to securityd that can stall for seconds (or indefinitely, waiting on an unlock).
    /// Settings once beach-balled inside this very read when it ran on the main thread
    /// during window layout (spin report, Jul 13) — so the Keychain work hops off the main
    /// actor and only the result comes back.
    public func credentialKindsSharedWithRunners() async -> [CredentialKind] {
        let hostID = syncHostID
        return await Task.detached(priority: .userInitiated) {
            let deviceID = hostID ?? HostIdentityStore.loadOrCreate().hostID
            return CredentialProvider(deviceID: deviceID).manifest().records.map(\.kind)
        }.value
    }

    /// A runner mirrored a rotated credential back and it was written into this Mac's source of
    /// truth (`credentialUpdate` — COVALENCE_RUNNER §6). Log for now; a future slice refreshes
    /// any live sandbox seeding. Hop to the actor so callers can be nonisolated closures.
    private func noteCredentialUpdateLanded(_ kinds: [CredentialKind]) {
        lastError = nil  // touch the actor; no user-facing surface yet
        print("nucleic: credential update landed from runner: \(kinds.map(\.rawValue).joined(separator: ", "))")
    }

    /// Merge a runner-pool credential shared over the mesh (item 4): newest `updatedAt` wins —
    /// a rotation (re-enroll) invalidates older secrets, so freshest is always the live one.
    /// Persists and hands the revision to any in-flight client so it takes effect immediately.
    public func mergeRunnerPoolCredential(_ incoming: WireRunnerPoolCredential) async {
        let current = RunnerPoolCredentialStore.load()
        guard incoming.updatedAt > (current?.updatedAt ?? .distantPast) else { return }
        let credential = RunnerPoolCredential(
            poolID: incoming.poolID, secret: incoming.secret,
            url: incoming.url, updatedAt: incoming.updatedAt)
        RunnerPoolCredentialStore.save(credential)
        await runnerPool?.adopt(credential)
    }

    /// 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
        cloudTombstonePruneTask?.cancel()
        cloudTombstonePruneTask = nil
        cloudPeerDisconnectedSince = [:]
        tailnetAddressTask?.cancel()
        tailnetAddressTask = nil
        transcriptSyncTicker?.cancel()
        transcriptSyncTicker = nil
        transcriptSyncCoordinator = nil
        // Carbon: replication dies with the mesh; the producer keeps running (mandatory, D17
        // — a mesh of one still snapshots locally).
        carbonService.deactivateReplication()
        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 = [:]
        if syncRunning { meshDiag(.peer, "mesh service stopped") }
        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/COVALENCE_RUNNER.md §3).
                selfKind: meshSelfKind,
                // Offer this device as an AFM executor to peers it dials (COVALENCE_RUNNER §5)
                // — how a Mac serves a runner's mesh intelligence queue. Nil ⇒ never advertised.
                intelligenceProfile: meshIntelligenceProfile,
                // Carbon replication (CARBON_SHARDING §5.1): every Mac/runner mirrors
                // (mandatory, D17); runners are always-on ⇒ pinned into replica sets (D4).
                mirrorsCarbon: true,
                alwaysOn: meshSelfKind == .cloud),
            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)
        }
        // Adopt a runner-pool credential a peer shares (COVALENCE_RUNNER item 4), newest wins.
        await client.setRunnerPoolCredentialHandler { [weak self] credential in
            await self?.mergeRunnerPoolCredential(credential)
        }
        // Credential mesh, device side (COVALENCE_RUNNER §6, item 5): answer a runner peer's
        // requests from this Mac's own Keychain/store credentials, and land a mirrored-back
        // rotation. Reads happen on the credential provider (host id = provenance).
        let credentialDeviceID = identity.hostID
        await client.setCredentialHandlers(
            manifest: {
                CredentialProvider(deviceID: credentialDeviceID).manifest()
            },
            seal: { need in
                CredentialProvider(deviceID: credentialDeviceID).sealedEnvelope(for: need)
            },
            landUpdate: { [weak self] envelope in
                let landed = CredentialProvider(deviceID: credentialDeviceID).landUpdate(envelope)
                if !landed.isEmpty {
                    await self?.noteCredentialUpdateLanded(landed)
                }
            },
            landRevocation: { [weak self] tombstones, deviceID in
                // A peer landed a mesh-wide deletion — clear our copy + propagate onward
                // (the apply path no-ops on a replay, so gossip loops terminate).
                await self?.receiveCredentialRevocation(tombstones, from: deviceID)
            })
        // Execute delegated intelligence jobs a runner peer pushes (COVALENCE_RUNNER §5):
        // render the shared template on this Mac's provider (AFM in the app; the heuristic
        // default answers with an error result, so the runner falls back immediately).
        await client.setIntelligenceExecutor { [weak self] request in
            let provider: any IntelligenceProviding =
                await self?.intelligence ?? HeuristicIntelligence()
            return await IntelligenceDelegateExecutor.execute(request, provider: provider)
        }
        // Mesh casting: subscribe every dialed peer with cursors covering ALL origins we hold
        // (any holder tops up any origin), and route what arrives into the single ingest path
        // (dedup + re-broadcast of fresh rows only).
        await client.setCastHandlers(
            cursors: { [weak self] in
                await self?.castSubscribeCursors() ?? []
            },
            ingest: { [weak self] event, deviceID in
                await self?.ingestPeerCasts(event, from: deviceID)
            })
        // Transcript revert/undo (full-transcript mesh sync): when a peer reverts a session, drop
        // the reverted tail from any mirror we hold for it so a later open doesn't re-seed it.
        await client.setRevertHandler { [weak self] sessionID, throughSeq, revertEpoch, deviceID in
            await self?.applyPeerRevert(
                sessionID, throughSeq: throughSeq, revertEpoch: revertEpoch, from: deviceID)
        }
        // Mesh dispatch: route peers' `chatStarted` acks into the requestID correlation map.
        await client.setChatStartedHandler { [weak self] outcome in
            await self?.handleChatStarted(outcome)
        }
        // Dispatch-time cloning: route peers' `projectCreated` outcomes likewise.
        await client.setProjectCreatedHandler { [weak self] outcome in
            await self?.handleProjectCreated(outcome)
        }
        // 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
        // Mesh dispatch: (re)announce this host's presence card now the mesh is up.
        publishRunnerPresence()
        // 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
        // Carbon replication (CARBON_SHARDING §8.2): bind the sync coordinator's transport to
        // this peer client, and route peers' head pushes into it. Relay peers are relay-class
        // for the §8.4 payload policy; bulk reconcile candidates stay LAN/tailnet-only.
        carbonService.activateReplication(transport: CarbonSyncCoordinator.PeerTransport(
            fetchHeads: { [weak client] peer, project in
                await client?.fetchCarbonHeads(projectUUID: project, from: peer)
            },
            fetchManifests: { [weak client] peer, project, ids in
                await client?.fetchCarbonManifests(ids: ids, projectUUID: project, from: peer) ?? [:]
            },
            fetchShards: { [weak client] peer, project, ids in
                await client?.fetchCarbonShards(ids: ids, projectUUID: project, from: peer) ?? [:]
            },
            transportClass: { [weak self] peer in
                await self?.carbonTransportClass(of: peer) ?? .direct
            }))
        await client.setCarbonHeadObserver { [weak self] head, deviceID in
            await self?.handleCarbonHeadPush(head, from: deviceID)
        }
        peerPresenceTask = Task { [weak self] in
            for await peers in presenceStream {
                guard let self, !Task.isCancelled else { break }
                self.recordMeshPeerChanges(from: self.meshPeers, to: peers)
                self.meshPeers = peers
                // Fold the runner's live sessions under their origin project and warm the cache for
                // the next cold start (items 1 & 4).
                self.refreshCovalenceMirrors()
                // Rotated-identity cleanup: a disconnected `.cloud` peer can never come back
                // (per-boot identity rotation), so stamp it and revoke after the grace window —
                // otherwise the dialer's reconnect loop wake-nudges its dead room forever.
                self.trackCloudRunnerDisconnects(peers)
                await self.reconcileTranscriptSync()
                await self.reconcileCarbonSync()
            }
        }
        // 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()
                await self.reconcileCarbonSync()
            }
        }
        // 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 }
                // Kick the Covalence room socket to redial now if it's down: a cellular/WAN
                // device reaches this Mac only while it's holding the relay room, so coming
                // back online must not leave the room hostless for the redial backoff (up to
                // 30s) or the watchdog interval.
                self.syncRelayListener?.networkPathChanged()
                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,
                    revertEpoch: summary.revertEpoch,
                    archived: summary.archived, ownerDeviceID: peer.deviceID))
            }
        }
        guard !refs.isEmpty else { return }
        await coordinator.sync(sessions: refs, candidates: candidates)
    }

    // MARK: - Carbon (docs/CARBON_SHARDING.md §15 phase 3 — producer drive + replication glue)

    /// Start the Carbon producer loop (idempotent). Carbon is mandatory (D17): this runs from
    /// launch regardless of mesh state — local snapshots alone power the restore browser and
    /// bound the crash-loss window; replication joins in when the peer client starts.
    func startCarbonProducer() {
        guard carbonTicker == nil else { return }
        carbonService.onHeadAdvanced = { [weak self] head in
            // §8.2 step 1: push every locally produced head to connected mirror-capable
            // clients; mirrors pull-fetch what they lack.
            self?.broadcast(.carbonHeadAdvanced(head))
        }
        carbonService.onLockLedgerAdvanced = { [weak self] head in
            // §17.5: push every lock-ledger append; members chain-sync + re-fold.
            self?.broadcast(.carbonLockLedgerAdvanced(head))
        }
        carbonTicker = Task { [weak self] in
            while !Task.isCancelled {
                try? await Task.sleep(for: .seconds(10))
                guard let self, !Task.isCancelled else { break }
                await self.carbonService.tick(sessions: await self.carbonSessionInputs())
            }
        }
    }

    /// The live sessions the producer may capture this pass: worktree-backed, non-terminal,
    /// non-archived, and quiesced — never mid-transfer (§6: the transfer owns the worktree).
    private func carbonSessionInputs() async -> [CarbonService.SessionInput] {
        var inputs: [CarbonService.SessionInput] = []
        for (id, controller) in controllers {
            guard !transferringSessions.contains(id) else { continue }
            let session = await controller.session
            guard !session.status.isTerminal, !session.archived,
                let worktreePath = session.worktreePath, !worktreePath.isEmpty,
                let branch = session.branch,
                let project = projects.first(where: { $0.id == session.projectID }),
                let input = carbonProjectInput(for: project)
            else { continue }
            inputs.append(CarbonService.SessionInput(
                sessionID: id.rawValue, worktreePath: worktreePath, branch: branch,
                baseSHA: session.baseSHA, project: input,
                nativeTranscriptURL: await controller.nativeTranscriptURL(),
                backendSessionID: session.backendSessionID))
        }
        return inputs
    }

    /// A project's Carbon identity: `ProjectID.rawValue` is a UUID string for every project
    /// this app created; a legacy non-UUID id derives a stable UUID from its SHA-256 so the
    /// project still enrolls under a consistent key.
    private func carbonProjectInput(for project: Project) -> CarbonService.ProjectInput? {
        let uuid = UUID(uuidString: project.id.rawValue)
            ?? CarbonService.derivedUUID(fromLegacyID: project.id.rawValue)
        return CarbonService.ProjectInput(
            uuid: uuid, rootPath: project.rootPath, defaultBranch: project.defaultBranch.value)
    }

    /// The Carbon Viewer's data source (spec §12): what this Mac holds plus what gossip says the
    /// mesh holds, scoped to the members that can actually mirror (Macs + runners, never phones).
    /// Read-only and on demand — the viewer polls it, it never drives production.
    public func carbonStoreSnapshot() async -> CarbonStoreSnapshot {
        await carbonService.storeSnapshot(
            peers: meshPeers.filter { $0.kind.canOwnSessions }.map(\.deviceID))
    }

    /// The display name for a Carbon project UUID — the store keys by UUID, the viewer shows
    /// names. nil for a project this Mac no longer has (evicted, or held purely as a mirror).
    public func carbonProjectName(for uuid: UUID) -> String? {
        projects.first { carbonProjectInput(for: $0)?.uuid == uuid }?.name
    }

    /// Reconcile Carbon chains against reachable peers (heads are state — §8.2 step 4). Bulk
    /// backfill stays off the metered relay, exactly like transcript sync.
    private func reconcileCarbonSync() async {
        let candidates = meshPeers
            .filter { $0.connected && $0.transport != nil && $0.transport != .relay }
            .map(\.deviceID)
        guard !candidates.isEmpty else { return }
        await carbonService.reconcile(peers: candidates)
    }

    /// A peer pushed a Carbon head (§8.2 step 1) — chain-sync it, with the other connected
    /// members as fallthrough holders.
    private func handleCarbonHeadPush(_ head: CarbonHead, from deviceID: String) async {
        let candidates = meshPeers
            .filter { $0.connected && $0.deviceID != deviceID }
            .map(\.deviceID)
        await carbonService.observePeerHead(head, from: deviceID, candidates: candidates)
        // Newly-synced content may satisfy a pending release fence (§17.4): a lock shown
        // `releasing — syncing files` clears the moment its heads land here.
        await lockManager.recheckFences()
    }

    /// The §8.4 transport class of a live peer connection (relay = metered, size-limited).
    private func carbonTransportClass(of peer: String) -> CarbonSyncCoordinator.TransportClass {
        meshPeers.first(where: { $0.deviceID == peer })?.transport == .relay ? .relay : .direct
    }

    /// 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)
        // And its mirrored casts (mesh casting) — we no longer re-serve its channels either.
        try? await castStore?.removeCasts(origin: 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
            try? await castStore?.removeCasts(origin: mac.deviceID)  // and its mirrored casts
        }
        await peerClient?.reconcile()
        await meshPeersChanged()
    }

    private func refreshPairedDevices() async {
        let previousInbound = inboundConnectedDeviceIDs
        pairedDevices = (await syncHost?.pairedDevices() ?? []).sorted { $0.pairedAt < $1.pairedAt }
        inboundConnectedDeviceIDs = await syncHost?.connectedDeviceIDs() ?? []
        recordInboundDeviceChanges(from: previousInbound, to: inboundConnectedDeviceIDs)
    }

    /// Diagnostics: an inbound device (an iPhone/iPad) connected or dropped — the mesh events the
    /// peer path misses. `recordMeshPeerChanges` only sees the *outbound* Mac dialer, but a phone
    /// never dials-out and never casts a `runner.presence` card: it only ever connects **inbound**
    /// (it dials us — SYNC_PROTOCOL §3.2). So without this its comings and goings never reach the
    /// Covalence Mesh Viewer log at all. Scoped to non-session-owning kinds (phones): a Mac peer's
    /// connect/disconnect is already logged from the dialer's presence stream, and double-logging an
    /// inbound+outbound Mac would just be noise. Runs on the main actor alongside `meshDiag`.
    private func recordInboundDeviceChanges(from old: Set<String>, to new: Set<String>) {
        guard old != new else { return }
        let byID = Dictionary(
            pairedDevices.map { ($0.deviceID, $0) }, uniquingKeysWith: { a, _ in a })
        func isPhone(_ id: String) -> Bool { byID[id]?.kind.canOwnSessions == false }
        func label(_ id: String) -> String {
            let device = byID[id]
            let name = device?.label.isEmpty == false ? device!.label : String(id.prefix(8))
            return "\(name) (\(device?.kind.displayName ?? "device"))"
        }
        for id in new.subtracting(old) where isPhone(id) {
            meshDiag(.peer, "device connected: \(label(id))")
        }
        for id in old.subtracting(new) where isPhone(id) {
            meshDiag(.peer, level: .warning, "device disconnected: \(label(id))")
        }
    }

    /// Settings ▸ Covalence ▸ "Reset Covalence…": wipe every trace of this Mac's local mesh config —
    /// the debugging escape hatch when the mesh is stuck in a state no single revoke/leave fixes.
    /// Broader than `leaveMesh()` (which only forgets sibling Macs): this forgets paired phones
    /// too and mints a fresh host identity, so it's the one action that recovers from a corrupted
    /// pairing store or a stale identity a peer refuses to talk to. Covalence is mandatory, so it
    /// restarts on the fresh identity afterward — every existing pairing QR/relay room is invalidated
    /// regardless, so this is a clean re-mint (fresh identity, empty roster), not a silent bounce.
    public func resetCovalenceMeshConfig() async {
        await stopSyncServer()
        for device in pairedDevices {
            await mirrorStore.removeSource(device.deviceID)
            try? await castStore?.removeCasts(origin: device.deviceID)
        }
        pairedDevices = []
        let pairedDeviceStoreURL = transcriptsDir.deletingLastPathComponent()
            .appendingPathComponent("paired-devices.json")
        try? FileManager.default.removeItem(at: pairedDeviceStoreURL)
        HostIdentityStore.reset()
        RelayRoomCredentialStore.clear()
        syncHostID = nil
        // Covalence is mandatory, so bring it straight back up on the fresh identity — there's no
        // longer a toggle for the user to turn it back on.
        await startSyncServer()
    }

    /// 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)
    }

    // MARK: - Account-level synced settings (SyncedSettings)

    /// The account-level synced settings this host holds, backed by `UserDefaults` so the value
    /// survives a restart and the macOS Settings toggle, the sync layer, and this bridge all read
    /// the one source of truth (`SyncedSettings.resolveApprovalsViaCloudKey`).
    public var syncedSettings: SyncedSettings {
        SyncedSettings(
            resolveApprovalsViaCloud:
                UserDefaults.standard.bool(forKey: SyncedSettings.resolveApprovalsViaCloudKey))
    }

    /// Apply a device's requested `SyncedSettings` change (`ClientMsg.updateSettings`): persist it,
    /// enforce the dependent host state, and broadcast the authoritative value so every device
    /// converges. The one dependent today is the relay: a lock-screen approval resolved over the
    /// Nucleic Edge can only reach this Mac if it's actually in the relay room, so turning the
    /// feature on forces the relay data-path method on (and bounces remote access to bring the
    /// listener up now); `startSyncServer` re-applies the same union on every start so it stays on.
    public func updateSyncedSettings(_ settings: SyncedSettings) async {
        UserDefaults.standard.set(
            settings.resolveApprovalsViaCloud, forKey: SyncedSettings.resolveApprovalsViaCloudKey)
        if settings.resolveApprovalsViaCloud { enforceRelayForCloudApprovals() }
        // Fan the new value out to every connected device. A device that misses this (e.g. its
        // link bounced with the relay restart below) re-reads the authoritative value from
        // `Welcome.settings` on reconnect, so convergence doesn't depend on this broadcast landing.
        broadcast(.settings(settings))
    }

    /// Force the Covalence data-path (`SyncTransportHint.relay`) into the enabled
    /// method set so this Mac joins its relay room, then bounce remote access if it's running so
    /// the relay leg comes up immediately (the listener set is read at start). A no-op when the
    /// relay is already enabled.
    private func enforceRelayForCloudApprovals() {
        var set = SyncTransportSetting.resolveEnabled()
        guard !set.contains(.relay) else { return }
        set.insert(.relay)
        SyncTransportSetting.saveEnabled(set)
        guard syncRunning else { return }
        Task { await self.stopSyncServer(); await self.startSyncServer() }
    }

    /// 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,
            revertEpoch: session.revertEpoch,
            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/COVALENCE_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,
            // Sealed-credential ingestion (COVALENCE_RUNNER §6 + REMOTE_AGENT_LOGIN §8):
            // every AppStore host lands sealed credentials now — a runner through its vault,
            // a cockpit Mac through `CredentialProvider.landUpdate` into the Keychain stores
            // (the phone's "use an API key" path). The cockpit still never *asks* for kinds
            // (its `credentialNeed` carries an empty list — the push exists to advertise the
            // sealing key).
            // Intelligence delegation (COVALENCE_RUNNER §5): a runner in mesh mode solicits
            // `intelligenceResult`s; a client never sends one at a host that didn't advertise
            // this (an older host throws on the unknown tag).
            canDelegateIntelligence: delegatesIntelligence,
            canReceiveSealedCredentials: 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,
            // Mesh casting: served whenever the metadata store persists casts (GRDB v27) —
            // both the cockpit Mac and nucleicd run that store.
            canCast: database is any CastPersisting,
            // Live composer streaming rides the cast machinery, so it's available exactly
            // when casting is.
            canStreamComposer: database is any CastPersisting,
            // Mesh dispatch: this host answers `startChat(requestID:)` with a correlated,
            // idempotent `chatStarted` (whether it *accepts* is the Settings toggle, checked
            // per request — the capability is the protocol contract, not the policy).
            canAcknowledgeDispatch: true,
            // Account-level settings sync: this host stamps `SyncedSettings` into `Welcome`,
            // applies `updateSettings`, and broadcasts `HostMsg.settings`. Every AppStore-backed
            // host (cockpit or runner) speaks it.
            canSyncSettings: true,
            // Covalence direct (SYNC §3.3): relay sessions may upgrade to a hole-punched QUIC
            // path. Platform-gated (Linux nucleicd has no Network.framework QUIC) and subject
            // to the NUCLEIC_DIRECT kill switch; the policy is re-checked per offer too.
            canDirectConnect: DirectPathSetting.platformSupported && DirectPathSetting.enabled(),
            // Remote agent sign-in (docs/REMOTE_AGENT_LOGIN.md): this host can run the
            // Claude/Codex OAuth flow for a device when the matching broker was constructed
            // (the app and nucleicd both construct them; tests may not). Which providers
            // exactly rides per-provider in the `agentAuthStatus` push.
            canBrokerAgentLogin: claudeCredentialBroker != nil || codexCredentialBroker != nil,
            // Mesh-wide credential deletion (REMOTE_AGENT_LOGIN follow-up): every
            // AppStore-backed host lands `credentialRevoke` — a runner clears its landed files,
            // a cockpit Mac its Keychain stores — records the tombstone, and fans it out.
            canRevokeCredentials: true,
            // Remote stall resolution: every AppStore-backed host runs the commands whose stalls
            // the alert reports, so it can honor a remote Kill / Keep-waiting (`resolveProcessStall`).
            canResolveProcessStall: true,
            // "Transfer…" from a Mac that doesn't own the chat: every AppStore-backed host can move
            // one of its own sessions on a peer's request — it's the local move, re-triggered.
            canTransferOnRequest: true,
            // Carbon replication (CARBON_SHARDING §8.1): every AppStore-backed host holds a
            // shard store and serves heads/manifests/shards — mandatory (D17).
            canServeCarbon: true,
            // Remote "Mark Done": every AppStore-backed host classifies dispositions, so it can
            // honor a hand-mark from a phone exactly as the sidebar's own does.
            canMarkSessionDone: 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()
    }

    // MARK: Remote agent sign-in (docs/REMOTE_AGENT_LOGIN.md)

    /// A device asked this host to run one agent sign-in. Kick the flow off in its own task and
    /// return — the caller is the connection's serial dispatch loop, and the flow suspends for
    /// minutes awaiting the browser round-trip whose `agentLoginCallback` arrives on that same
    /// loop. On success, note it where the user will see it and converge every device's Agent
    /// Accounts view.
    public func beginAgentLogin(
        _ request: WireAgentLoginBegin, from deviceID: String,
        send: @escaping @Sendable (HostMsg) async -> Void
    ) async {
        let coordinator = agentLoginCoordinator
        Task { [weak self] in
            guard await coordinator.run(request, send: send) else { return }
            await self?.remoteAgentLoginSucceeded(request.provider, from: deviceID)
        }
    }

    public func deliverAgentLoginCallback(_ callback: WireAgentLoginCallback) async {
        await agentLoginCoordinator.deliverCallback(callback)
    }

    public func cancelAgentLogin(requestID: String) async {
        await agentLoginCoordinator.cancel(requestID: requestID)
    }

    /// The host's per-provider install/auth state for the `agentAuthStatus` push — Settings ▸
    /// Agents, projected onto the wire. Claude/Codex additionally advertise whether THIS host
    /// can broker their remote sign-in (the device's "Sign in" button gates on it).
    public func providerAuthStatuses() async -> [WireProviderAuthStatus] {
        let probed = await ProviderAvailability.probeAll()
        // Which auth source backs Claude/Codex ("oauth" | "apiKey") — the row's chip, and the
        // implicit ack when a phone-set key lands (REMOTE_AGENT_LOGIN §8). Keychain/file reads
        // hop off the main actor, like every other credential touch.
        let methods = await Task.detached(priority: .userInitiated) { () -> [BackendID: String] in
            func present(_ value: String?) -> Bool {
                value.map { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } ?? false
            }
            let env = ProcessInfo.processInfo.environment
            var methods: [BackendID: String] = [:]
            if let raw = ClaudeLoginKeychain.read(), ClaudeLoginKeychain.isValidLogin(raw) {
                methods[.claudeCode] = "oauth"
            } else if present(env["ANTHROPIC_API_KEY"]) || present(ControlAPIKeyStore.load()) {
                methods[.claudeCode] = "apiKey"
            }
            let codexAuth = FileManager.default.homeDirectoryForCurrentUser
                .appendingPathComponent(".codex/auth.json").path
            if FileManager.default.fileExists(atPath: codexAuth) {
                methods[.codex] = "oauth"
            } else if present(env["OPENAI_API_KEY"]) || present(CodexControlAPIKeyStore.load()) {
                methods[.codex] = "apiKey"
            }
            return methods
        }.value
        return probed.map { status in
            let provider: AgentLoginProvider
            let canBroker: Bool
            switch status.backend {
            case .claudeCode:
                provider = .claude
                canBroker = claudeCredentialBroker != nil
            case .codex, .codexExec:
                provider = .codex
                canBroker = codexCredentialBroker != nil
            default:
                provider = AgentLoginProvider(rawValue: status.backend.rawValue)
                canBroker = false
            }
            return WireProviderAuthStatus(
                provider: provider, name: status.name,
                installed: status.installed, authenticated: status.authenticated,
                method: status.authenticated ? methods[status.backend] : nil,
                canBrokerLogin: canBroker)
        }
    }

    /// A remote device completed a provider sign-in on this host: leave a visible audit line
    /// and push the refreshed auth state so every connected device converges.
    private func remoteAgentLoginSucceeded(_ provider: AgentLoginProvider, from deviceID: String) async {
        if provider == .codex { refreshCodexSubscriptionPlan() }
        let deviceLabel = pairedDevices.first { $0.deviceID == deviceID }?.label
            ?? "A paired device"
        let providerLabel = provider == .claude ? "Claude"
            : provider == .codex ? "Codex" : provider.rawValue
        await noteToOpenSession(
            "\(deviceLabel) signed in to \(providerLabel) on this host.", icon: "key.fill")
        await rebroadcastAgentAuthStatus()
    }

    /// Re-push the per-provider auth state at every connected device — after any sign-in or
    /// key change, local or remote, so Agent Accounts UIs never go stale.
    func rebroadcastAgentAuthStatus() async {
        guard let syncHost else { return }
        await syncHost.broadcastAgentAuthStatus(await providerAuthStatuses())
    }

    /// 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 {
        // Headless runner: no operator to confirm, and the one-shot pairing code already gates
        // this join cryptographically — accept without a prompt (see `meshAutoApprovePairing`).
        if meshAutoApprovePairing { return true }
        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 reconcileSupersededMacIdentities()
        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() }
        // …and to place any chats parked in the durable mesh-dispatch queue on a freed runner.
        Task { [weak self] in await self?.retryQueuedMeshDispatches() }
    }

    /// Reconcile all mesh projections after macOS wakes (including a Dark Wake maintenance
    /// window). Live connections pull replacement session/dashboard snapshots and re-subscribe
    /// with durable cast cursors; stale LAN/relay sockets are dropped so their normal reconnect
    /// bootstrap performs the same catch-up. This makes changes accumulated between wake windows
    /// replay instead of relying on best-effort live broadcasts.
    public func reconcileMeshAfterWake() async {
        guard syncHost != nil else { return }
        // The relay room socket typically dies while asleep; redial it now so the Mac is back
        // in its room immediately for cellular/WAN devices, rather than waiting out the redial
        // backoff. A no-op when the socket survived the wake (leaves the healthy one in place).
        syncRelayListener?.networkPathChanged()
        await peerClient?.refreshAfterWake()
        await peerClient?.setLocalAddresses(await currentLocalAddresses())
        meshDiag(.peer, "mesh reconciliation requested after wake")
    }

    /// Lightweight counterpart used by Smart Sleep's Dark Wake poll. It requests durable
    /// snapshots/cast gaps without deliberately cycling healthy sockets; if macOS suspended the
    /// task, the next Dark Wake simply resumes it and performs the missed catch-up.
    public func reconcileMeshDuringDarkWake() async {
        guard syncHost != nil else { return }
        await peerClient?.refreshAfterWake(reconnectStaleTransports: false)
    }

    /// Collapse a Mac's **superseded mesh identities** (the "same Mac showing N times" bug). A Mac
    /// that re-pairs or runs "Reset Covalence…" mints a *fresh* Noise key, so its `deviceID`
    /// (`sha256(staticKey)`) changes — but its old identity never goes away on its own: the stale
    /// paired record keeps getting dialed (a perpetually-offline peer row) and its last
    /// `runner.presence` card is re-seeded from the durable cast store on every launch (a
    /// presence-only host row). Each dead identity of one physical Mac renders as its own Hosts row.
    ///
    /// When a Mac is **live** under one identity, any *other* paired Mac record that shares its host
    /// name, is offline, and was last seen no later than the live one is that same Mac's prior
    /// identity — tombstone it (mesh "join" group revoke, so gossip can't resurrect it), drop its
    /// mirrored transcripts/casts, and clear its in-memory presence card. Conservative by
    /// construction: only a *connected* identity supersedes, and only an *offline*, same-named,
    /// not-more-recently-seen sibling is removed — so two genuinely distinct Macs that happen to
    /// share a name, both online, are never merged.
    private func reconcileSupersededMacIdentities() async {
        guard syncHost != nil, !reconcilingSupersededIdentities else { return }
        let connected = inboundConnectedDeviceIDs
            .union(meshPeers.filter(\.connected).map(\.deviceID))
        guard !connected.isEmpty else { return }

        func normalizedName(_ label: String) -> String {
            label.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
        }
        let macs = pairedDevices.filter { $0.kind.canOwnSessions }
        var superseded: Set<String> = []
        for live in macs where connected.contains(live.deviceID) {
            let liveName = normalizedName(live.label)
            let liveSeen = live.lastSeenAt ?? .distantPast
            guard !liveName.isEmpty else { continue }
            for other in macs
            where other.deviceID != live.deviceID
                && !connected.contains(other.deviceID)
                && (other.lastSeenAt ?? .distantPast) <= liveSeen
                && normalizedName(other.label) == liveName {
                superseded.insert(other.deviceID)
            }
        }
        guard !superseded.isEmpty else { return }

        reconcilingSupersededIdentities = true
        defer { reconcilingSupersededIdentities = false }
        for deviceID in superseded {
            meshDiag(.peer, level: .warning,
                "superseding a stale identity of \(meshHostLabel(deviceID))",
                detail: "re-paired under a new key — dropping the leftover \(deviceID.prefix(8))")
            runnerPresenceByHost.removeValue(forKey: deviceID)
            await syncHost?.tombstone(deviceID: deviceID)
            await mirrorStore.removeSource(deviceID)
            try? await castStore?.removeCasts(origin: deviceID)
        }
        // Reconcile our own view + gossip the tombstones onward — but NOT via `meshPeersChanged`,
        // which would re-enter this method (the single-flight guard also covers that).
        await refreshPairedDevices()
        await peerClient?.reconcile()
        await gossipRosterIfChanged()
    }

    // 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 *meaningfully* differs from the last one we sent. The comparison strips freshness
    /// bookkeeping (`lastSeenAt`, `addresses.updatedAt`): every hello anywhere bumps those, and
    /// deduping on the full value re-pushed the roster mesh-wide per hello — each wave triggering
    /// the receivers' own merge → gossip → merge echo across members.
    private func gossipRosterIfChanged() async {
        guard let syncHost else { return }
        let roster = await meshRoster()
        let signature = Self.rosterSignature(roster)
        guard signature != lastGossipedRoster else { return }
        lastGossipedRoster = signature
        await syncHost.broadcastMeshRoster(roster)
        await peerClient?.pushRoster(roster)
    }

    /// The roster with its freshness metadata zeroed — what `gossipRosterIfChanged` dedups on.
    private static func rosterSignature(_ push: MeshRosterPush) -> MeshRosterPush {
        MeshRosterPush(
            members: push.members.map { m in
                MeshMember(
                    deviceID: m.deviceID, label: m.label, kind: m.kind,
                    capabilities: m.capabilities, staticPublicKey: m.staticPublicKey,
                    addresses: m.addresses.map {
                        PeerAddresses(
                            lanHint: $0.lanHint, tailnet: $0.tailnet,
                            relayRoomID: $0.relayRoomID, updatedAt: nil)
                    },
                    pairedAt: m.pairedAt, lastSeenAt: nil)
            },
            tombstones: push.tombstones)
    }

    // 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)
            },
            mirroredTranscript: { [weak self] sessionID, _, minSeq in
                await self?.mirroredTranscriptForTransfer(sessionID, minSeq: minSeq)
            })
        let importer = SessionTransferImporter(
            store: database, worktrees: worktrees, environment: environment,
            stagingRoot: transcriptsDir.deletingLastPathComponent()
                .appendingPathComponent("transfers", isDirectory: true),
            now: { [now] in now() })
        _transferImporter = importer
        return importer
    }

    /// The transfer importer's mesh-mirror fast path: this Mac's mirrored copy of an inbound
    /// session's transcript, when the mirror is complete through the offer's `lastSeq`. Events
    /// carry canonical source-assigned seqs (mirrored verbatim over the authenticated channel),
    /// so a current mirror IS the transcript the source would stream — the importer adopts it
    /// and the source skips the transcript item entirely.
    func mirroredTranscriptForTransfer(_ sessionID: SessionID, minSeq: UInt64) async -> URL? {
        guard await mirrorStore.heldSeq(sessionID) >= minSeq else { return nil }
        return await mirrorStore.url(sessionID)
    }

    /// 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 }
        meshDiag(.transfer, "recovering \(locks.count) interrupted transfer(s)")
        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] }
        let confirmed = await SessionTransferCoordinator.recoverTombstoned(
            lock: lock, session: session, project: project, channel: channel,
            store: database, worktrees: worktrees, now: now())
        await peerClient.closeTransferChannel(to: lock.peerDeviceID)
        // A move to a runner that only finished here on recovery still needs its fold record, or the
        // arrived chat shows up under a second, globe-badged copy of the project.
        if confirmed, let project {
            await noteTransferLanding(
                lock.sessionID, on: lock.peerDeviceID, originProjectID: project.id)
        }
    }

    /// 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)))
        meshDiag(.transfer, "transfer in: \"\(session.title)\" activated here")
        // Fleet activity feed: the session arrived here from its source Mac.
        recordFeedEvent(
            .transferredIn, session: session,
            counterpartHostID: session.arrivedFromDeviceID,
            counterpartHostName: arrivedSource(for: session)?.deviceName)
    }

    public func receiveTransferOffer(_ offer: TransferOffer, from deviceID: String) async -> [HostMsg] {
        meshDiag(.transfer, "transfer in: offer from \(meshHostLabel(deviceID))",
            detail: "id \(offer.transferID.prefix(8))")
        return await transferImporter().receiveOffer(offer, from: deviceID)
    }
    public func receiveTransferChunk(_ chunk: TransferChunk) async -> [HostMsg] {
        await transferImporter().receiveChunk(chunk)
    }
    public func receiveTransferCommit(_ transferID: String) async -> [HostMsg] {
        meshDiag(.transfer, "transfer in: commit received", detail: "id \(transferID.prefix(8))")
        return await transferImporter().receiveCommit(transferID)
    }
    public func receiveTransferCancel(_ transferID: String) async {
        meshDiag(.transfer, level: .warning, "transfer in: cancelled by source",
            detail: "id \(transferID.prefix(8))")
        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/COVALENCE_RUNNER.md §3).
    public func transferDestinations() -> [PeerClient.PeerPresence] {
        meshPeers.filter { $0.connected && $0.canReceiveSessionTransfer && $0.kind.canOwnSessions }
    }

    /// One entry in a chat's "Transfer…" menu: where it can go, and how to say so. `isThisMac`
    /// marks the bring-it-here destination — reachable only for a chat another device owns, since
    /// a session can't be moved to the machine already running it.
    public struct TransferDestination: Identifiable, Sendable, Equatable {
        public var id: String { deviceID }
        public let deviceID: String
        public let label: String
        public let isThisMac: Bool
    }

    /// Where `summary` can be moved right now, whoever owns it.
    ///
    /// A chat this Mac owns goes to any connected session-owning peer, exactly as before. A chat a
    /// *peer* owns is moved by asking that peer (`ClientMsg.requestSessionTransfer`) — the transfer
    /// always runs from the machine holding the worktree — so the destinations are the devices
    /// **it** can see: this Mac (always offered; it's connected to us by definition) plus its own
    /// online session-owning peers, which covers peer→peer moves this Mac merely brokers. Empty
    /// when the owner predates `canTransferOnRequest`, so the menu stays hidden rather than
    /// offering a move that would be rejected as an unknown tag.
    public func transferDestinations(for summary: SessionSummary) -> [TransferDestination] {
        guard !summary.archived, summary.movedTo == nil else { return [] }
        guard let hostID = summary.hostID else {
            return transferDestinations().map {
                TransferDestination(deviceID: $0.deviceID, label: $0.label, isThisMac: false)
            }
        }
        guard let owner = meshPeers.first(where: { $0.connected && $0.deviceID == hostID }),
              owner.canTransferOnRequest else { return [] }
        let me = castOriginID
        var items = [TransferDestination(
            deviceID: me, label: syncHostName.isEmpty ? "This Mac" : "\(syncHostName) (this Mac)",
            isThisMac: true)]
        for peer in owner.remotePeers
        where peer.online && peer.kind.canOwnSessions && peer.deviceID != me && peer.deviceID != hostID {
            items.append(TransferDestination(
                deviceID: peer.deviceID, label: peer.label, isThisMac: false))
        }
        return items
    }

    /// Move `summary` to `destination` from wherever it lives: locally owned chats run the transfer
    /// here; a peer's chat is moved by its owner on request. Reports failures via `lastError`, so
    /// the sidebar menu can fire it in a plain `Task`.
    public func transferSession(_ summary: SessionSummary, to destination: TransferDestination) async {
        guard let hostID = summary.hostID else {
            await moveSessionToPeer(summary.id, to: destination.deviceID, label: destination.label)
            return
        }
        // The owner drives it; we only name the destination. Its progress and outcome arrive as the
        // usual mirrored session-list updates (the row lands here, or stays there on a rejection).
        await sendToPeer(
            .requestSessionTransfer(summary.id, destination.deviceID),
            host: hostID, about: summary.title)
    }

    /// A peer asked us to move a session we own (`ClientMsg.requestSessionTransfer`) — the
    /// "Transfer…" menu on a Mac that doesn't hold the chat. Same path as the local menu, so every
    /// classification, quiesce and 2-phase commit still runs here; the error rides back as a
    /// `WireError` for the requester.
    public func transferSession(_ id: SessionID, to destinationDeviceID: String) async throws {
        meshDiag(.transfer, "transfer requested by peer → \(meshHostLabel(destinationDeviceID))",
            detail: "session \(id.short)")
        try await moveSession(id, to: destinationDeviceID)
    }

    /// 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
        projectors[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: SessionCarbonCopy
        do {
            package = try SessionCarbonCopy.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)")
        }

        meshDiag(.transfer,
            "transfer out: \"\(session.title)\" → \(meshHostLabel(peerDeviceID))")
        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.
            meshDiag(.transfer, level: .warning,
                "transfer out: \"\(session.title)\" committed but unconfirmed — recovery finishes it")
            await close()
            await noteTransferLanding(id, on: peerDeviceID, originProjectID: project.id)
            await finalizeMovedTeardown(id, controller: controller)
            throw SessionTransferError.committedButUnconfirmed
        } catch {
            meshDiag(.transfer, level: .error,
                "transfer out: \"\(session.title)\" failed pre-tombstone — session stays",
                detail: "\(error)")
            await close(); restore()  // pre-tombstone failure — the session lives here again
            throw error
        }
        meshDiag(.transfer,
            "transfer out: \"\(session.title)\" committed to \(meshHostLabel(peerDeviceID))")
        await close()
        await noteTransferLanding(id, on: peerDeviceID, originProjectID: project.id)
        await finalizeMovedTeardown(id, controller: controller)
    }

    /// Record where a just-moved session landed. A Covalence Cloud runner destination gets a mirror
    /// record so the sidebar folds the arrived chat under the local project it left (item 1) — the
    /// same bookkeeping `dispatch` does when it *starts* a chat on a runner. Without it the runner's
    /// copy of the project surfaces as a second, globe-badged section. A peer-Mac destination keeps
    /// its own project section, so it isn't mirrored.
    func noteTransferLanding(
        _ id: SessionID, on peerDeviceID: String, originProjectID: ProjectID
    ) async {
        guard isCloudRunnerHost(peerDeviceID) else { return }
        // Pull the runner's fresh list first so the mirror caches the arrived row's summary for an
        // immediate (and next-launch) render; the fold works either way once presence catches up.
        if let peerClient {
            _ = await peerClient.sendCommand(.listSessions, to: peerDeviceID)
        }
        recordCovalenceDispatch(
            sessionID: id, hostID: peerDeviceID, originProjectID: originProjectID)
    }

    /// 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)))
            // Fleet activity feed: the session left this Mac.
            let destination = movedDestination(for: moved)
            recordFeedEvent(
                .transferredOut, session: moved,
                counterpartHostID: moved.movedToDeviceID,
                counterpartHostName: destination?.deviceName)
        }
        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)`).
    // Carbon replication serving (CARBON_SHARDING §8.1): the shard store, read-only. Blobs
    // are ciphertext, verified by the store on every read (§7.2) — a rotted blob answers as
    // absent, never poisons the mesh.
    public func carbonHeads(projectUUID: UUID?) async -> [CarbonHead] {
        await carbonService.headsSnapshot(projectUUID: projectUUID)
    }

    public func carbonManifestBlob(id: String, projectUUID: UUID) async -> Data? {
        await carbonService.store.manifestBlob(id: id, projectUUID: projectUUID)
    }

    public func carbonShardBlob(id: String, projectUUID: UUID) async -> Data? {
        await carbonService.store.shard(id: id, projectUUID: projectUUID)
    }

    // Carbon lock arbitration (CARBON_SHARDING §17.5): this host is the lock authority for
    // the projects it controls (§17.3); a remote session's acquire/release routes here and
    // runs through the same `CarbonLockManager` as local sessions — single-actor semantics
    // preserved, the suspension just spans the wire.
    public func carbonLockWouldBlock(_ request: CarbonLockRequest, from deviceID: String) async -> Bool {
        await lockManager.wouldBlock(
            SessionID(rawValue: request.sessionID), domain: LockDomain(request.domainRef),
            files: request.paths ?? [])
    }

    public func carbonLockAcquire(_ request: CarbonLockRequest, from deviceID: String) async
        -> CarbonLockDecisionMsg.Outcome
    {
        guard let paths = request.paths, !paths.isEmpty else { return .cancelled }
        let sid = SessionID(rawValue: request.sessionID)
        // AUTHORIZATION (§17.5). `sessionID` is attacker-chosen data on the wire, not a
        // capability, so a peer must not be able to name a session it doesn't own. Two refusals:
        //
        // 1. A session running HERE arbitrates locally through `arbitrate` and never routes over
        //    the wire. Accepting a remote acquire under its id would let a peer record itself as
        //    that session's origin and then release its locks mid-edit (see `carbonLockRelease`).
        // 2. A session already claimed by a DIFFERENT device while that device still holds locks
        //    for it. A stale claim holding nothing stays re-claimable, so a transferred session
        //    arbitrating from its new host isn't wedged out.
        guard controllers[sid] == nil else {
            lockLog.error("LOCKTRACE wire-acquire REFUSED(local-session) session=\(sid.rawValue, privacy: .public) from=\(deviceID, privacy: .public)")
            return .cancelled
        }
        if let origin = remoteLockSessions[sid], origin.deviceID != deviceID,
            !(await lockManager.heldPaths(sid).isEmpty)
        {
            lockLog.error("LOCKTRACE wire-acquire REFUSED(owned-by-other) session=\(sid.rawValue, privacy: .public) from=\(deviceID, privacy: .public) owner=\(origin.deviceID, privacy: .public)")
            return .cancelled
        }
        // Record the session's origin: it has no local controller, so this map is the reap
        // exemption (its parked waiter isn't culled as "gone" — its own host re-acquires after a
        // crash, the LOCKING §6.2 restart argument mesh-wide), the ledger attribution source
        // (device + project) for its events, AND the authorization record a later release checks.
        remoteLockSessions[sid] = RemoteLockSession(
            deviceID: deviceID, projectUUID: request.projectUUID)
        switch await lockManager.acquire(sid, domain: LockDomain(request.domainRef), files: paths) {
        case .granted, .grantedNeedsReground, .updateConflict:
            // Update-on-grant/re-ground run on the session's own host against its worktree;
            // the authority's answer is simply "you hold the locks".
            return .granted
        case .cancelled:
            if await lockManager.heldPaths(sid).isEmpty { remoteLockSessions[sid] = nil }
            return .cancelled
        }
    }

    public func carbonLockRelease(_ request: CarbonLockRequest, from deviceID: String) async {
        let sid = SessionID(rawValue: request.sessionID)
        // AUTHORIZATION (§17.5). Releasing is the destructive half of arbitration — it hands the
        // session's files to whoever is queued behind them — so it is honored ONLY from the device
        // that owns the session. `deviceID` is the authenticated peer identity; the origin record
        // is written when that device acquires (which is itself now ownership-checked). Without
        // this, any control-scope peer could free ANY session's locks by naming its id, including
        // a session running locally on this host, mid-edit.
        //
        // Refusing is safe: it leaves the lock held (fail closed, §17.3). The owning host's own
        // landing-driven release retries, and the manual force-release remains the escape hatch.
        guard let origin = remoteLockSessions[sid] else {
            lockLog.error("LOCKTRACE wire-release REFUSED(not-a-remote-session) session=\(sid.rawValue, privacy: .public) from=\(deviceID, privacy: .public)")
            return
        }
        guard origin.deviceID == deviceID else {
            lockLog.error("LOCKTRACE wire-release REFUSED(wrong-device) session=\(sid.rawValue, privacy: .public) from=\(deviceID, privacy: .public) owner=\(origin.deviceID, privacy: .public)")
            return
        }
        guard origin.projectUUID == request.projectUUID else {
            lockLog.error("LOCKTRACE wire-release REFUSED(wrong-project) session=\(sid.rawValue, privacy: .public) from=\(deviceID, privacy: .public)")
            return
        }
        // The landing host produced (and replicated) the fence heads before sending; the
        // authority publishes `released(paths, requiredHeads)` with them (§17.4 stage 1).
        // These are a *liveness* hint, not a proof of landing: they only gate when each device
        // observes the release. Trusting them from the wire is fine precisely because the release
        // itself is now authorized above — the worst a malformed set can do is release as
        // promptly as an unfenced one, i.e. today's single-host behavior.
        let heads = request.requiredHeads ?? []
        if let paths = request.paths {
            await lockManager.release(sid, paths: paths, requiredHeads: heads)
        } else {
            await lockManager.releaseAll(sid, requiredHeads: heads)
            // Drop the origin record only after the release published (the ledger append
            // resolves attribution through it).
            await lockManager.ledgerQuiesce()
            remoteLockSessions[sid] = nil
        }
    }

    public func transcriptEvents(_ id: SessionID, afterSeq: UInt64) async
        -> (header: SessionHeader?, revertEpoch: UInt64?, events: [AgentEvent])? {
        func slice(_ all: [AgentEvent]) -> [AgentEvent] {
            afterSeq == 0 ? all : all.filter { $0.seq > afterSeq }
        }

        // 1. Live controller. As the owner we serve the session row's revert epoch — the
        // authoritative counter — and re-stamp it onto the served header, so a mirror built from
        // this fetch records the exact epoch we advertise in summaries even if the on-disk
        // header's stamp lagged (e.g. a failed header rewrite during a past revert).
        if !transferringSessions.contains(id), let controller = controllers[id] {
            let all = await controller.transcriptSoFar()
            let session = await controller.snapshot.session
            var header: SessionHeader?
            if afterSeq == 0 {
                header = try? TranscriptReader(url: URL(fileURLWithPath: session.transcriptPath))
                    .readHeader()
                header?.revertEpoch = session.revertEpoch
            }
            return (header, session.revertEpoch, slice(all))
        }

        // 2. Owned on-disk transcript (session exists here but no live controller). The row's
        // epoch is authoritative here too (same re-stamp as above).
        if let session = try? await database.loadSession(id: id) {
            let url = URL(fileURLWithPath: session.transcriptPath)
            if FileManager.default.fileExists(atPath: url.path),
               let (readHeader, all) = try? TranscriptReader(url: url).read() {
                var header = readHeader
                header.revertEpoch = session.revertEpoch
                return (afterSeq == 0 ? header : nil, session.revertEpoch, slice(all))
            }
        }

        // 3. Mirror synced from another device — re-served with the epoch its own header was
        // synced at (nil if it predates epoch stamping; the requester then can't verify and
        // treats the copy as best-effort).
        if let url = await mirrorStore.url(id),
           let (header, all) = try? TranscriptReader(url: url).read() {
            return (afterSeq == 0 ? header : nil, header.revertEpoch, 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,
            covalenceOriginDeviceID: request.covalenceOriginDeviceID)
        await pushDashboard()
    }

    /// Mesh dispatch (mesh casting): the acknowledged `startChat`. **Idempotent per
    /// requestID** — the double-start killer: a dispatcher that timed out and retried gets the
    /// already-created session's id back, so at most one session ever exists per (host,
    /// requestID). Entries age out after 10 minutes (far past any retry window).
    public func startChatAcknowledged(
        _ request: StartChatRequest, requestID: String
    ) async -> WireChatStarted {
        recentDispatches = recentDispatches.filter { now().timeIntervalSince($0.value.at) < 600 }
        if let existing = recentDispatches[requestID] {
            return WireChatStarted(requestID: requestID, sessionID: existing.sessionID)
        }
        guard MeshDispatchSettings.acceptsDispatch else {
            return WireChatStarted(
                requestID: requestID, error: "This host isn't accepting mesh dispatches.")
        }
        guard let project = projectsByID[request.projectID] else {
            return WireChatStarted(requestID: requestID, error: "No such project on this host.")
        }
        let attachments = request.attachments.map {
            PendingAttachment(filename: $0.filename, data: $0.data)
        }
        guard let sessionID = 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,
            covalenceOriginDeviceID: request.covalenceOriginDeviceID)
        else {
            return WireChatStarted(
                requestID: requestID,
                error: lastError ?? "The chat couldn't be started on this host.")
        }
        recentDispatches[requestID] = (sessionID, now())
        // Fleet activity feed: tell the mesh where this dispatch landed.
        if let session = try? await database.loadSession(id: sessionID) {
            recordFeedEvent(.dispatched, session: session)
        }
        await pushDashboard()
        return WireChatStarted(requestID: requestID, sessionID: sessionID)
    }

    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) }
    }
    /// A phone's (or peer Mac's) "Mark Done". Routes through the *same* `markSessionDone` the Mac
    /// sidebar calls rather than a bare `setDisposition`, so a hand-mark from the phone finalizes
    /// the chat identically — an autoship-armed chat ships, an idle VM suspends — instead of
    /// only relabeling the row. The controller lookup is the `.unknownSession` gate the other
    /// remote session verbs share; the summary push is what `mutateSessionForRemote` would have
    /// done, issued after the finalization so the broadcast carries the settled state.
    public func markSessionDoneRemote(_ id: SessionID) async throws {
        guard let controller = controllers[id] else {
            throw WireError(code: .unknownSession, message: "no such session", sessionID: id)
        }
        await markSessionDone(id)
        await pushSessionSummary(for: controller, id: id)
    }
    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 {  // mediated release, fenced (§17.4)
                await publishLockRelease(
                    id, paths: nil, requiredHeads: await landedRequiredHeads(for: id))
            }
        } 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)
        }
        // Devices fire `createProject` moments after connecting — usually before the credential
        // mesh has landed a git credential (observed: the bundle/token trail the connect by up
        // to ~90 s). Cloning a private repo immediately would fail on auth, so give the
        // credential a bounded window to land, then clone either way (a public repo needs none).
        await waitForGitCredential()
        guard let created = await addClonedProject(
            name: request.name ?? "", gitURL: url, nucleicControl: true,
            destinationParent: nil, branch: request.branch.map { GitRef($0) })
        else {
            var message = lastError ?? "createProject: clone failed"
            if runnerCredentialVault != nil, !GitHubCredentialConfig.current().isActive {
                message += " — no git credential has landed on this runner yet; check that the "
                    + "connected device holds a GitHub token or Managed Git key"
            }
            throw WireError(code: .backpressure, message: message)
        }
        await pushDashboard()
        return WireProjectCreated(
            requestID: request.requestID, projectID: created.id, name: created.name)
    }

    /// Runner-only (no-op without the credential vault, i.e. on a cockpit Mac): poll briefly
    /// for a usable Managed Git credential before a remote-requested clone, so the
    /// createProject-vs-credential-landing race only fails when a credential truly never
    /// arrives. Landing flips `GitHubCredentialConfig.current()` active — via the device's
    /// `gitCredentialBundle` or the adopted github token (`adoptGitHubTokenFallback`). Bounded;
    /// afterwards the clone proceeds regardless, since a public repo clones credential-less.
    private func waitForGitCredential(timeout: Duration = .seconds(60)) async {
        guard runnerCredentialVault != nil, !GitHubCredentialConfig.current().isActive else { return }
        let deadline = ContinuousClock.now + timeout
        while ContinuousClock.now < deadline {
            try? await Task.sleep(for: .seconds(2))
            if GitHubCredentialConfig.current().isActive { return }
        }
    }

    /// 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 {
            markApprovalAnswered(id)
            await peerClient?.respondRemoteApproval(id, decision, on: hostID)
            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 }) {
                // Retire the card here too (the operator-assist popup answers through this
                // entry): the request stays pending on the controller for the length of the
                // resolution's round-trip back through the run stream.
                markApprovalAnswered(id)
                do { try await controller.respondToApproval(id, decision, by: responder) }
                catch {
                    await unmarkApprovalAnswered(id, controller)
                    throw error
                }
                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)
        }
        // Gate on the launch lock/nvrsion reconciliation (plan item 6): a remotely-driven turn
        // runs HERE, so it must not start against not-yet-rebuilt lock state either.
        await awaitStartupLockReconciliation()
        // 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)
        autoContinuedWaiting.remove(id)  // a fresh user message ends any dead-wait streak
        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)
        }
        // Unblock a `nucleic_wait_for_message` parked in this session so the interrupt takes
        // effect instead of the tool sitting out its timeout.
        meshMessageWaiters.removeValue(forKey: id)?
            .continuation.resume(returning: "{\"unavailable\": true, \"reason\": \"interrupted\"}")
        await controller.interrupt()
    }

    public func resolveProcessStall(_ id: SessionID, stallID: String, kill: Bool) async throws {
        guard controllers[id] != nil else {
            throw WireError(code: .unknownSession, message: "no such session", sessionID: id)
        }
        // Reuse the same path the Mac's own transcript buttons take: mark the alert resolved so its
        // row retires (locally and for every subscriber, via the resolution note the backend emits)
        // and tear down / dismiss on the running controller.
        await resolveProcessStall(stallID, kill: kill, inSession: id)
    }

    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)
        await pushSessionSummary(for: controller, id: id)
    }

    /// Re-read a session's controller and fan the refreshed summary out — the broadcast half of
    /// `mutateSessionForRemote`, split out for remote verbs that route through an existing
    /// `AppStore` action (which already mutates the controller its own way) rather than a bare
    /// controller setter.
    private func pushSessionSummary(for controller: SessionController, id: SessionID) async {
        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 }
    }

    // MARK: - Mesh casting (CastMessages.swift)

    /// The cast table, when the metadata store provides one (GRDB v27). Nil on a store that
    /// doesn't persist casts — publishing becomes a no-op, never an error.
    private var castStore: (any CastPersisting)? { database as? CastPersisting }

    /// This device's cast origin id — its `HostID`, independent of whether the sync server is
    /// up (a cast published while sync is off must still be durably sequenced so late joiners
    /// catch up on it once the server returns).
    var castOriginID: String { syncHostID ?? HostIdentityStore.loadOrCreate().hostID }

    /// Publish one cast onto the mesh as this host: durably assign its per-(origin, channel)
    /// seq, then fan it out to subscribed connections. The origin-side entry point for every
    /// cast-riding feature (activity feed, messaging, runner presence); nucleicd publishes
    /// through the exact same path.
    public func publishCast(
        channel: String, typeID: String, key: String? = nil,
        payload: Data, deleted: Bool = false
    ) async {
        guard let castStore else { return }
        let policy = CastChannelRegistry.policy(for: channel)
        guard payload.count <= policy.maxPayloadBytes else {
            castLog.error("cast dropped: payload \(payload.count)B over cap on \(channel, privacy: .public)")
            return
        }
        let mode = policy.mode ?? .stream
        if mode == .state, key == nil {
            castLog.error("cast dropped: state channel \(channel, privacy: .public) requires a key")
            return
        }
        do {
            let cast = try await castStore.publishCast(
                origin: castOriginID, channel: channel, mode: mode, key: key,
                typeID: typeID, payload: payload, deleted: deleted, sentAt: Date())
            broadcast(.casts([cast]))
            appendToActivityFeed([cast])
            deliverMeshMessages([cast])
            updateRunnerPresence([cast])
            updateComposerTyping([cast])
            // Diagnostics: typing casts are throttled-per-keystroke and would flood the log;
            // the viewer shows live typing through `composerTypingBySession` instead.
            if channel != MeshCastChannel.composerTyping {
                meshDiag(.cast, "cast → \(channel) #\(cast.castSeq)")
            }
        } catch {
            castLog.error("cast publish failed on \(channel, privacy: .public): \(error)")
            meshDiag(.cast, level: .error, "cast publish failed on \(channel)", detail: "\(error)")
        }
    }

    /// Every (origin, channel) this host holds — the catch-up index `ConnectionHandler`
    /// iterates when a subscriber connects (`SyncHostBridge`).
    public func castTips() async -> [CastTip] {
        guard let castStore else { return [] }
        return (try? await castStore.castTips()) ?? []
    }

    /// The catch-up batches for one (origin, channel) at a subscriber's cursor
    /// (`SyncHostBridge`): state ⇒ compacted snapshot (`reset`), stream ⇒ the gap
    /// `(afterSeq, tip]`, or a reset when the cursor fell below the retention floor. Always at
    /// least one batch (possibly empty) carrying `complete: true` + `tipSeq`, so the
    /// subscriber's cursor advances even when there's nothing new.
    public func castCatchUp(
        origin: String, channel: String, afterSeq: UInt64
    ) async -> [CastCatchUp] {
        guard let castStore else { return [] }
        do {
            guard let tip = try await castStore.castTips()
                .first(where: { $0.originHostID == origin && $0.channel == channel })
            else { return [] }
            let mode = CastChannelRegistry.policy(for: channel).mode ?? tip.mode
            var casts: [WireCast] = []
            var reset = false
            if afterSeq >= tip.tipSeq {
                // Current (or ahead of us — a fresher holder exists): one empty complete batch.
                // The subscriber keeps max(cursor, tipSeq), so it never regresses.
            } else if mode == .state {
                // Late joiner: the compacted snapshot replaces its state wholesale.
                casts = try await castStore.castStateSnapshot(origin: origin, channel: channel)
                reset = true
            } else if afterSeq + 1 < tip.floorSeq {
                // The gap fell below our retention floor — unrecoverable here; re-baseline.
                casts = try await castStore.castsAfter(origin: origin, channel: channel, afterSeq: 0)
                reset = true
            } else {
                casts = try await castStore.castsAfter(
                    origin: origin, channel: channel, afterSeq: afterSeq)
            }
            return Self.chunkCastCatchUp(
                origin: origin, channel: channel, mode: mode, casts: casts,
                tipSeq: tip.tipSeq, reset: reset)
        } catch {
            castLog.error("cast catch-up failed for \(channel, privacy: .public): \(error)")
            return []
        }
    }

    // MARK: Fleet activity feed

    public var feedUnreadCount: Int {
        activityFeed.reduce(0) { $0 + ($1.event.at > feedLastSeenAt ? 1 : 0) }
    }
    public func markFeedSeen() {
        feedLastSeenAt = now()
        UserDefaults.standard.set(feedLastSeenAt, forKey: "nucleic.feed.lastSeenAt")
    }

    /// Seed the feed from the durable cast table (launch): items published while the app was
    /// closed — here or on peers whose casts we mirrored — render immediately.
    public func seedActivityFeed() async {
        guard let castStore else { return }
        var casts: [WireCast] = []
        for tip in (try? await castStore.castTips()) ?? [] {
            switch tip.channel {
            case MeshCastChannel.activity:
                casts += (try? await castStore.castsAfter(
                    origin: tip.originHostID, channel: tip.channel, afterSeq: 0)) ?? []
            case MeshCastChannel.runnerPresence:
                // Seed the dispatcher's candidate pool from mirrored presence cards too.
                updateRunnerPresence((try? await castStore.castStateSnapshot(
                    origin: tip.originHostID, channel: tip.channel)) ?? [])
            default:
                break
            }
        }
        activityFeed = ActivityFeedItem.merged(activityFeed, adding: casts)
        await refreshQueuedDispatchCount()
    }

    /// Fold freshly stored casts into the in-memory feed (identity-deduped, capped).
    private func appendToActivityFeed(_ casts: [WireCast]) {
        activityFeed = ActivityFeedItem.merged(activityFeed, adding: casts)
    }

    // MARK: Mesh dispatch — presence + routing (the "runner.presence" channel)

    /// Publish this host's runner-presence card, debounced 2 s — called on change (boot,
    /// project add/remove, session start/finish, Settings flip), NEVER as a heartbeat:
    /// liveness is the dispatcher's own connection to us, this cast carries only the
    /// capability metadata a socket can't (capacity, backends, held projects).
    public func publishRunnerPresence() {
        presencePublishTask?.cancel()
        presencePublishTask = Task { [weak self] in
            try? await Task.sleep(for: .seconds(2))
            guard !Task.isCancelled else { return }
            await self?.publishRunnerPresenceNow()
        }
    }

    func publishRunnerPresenceNow() async {
        guard castStore != nil else { return }
        guard let presence = await localRunnerPresence(),
              let payload = presence.encodedCastPayload() else { return }
        await publishCast(
            channel: MeshCastChannel.runnerPresence, typeID: RunnerPresence.typeID,
            key: castOriginID, payload: payload)
    }

    /// This host's own presence card — what peers rank us by, and the "current owner" side of a
    /// Covalence rebalance comparison. Never a candidate for our own Covalence dispatch ("Covalence"
    /// excludes the issuing device by definition — the local run is the composer's "Here").
    /// `backends` is left empty in v1 (= unknown; the scorer skips the backend filter for an
    /// empty list) until per-provider availability is threaded in.
    func localRunnerPresence() async -> RunnerPresence? {
        RunnerPresence(
            hostName: syncHostName, kind: meshSelfKind, canRunAgents: true,
            acceptsMeshDispatch: MeshDispatchSettings.acceptsDispatch,
            activeSessionCount: summaries.filter { $0.status == .running }.count,
            maxConcurrent: nil,
            backends: [],
            projects: await projectDescriptors(),
            canCreateProjects: true, updatedAt: now())
    }

    /// Identity descriptors for every active project (cached — root commit and normalized
    /// remote are stable per repo; a probe is a git round-trip).
    func projectDescriptors() async -> [ProjectDescriptor] {
        var descriptors: [ProjectDescriptor] = []
        for project in projects where !project.isArchived {
            if let cached = projectDescriptorCache[project.id] {
                descriptors.append(cached)
                continue
            }
            let identity = try? await TransferGitProbe().repoIdentity(rootPath: project.rootPath)
            let descriptor = ProjectDescriptor(
                projectID: project.id, name: project.name,
                rootCommitSHA: identity?.rootCommitSHA,
                normalizedRemote: identity?.normalizedRemote,
                defaultBranch: project.defaultBranch.value)
            projectDescriptorCache[project.id] = descriptor
            descriptors.append(descriptor)
        }
        return descriptors
    }

    /// Whether the composer should offer the "Mesh" destination for `project`: some connected,
    /// acknowledging peer's presence card holds a matching repo. Synchronous (drives a menu's
    /// visibility every render) — identity matching uses the cached descriptor, falling back to
    /// UUID-only until the first presence publish warms the cache.
    public func meshDispatchAvailable(for project: Project) -> Bool {
        let descriptor = projectDescriptorCache[project.id] ?? ProjectDescriptor(
            projectID: project.id, name: project.name, rootCommitSHA: nil,
            normalizedRemote: nil, defaultBranch: project.defaultBranch.value)
        return runnerPresenceByHost.contains { hostID, presence in
            guard hostID != castOriginID, presence.acceptsMeshDispatch,
                  presence.canRunAgents, presence.kind.canOwnSessions else { return false }
            guard let peer = meshPeers.first(where: { $0.deviceID == hostID }),
                  peer.connected, peer.canAcknowledgeDispatch else { return false }
            return presence.projects.contains { $0.matches(descriptor) }
        }
    }

    /// Whether the composer should even *offer* the Covalence destination for `project`: a live
    /// runner is available now (`meshDispatchAvailable`) **or** the Covalence Cloud runner is
    /// enabled and can be auto-provisioned on demand. The composer's "Run on" picker gates on this
    /// (not `meshDispatchAvailable`) so Covalence is offered before any runner is live — selecting
    /// it boots one hands-free (COVALENCE_RUNNER §3).
    public func meshDispatchPossible(for project: Project) -> Bool {
        meshDispatchAvailable(for: project) || RunnerSettings.serviceEnabled
    }

    /// Fold fresh presence casts into the dispatcher's candidate pool (latest per host).
    private func updateRunnerPresence(_ casts: [WireCast]) {
        var changed = false
        for cast in casts where cast.channel == MeshCastChannel.runnerPresence {
            guard let key = cast.key, let presence = RunnerPresence.decode(cast) else { continue }
            let previous = runnerPresenceByHost[key]
            runnerPresenceByHost[key] = presence
            changed = true
            // Diagnostics: a re-fold of an identical card (catch-up replay) isn't a change.
            if previous != presence {
                let cap = presence.maxConcurrent.map(String.init) ?? "∞"
                meshDiag(.presence,
                    "presence: \(meshHostLabel(key)) \(presence.activeSessionCount)/\(cap) active",
                    detail: "dispatch \(presence.acceptsMeshDispatch ? "on" : "off")"
                        + " · \(presence.projects.count) project(s)"
                        + (presence.canCreateProjects ? " · can clone" : ""))
            }
        }
        // A runner's presence just changed (came online / freed capacity / added a project) — a
        // chance to place a parked dispatch. Fire-and-forget + single-flight.
        if changed, queuedMeshDispatchCount > 0 {
            Task { [weak self] in await self?.retryQueuedMeshDispatches() }
        }
        // Presence just told us which peers are `.cloud`; re-fold the mirror so runner sessions
        // land under their origin project (items 1 & 4).
        if changed { refreshCovalenceMirrors() }
        // …and the "a more optimal host opened after a turn" moment for idle Covalence-managed
        // sessions sitting here — re-rank them against the fresh card (debounced).
        if changed { scheduleCovalenceRebalanceSweep() }
    }

    // MARK: Live composer streaming (the "composer.typing" state channel)

    /// A session composer's draft changed on THIS Mac — stream it to the mesh (throttled) and
    /// (re)arm the idle stop. An emptied field ends the typing immediately (the tombstone
    /// unlocks every watcher) rather than streaming an empty draft.
    public func composerDraftChanged(_ sessionID: SessionID, text: String) {
        guard !text.isEmpty else { composerDraftEnded(sessionID); return }
        streamComposerText(sessionID, text: text, deviceID: castOriginID, deviceName: syncHostName)
    }

    /// Typing in THIS Mac's composer finished — sent, cleared, or the chat closed.
    public func composerDraftEnded(_ sessionID: SessionID) {
        endComposerTyping(sessionID, deviceID: castOriginID)
    }

    /// A client's live composer report (`ClientMsg.composerTyping`) — republish it on the
    /// client's behalf, stamped with the authenticated connection's identity (a client can't
    /// cast, and can't forge who is typing). The idle stop is armed here too, so a phone that
    /// vanishes mid-word still gets its entry tombstoned for the whole mesh.
    public func receiveComposerTyping(_ typing: WireComposerTyping, from deviceID: String) async {
        guard typing.active, !typing.text.isEmpty else {
            endComposerTyping(typing.sessionID, deviceID: deviceID)
            return
        }
        let label = pairedDevices.first { $0.deviceID == deviceID }?.label
        let name = label.flatMap { $0.isEmpty ? nil : $0 } ?? "another device"
        streamComposerText(typing.sessionID, text: typing.text, deviceID: deviceID, deviceName: name)
    }

    /// Publisher core: remember the newest draft + whose it is, arm the idle stop, and flush
    /// through the throttle — immediately when the window is clear, else as one trailing
    /// publish when it closes (watchers always converge on the final text).
    private func streamComposerText(
        _ sessionID: SessionID, text: String, deviceID: String, deviceName: String
    ) {
        // A different device took over the session's key (a race — composers lock while
        // someone types, so takeover is exceptional; last writer wins): drop the previous
        // holder's pending state so its trailing publish can't resurrect stale text.
        if let held = composerPublishedDevice[sessionID], held.deviceID != deviceID {
            composerPublishTasks[sessionID]?.cancel()
            composerPublishTasks[sessionID] = nil
            composerPendingText[sessionID] = nil
        }
        composerPublishedDevice[sessionID] = (deviceID, deviceName)
        composerPendingText[sessionID] = text
        scheduleComposerIdleStop(sessionID, deviceID: deviceID)
        guard composerPublishTasks[sessionID] == nil else { return }  // trailing edge armed
        let elapsed = now().timeIntervalSince(composerLastPublishAt[sessionID] ?? .distantPast)
        let delay = max(0, ComposerTypingState.minPublishInterval - elapsed)
        composerPublishTasks[sessionID] = Task { [weak self] in
            if delay > 0 { try? await Task.sleep(for: .seconds(delay)) }
            guard !Task.isCancelled else { return }
            await self?.flushComposerDraft(sessionID)
        }
    }

    /// One throttle-window flush: publish the newest pending draft as the session's state entry.
    private func flushComposerDraft(_ sessionID: SessionID) async {
        composerPublishTasks[sessionID] = nil
        guard let text = composerPendingText.removeValue(forKey: sessionID),
              let device = composerPublishedDevice[sessionID] else { return }
        composerLastPublishAt[sessionID] = now()
        let state = ComposerTypingState(
            sessionID: sessionID, deviceID: device.deviceID, deviceName: device.deviceName,
            text: text, at: now())
        guard let payload = state.encodedCastPayload() else { return }
        await publishCast(
            channel: MeshCastChannel.composerTyping, typeID: ComposerTypingState.typeID,
            key: sessionID.rawValue, payload: payload)
    }

    /// End a device's typing for a session (send / cleared / idle / relay said stop): drop the
    /// publisher bookkeeping and — if anything was actually published — tombstone the state
    /// entry so every watcher unlocks. Device-guarded: an end from a device that no longer
    /// holds the session's entry (a stale race loser) clears nothing.
    private func endComposerTyping(_ sessionID: SessionID, deviceID: String) {
        guard let held = composerPublishedDevice[sessionID], held.deviceID == deviceID else { return }
        composerPublishTasks[sessionID]?.cancel()
        composerPublishTasks[sessionID] = nil
        composerPendingText[sessionID] = nil
        composerIdleTasks[sessionID]?.cancel()
        composerIdleTasks[sessionID] = nil
        composerPublishedDevice[sessionID] = nil
        guard composerLastPublishAt.removeValue(forKey: sessionID) != nil else { return }
        let tombstone = ComposerTypingState(
            sessionID: sessionID, deviceID: held.deviceID, deviceName: held.deviceName,
            text: "", at: now())
        guard let payload = tombstone.encodedCastPayload() else { return }
        Task { [weak self] in
            await self?.publishCast(
                channel: MeshCastChannel.composerTyping, typeID: ComposerTypingState.typeID,
                key: sessionID.rawValue, payload: payload, deleted: true)
        }
    }

    /// Re-arm the typer-side idle stop: `idleTimeout` with no fresh draft ends the typing.
    private func scheduleComposerIdleStop(_ sessionID: SessionID, deviceID: String) {
        composerIdleTasks[sessionID]?.cancel()
        composerIdleTasks[sessionID] = Task { [weak self] in
            try? await Task.sleep(for: .seconds(ComposerTypingState.idleTimeout))
            guard !Task.isCancelled else { return }
            self?.endComposerTyping(sessionID, deviceID: deviceID)
        }
    }

    /// Fold composer-typing casts into the watcher projection: fresh entries from other devices
    /// lock (and render into) the session's composer; tombstones — and local expiry, for a
    /// typer that vanished without one — unlock it.
    private func updateComposerTyping(_ casts: [WireCast]) {
        for cast in casts where cast.channel == MeshCastChannel.composerTyping {
            guard let state = ComposerTypingState.decode(cast) else { continue }
            if cast.deleted {
                guard composerTypingBySession[state.sessionID]?.deviceID == state.deviceID
                else { continue }
                composerTypingBySession[state.sessionID] = nil
                composerRemoteExpiryTasks[state.sessionID]?.cancel()
                composerRemoteExpiryTasks[state.sessionID] = nil
                continue
            }
            // Own typing (echoed back through the local publish fold) never locks this
            // composer; an entry already past its window (a catch-up replay) never locks at all.
            guard state.deviceID != castOriginID, state.isFresh(at: now()) else { continue }
            composerTypingBySession[state.sessionID] = state
            scheduleComposerRemoteExpiry(state.sessionID, publishedAt: state.at)
        }
    }

    /// Arm (or re-arm) the watcher-side expiry for a session's typing entry.
    private func scheduleComposerRemoteExpiry(_ sessionID: SessionID, publishedAt: Date) {
        composerRemoteExpiryTasks[sessionID]?.cancel()
        let deadline = publishedAt.addingTimeInterval(ComposerTypingState.staleTimeout)
        let delay = max(0, deadline.timeIntervalSince(now()))
        composerRemoteExpiryTasks[sessionID] = Task { [weak self] in
            try? await Task.sleep(for: .seconds(delay))
            guard !Task.isCancelled, let self else { return }
            guard let entry = self.composerTypingBySession[sessionID],
                  !entry.isFresh(at: self.now()) else { return }
            self.composerTypingBySession[sessionID] = nil
            self.composerRemoteExpiryTasks[sessionID] = nil
        }
    }

    /// A peer's `chatStarted` dispatch ack arrived — resume the matching waiter.
    func handleChatStarted(_ outcome: WireChatStarted) {
        chatStartedWaiters.removeValue(forKey: outcome.requestID)?.resume(returning: outcome)
    }

    /// A peer's `projectCreated` outcome arrived (dispatch-time clone) — resume the waiter.
    func handleProjectCreated(_ outcome: WireProjectCreated) {
        guard let requestID = outcome.requestID else { return }
        projectCreatedWaiters.removeValue(forKey: requestID)?.resume(returning: outcome)
    }

    /// The cloneable origin URL for a local project (dispatch-time cloning). `git remote get-url
    /// origin` — the real URL (creds intact for the runner to clone with), not the normalized
    /// identity. Nil for a repo with no origin remote (a purely local repo can't be cloned onto
    /// a runner — that host is simply not a cloner candidate).
    func projectCloneURL(_ project: Project) async -> String? {
        let url = try? await GitRunner().checked(["remote", "get-url", "origin"], in: project.rootPath)
        let trimmed = url?.trimmingCharacters(in: .whitespacesAndNewlines)
        return (trimmed?.isEmpty == false) ? trimmed : nil
    }

    /// Ask `hostID` to clone `gitURL` and register it, awaiting the correlated `projectCreated`
    /// (nil on timeout / unreachable / failure). The new project's id is used for the follow-up
    /// `startChat`. Long timeout — a clone of a real repo can take a while.
    private func sendCreateProject(
        to hostID: String, gitURL: String, branch: String?, timeout: Duration
    ) async -> ProjectID? {
        guard let peerClient else { return nil }
        let requestID = UUID().uuidString
        let request = WireCreateProjectRequest(gitURL: gitURL, branch: branch, requestID: requestID)
        guard await peerClient.sendCommand(.createProject(request), to: hostID) else { return nil }
        let outcome: WireProjectCreated? = await withCheckedContinuation { continuation in
            projectCreatedWaiters[requestID] = continuation
            Task { [weak self] in
                try? await Task.sleep(for: timeout)
                self?.projectCreatedWaiters.removeValue(forKey: requestID)?.resume(returning: nil)
            }
        }
        if let error = outcome?.error {
            lastError = "\(meshPeers.first { $0.deviceID == hostID }?.label ?? "Mesh host"): \(error)"
        }
        return outcome?.projectID
    }

    /// Send one dispatch to `hostID` and await its correlated ack (nil = timed out /
    /// unreachable). The requestID makes a re-send of the SAME request idempotent host-side.
    private func sendDispatch(
        _ request: StartChatRequest, to hostID: String, timeout: Duration
    ) async -> WireChatStarted? {
        guard let peerClient, let requestID = request.requestID else { return nil }
        guard await peerClient.sendCommand(.startChat(request), to: hostID) else { return nil }
        return await withCheckedContinuation { continuation in
            chatStartedWaiters[requestID] = continuation
            Task { [weak self] in
                try? await Task.sleep(for: timeout)
                self?.chatStartedWaiters.removeValue(forKey: requestID)?.resume(returning: nil)
            }
        }
    }

    /// The durable dispatch queue, when the store provides one.
    private var meshDispatchStore: (any MeshDispatchPersisting)? { database as? MeshDispatchPersisting }

    /// Dispatch a new chat to the abstract **Covalence** destination: the best eligible host on the
    /// mesh **other than this device** ("Covalence" means "anywhere but here" — the local run is the
    /// composer's separate "Here" target). Routes with a correlated idempotent ack +
    /// timeout-retry + candidate fallback, and **queues the chat durably when no runner is free
    /// right now** — it starts automatically when one comes online
    /// (`retryQueuedMeshDispatches`). The landed session is stamped Covalence-managed (this device
    /// as its origin), so its owner keeps it on the optimal host between turns. Returns the
    /// landing (host, session), or nil when it was queued / the project couldn't be identified.
    @discardableResult
    public func dispatchChatToMesh(
        project: Project, message: String, model: String? = nil, effort: String? = nil,
        auto: Bool? = nil
    ) async -> (hostID: String, sessionID: SessionID)? {
        let descriptors = await projectDescriptors()
        guard let descriptor = descriptors.first(where: { $0.projectID == project.id }) else {
            lastError = "Covalence dispatch: couldn't identify this project's repository."
            meshDiag(.dispatch, level: .error,
                "dispatch failed: repository unidentifiable", detail: project.name)
            return nil  // permanent — never queued
        }
        meshDiag(.dispatch, "Covalence dispatch: new chat in \(project.name)")
        if let landed = await attemptMeshDispatch(
            project: project, descriptor: descriptor, message: message,
            model: model, effort: effort, auto: auto, reveal: true) {
            return landed
        }
        // Transient: no runner free now — park it durably and retry when one appears.
        let recordID = await enqueueMeshDispatch(
            project: project, message: message, model: model, effort: effort, auto: auto)
        // Zero-touch, demand-driven scale-up (COVALENCE_RUNNER §3): if the Covalence Cloud runner
        // is enabled and the live fleet is below `maxRunners`, boot one more now. It joins the mesh
        // as its own `.cloud` peer, publishes presence, and `retryQueuedMeshDispatches` — fired on
        // that presence change — lands this parked chat with no further user action. Single-flight
        // in `provisionAndJoinRunner`, so a burst of queued dispatches boots exactly one more runner
        // per round; a later parked dispatch grows the fleet again until the cap is reached.
        let willProvision = RunnerSettings.serviceEnabled && liveCloudRunnerCount < RunnerSettings.maxRunners
        // Open a session view immediately with live provisioning progress (item 5) — no more
        // opaque toast-then-wait. `waitingForRunner` distinguishes "at capacity, just waiting" from
        // "a runner is booting for this chat right now".
        if let recordID {
            openPendingCovalenceDispatch(PendingCovalenceDispatch(
                id: recordID, projectID: project.id, message: message, createdAt: now(),
                waitingForRunner: !willProvision))
        }
        if willProvision {
            meshDiag(.dispatch, "Covalence fleet below cap — auto-provisioning another cloud runner",
                detail: project.name)
            // Await the choreography and surface its failure in the provisioning view (not a toast):
            // a fire-and-forget provision used to leave an optimistic banner up forever whenever
            // provision/pair/join failed. On success the parked dispatch is landed by
            // `retryQueuedMeshDispatches` (fired on the runner's presence change), which opens it.
            Task { [weak self] in
                guard let self else { return }
                guard await self.provisionAndJoinRunner() else {
                    if case .failed(let reason) = self.runnerProvisionPhase {
                        if let recordID { self.failPendingCovalence(recordID: recordID, reason: reason) }
                        self.meshDiag(.dispatch, level: .error,
                            "cloud runner auto-provision failed", detail: reason)
                    }
                    return
                }
                // The runner is fully joined now — its dispatch capabilities are known, so place the
                // parked chat deterministically instead of waiting for the next presence cast. This
                // kills the "runner declines, then accepts on retry" churn (item 3), where the first
                // retry fired on the presence cast before the peer's welcome/capabilities landed.
                await self.retryQueuedMeshDispatches()
            }
        } else {
            meshDiag(.dispatch, level: .warning,
                "no Covalence host free — chat parked durably", detail: project.name)
        }
        return nil
    }

    // MARK: - Pending Covalence provisioning (item 5)

    /// Show a just-parked Covalence chat in the detail pane with live provisioning progress, and
    /// list it in its project's sidebar section. Opening it clears any real open session so the
    /// provisioning view takes the pane.
    func openPendingCovalenceDispatch(_ pending: PendingCovalenceDispatch) {
        pendingCovalenceDispatches.removeAll { $0.id == pending.id }
        pendingCovalenceDispatches.append(pending)
        openSessionID = nil          // didSet clears remote streaming / open session
        openPendingCovalence = pending
    }

    /// Re-open an existing pending dispatch (a sidebar tap on its provisioning row).
    public func reopenPendingCovalence(_ id: String) {
        guard let pending = pendingCovalenceDispatches.first(where: { $0.id == id }) else { return }
        openSessionID = nil
        openPendingCovalence = pending
    }

    /// The parked chat landed on a runner: drop the pending entry and, if the user was watching its
    /// provisioning view, open the real session in its place.
    func resolvePendingCovalence(recordID: String, landed: (hostID: String, sessionID: SessionID)) {
        let wasOpen = openPendingCovalence?.id == recordID
        pendingCovalenceDispatches.removeAll { $0.id == recordID }
        if wasOpen {
            openPendingCovalence = nil
            openSessionID = landed.sessionID   // remote routing in didSet opens the runner's session
        }
    }

    /// Provisioning failed — keep the chat's view up with a reason instead of a vanishing toast.
    func failPendingCovalence(recordID: String, reason: String) {
        mutatePendingCovalence(recordID) { $0.failure = reason }
    }

    /// Drop a pending entry entirely (its parked record was removed — project gone, or cancelled).
    public func removePendingCovalence(recordID: String) {
        pendingCovalenceDispatches.removeAll { $0.id == recordID }
        if openPendingCovalence?.id == recordID { openPendingCovalence = nil }
    }

    /// User cancelled a Covalence chat still waiting for a runner: drop the durable parked record so
    /// it never lands, and clear its provisioning row/view.
    public func cancelPendingCovalence(_ id: String) async {
        try? await meshDispatchStore?.removeMeshDispatch(id: id)
        await refreshQueuedDispatchCount()
        removePendingCovalence(recordID: id)
    }

    private func mutatePendingCovalence(_ id: String, _ body: (inout PendingCovalenceDispatch) -> Void) {
        if let idx = pendingCovalenceDispatches.firstIndex(where: { $0.id == id }) {
            body(&pendingCovalenceDispatches[idx])
        }
        if openPendingCovalence?.id == id, var open = openPendingCovalence {
            body(&open)
            openPendingCovalence = open
        }
    }

    /// Rebuild the in-memory pending-Covalence list from the durable dispatch queue at launch, so a
    /// chat still waiting for a runner when the app quit shows its provisioning row again (item 5).
    func rehydratePendingCovalence() async {
        guard let store = meshDispatchStore else { return }
        let queued = (try? await store.queuedMeshDispatches()) ?? []
        let existing = Set(pendingCovalenceDispatches.map(\.id))
        for record in queued
        where !existing.contains(record.id) && projectsByID[record.projectID] != nil {
            pendingCovalenceDispatches.append(PendingCovalenceDispatch(
                id: record.id, projectID: record.projectID, message: record.message,
                createdAt: record.createdAt, waitingForRunner: true))
        }
    }

    /// A one-line, user-facing status for a pending Covalence chat's provisioning view — the phase
    /// wording items 5 asks for ("provisioning", "waiting for a runner", …). Failures win; then a
    /// runner actively booting (the shared `runnerProvisionPhase`); otherwise it's simply queued.
    public func pendingCovalenceStatus(_ pending: PendingCovalenceDispatch) -> String {
        if let failure = pending.failure { return failure }
        if !pending.waitingForRunner {
            switch runnerProvisionPhase {
            case .booting: return "Provisioning a cloud runner…"
            case .pairing: return "Runner ready — joining the mesh…"
            case .joined: return "Runner joined — starting your chat…"
            case .failed(let why): return why
            case .idle: return "Provisioning a cloud runner…"
            }
        }
        return "Waiting for a runner to free up…"
    }

    /// Enqueue a mesh dispatch for later retry (durable queue). Returns the record id — the stable
    /// handle the pending-Covalence provisioning view (item 5) correlates on — or `nil` if there's
    /// no durable store to hold it.
    @discardableResult
    private func enqueueMeshDispatch(
        project: Project, message: String, model: String?, effort: String?, auto: Bool?
    ) async -> String? {
        guard let store = meshDispatchStore else { return nil }
        let id = UUID().uuidString
        try? await store.enqueueMeshDispatch(MeshDispatchRecord(
            id: id, projectID: project.id, message: message,
            model: model, effort: effort, auto: auto, createdAt: now()))
        await refreshQueuedDispatchCount()
        return id
    }

    /// Retry every parked mesh dispatch (durable queue) — driven when a runner's presence
    /// changes or a peer connects. Single-flight; a dispatch that places is removed, others stay.
    public func retryQueuedMeshDispatches() async {
        guard let store = meshDispatchStore, !reconcilingDispatches else { return }
        reconcilingDispatches = true
        defer { reconcilingDispatches = false }
        let queued = (try? await store.queuedMeshDispatches()) ?? []
        if !queued.isEmpty {
            meshDiag(.dispatch, "retrying \(queued.count) parked dispatch(es)")
        }
        for record in queued {
            guard let project = projectsByID[record.projectID], !project.isArchived else {
                try? await store.removeMeshDispatch(id: record.id)  // project gone — drop it
                removePendingCovalence(recordID: record.id)
                meshDiag(.dispatch, level: .warning, "parked dispatch dropped — project gone")
                continue
            }
            let descriptors = await projectDescriptors()
            guard let descriptor = descriptors.first(where: { $0.projectID == project.id }) else {
                continue
            }
            // Reveal only in the interactive path — a background retry must not yank the user's view.
            // The pending-Covalence view (item 5) is resolved explicitly below regardless of `reveal`.
            if let landed = await attemptMeshDispatch(
                project: project, descriptor: descriptor, message: record.message,
                model: record.model, effort: record.effort, auto: record.auto, reveal: false) {
                try? await store.removeMeshDispatch(id: record.id)
                resolvePendingCovalence(recordID: record.id, landed: landed)
                meshDiag(.dispatch, "parked dispatch placed", detail: project.name)
            }
        }
        await refreshQueuedDispatchCount()
    }

    private func refreshQueuedDispatchCount() async {
        queuedMeshDispatchCount = ((try? await meshDispatchStore?.queuedMeshDispatches()) ?? []).count
    }

    /// One placement attempt: rank candidates and try them in order (clone-first when a cloner is
    /// picked). Returns the landing, or nil when nothing could take it (the caller decides whether
    /// to queue). Never queues; never emits the "no runner" error itself.
    ///
    /// Covalence's exclusion invariant lives here: this device is never a candidate — the composer's
    /// "Covalence" target means "the best host on the mesh *other than* the one the job was issued
    /// from", and the origin rides the request (`covalenceOriginDeviceID`) so the landed session's
    /// between-turn rebalancing keeps honoring it.
    private func attemptMeshDispatch(
        project: Project, descriptor: ProjectDescriptor, message: String,
        model: String?, effort: String?, auto: Bool?, reveal: Bool
    ) async -> (hostID: String, sessionID: SessionID)? {
        var candidates: [MeshDispatchScorer.Candidate] = []
        for (hostID, presence) in runnerPresenceByHost where hostID != castOriginID {
            let peer = meshPeers.first { $0.deviceID == hostID }
            candidates.append(MeshDispatchScorer.Candidate(
                hostID: hostID, presence: presence,
                live: peer?.connected ?? false,
                canAcknowledgeDispatch: peer?.canAcknowledgeDispatch ?? false))
        }
        // Dispatch-time cloning: a runner that doesn't hold the repo but can create projects is
        // an eligible (lower-ranked) candidate when the repo has a cloneable origin.
        let cloneURL = await projectCloneURL(project)
        var excluded: Set<String> = []
        while true {
            guard let pick = MeshDispatchScorer.rank(
                candidates: candidates, project: descriptor,
                backend: nil, excluding: excluded, cloneURL: cloneURL).first
            else {
                meshDiag(.dispatch, level: .warning,
                    "dispatch unplaced: no eligible host",
                    detail: "\(candidates.count) candidate(s) known"
                        + (excluded.isEmpty ? "" : ", \(excluded.count) declined"))
                return nil  // exhausted — the caller queues it (or reports failure)
            }
            meshDiag(.dispatch,
                "dispatch → \(meshHostLabel(pick.hostID))\(pick.mustClone ? " (clone-first)" : "")")
            // A cloner pick: seed the repo onto the runner first, then dispatch into it. A clone
            // failure/timeout cools the candidate down and falls through to the next.
            let targetProjectID: ProjectID
            if let held = pick.projectID {
                targetProjectID = held
            } else if let cloneURL,
                      let cloned = await sendCreateProject(
                        to: pick.hostID, gitURL: cloneURL,
                        branch: project.defaultBranch.value, timeout: .seconds(180)) {
                targetProjectID = cloned
            } else {
                meshDiag(.dispatch, level: .warning,
                    "clone on \(meshHostLabel(pick.hostID)) failed — trying next candidate")
                excluded.insert(pick.hostID)
                continue
            }
            let requestID = UUID().uuidString
            let request = StartChatRequest(
                projectID: targetProjectID, message: message, model: model, effort: effort,
                auto: auto, requestID: requestID, covalenceOriginDeviceID: castOriginID)
            // One ack window, then one idempotent re-send to the SAME candidate — never a
            // fresh requestID at a new host while the first could still be starting.
            var outcome = await sendDispatch(request, to: pick.hostID, timeout: .seconds(10))
            if outcome == nil {
                outcome = await sendDispatch(request, to: pick.hostID, timeout: .seconds(5))
            }
            if let outcome, let sessionID = outcome.sessionID {
                // Surface where it landed: refresh the peer's mirrored list, then open the row
                // (remote routing in `openSessionID.didSet` handles a peer's session).
                if let peerClient {
                    _ = await peerClient.sendCommand(.listSessions, to: pick.hostID)
                    _ = await peerClient.sendCommand(.listDashboard, to: pick.hostID)
                }
                // Remember a Covalence Cloud runner landing so the sidebar folds it under this local
                // project (item 1) and renders it at next launch (item 4). A peer-Mac landing keeps
                // that Mac's own project section, so it isn't mirrored.
                if isCloudRunnerHost(pick.hostID) {
                    recordCovalenceDispatch(
                        sessionID: sessionID, hostID: pick.hostID, originProjectID: project.id)
                }
                meshDiag(.dispatch, "chat started on \(meshHostLabel(pick.hostID))",
                    detail: "session \(sessionID.short)")
                if reveal { openSessionID = sessionID }
                return (pick.hostID, sessionID)
            }
            if let failure = outcome?.error {
                lastError = "\(meshPeers.first { $0.deviceID == pick.hostID }?.label ?? "Mesh host"): \(failure)"
                meshDiag(.dispatch, level: .warning,
                    "\(meshHostLabel(pick.hostID)) declined dispatch — trying next candidate",
                    detail: failure)
            } else {
                meshDiag(.dispatch, level: .warning,
                    "no ack from \(meshHostLabel(pick.hostID)) — trying next candidate")
            }
            excluded.insert(pick.hostID)
        }
    }

    // MARK: Covalence rebalancing — keep managed sessions on the optimal host between turns

    /// How long a Covalence session rests after a rebalance evaluation (moved or declined) before
    /// it's considered again. Belt-and-braces on top of the scorer's hysteresis margin.
    static let covalenceRebalanceCooldown: TimeInterval = 120

    /// Arm (or re-arm) the debounced Covalence rebalance sweep. Fired on turn ends and on
    /// runner-presence changes ("a more optimal host opened") — the debounce coalesces the
    /// burst both produce and lets the just-finished turn's status/presence settle first.
    func scheduleCovalenceRebalanceSweep() {
        covalenceRebalanceSweepTask?.cancel()
        covalenceRebalanceSweepTask = Task { [weak self] in
            try? await Task.sleep(for: .seconds(3))
            guard !Task.isCancelled else { return }
            await self?.sweepCovalenceRebalance()
        }
    }

    /// One sweep: every idle Covalence-managed session on this host is re-ranked against the mesh,
    /// and moves when a strictly better host is open. Never touches a session mid-turn (the
    /// quiescence gates below + the transfer classifier's own re-check).
    func sweepCovalenceRebalance() async {
        for (id, controller) in controllers where !transferringSessions.contains(id) {
            let session = await controller.snapshot.session
            guard session.covalenceOriginDeviceID != nil else { continue }
            await rebalanceCovalenceSession(session)
        }
    }

    /// Evaluate one Covalence-managed session for a between-turn move, and run the move when a
    /// strictly better host is open. Gates, in order: not already moving; quiescent
    /// (`awaitingInput` — never mid-turn); nobody on the mesh is typing a follow-up into it
    /// (the `composer.typing` cast — moving a chat out from under a half-typed message would
    /// strand the draft); not an autoship chat (its post-turn merge pipeline must not race a
    /// transfer); past the per-session cooldown. Then the shared scorer decides: candidates are
    /// every presence-known peer except this host and the session's Covalence origin device, and
    /// the pick must beat our load by `MeshDispatchScorer.rebalanceMargin`.
    ///
    /// The move itself is the ordinary session transfer (2-phase commit, git bundle + mesh-
    /// mirrored transcript), and the Covalence marker rides the transfer record — so the new owner
    /// picks up the management duty and this host keeps only the tombstone.
    func rebalanceCovalenceSession(_ session: Session) async {
        let id = session.id
        guard let originDeviceID = session.covalenceOriginDeviceID,
              !covalenceRebalancingSessions.contains(id),
              !transferringSessions.contains(id),
              !session.archived,
              session.status == .awaitingInput,
              composerTypingBySession[id] == nil,
              !session.autoShip
        else { return }
        if let last = covalenceRebalanceLastAttempt[id],
           now().timeIntervalSince(last) < Self.covalenceRebalanceCooldown { return }
        guard let project = projectsByID[session.projectID],
              let descriptor = await projectDescriptors()
                  .first(where: { $0.projectID == project.id }),
              let local = await localRunnerPresence()
        else { return }

        var candidates: [MeshDispatchScorer.Candidate] = []
        for (hostID, presence) in runnerPresenceByHost
        where hostID != castOriginID && hostID != originDeviceID {
            guard let peer = meshPeers.first(where: { $0.deviceID == hostID }) else { continue }
            candidates.append(MeshDispatchScorer.Candidate(
                hostID: hostID, presence: presence,
                live: peer.connected,
                canAcknowledgeDispatch: peer.canAcknowledgeDispatch,
                canReceiveSessionTransfer: peer.canReceiveSessionTransfer))
        }
        guard let pick = MeshDispatchScorer.rebalanceTarget(
            current: local, candidates: candidates, project: descriptor)
        else {
            meshDiag(.rebalance, "\"\(session.title)\" stays here (no better host)",
                detail: "local load "
                    + String(format: "%.2f", MeshDispatchScorer.loadRatio(local))
                    + " · \(candidates.count) candidate(s)")
            return
        }

        meshDiag(.rebalance, "rebalancing \"\(session.title)\" → \(meshHostLabel(pick.hostID))")
        covalenceRebalanceLastAttempt[id] = now()
        covalenceRebalancingSessions.insert(id)
        defer { covalenceRebalancingSessions.remove(id) }
        do {
            try await moveSession(id, to: pick.hostID)
            meshDiag(.rebalance, "\"\(session.title)\" moved to \(meshHostLabel(pick.hostID))")
        } catch SessionTransferError.committedButUnconfirmed {
            // Durably moved; only the ack is missing — recovery finishes it. Not a failure.
            meshDiag(.rebalance,
                "\"\(session.title)\" moved to \(meshHostLabel(pick.hostID)) (ack pending)")
        } catch {
            // A background rebalance never alarms the user — the session simply stays here and
            // the cooldown keeps it from thrashing against a host that just rejected it.
            meshDiag(.rebalance, level: .warning,
                "rebalance of \"\(session.title)\" failed — staying here", detail: "\(error)")
        }
    }

    // MARK: Covalence mesh diagnostics (MeshDiagnostics.swift)

    /// Record one structural event on the mesh-diagnostics log (and the "mesh" unified-logging
    /// category). Structural metadata only — never prompt/draft/message content.
    func meshDiag(
        _ category: MeshDiagnosticsEvent.Category, level: MeshDiagnosticsEvent.Level = .info,
        _ message: String, detail: String? = nil
    ) {
        meshDiagnostics.record(category, level: level, message, detail: detail, at: now())
    }

    /// Assemble the Covalence-mesh diagnostics export (Settings ▸ Covalence ▸ Diagnostics): one
    /// plain-text report with this host's mesh state, the in-memory diagnostics ring, the
    /// on-disk debug trail, and the *cloud* side — the relay room's lifecycle log
    /// (`/v1/relay/roomlog`) and the runner pool's status. Every section is best-effort:
    /// an unreachable relay or unenrolled pool becomes a note in the report, never a
    /// failure — this is exactly the tool for when things are broken.
    public func exportMeshDiagnostics() async -> String {
        await meshDiagnosticsReport(detailed: false)
    }

    /// Assemble the **detailed** Covalence diagnostics export: everything
    /// `exportMeshDiagnostics()` reports, plus the Covalence-specific state a placement or
    /// rebalance bug actually lives in — the managed sessions with their cooldowns, every
    /// dispatch candidate with the load ratio the scorer ranks on, the durable parked queue,
    /// this launch's pending dispatches, the session-mirror table, in-flight transfers, the
    /// lifetime event totals, and one extra rotation generation of the on-disk trail.
    ///
    /// Same redaction invariant as the diagnostics ring (`MeshDiagnosticsEvent`): structural
    /// metadata only. Queued/pending dispatches carry the user's prompt, so their bodies are
    /// reported as a character count and never quoted.
    public func exportDetailedCovalenceDiagnostics() async -> String {
        await meshDiagnosticsReport(detailed: true)
    }

    private func meshDiagnosticsReport(detailed: Bool) async -> String {
        var out: [String] = []
        let stamp = ISO8601DateFormatter().string(from: now())
        let bundle = Bundle.main
        let version = bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "?"
        let build = bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "?"
        let identity = HostIdentityStore.loadOrCreate()

        out.append("Nucleic Covalence \(detailed ? "detailed " : "")mesh diagnostics "
            + "— exported \(stamp)")
        out.append("App: \(version) (\(build))  Host: \(syncHostName)")
        out.append("Room: \(identity.hostID)")
        out.append("Relay: \(RelayAccess.resolveBaseURL().absoluteString)")
        out.append("Mesh debug logging: \(MeshDebugLog.enabled ? "on" : "off")")
        out.append("")

        out.append("== Mesh state ==")
        out.append("Covalence running: \(syncRunning)")
        out.append("Active transports: "
            + (syncActiveTransports.isEmpty
                ? "none" : syncActiveTransports.map(\.label).sorted().joined(separator: ", ")))
        for (transport, failure) in syncTransportHealth.sorted(by: { $0.key.label < $1.key.label }) {
            out.append("Health \(transport.label): \(failure)")
        }
        for peer in meshPeers {
            out.append("Peer \(peer.label) [\(peer.deviceID.prefix(12))] "
                + (peer.connected ? "connected via \(peer.transport?.label ?? "?")" : "disconnected"))
        }
        out.append("")

        if detailed {
            out.append(contentsOf: await covalenceDetailSections())
        }

        out.append("== Diagnostics events (this launch, newest last) ==")
        if meshDiagnostics.events.isEmpty { out.append("(none recorded)") }
        let eventStamp = DateFormatter()
        eventStamp.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS"
        for event in meshDiagnostics.events {
            let line = event.detail.map { "\(event.message) — \($0)" } ?? event.message
            out.append("[\(eventStamp.string(from: event.at))] [\(event.level.rawValue)] "
                + "\(event.category.rawValue): \(line)")
        }
        out.append("")

        if detailed, let previous = MeshDebugLog.previousText() {
            out.append("== Debug trail, previous generation "
                + "(\(MeshDebugLog.previousLogFileURL.path)) ==")
            out.append(previous)
            out.append("")
        }

        out.append("== Debug trail (\(MeshDebugLog.logFileURL.path)) ==")
        out.append(MeshDebugLog.recentText()
            ?? "(no debug trail on disk — enable “Mesh debug logging” to start one)")
        out.append("")

        out.append("== Covalence cloud: relay room log ==")
        do {
            let access = relayAccess ?? RelayAccess(identity: identity)
            let data = try await access.fetchRoomLog()
            out.append(Self.formatRoomLog(data))
        } catch {
            out.append("(unavailable: \(error.localizedDescription))")
        }
        out.append("")

        out.append("== Covalence cloud: runner pool ==")
        do {
            let status = try await runnerPoolClient().status()
            out.append("Pool \(status.poolId)  epoch \(status.epoch)  "
                + "maxRunners \(status.settings.maxRunners)")
            if status.instances.isEmpty { out.append("(no runners)") }
            for instance in status.instances {
                out.append("Runner \(instance.instanceId): \(instance.state)"
                    + (instance.stale ? " (stale)" : "")
                    + "  sessions \(instance.sessions)  epoch \(instance.epoch)")
            }
        } catch {
            out.append("(unavailable: \(error.localizedDescription))")
        }
        out.append("")

        return out.joined(separator: "\n")
    }

    /// The Covalence-specific sections of the detailed export — the state the summary report
    /// leaves out because it's only interesting when placement or rebalancing is misbehaving.
    /// Best-effort like every other section: a missing store or an unreadable table becomes a
    /// note, never a failure.
    private func covalenceDetailSections() async -> [String] {
        var out: [String] = []
        let at = now()
        let stamp = DateFormatter()
        stamp.dateFormat = "yyyy-MM-dd HH:mm:ss"
        func age(_ date: Date) -> String {
            "\(stamp.string(from: date)) (\(Int(at.timeIntervalSince(date)))s ago)"
        }
        func seconds(_ interval: TimeInterval) -> String { "\(Int(interval.rounded()))s" }

        out.append("== Covalence sessions managed here ==")
        out.append("Rebalance cooldown: \(seconds(Self.covalenceRebalanceCooldown))  "
            + "margin: \(MeshDispatchScorer.rebalanceMargin)  "
            + "sweep armed: \(covalenceRebalanceSweepTask != nil)")
        let managed = await covalenceSessionDiagnostics()
        if managed.isEmpty { out.append("(none — no Covalence-managed session runs on this host)") }
        for session in managed {
            out.append("Session \(session.sessionID.rawValue) “\(session.title)” "
                + "[\(session.projectName)] status \(String(describing: session.status))")
            out.append("  origin \(meshHostLabel(session.originDeviceID)) "
                + "[\(session.originDeviceID.prefix(12))] — excluded from its rebalances")
            out.append("  rebalancing: \(session.isRebalancing)  "
                + "last attempt: \(session.lastRebalanceAttempt.map(age) ?? "never this launch")  "
                + "cooldown left: \(seconds(session.cooldownRemaining))")
        }
        out.append("")

        // The scorer's inputs, verbatim: when a chat lands somewhere surprising (or won't move),
        // this table is what `MeshDispatchScorer.rank` saw.
        out.append("== Dispatch candidates (scorer inputs) ==")
        if let local = await localRunnerPresence() {
            out.append("This host \(syncHostName): "
                + "load \(String(format: "%.3f", MeshDispatchScorer.loadRatio(local))) "
                + "(\(local.activeSessionCount)/\(local.maxConcurrent.map(String.init) ?? "∞"))  "
                + "accepts \(local.acceptsMeshDispatch)  projects \(local.projects.count)")
        }
        if runnerPresenceByHost.filter({ $0.key != castOriginID }).isEmpty {
            out.append("(no presence cards — nothing on the mesh is dispatchable right now)")
        }
        for (hostID, presence) in runnerPresenceByHost.sorted(by: { $0.key < $1.key })
        where hostID != castOriginID {
            let peer = meshPeers.first { $0.deviceID == hostID }
            out.append("Candidate \(presence.hostName) [\(hostID.prefix(12))] "
                + "kind \(String(describing: presence.kind))")
            out.append("  load \(String(format: "%.3f", MeshDispatchScorer.loadRatio(presence))) "
                + "(\(presence.activeSessionCount)/\(presence.maxConcurrent.map(String.init) ?? "∞"))"
                + "  accepts \(presence.acceptsMeshDispatch)"
                + "  agents \(presence.canRunAgents)"
                + "  canClone \(presence.canCreateProjects)")
            out.append("  live \(peer?.connected ?? false)"
                + "  ack \(peer?.canAcknowledgeDispatch ?? false)"
                + "  transferable \(peer?.canReceiveSessionTransfer ?? false)"
                + "  projects \(presence.projects.count)"
                + "  backends \(presence.backends.map { String(describing: $0) }.sorted().joined(separator: ",") )")
            out.append("  presence updated \(age(presence.updatedAt))")
        }
        out.append("")

        // Prompt bodies stay out (redaction invariant) — length only, so a truncated or empty
        // message is still diagnosable.
        out.append("== Parked dispatch queue (durable) ==")
        let queued = await queuedMeshDispatchRecords()
        if queued.isEmpty { out.append("(empty)") }
        for record in queued {
            out.append("Dispatch \(record.id) project \(record.projectID.rawValue) "
                + "queued \(age(record.createdAt))")
            out.append("  model \(record.model ?? "default")  effort \(record.effort ?? "default")"
                + "  auto \(record.auto.map(String.init) ?? "default")"
                + "  message \(record.message.count) chars (withheld)")
        }
        out.append("")

        out.append("== Pending Covalence dispatches (this launch) ==")
        if pendingCovalenceDispatches.isEmpty { out.append("(none)") }
        for pending in pendingCovalenceDispatches {
            out.append("Pending \(pending.id) project \(pending.projectID.rawValue) "
                + "created \(age(pending.createdAt))")
            out.append("  waitingForRunner \(pending.waitingForRunner)"
                + "  failure \(pending.failure ?? "none")"
                + "  message \(pending.message.count) chars (withheld)")
        }
        out.append("")

        out.append("== Covalence session mirrors ==")
        out.append("Loaded from defaults: \(covalenceMirrorsLoaded)")
        if covalenceMirrors.isEmpty { out.append("(none — nothing dispatched from this host)") }
        for mirror in covalenceMirrors.values.sorted(by: { $0.sessionID.rawValue < $1.sessionID.rawValue }) {
            out.append("Mirror \(mirror.sessionID.rawValue) → \(meshHostLabel(mirror.hostID)) "
                + "[\(mirror.hostID.prefix(12))]  origin project \(mirror.originProjectID.rawValue)")
            out.append("  summary \(mirror.summary == nil ? "not yet seen" : "cached")"
                + "  refreshed \(age(mirror.updatedAt))")
        }
        out.append("")

        out.append("== Active session transfers ==")
        let transfers = await activeTransferDiagnostics()
        if transfers.isEmpty { out.append("(none in flight)") }
        for lock in transfers {
            out.append("Transfer \(lock.transferID) session \(lock.sessionID.rawValue) "
                + "\(String(describing: lock.direction)) \(meshHostLabel(lock.peerDeviceID))")
            out.append("  state \(String(describing: lock.state))"
                + "  opened \(age(lock.createdAt))  updated \(age(lock.updatedAt))")
        }
        out.append("")

        // Lifetime totals outlive ring trimming — they say whether a category ever fired at all.
        out.append("== Event totals since launch ==")
        out.append("Retained \(meshDiagnostics.events.count)/\(meshDiagnostics.capacity) events")
        for category in MeshDiagnosticsEvent.Category.allCases {
            out.append("  \(category.rawValue): \(meshDiagnostics.totalsByCategory[category] ?? 0)")
        }
        out.append("")

        return out
    }

    /// Render the relay's `/v1/relay/roomlog` JSON (`{"events":[{"at":ms,"ev":"…"}]}`) as
    /// readable timestamped lines; falls back to the raw body on shape drift.
    private static func formatRoomLog(_ data: Data) -> String {
        struct RoomLog: Decodable {
            struct Entry: Decodable {
                let at: Double
                let ev: String
            }
            let events: [Entry]
        }
        guard let log = try? JSONDecoder().decode(RoomLog.self, from: data) else {
            return String(decoding: data, as: UTF8.self)
        }
        guard !log.events.isEmpty else { return "(no room events recorded yet)" }
        let stamp = ISO8601DateFormatter()
        stamp.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
        return log.events
            .map { "[\(stamp.string(from: Date(timeIntervalSince1970: $0.at / 1000)))] \($0.ev)" }
            .joined(separator: "\n")
    }

    /// A friendly display label for a mesh host id, for diagnostics rows/log lines: this host's
    /// own name, a paired peer's label, the presence card's host name, or a short id prefix.
    public func meshHostLabel(_ hostID: String) -> String {
        if hostID == castOriginID { return syncHostName }
        if let peer = meshPeers.first(where: { $0.deviceID == hostID }), !peer.label.isEmpty {
            return peer.label
        }
        if let name = runnerPresenceByHost[hostID]?.hostName, !name.isEmpty { return name }
        return String(hostID.prefix(8))
    }

    /// The chats parked in the durable mesh-dispatch queue, for the viewer's queue section.
    /// (The message body stays out of the UI — the viewer shows project/date/model only.)
    public func queuedMeshDispatchRecords() async -> [MeshDispatchRecord] {
        guard let store = meshDispatchStore else { return [] }
        return (try? await store.queuedMeshDispatches()) ?? []
    }

    /// Drop a single parked dispatch from the durable queue — the viewer's per-row remove action.
    /// The job is abandoned (never retried); the discarded prompt is gone with it.
    public func removeQueuedMeshDispatch(id: String) async {
        guard let store = meshDispatchStore else { return }
        try? await store.removeMeshDispatch(id: id)
        meshDiag(.dispatch, "parked dispatch removed by user")
        await refreshQueuedDispatchCount()
    }

    /// Every in-flight transfer lock (both directions), for the viewer's transfers section.
    public func activeTransferDiagnostics() async -> [SessionTransferLock] {
        (try? await database.allActiveTransfers()) ?? []
    }

    /// Every Covalence-managed session on this host plus its rebalance bookkeeping, for the
    /// viewer's Covalence sessions section.
    public func covalenceSessionDiagnostics() async -> [CovalenceSessionDiagnostic] {
        var out: [CovalenceSessionDiagnostic] = []
        for (id, controller) in controllers {
            let session = await controller.snapshot.session
            guard let origin = session.covalenceOriginDeviceID, !session.archived else { continue }
            let last = covalenceRebalanceLastAttempt[id]
            let remaining = last.map {
                max(0, Self.covalenceRebalanceCooldown - now().timeIntervalSince($0))
            } ?? 0
            out.append(CovalenceSessionDiagnostic(
                sessionID: id, title: session.title,
                projectName: projectsByID[session.projectID]?.name ?? "",
                originDeviceID: origin, status: session.status,
                isRebalancing: covalenceRebalancingSessions.contains(id)
                    || transferringSessions.contains(id),
                lastRebalanceAttempt: last, cooldownRemaining: remaining))
        }
        return out.sorted { $0.title < $1.title }
    }

    // MARK: Mesh messaging (the "messages" channel)

    /// The tool bridge a new session's controller gets when agent messaging is enabled —
    /// provenance is stamped here from the calling session's record, never from tool args.
    func meshMessagingHandlers(for sessionID: SessionID) -> MeshMessagingHandlers? {
        guard MeshMessagingSettings.enabled else { return nil }
        return MeshMessagingHandlers(
            send: { [weak self] request in
                await self?.sendMeshMessage(from: sessionID, request: request)
                    ?? "Nucleic is no longer available."
            },
            subscribe: { [weak self] topic in
                await self?.subscribeMeshTopic(session: sessionID, topic: topic)
                    ?? "Nucleic is no longer available."
            },
            waitForMessage: { [weak self] topic, timeoutSeconds in
                await self?.waitForMeshMessage(
                    session: sessionID, topic: topic, timeoutSeconds: timeoutSeconds)
                    ?? "{\"error\": \"Nucleic is no longer available.\"}"
            })
    }

    /// The `nucleic_monitor` event sink for a session: deliver a fired monitor's output back into
    /// the session as a fresh turn (the cross-turn re-invocation the native `Monitor` can't do
    /// under Nucleic's single-shot turn model). Injection is an ordinary `sendInput` — it starts a
    /// turn when the session is idle, queues (and coalesces) when one is in flight. The agent sees
    /// the wake-up as user-role text, but it is *not* echoed to the transcript as a user message:
    /// nobody typed it, and a wall of monitored command output masquerading as the user's own bubble
    /// misreads the conversation badly. A muted note carries the same payload instead, so every
    /// wake-up stays auditable while reading as what it is — machine output. Supplied
    /// unconditionally; the backend only calls it for a monitor the agent itself armed.
    func monitorEventSink(for sessionID: SessionID) -> MonitorEventSink {
        { [weak self] description, event in
            guard let self, let controller = self.controllers[sessionID] else { return }
            let header = "[Monitor: \(description)]"
            await controller.note("\(header)\n\(event)", icon: "binoculars")
            try? await controller.sendInput(
                AgentInput(text: "\(header)\n\(event)"), echoToTranscript: false)
        }
    }

    /// Block until a mesh message arrives for `sessionID` (optionally on `topic`) or the timeout
    /// lapses, returning it as the tool result (`nucleic_wait_for_message`). A `topic` is
    /// subscribed for the session first so the message reaches it. One waiter per session — a new
    /// wait supersedes a pending one. Resolved by `deliverMeshMessages` on a match, by the timeout,
    /// or by session teardown (`resolveMeshWaiters`).
    func waitForMeshMessage(
        session sessionID: SessionID, topic: String?, timeoutSeconds: Int?
    ) async -> String {
        guard MeshMessagingSettings.enabled else {
            return "{\"error\": \"Mesh messaging is disabled in Settings.\"}"
        }
        let trimmedTopic = topic?.trimmingCharacters(in: .whitespacesAndNewlines)
        let filterTopic = (trimmedTopic?.isEmpty == false) ? trimmedTopic : nil
        if let filterTopic { meshTopicSubscriptions[sessionID, default: []].insert(filterTopic) }
        // Supersede any pending waiter for this session (only one park at a time).
        meshMessageWaiters.removeValue(forKey: sessionID)?
            .continuation.resume(returning: "{\"superseded\": true}")
        let seconds = min(max(timeoutSeconds ?? 300, 1), 3600)
        return await withCheckedContinuation { continuation in
            meshMessageWaiters[sessionID] = (filterTopic, continuation)
            Task { [weak self] in
                try? await Task.sleep(for: .seconds(seconds))
                self?.meshMessageWaiters.removeValue(forKey: sessionID)?
                    .continuation.resume(returning: "{\"timedOut\": true}")
            }
        }
    }

    /// Resolve every pending mesh-message waiter (session teardown / server stop) so a parked
    /// tool call never hangs a dying run.
    func resolveMeshWaiters(reason: String) {
        for (_, waiter) in meshMessageWaiters {
            waiter.continuation.resume(returning: "{\"unavailable\": true, \"reason\": \"\(reason)\"}")
        }
        meshMessageWaiters.removeAll()
    }

    /// Whether `sessionID` has a parked waiter that should intercept `message` (returning it as
    /// the tool result) rather than have it injected as a user turn. Resumes + clears on a match.
    private func deliverToMeshWaiter(_ message: MeshMessage, session sessionID: SessionID) -> Bool {
        guard let waiter = meshMessageWaiters[sessionID] else { return false }
        // A topic-scoped wait only catches messages on that topic; a nil-topic wait catches any.
        if let want = waiter.topic, want != message.topic { return false }
        meshMessageWaiters.removeValue(forKey: sessionID)
        var payload: [String: String] = [
            "from": message.fromSessionTitle.map { "\($0) on \(message.fromHostName)" }
                ?? message.fromHostName,
            "body": message.body,
        ]
        if let topic = message.topic { payload["topic"] = topic }
        let json = (try? JSONSerialization.data(withJSONObject: payload))
            .flatMap { String(data: $0, encoding: .utf8) } ?? "{\"body\": \"\(message.body)\"}"
        waiter.continuation.resume(returning: json)
        return true
    }

    /// Publish one mesh message on behalf of a session (the `nucleic_send_message` handler).
    /// Returns the agent-facing result text.
    func sendMeshMessage(from sessionID: SessionID, request: MeshSendRequest) async -> String {
        guard MeshMessagingSettings.enabled else { return "Mesh messaging is disabled in Settings." }
        let body = request.body.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !body.isEmpty else { return "Nothing to send: `body` is empty." }
        guard body.utf8.count <= MeshMessage.maxBodyBytes else {
            return "Message too large (\(body.utf8.count) bytes; max \(MeshMessage.maxBodyBytes)). "
                + "Send a summary and offer details on request."
        }
        let topic = request.topic?.trimmingCharacters(in: .whitespacesAndNewlines)
        var toHostID: String?
        if let to = request.to?.trimmingCharacters(in: .whitespacesAndNewlines), !to.isEmpty {
            guard let resolved = resolveMeshHost(named: to) else {
                let known = ([syncHostName] + pairedDevices.filter(\.kind.canOwnSessions).map(\.label))
                    .joined(separator: ", ")
                return "No mesh host matches \"\(to)\". Known hosts: \(known)."
            }
            toHostID = resolved
        }
        switch (topic?.isEmpty == false, toHostID != nil) {
        case (false, false):
            return "Pass a destination: `topic`, or `to` (a host) with optional `session`."
        case (true, true):
            return "Pass exactly one destination: `topic` OR `to`/`session`, not both."
        default: break
        }
        // Provenance from the HOST's own session record — tool args can't forge identity.
        let title = await controllers[sessionID]?.snapshot.session.title
        let message = MeshMessage(
            fromSessionID: sessionID, fromSessionTitle: title, fromHostName: syncHostName,
            topic: (topic?.isEmpty == false) ? topic : nil,
            toHostID: toHostID,
            toSessionID: request.sessionID.map(SessionID.init(rawValue:)),
            body: body, at: now())
        guard let payload = message.encodedCastPayload() else { return "Couldn't encode the message." }
        let key = message.topic.map { "topic:\($0)" } ?? "host:\(toHostID ?? "")"
        await publishCast(
            channel: MeshCastChannel.messages, typeID: MeshMessage.typeID,
            key: key, payload: payload)
        if let topic = message.topic {
            return "Broadcast on topic \"\(topic)\". Subscribed sessions across the mesh receive it as a user turn."
        }
        return "Sent. It will land in the target as a user turn (delivery is asynchronous and best-effort)."
    }

    /// Subscribe a session to a broadcast topic (the `nucleic_subscribe_topic` handler).
    func subscribeMeshTopic(session sessionID: SessionID, topic: String) async -> String {
        let trimmed = topic.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else { return "Pass a non-empty `topic`." }
        meshTopicSubscriptions[sessionID, default: []].insert(trimmed)
        return "Subscribed to \"\(trimmed)\" for this session's lifetime."
    }

    /// Resolve a host reference from tool args: this host's name, a paired host's label, or a
    /// hostID (full or unambiguous prefix, 8+ chars).
    private func resolveMeshHost(named reference: String) -> String? {
        let needle = reference.lowercased()
        if needle == syncHostName.lowercased() || needle == castOriginID.lowercased() {
            return castOriginID
        }
        let hosts = pairedDevices.filter { $0.kind.canOwnSessions }
        if let byLabel = hosts.first(where: { $0.label.lowercased() == needle }) {
            return byLabel.deviceID
        }
        if needle.count >= 8 {
            let byID = hosts.filter { $0.deviceID.lowercased().hasPrefix(needle) }
            if byID.count == 1 { return byID[0].deviceID }
        }
        return nil
    }

    /// Deliver freshly stored mesh messages addressed to THIS host: a directed message to a
    /// live session it owns, or a topic broadcast to sessions subscribed here. Injection is an
    /// ordinary queued user turn — mid-turn it queues (the composer's existing semantics), and
    /// it's transcript-visible, so every delivery is auditable. At-most-once per host: only
    /// freshly inserted casts reach this (the ingest dedup), and a session never receives its
    /// own message back.
    private func deliverMeshMessages(_ casts: [WireCast]) {
        guard MeshMessagingSettings.enabled else { return }
        for cast in casts where cast.channel == MeshCastChannel.messages {
            guard let message = MeshMessage.decode(cast) else { continue }
            var targets: [SessionID] = []
            if let toHostID = message.toHostID {
                guard toHostID == castOriginID else { continue }  // directed elsewhere
                if let toSession = message.toSessionID {
                    targets = [toSession]
                }
                // Host-directed with no session id names a device, not a chat — nothing to
                // inject into; dropped (a future device-level inbox could surface these).
            } else if let topic = message.topic {
                // Drop subscriptions whose session is gone, then deliver to the rest.
                meshTopicSubscriptions = meshTopicSubscriptions.filter { controllers[$0.key] != nil }
                targets = meshTopicSubscriptions
                    .filter { $0.value.contains(topic) }
                    .map(\.key)
            }
            for target in targets where target != message.fromSessionID {
                // A session parked in `nucleic_wait_for_message` receives it as the tool result
                // (returned to the blocked agent) rather than a queued user turn.
                if deliverToMeshWaiter(message, session: target) { continue }
                guard let controller = controllers[target] else { continue }
                let header = message.fromSessionTitle.map {
                    "[Mesh message from \"\($0)\" on \(message.fromHostName)]"
                } ?? "[Mesh message from \(message.fromHostName)]"
                Task {
                    try? await controller.sendInput(AgentInput(text: "\(header)\n\(message.body)"))
                }
            }
        }
    }

    /// Record one fleet-activity item (the "activity" stream channel): what just happened on
    /// this host, broadcast mesh-wide so every Mac and phone renders one merged feed. Durable
    /// via `publishCast` even while the sync server is down — late joiners replay it. The feed
    /// never alerts (the existing notification/alarm paths stay the only alerting); it's a
    /// passive history surface.
    func recordFeedEvent(
        _ kind: FeedEvent.Kind, session: Session, detail: String? = nil,
        approvalID: ApprovalID? = nil,
        counterpartHostID: String? = nil, counterpartHostName: String? = nil
    ) {
        let event = FeedEvent(
            kind: kind, sessionID: session.id, sessionTitle: session.title,
            projectName: projectsByID[session.projectID]?.name ?? "",
            hostName: syncHostName, detail: detail, approvalID: approvalID,
            counterpartHostID: counterpartHostID, counterpartHostName: counterpartHostName,
            at: now())
        guard let payload = event.encodedCastPayload() else { return }
        Task {
            await publishCast(
                channel: MeshCastChannel.activity, typeID: FeedEvent.typeID, payload: payload)
        }
    }

    /// A user answered an approval (`approvalResolved`): cast the outcome onto the mesh — the
    /// `responded` bookend to the `needsApproval` feed item — and log a structural diagnostics row
    /// so the exchange shows in the Covalence Mesh Viewer. Consumes the remembered request meta so
    /// the row can name what was answered without the (already-dropped) request. Sentinel resolvers
    /// (session teardown's "system" deny, an always-rule auto-allow) carry no device identity and
    /// aren't deliberate human answers — dropped so the feed and viewer reflect only real responses.
    private func recordApprovalResponse(_ res: ApprovalResolved, session: Session) {
        let meta = approvalMetaByID.removeValue(forKey: res.id)
        guard res.decidedBy != "system", res.decidedBy != "always-rule" else { return }
        let isQuestion = meta?.isQuestion ?? false
        let verb: String = switch res.decision {
        case .allow, .allowAlways: isQuestion ? "answered" : "approved"
        case .deny: "denied"
        case .cancelRun: "cancelled"
        }
        // The title is the same one-liner already broadcast when the approval surfaced — structural,
        // never the tool input or the chosen answer text.
        let title = meta?.title
        let subject = isQuestion ? "question" : "approval"
        let feedDetail = title.map { "\(verb.capitalized): \($0)" } ?? verb.capitalized
        recordFeedEvent(.responded, session: session, detail: feedDetail, approvalID: res.id)
        let responder = ResponderLabel.split(res.decidedBy).name
        meshDiag(.approval, "\(subject) \(verb) by \(responder)", detail: title)
    }

    /// The cursors this device's peer dials subscribe with — the tips of every (origin,
    /// channel) it holds, own casts included (echo suppression on the serving side skips those
    /// anyway; carrying them costs a few bytes and keeps the contract simple).
    func castSubscribeCursors() async -> [CastCursor] {
        await castTips().map {
            CastCursor(originHostID: $0.originHostID, channel: $0.channel, afterSeq: $0.tipSeq)
        }
    }

    /// The single ingest path for casts arriving from peer Macs/runners (mesh casting): store
    /// idempotently, then re-broadcast ONLY the freshly inserted rows to this host's own
    /// subscribers (phones, and peers dialed into us). The insert-nothing ⇒ re-broadcast-nothing
    /// guard is what terminates gossip loops (the `MeshRosterMerge` convergence trick); the
    /// origin's own casts never bounce back to it thanks to `deliver`'s echo suppression.
    func ingestPeerCasts(_ event: PeerClient.PeerCastEvent, from deviceID: String) async {
        guard let castStore else { return }
        do {
            switch event {
            case .live(let casts):
                let fresh = try await castStore.ingestCasts(casts)
                if !fresh.isEmpty {
                    broadcast(.casts(fresh))
                    appendToActivityFeed(fresh)
                    deliverMeshMessages(fresh)
                    updateRunnerPresence(fresh)
                    updateComposerTyping(fresh)
                    recordCastIngest(fresh, from: deviceID, catchUp: false)
                }
            case .catchUp(let batch):
                if batch.reset {
                    // Baseline replace for this (origin, channel): a state snapshot from a
                    // fresher holder, or a stream re-baseline past a retention gap. Later
                    // batches of the same catch-up arrive with reset == false and append.
                    try await castStore.replaceCasts(
                        origin: batch.originHostID, channel: batch.channel, with: batch.casts)
                    if !batch.casts.isEmpty {
                        broadcast(.casts(batch.casts))
                        appendToActivityFeed(batch.casts)
                        deliverMeshMessages(batch.casts)
                        updateRunnerPresence(batch.casts)
                        updateComposerTyping(batch.casts)
                        recordCastIngest(batch.casts, from: deviceID, catchUp: true)
                    }
                } else {
                    let fresh = try await castStore.ingestCasts(batch.casts)
                    if !fresh.isEmpty {
                        broadcast(.casts(fresh))
                        appendToActivityFeed(fresh)
                        deliverMeshMessages(fresh)
                        updateRunnerPresence(fresh)
                        updateComposerTyping(fresh)
                        recordCastIngest(fresh, from: deviceID, catchUp: true)
                    }
                }
            }
        } catch {
            castLog.error("peer cast ingest failed: \(error)")
            meshDiag(.cast, level: .error,
                "peer cast ingest failed from \(meshHostLabel(deviceID))", detail: "\(error)")
        }
    }

    /// Diagnostics: the peer-presence deltas worth a log row — connects (with the transport the
    /// dial landed on), disconnects (with the dialer's last error, when it kept one), and a live
    /// connection re-forming over a different transport (Wi-Fi ⇄ tailnet ⇄ relay).
    private func recordMeshPeerChanges(
        from old: [PeerClient.PeerPresence], to new: [PeerClient.PeerPresence]
    ) {
        let oldByID = Dictionary(old.map { ($0.deviceID, $0) }, uniquingKeysWith: { a, _ in a })
        for peer in new {
            let label = peer.label.isEmpty ? String(peer.deviceID.prefix(8)) : peer.label
            let before = oldByID[peer.deviceID]
            if peer.connected, before?.connected != true {
                meshDiag(.peer,
                    "peer connected: \(label) via \(peer.transport?.label ?? "unknown transport")")
            } else if !peer.connected, before?.connected == true {
                meshDiag(.peer, level: .warning,
                    "peer disconnected: \(label)", detail: peer.lastError)
            } else if peer.connected, let before, before.connected,
                      before.transport != peer.transport {
                meshDiag(.peer,
                    "peer \(label) switched to \(peer.transport?.label ?? "unknown transport")")
            }
        }
    }

    /// Diagnostics: summarize one ingest fan-out as a single log row ("casts ⇐ Studio —
    /// activity×3, runner.presence×1"). Typing casts are skipped (throttled per keystroke —
    /// they'd flood the log; the viewer shows live typing via `composerTypingBySession`).
    private func recordCastIngest(_ casts: [WireCast], from deviceID: String, catchUp: Bool) {
        let visible = casts.filter { $0.channel != MeshCastChannel.composerTyping }
        guard !visible.isEmpty else { return }
        let byChannel = Dictionary(grouping: visible, by: \.channel)
            .map { "\($0.key)×\($0.value.count)" }
            .sorted().joined(separator: ", ")
        meshDiag(.cast,
            "casts ⇐ \(meshHostLabel(deviceID))\(catchUp ? " (catch-up)" : "")",
            detail: byChannel)
    }

    /// Split a catch-up into ordered `CastCatchUp` batches (≤ `maxCastsPerBatch`), the last one
    /// `complete: true`; an empty backlog still yields one empty complete batch so the cursor
    /// advances. `reset` rides only the first batch (the receiver clears once, then appends).
    static func chunkCastCatchUp(
        origin: String, channel: String, mode: CastMode, casts: [WireCast],
        tipSeq: UInt64, reset: Bool
    ) -> [CastCatchUp] {
        var batches: [CastCatchUp] = []
        var index = 0
        var start = casts.startIndex
        repeat {
            let end = casts.index(
                start, offsetBy: CastCatchUp.maxCastsPerBatch, limitedBy: casts.endIndex)
                ?? casts.endIndex
            batches.append(CastCatchUp(
                originHostID: origin, channel: channel, mode: mode, batchIndex: index,
                casts: Array(casts[start..<end]), complete: end == casts.endIndex,
                tipSeq: tipSeq, reset: reset && index == 0))
            index += 1
            start = end
        } while start < casts.endIndex
        return batches
    }
}
