import Foundation
import Observation

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

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

    public static let empty = DashboardStats()
}

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

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

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

/// A compact, `Sendable` view of a session for the sidebar / dashboard list.
public struct SessionSummary: Sendable, Identifiable, Equatable {
    public let id: SessionID
    public let projectID: ProjectID
    public var title: String
    public var status: SessionStatus
    /// Refines `.awaitingInput`: whether the last turn ended asking the user
    /// something vs. finishing the work. `nil` until classified.
    public var disposition: TurnDisposition?
    public var diffStat: DiffStat?
    public var pendingApprovalCount: Int
    /// When the chat is blocked on an `AskUserQuestion` approval, the number of
    /// questions it's asking — drives the sidebar's "Question Asked" / "Questions
    /// Asked" label (plural for a multi-question call). `nil` when the pending block
    /// isn't a question (a plain approval) or there's none.
    public var pendingQuestionCount: Int?
    public var auto: Bool
    /// The session's selected reasoning effort, or the orchestra sentinel when orchestration
    /// mode is on — raw per-session state (not default-resolved), so `nil` means the chat
    /// inherits the app default. Drives the sidebar's orchestra marker via `isOrchestra`.
    public var effort: String?
    public var favorite: Bool
    public var archived: Bool
    /// The chat finished its work but the user hasn't opened it since — drives the
    /// sidebar's unread dot. Only meaningful together with `isCompleted`.
    public var unseenCompletion: Bool
    /// Autoship was the most recent thing to happen in this chat (a merge-queue note,
    /// with nothing since) — drives the sidebar's autoship icon. See
    /// `Session.lastEventWasAutoship`.
    public var lastEventWasAutoship: Bool
    /// Autoship disabled itself on a hard failure and the user hasn't yet seen it — drives
    /// the sidebar's "autoship needs attention" icon. Cleared on re-enable or on opening the
    /// chat. See `Session.autoShipFailed`.
    public var autoShipFailed: Bool
    /// Autoship hit a merge *conflict* but stayed armed — drives the same sidebar "needs
    /// attention" icon, without implying autoship is off. Cleared when the work lands or the
    /// user re-arms. See `Session.autoShipConflict`.
    public var autoShipConflict: Bool
    public var createdAt: Date
    public var updatedAt: Date
    /// When the user last sent a message here; drives the sidebar's ordering. Falls
    /// back to `createdAt` when sorting if the chat has never received one (or predates
    /// the tracking). See `Session.lastUserMessageAt`.
    public var lastUserMessageAt: Date?

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

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

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

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

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

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

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

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

    public private(set) var projects: [Project] = []
    /// Archived projects (hidden from the active list), most-recently-archived first.
    public private(set) var archivedProjects: [Project] = []
    public private(set) var summaries: [SessionSummary] = []
    /// Captured ideas (the home "to-do" inbox), most-recently-updated first.
    public private(set) var todos: [Todo] = []
    /// Drives the global quick-add to-do sheet (toggled by the Cmd-T command).
    public var quickTodoPresented: Bool = false
    /// Project to pre-select in the quick-add sheet (set when capturing from a
    /// project page; nil for a global Cmd-T capture).
    public var quickTodoProjectID: ProjectID?
    /// When set, the edit-to-do sheet is shown for this idea (home or project page).
    public var editingTodoID: TodoID?
    /// Per-group to-do summaries, keyed by group id (project id, or "" unassigned).
    /// Filled asynchronously; `todoGroups` reads it so the UI updates as they land.
    private var todoSummaries: [String: String] = [:]
    /// Content fingerprint per group so a group is only re-summarized when its ideas
    /// actually change (avoids a model call on every unrelated mutation).
    private var todoSummarySignatures: [String: Int] = [:]
    // The collapsed tool-call block's AI headline is built family-by-family. Bash takes a
    // map→reduce path: each command is gisted once (`bashCallSummaries`), and the block's
    // running list of gists is condensed into one live line (`bashBlockLines`), re-merged
    // additively as the block grows. Every other eligible family (Grep/Glob/… under the
    // "all" scope) is merged once it settles (`familySummaryLines`). The view composes
    // these per-family, showing a loading placeholder for an eligible family until its
    // line lands — so the deterministic heuristic never flashes in for a summarized call.

    /// Per-command Bash gist (the map step), keyed by the single call's content signature
    /// (`TranscriptProjection.toolGroupSignature([call])`) so each distinct command is
    /// summarized exactly once and reused as the block grows. Filled asynchronously.
    private var bashCallSummaries: [Int: String] = [:]
    /// Single-call signatures with a map task in flight (don't re-summarize the same command).
    private var bashCallSummariesInFlight: Set<Int> = []
    /// The merged Bash line per block (the reduce step), keyed by the block's STABLE id —
    /// the first Bash call's `toolCallID`, which doesn't change as the run appends — so the
    /// line updates in place (no flicker) as the block grows. Filled asynchronously; the
    /// transcript's Bash block reads it as it lands.
    private var bashBlockLines: [String: String] = [:]
    /// The signature of the per-command gist list that produced `bashBlockLines[id]`, so a
    /// re-merge fires exactly when the block's gists actually change (a new command lands)
    /// and not on every unrelated transcript mutation.
    private var bashBlockInputs: [String: Int] = [:]
    /// Block ids with a map/reduce task in flight. Without this guard each event spawns a
    /// rival task; the on-device model is non-deterministic, so rivals write different lines
    /// and the headline visibly flashes. A block is worked exactly once per gist-list change.
    private var bashBlocksInFlight: Set<String> = []
    /// AFM-merged lines per non-Bash eligible family (Grep/Glob/… under the "all tool calls"
    /// scope), keyed by the family's content signature. Filled asynchronously once the
    /// family settles. Read families are never here — they always get the heuristic merge.
    private var familySummaryLines: [Int: [String]] = [:]
    /// Family signatures with a summary task in flight (summarize each settled family once).
    private var familyLinesInFlight: Set<Int> = []
    /// Tool-group signatures already present when the open chat was loaded from a PRIOR
    /// app run — its pre-existing history. These are never AI-summarized: opening an old
    /// chat must not fire a burst of on-device model calls over its whole transcript.
    /// Only groups produced by live turns after a chat is opened get summarized. Empty
    /// for chats created this run (all their activity is live). Recomputed on each open.
    private var historicalToolGroupSignatures: Set<Int> = []
    /// Sessions created during this app run. Their transcripts have no "history" to skip —
    /// every tool group is live activity, so opening one summarizes everything (whereas a
    /// chat carried over from a previous run baselines its loaded groups as historical).
    private var sessionsCreatedThisRun: Set<SessionID> = []
    /// Home-dashboard rollup across all projects/sessions (recomputed on demand).
    public private(set) var dashboard: DashboardStats = .empty
    /// User-message counts per calendar day, for the activity grid and the streak.
    public private(set) var activityByDay: [Date: Int] = [:]
    /// Tokens used per calendar day — the activity grid's primary intensity signal (it falls back
    /// to `activityByDay` for days with no recorded usage), and the per-day series behind the
    /// dashboard's total-tokens metric.
    public private(set) var tokensByDay: [Date: Int] = [:]
    public var openSessionID: SessionID? {
        didSet {
            guard oldValue != openSessionID else { return }
            // Opening a chat is the user "returning to view it" — silence its escalating alarm.
            if let opened = openSessionID { updateAlarm(clearing: opened) }
            // Reset synchronously so the (possibly shorter) newly-selected session's
            // transcript can replace the previous one; the async reload then fills it.
            openTranscript = []
            openTranscriptVersion &+= 1
            openApprovals = []
            openSession = nil
            openSummary = ""
            clearToolGroupSummaries()  // the previous session's tool-group summaries don't apply
            historicalToolGroupSignatures = []  // recomputed for the newly-opened chat in reloadOpen
            summaryToken += 1  // invalidate any in-flight summary for the previous session
            intelligenceWorkSeq += 1
            reloadTask = Task { await reloadOpen() }
        }
    }
    /// When set (and no session is open), the detail pane shows the project
    /// overview page instead of the home dashboard. Cleared by `goHome()`.
    public var openProjectID: ProjectID?
    public private(set) var openTranscript: [AgentEvent] = []
    /// Monotonic token bumped on every assignment to `openTranscript` (the three writers:
    /// the session-switch reset above, `revertOpenSession`, and `applyOpenTranscript`). The
    /// transcript view keys its projection cache on this, so the many body re-evaluations
    /// that don't touch the transcript (scrolling, hover, viewport measurement) can skip
    /// recomputing the O(n) projection. A plain `count`/`last.seq` signature is *not* enough:
    /// `revert` resets the canonical seq counter (Transcript.swift), so a revert-then-regrow
    /// can reuse a prior (count, seq) pair over different content — a strictly increasing
    /// version cannot collide.
    public private(set) var openTranscriptVersion: Int = 0
    public private(set) var openApprovals: [ApprovalRequest] = []
    /// Full state of the open session (title, model, effort, status…) for the detail view.
    public private(set) var openSession: Session?
    /// Model-generated "what just happened" blurb for the open session's summary card.
    public private(set) var openSummary: String = ""
    public private(set) var summarizing: Bool = false
    /// Surfaced transient error for the UI; settable so views can post their own.
    /// Changing the message clears any deep-link (`lastErrorSessionID`) so a generic
    /// error never inherits a stale chat target — set the link *after* the message.
    public var lastError: String? {
        didSet { if lastError != oldValue { lastErrorSessionID = nil } }
    }
    /// 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?
    /// 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)?
    private var summaryToken = 0
    private var namedSessions: Set<SessionID> = []
    /// Per-session bookkeeping for the one-time post-run rename (see `maybeRenameSession`):
    /// the opening message and the provisional title we assigned, plus the sessions whose
    /// rename pass has already run so it fires at most once.
    private var firstMessages: [SessionID: String] = [:]
    private var autoTitles: [SessionID: String] = [:]
    private var renamedSessions: Set<SessionID> = []

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

    /// In-flight intelligence work, tracked so tests can deterministically await it
    /// (see `awaitOpenSessionSettled`) instead of sleeping. These chain: reopening a
    /// session can spawn a summary, and a turn's classification spawns a summary too.
    private var reloadTask: Task<Void, Never>?
    private var classifyTask: Task<Void, Never>?
    private var summaryTask: Task<Void, Never>?
    /// Most recently issued tool-group summary task (one of possibly several per
    /// transcript update); drained by the settle helper alongside the others.
    private var toolGroupTask: Task<Void, Never>?
    /// 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 defaultAuto: Bool = false
    public var defaultAutoShip: Bool = false

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

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

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

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

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

    private let database: any SessionMetadataStore
    private let worktrees: any WorktreeManaging
    private let backendFactory: BackendFactory
    private let transcriptsDir: URL
    private let now: @Sendable () -> Date
    /// Shared sandbox orchestrator (nil in tests / non-sandbox builds). Same instance the
    /// backend factory hands to each `ClaudeCodeBackend`, so lifecycle stays consistent.
    private let containerManager: ContainerManager?
    /// 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>] = [:]

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

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

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

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

    /// The running LAN sync server (nil when remote access is off).
    private var syncHost: SyncHost?
    private var syncListener: LANListener?
    /// Whether the iPhone remote server is currently listening.
    public private(set) var syncRunning = 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] = []
    /// Sessions currently blocked in the `LockManager` queue, so the UI can show a "waiting"
    /// indicator on the blocked chat (LOCKING §4). Driven by `arbitrate` + `reconcileLocks`.
    public private(set) var sessionsWaitingForAccess: Set<SessionID> = []
    /// Sessions caught in a wait-for cycle the queue can't clear on its own — a genuine deadlock
    /// (LOCKING §8). Non-empty drives the toolbar's File Locks button to its orange alarm state.
    /// Recomputed alongside `sessionsWaitingForAccess`.
    public private(set) var deadlockedSessions: Set<SessionID> = []

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

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

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

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

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

    // MARK: - Projects

    public func loadProjects() async {
        let loaded = (try? await database.loadProjects()) ?? []
        // Active projects drive the sidebar/pickers; archived ones are kept aside but still
        // resolvable by id (so a lingering session/todo lookup never comes back empty).
        projects = loaded.filter { !$0.isArchived }.sorted { $0.name < $1.name }
        archivedProjects = loaded.filter { $0.isArchived }
            .sorted { ($0.archivedAt ?? .distantPast) > ($1.archivedAt ?? .distantPast) }
        projectsByID = Dictionary(loaded.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
    }

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

        // --- Phase 1: instant sidebar, no transcripts ---
        var pending: [Session] = []
        var normalized: Set<SessionID> = []  // status was reset → needs a persist in Phase 2
        for var session in allSessions
        where controllers[session.id] == nil && activeProjectIDs.contains(session.projectID) {
            // A session 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)
        }
        // One assignment → one observable update → the sidebar fills in all at once.
        upsertSummaries(pending.map { SessionSummary($0, pendingApprovalCount: 0) })

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

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

        // Clean up any sandbox containers orphaned by a previous run/crash.
        await containerManager?.reconcile(activeSessions: Array(controllers.keys))
        // Rebuild held locks from authoritative git state now that controllers exist (LOCKING §6).
        await reconstructLocks()
        // Commit any uncommitted residue a crash left on an nvrsion trunk (NVRSION §8).
        await reconcileNvrsionTrunks()
    }

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

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

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

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

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

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

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

    /// Clone a remote git repository and register the result as a project. When
    /// `nucleicControl` is true the repo is cloned under `~/.nucleic/control/<name>` (kept
    /// out of iCloud and away from manual edits) and `destinationParent` is ignored;
    /// otherwise it's cloned into `destinationParent/<name>`. The folder name comes from the
    /// git URL; the project's default branch is detected from what the clone checks out.
    /// 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?
    ) 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)
        } 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
        }
    }

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

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

    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()
        } catch {
            lastError = "\(archived ? "Archive" : "Unarchive") project failed: \(error)"
        }
        // Archiving a control project may have freed the last user of the shared container.
        if archived { await reapSharedControlContainerIfUnused() }
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    public func project(_ id: ProjectID) -> Project? { projectsByID[id] }

    /// 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 && project(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) and open it. Used by the home dashboard's chat bar. An empty
    /// message just opens a fresh idle chat.
    @discardableResult
    public func startChat(
        in project: Project, message: String, model: String? = nil, effort: String? = nil,
        base: GitRef? = nil, useWorktree: Bool = true, auto: Bool? = nil, autoShip: Bool? = nil,
        attachments: [PendingAttachment] = []
    ) async -> SessionID? {
        let text = message.trimmingCharacters(in: .whitespacesAndNewlines)
        do {
            let id = try await createSession(
                in: project, title: RandomName.generate(), prompt: text, base: base,
                model: model, effort: effort, useWorktree: useWorktree, auto: auto, autoShip: autoShip,
                attachments: attachments)
            openSessionID = id
            return id
        } catch {
            lastError = "New chat failed: \(error)"
            return nil
        }
    }

    /// 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? {
        if let sessionID = openSessionID {
            return openSession?.projectID
                ?? summaries.first(where: { $0.id == sessionID })?.projectID
        }
        return openProjectID
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    /// Surface a lock-lifecycle event (LOCKING §4) in the session's chat log as a muted note, so
    /// the contention story — waiting on a file, acquiring it, releasing it — is visible in-chat,
    /// not just in the `LOCKTRACE` os_log. Fed by the `LockManager` hook with the exact paths.
    private func recordLockEvent(_ sessionID: SessionID, _ event: LockEvent) async {
        guard let controller = controllers[sessionID] else { return }
        // Each note also carries structured `lock` detail (state + paths) so the transcript can
        // fold it onto the edit's tool card it brackets (LOCKING §4) instead of leaving it as a
        // standalone row — which, anchored after the tool-use start, otherwise always renders
        // after the call. The plain text/icon remain the fallback for an unfoldable note.
        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))
        case .acquired(let paths):
            await controller.note("Lock acquired on \(Self.lockPathList(paths)).", icon: "lock.fill",
                                  lockEvent: true, lock: NoteLock(state: .acquired, paths: paths))
        case .released(let paths):
            await controller.note("Lock released on \(Self.lockPathList(paths)).", icon: "lock.open",
                                  lockEvent: true, lock: NoteLock(state: .released, paths: paths))
        }
    }

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

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

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

    /// `ConflictArbiter` (LOCKING §4.6): the agent in `sessionID` is about to edit `files`.
    /// Acquire their locks in the session's root-tree domain — granted instantly if free, else the
    /// call parks in the queue (all-or-nothing + demotion) until every file is free, then resumes.
    /// No prompt (automatic). Fail-open on any gap so a coordination miss never blocks an agent.
    public func arbitrate(sessionID: SessionID, task: String, files: [String]) 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

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

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

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

        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.
    public func endHostCommand(sessionID: SessionID) async {
        runningHostCommands[sessionID] = nil
    }

    /// 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")
                }
                // 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")
            case .failed(let why):
                await controller.note("\(NoteEvent.nvrsionPrefix) could not land \(rel): \(why)",
                                      icon: "exclamationmark.triangle")
            }
        case .runFinished:
            // Turn-end: if this turn landed work, fold its contribution into the trunk's running
            // change summary (NVRSION §6) — built incrementally, per turn, so the eventual promotion
            // ships a meaningful, on-device-accurate message instead of summarizing the whole diff.
            if let landed = nvrsionTurnLanded.removeValue(forKey: sessionID), !landed.isEmpty {
                let digest = Self.nvrsionTurnDigest(
                    title: session.title, events: await controller.transcriptSoFar(),
                    files: landed.sorted())
                foldNvrsionSummary(projectID: session.projectID, trunkPath: trunkPath, digest: digest)
            }
            // Release every file the turn held warm (NVRSION §4).
            await lockManager.releaseAll(sessionID)
            await nvrsionGovernor.forget(sessionID)
        default:
            break
        }
    }

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

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

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

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

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

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

    /// Promote an nvrsion project's accumulated trunk work into its real base branch (NVRSION §6) —
    /// the explicit "ship everything on the trunk" action. Squashes the trunk into the base as one
    /// commit, captioned with the running change summary built up as sessions landed work. Returns
    /// true on success; reports nothing-to-promote / conflicts / failure via `lastError`.
    @discardableResult
    public func promoteNvrsionTrunk(_ projectID: ProjectID) 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)
            lastError = "Nothing on the nvrsion trunk to promote to \(base)."
            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)
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        // Walk the live controllers once: count Control sessions and build their autoship entries.
        let controlProjectIDs = Set(projectsByID.values.filter(\.isNucleicControlled).map(\.id))
        let nowDate = now()
        var activeSessions = 0
        var autoship: [AutoshipEntry] = []
        for (id, controller) in controllers {
            let session = await controller.snapshot.session
            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)

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

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

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

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

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

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

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

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

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

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

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

    // MARK: - Dashboard

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

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

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

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

    /// Non-archived chats for a 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] {
        summaries
            .filter { $0.projectID == projectID && !$0.archived }
            .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
            }
    }

    /// 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 }
            .sorted { $0.updatedAt > $1.updatedAt }
    }

    /// The most-recently-active non-archived chats across every project, 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
            .filter { !$0.archived }
            .sorted { $0.updatedAt > $1.updatedAt }
            .prefix(limit)
            .map { $0 }
    }

    /// How many non-archived chats were active within the last `window` (default 24h),
    /// measured by `updatedAt`. 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.filter { !$0.archived && now.timeIntervalSince($0.updatedAt) < window }.count
    }

    // MARK: - Session lifecycle

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

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

    /// Create a worktree, open a transcript, spawn a (conversational) controller, and
    /// persist. If `prompt` is non-empty the first turn starts immediately; otherwise
    /// the session waits for the user's first message (PLAN M1).
    @discardableResult
    public func createSession(
        in project: Project, title: String, prompt: String = "", base: GitRef? = nil,
        model: String? = nil, effort: String? = nil, useWorktree: Bool = true, auto: Bool? = nil,
        autoShip: Bool? = nil, attachments: [PendingAttachment] = []
    ) async throws -> SessionID {
        let sessionID = SessionID.generate()
        let 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)
        }

        // 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)
            ? try await worktrees.create(for: sessionID, in: project, base: resolvedBase, slug: title)
            : nil
        let worktreePath = 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`).
        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.
        let parentSession = 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
        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,
            model: resolvedModel, effort: effort ?? defaultEffort,
            transcriptPath: transcriptURL.path, auto: resolvedAuto,
            autoShip: resolvedAutoShip,
            createdAt: now(), updatedAt: now())

        let controller = SessionController(
            session: session, backend: backendFactory(session), transcript: writer,
            worktreeManager: worktrees, project: project, worktree: worktree,
            metadataStore: database, conversational: true, 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))

        observe(controller, sessionID)
        if !trimmedPrompt.isEmpty {
            await controller.start(prompt: AgentInput(text: trimmedPrompt))
            maybeNameSession(sessionID, firstMessage: trimmedPrompt)
        }
        return sessionID
    }

    public func respondToOpenApproval(_ id: ApprovalID, _ decision: Decision) async {
        guard let sessionID = openSessionID, let controller = controllers[sessionID] else { return }
        do { try await controller.respondToApproval(id, decision) }
        catch { lastError = "Approval failed: \(error)" }
    }

    public func sendToOpenSession(_ text: String, attachments: [PendingAttachment] = []) async {
        guard let sessionID = openSessionID, let controller = controllers[sessionID] else { return }
        // Drop any attached files into the session's working tree and fold their paths into the
        // message, so the agent (which reads that tree, even from inside the sandbox) can open them.
        let text = await augmentWithAttachments(text, attachments, worktree: openSession?.worktreePath)
        do {
            try await controller.sendInput(AgentInput(text: text))
            maybeNameSession(sessionID, firstMessage: text)  // 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.count)))
            }
        } 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`. 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) when there are no attachments or no working tree.
    private func augmentWithAttachments(
        _ text: String, _ attachments: [PendingAttachment], worktree: String?
    ) async -> String {
        guard !attachments.isEmpty, let worktree else { return text }
        do {
            let paths = try await Task.detached(priority: .userInitiated) {
                try AttachmentMaterializer.materialize(attachments, intoWorktree: worktree)
            }.value
            return AttachmentMaterializer.augment(message: text, withReferencePaths: paths)
        } catch {
            lastError = "Attaching files failed: \(error.localizedDescription)"
            return text
        }
    }

    /// Cancel the open chat's queued (not-yet-sent) follow-up, returning its text so the
    /// composer can refill it for editing. Returns `nil` if nothing was queued.
    @discardableResult
    public func cancelOpenQueuedMessage() async -> String? {
        guard let sessionID = openSessionID, let controller = controllers[sessionID] else { return nil }
        let text = await controller.cancelQueuedInput()
        let snapshot = await controller.snapshot
        if sessionID == openSessionID { openSession = snapshot.session }
        broadcast(.sessionUpdated(wireSummary(for: snapshot.session, pendingApprovals: snapshot.pendingApprovals.count)))
        return text
    }

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

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

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

    public func interruptOpenSession() async {
        guard let sessionID = openSessionID, let controller = controllers[sessionID] else { return }
        await controller.interrupt()
    }

    public func renameOpenSession(to title: String) async {
        await mutateOpenSession { await $0.rename(title) }
    }

    /// Set the model (nil = default) for the open session's next turn.
    public func setOpenSessionModel(_ model: String?) async {
        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 {
        await mutateOpenSession { await $0.setEffort(effort) }
    }

    /// Toggle Claude's auto-approval mode for the open session's next turn.
    public func setOpenSessionAuto(_ auto: Bool) async {
        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 {
        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 {
        await mutateOpenSession { await $0.setShipBranch(branch) }
    }

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

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

    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])
            }
            await reapSharedControlContainerIfUnused()
        } 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 {
        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])
        }
        observers[id]?.cancel()
        observers[id] = nil
        controllers[id] = nil
        forgetShipState(id)
        try? await database.deleteSession(id: id)
        summaries.removeAll { $0.id == id }
        updateAlarm(clearing: id)  // a deleted chat can't be returned to — don't keep ringing for it
        if openSessionID == id { openSessionID = nil }
        await reapSharedControlContainerIfUnused()
    }

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

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

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

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

    public func discardOpenSession() async {
        guard let sessionID = openSessionID else { return }
        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 }
            }
        }
        statusFeeds = providers.compactMap { provider in
            fetched[provider] ?? statusFeeds.first { $0.provider == provider }
        }
        revertModelFailoverIfRecovered()
    }

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

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

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

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

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

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

    // MARK: - Auto-archive

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

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

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

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

    /// 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)
        for (id, controller) in controllers {
            let session = await controller.snapshot.session
            guard session.archived, let archivedAt = session.archivedAt, archivedAt <= cutoff,
                  let path = session.worktreePath, let branch = session.branch,
                  let baseSHA = session.baseSHA,
                  FileManager.default.fileExists(atPath: path),
                  let project = project(session.projectID)
            else { continue }
            let worktree = Worktree(
                sessionID: id, path: path, branch: branch, baseSHA: baseSHA,
                createdAt: session.createdAt)
            do { try await worktrees.reclaim(worktree, in: project) }
            catch { /* best-effort — leave it for the next sweep */ }
        }
    }

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

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

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

    private func ingestUI(_ sessionID: SessionID, _ event: AgentEvent) async {
        guard let controller = controllers[sessionID] else { return }
        await handleNvrsionEvent(sessionID, controller, event)
        // The account-wide rate-limit window the CLI reports out of band: mirror the
        // latest one as the coarse fallback for the global quota indicator.
        if case .rateLimit(let rl) = event.kind { latestRateLimit = rl }
        // A finished turn just consumed quota; refresh the precise subscription usage
        // so the indicator reflects it promptly rather than waiting for the next poll.
        if case .runFinished = event.kind, quotaPollTask != nil {
            Task { await refreshSubscriptionUsage() }
        }
        // Recompute the live diff at turn boundaries so the summary's diffstat is fresh — for an
        // nvrsion session (no worktree) this measures its per-session trunk contribution instead.
        switch event.kind {
        case .turnCompleted, .runFinished:
            await refreshDiffStat(sessionID, controller)
        default:
            break
        }
        // A finished turn left the session idle at .awaitingInput; classify (for every
        // session, not just the open one) whether the agent is actually waiting on the
        // user or has finished, so the sidebar's "Awaiting input"/"Done" is accurate.
        if case .runFinished(let finished) = event.kind {
            classifyDisposition(sessionID, controller, outcome: finished.outcome)
        }
        // A finished turn is the first point where the agent's actions can sharpen a
        // weak first-message title; attempt the one-time post-run rename.
        if case .runFinished = event.kind { maybeRenameSession(sessionID, controller) }
        let snapshot = await controller.snapshot
        upsertSummary(SessionSummary(snapshot.session, pendingApprovals: snapshot.pendingApprovals))
        // This event may have flipped whether any chat in the project is mid-turn — the gate the
        // auto-integrate countdown waits on. Re-evaluate it: a turn ending in the last working chat
        // arms the countdown; a turn starting cancels it.
        reconsiderNvrsionAutoIntegration(snapshot.session.projectID)
        // A finished run (or a fresh turn) flips whether the chat is completed; refresh the
        // unread dot now. Disposition-classified completion is handled in classifyDisposition.
        await reconcileUnseenCompletion(sessionID, snapshot)

        // Project to any connected iPhone (SYNC §5): the event itself, the dedicated
        // approval push for approval kinds, and the updated session summary.
        broadcast(.events(sessionID, [event]))
        switch event.kind {
        case .approvalRequested(let req):
            broadcast(.approvalRequested(req))
        case .approvalResolved(let res): broadcast(.approvalResolved(res))
        default: break
        }
        broadcast(.sessionUpdated(wireSummary(for: snapshot.session, pendingApprovals: snapshot.pendingApprovals.count)))
        // A finished turn shifts the aggregate counts (active chats, messages); refresh the
        // dashboard caches and push so the phone's Home/Projects stay live without polling.
        if case .runFinished = event.kind, syncBroadcastContinuation != nil {
            await loadDashboard()
            broadcast(.dashboard(await dashboardSnapshot()))
        }

        // A `toolCallInputDelta` streams a tool call's argument JSON one fragment at a time.
        // The transcript projection deliberately ignores these (the tool name shows on start,
        // the authoritative full input lands on completion — see TranscriptProjection.flatItems),
        // so a delta changes nothing on screen. But each one still grows `history`, and the open
        // session block below would, per fragment, re-pull the whole history, re-run the
        // O(transcript) tool-group summary pass, and bump `openTranscriptVersion` — which forces
        // the (deliberately eager, non-lazy) transcript VStack to re-lay-out *every* row. A large
        // or streaming tool input (a big Write/MultiEdit, a long heredoc, a fat Task prompt) emits
        // hundreds of these fragments in a tight burst on the main actor, so on a long chat that
        // burst wedges the main thread for seconds — the beach ball, while Core Animation keeps the
        // status spinner turning. Skip the display work for input deltas; the next event that can
        // actually change the view pulls the full history (these fragments included) in one pass.
        let eventChangesDisplay: Bool = {
            if case .toolCallInputDelta = event.kind { return false }
            return true
        }()
        if sessionID == openSessionID, eventChangesDisplay {
            // Pull the full authoritative history *before* mutating any @Observable
            // state (rather than appending the single event): combined with the
            // monotonic guard below, this is robust against interleaving with
            // reloadOpen (both only ever grow the list). Reading it up front also keeps
            // `openSession` (which drives the "Working…" card) and `openTranscript` in
            // the same synchronous main-actor turn — with the await sandwiched between
            // the two writes, main-actor contention (many parallel sessions) let other
            // work interleave there, rendering a running session over an empty
            // transcript until ingest resumed.
            let history = await controller.transcriptSoFar()
            openSession = snapshot.session
            applyOpenTranscript(history, for: sessionID)
            openApprovals = snapshot.pendingApprovals
            regenerateToolGroupSummaries()  // summarize any newly-settled tool-call groups
            if case .runFinished = event.kind { regenerateSummary() }  // turn ended
        }
        // Record how far the observer has drained this session's event stream — any
        // work this event spawned (e.g. classification on runFinished) has been issued
        // by now, so awaitOpenSessionSettled can tell when it's safe to drain tasks.
        lastIngestedSeq[sessionID] = max(lastIngestedSeq[sessionID] ?? 0, event.seq)
    }

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

    // MARK: - 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
            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] }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    /// Classify the just-finished turn (asking vs. done) from the agent's last reply
    /// and stamp it on the session, so the sidebar can distinguish "the agent needs
    /// you" from "the agent finished" and the open session's summary card can switch to
    /// a done recap. Runs off the main loop; the controller ignores a stale
    /// classification if a new turn has since started.
    private func classifyDisposition(
        _ sessionID: SessionID, _ controller: SessionController, outcome: RunFinished.Outcome
    ) {
        let intelligence = self.intelligence
        intelligenceWorkSeq += 1
        classifyTask = Task {
            let events = await controller.transcriptSoFar()
            // No assistant prose this turn → leave it as plain "awaiting input".
            guard let reply = HeuristicSummary.lastTurn(events).reply,
                  !reply.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
            else { return }
            let disposition = await intelligence.classifyTurn(lastReply: reply)
            await controller.setDisposition(disposition)
            let snapshot = await controller.snapshot
            self.upsertSummary(SessionSummary(
                snapshot.session, pendingApprovals: snapshot.pendingApprovals))
            if sessionID == self.openSessionID { self.openSession = snapshot.session }
            // 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)
            // Finalize the chat (autoship + free its sandbox/locks) only when the agent both
            // classified the turn done *and* the run actually completed cleanly. A run that
            // errored, was interrupted, or hit the turn cap can still leave done-looking prose
            // the classifier reads as `.completed`; shipping that half-finished work — or
            // tearing down its sandbox/locks — is exactly the bug this guards against.
            if disposition == .completed, outcome == .completed {
                await self.releaseSandboxIfCompleted(sessionID)
                await self.shipIfCompleted(sessionID)
                // Release any locks whose work has already landed now that the chat is done, so a
                // completed chat never sits on a stale lock waiting for the background poll or an
                // archive sweep (LOCKING §4.4). Unmerged work pending the autoship enqueued just
                // above stays locked and releases via its merge.
                await self.releaseCompletedSessionLocks(sessionID)
            }
        }
    }

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

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

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

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

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

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

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

    private func upsertSummary(_ summary: SessionSummary) {
        if let index = summaries.firstIndex(where: { $0.id == summary.id }) {
            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 advertising + listening for iPhone clients (SYNC §3.1). Idempotent.
    public func startSyncServer() async {
        guard syncHost == nil else { return }
        if syncHostName == "Mac" { syncHostName = Host.current().localizedName ?? "Mac" }
        let identity = HostIdentityStore.loadOrCreate()
        let store = InMemoryPairedDeviceStore()
        let host = SyncHost(identity: identity, bridge: self, store: store)
        let listener = LANListener(identityFingerprint: identity.fingerprint)
        do {
            try await host.start(listener: listener)
            syncHost = host
            syncListener = listener
            syncRunning = true
            await refreshPairedDevices()
        } catch {
            lastError = "Couldn't start remote access: \(error)"
        }
    }

    public func stopSyncServer() async {
        await syncHost?.stop()
        syncHost = nil
        syncListener = nil
        syncRunning = false
        pairingQR = nil
    }

    /// Begin a pairing window and publish the QR string for the Settings sheet to render.
    /// The QR carries this Mac's LAN IP + port so the phone dials directly (Bonjour is only a
    /// fallback).
    public func beginPairing() async {
        guard let syncHost else { return }
        let payload = await syncHost.beginPairing(
            lanHost: LANAddress.primaryIPv4(), lanPort: syncListener?.assignedPort)
        pairingQR = try? payload.qrString()
    }

    public func endPairing() async {
        await syncHost?.cancelPairing()
        pairingQR = nil
        await refreshPairedDevices()
    }

    public func revokeDevice(_ deviceID: String) async {
        await syncHost?.revoke(deviceID: deviceID)
        await refreshPairedDevices()
    }

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

    // MARK: - Sync host bridge support

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

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

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

    /// Map a host `Session` to the wire `SessionSummary` the phone renders.
    fileprivate func wireSummary(for session: Session, pendingApprovals: Int = 0) -> WireSessionSummary {
        WireSessionSummary(
            sessionID: session.id,
            projectID: session.projectID.rawValue,
            projectName: projectsByID[session.projectID]?.name ?? "",
            backend: session.backend,
            status: session.status,
            disposition: session.lastTurnDisposition,
            title: session.title,
            branch: session.branch ?? "",
            lastSeq: session.lastSeq,
            diffStat: session.diffStat,
            pendingApprovalCount: pendingApprovals,
            favorite: session.favorite,
            archived: session.archived,
            queuedMessage: session.queuedMessage,
            model: session.model,
            effort: session.effort,
            auto: session.auto,
            autoShip: session.autoShip,
            shipBranch: session.shipBranch,
            updatedAt: session.updatedAt)
    }
}

/// `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 {
        HostInfo(hostID: syncHostName, hostName: syncHostName)
    }

    public var capabilities: WireCapabilities {
        // Mirrors ClaudeCodeBackend (BACKEND_PROTOCOL §2.1): modify-and-allow + the
        // always-rule scopes the Mac offers.
        WireCapabilities(
            canModifyToolInput: true,
            allowAlwaysScopes: [.session, .toolName, .toolNameWithPattern])
    }

    public var modelCatalog: WireModelCatalog { injectedModelCatalog }

    public func sessionSummaries() async -> [WireSessionSummary] {
        var result: [WireSessionSummary] = []
        for (_, controller) in controllers {
            let snap = await controller.snapshot
            result.append(wireSummary(for: snap.session, pendingApprovals: snap.pendingApprovals.count))
        }
        return result.sorted { $0.updatedAt > $1.updatedAt }
    }

    public func snapshot(_ id: SessionID, sinceSeq: UInt64?) async -> SessionSnapshot? {
        guard let controller = controllers[id] else { return nil }
        let snap = await controller.snapshot
        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.count),
            recentEvents: events,
            pendingApprovals: snap.pendingApprovals,
            cursor: cursor)
    }

    // 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,
                updatedAt: todo.updatedAt)
        }
        return DashboardSnapshot(counts: counts, activity: activity, projects: projects, todos: todos)
    }

    public func startChat(_ request: StartChatRequest) async throws {
        guard let project = projectsByID[request.projectID] else {
            throw WireError(code: .unknownSession, message: "no such project")
        }
        _ = 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)
        await pushDashboard()
    }

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

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

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

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

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

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

    public func respondToApproval(_ id: ApprovalID, _ decision: Decision) async throws {
        // Route by finding the controller whose pending set holds this approval. If none
        // do, it was already resolved (likely answered on the Mac) — first-responder-wins.
        for (_, controller) in controllers {
            let pending = await controller.snapshot.pendingApprovals
            if pending.contains(where: { $0.id == id }) {
                try await controller.respondToApproval(id, decision)
                return
            }
        }
        throw WireError(code: .alreadyResolved, message: "approval already resolved")
    }

    public func sendInput(_ id: SessionID, _ input: AgentInput) async throws {
        guard let controller = controllers[id] else {
            throw WireError(code: .unknownSession, message: "no such session", sessionID: id)
        }
        try await controller.sendInput(input)
        // 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.count)))
        if id == openSessionID { openSession = snapshot.session }
    }

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

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

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

    /// Apply a setter to a session addressed by id, then push the refreshed summary so the
    /// phone's header reflects the change immediately (these mutations emit no transcript
    /// event). Mirrors `sendInput`'s explicit `sessionUpdated` broadcast. Throws
    /// `.unknownSession` if the session is gone (so the phone surfaces it).
    private func mutateSessionForRemote(
        _ id: SessionID, _ body: (SessionController) async -> Void
    ) async throws {
        guard let controller = controllers[id] else {
            throw WireError(code: .unknownSession, message: "no such session", sessionID: id)
        }
        await body(controller)
        let snapshot = await controller.snapshot
        if id == openSessionID { openSession = snapshot.session }
        broadcast(.sessionUpdated(
            wireSummary(for: snapshot.session, pendingApprovals: snapshot.pendingApprovals.count)))
    }

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