import Foundation

/// The hub for ONE session and the **single writer** of its state (RUNTIME §1-2).
///
/// It consumes the backend's `AgentEvent` stream on one task, stamps each event
/// with a canonical session-wide `seq` via `TranscriptWriter`, derives the session
/// status, and fans the canonical events out to subscribers (UI, and later sync).
/// UI never touches the backend or git directly — it sends intents here
/// (`sendInput`, `respondToApproval`, `integrate`) and observes `snapshot`.
public actor SessionController {
    private let backend: any AgentBackend
    private let transcript: TranscriptWriter
    private let now: @Sendable () -> Date
    /// Conversational (chat) mode: each turn is its own single-shot run (the first
    /// via `start`, follow-ups via `resume(prompt:)` — the only reliable way to use
    /// the approval server, since Claude's streaming-input mode hangs alongside
    /// `--permission-prompt-tool`). A turn's terminal `runFinished` therefore means
    /// "awaiting the next message", not "session over". The session ends only on
    /// explicit `shutdown`/`discard`.
    private let conversational: Bool

    // Optional git wiring — present for real sessions, nil in pure pipeline tests.
    private let worktreeManager: (any WorktreeManaging)?
    private let project: Project?
    private let worktree: Worktree?
    /// Optional metadata sink (RUNTIME §2 step 3). Persistence is best-effort: a
    /// store failure never corrupts the canonical transcript or the live fan-out.
    private let metadataStore: (any SessionMetadataStore)?
    /// Optional Nucleic-managed worker spawner for Orchestra. The app supplies this so the
    /// backend's MCP tool can create ordinary observed Nucleic sessions for subagents.
    private let orchestraSubagentSpawner: OrchestraSubagentSpawner?
    /// Optional Nucleic-managed **blocking** subagent spawner. Supplied for every session; exposed as
    /// the `nucleic_subagent` tool (blocking shape) only when this session is on a Nucleic Control
    /// project and is NOT an active Orchestra supervisor — see `activeBlockingSubagentSpawner`. Lets
    /// ordinary (non-Orchestra) sessions route their subagents through observed Nucleic sessions too.
    private let blockingSubagentSpawner: OrchestraSubagentSpawner?
    /// Supervisor-side Orchestra plan handler — exposed as `nucleic_delegate_plan` while this session
    /// is an active supervisor. Takes the orchestrator's whole batched task list and spawns a worker
    /// per task. Supplied for every session; gated by `orchestraActive` like the spawner.
    private let orchestraPlanHandler: OrchestraPlanHandler?
    /// Supervisor-side Orchestra drive/reply handlers — exposed as `nucleic_supervise` /
    /// `nucleic_reply_to_worker` while this session is an active supervisor. Supplied for every
    /// session; gated by `orchestraActive` like `orchestraSubagentSpawner`.
    private let orchestraSuperviseHandler: OrchestraSuperviseHandler?
    private let orchestraReplyToWorkerHandler: OrchestraReplyToWorkerHandler?
    /// Worker-side Orchestra handler — exposed as `nucleic_ask_supervisor`. Supplied ONLY for a
    /// machine-spawned worker session, so its presence is what marks this run as a worker (it drives
    /// both the worker briefing and the tool). See `orchestraRole`.
    private let orchestraAskSupervisorHandler: OrchestraAskSupervisorHandler?
    /// Mesh messaging tool bridge (mesh casting) — exposed as `nucleic_send_message` /
    /// `nucleic_subscribe_topic` on every run when supplied (the app supplies it only while the
    /// Settings toggle is on).
    private let meshMessagingHandlers: MeshMessagingHandlers?
    /// Monitor event delivery sink (mesh-independent) — exposed as `nucleic_monitor` on every run
    /// when supplied. The app supplies it unconditionally; the backend uses it to re-invoke this
    /// session as a fresh turn when an armed monitor fires. See `MonitorEventSink`.
    private let monitorEventSink: MonitorEventSink?
    /// App-wide serialized Claude OAuth broker. Nil in tests and host-only configurations.
    private let claudeCredentialBroker: ClaudeCredentialBroker?

    private(set) var session: Session
    private var pendingApprovals: [ApprovalID: ApprovalRequest] = [:]
    private var history: [AgentEvent] = []
    private var subscribers: [UUID: AsyncStream<AgentEvent>.Continuation] = [:]
    private var runTask: Task<Void, Never>?
    /// True while this actor is awaiting the shared credential broker before spawning a turn.
    /// Actor reentrancy means a second message can arrive during that await; it joins the ordinary
    /// pending queue instead of starting a second process with stale credentials.
    private var preparingTurn = false
    private var userTurnCount = 0
    /// Conversational follow-ups that arrived while the previous turn's run was still
    /// closing (its terminal event already flipped us to `awaitingInput`, but the backend
    /// stream hadn't finished, so `runTask` was still set). Held here and sent — coalesced
    /// into a single combined turn — the instant the current run finishes; never dropped.
    /// Kept as discrete items (not one merged input) so the composer can show and cancel each
    /// queued message individually. `input` is the agent-facing text (attachment paths already
    /// folded in); `display` is the original text + attachments for the composer's indicator.
    private var pendingQueue: [(input: AgentInput, display: QueuedMessage, silent: Bool)] = []

    public init(
        session: Session,
        backend: any AgentBackend,
        transcript: TranscriptWriter,
        worktreeManager: (any WorktreeManaging)? = nil,
        project: Project? = nil,
        worktree: Worktree? = nil,
        metadataStore: (any SessionMetadataStore)? = nil,
        conversational: Bool = false,
        orchestraSubagentSpawner: OrchestraSubagentSpawner? = nil,
        blockingSubagentSpawner: OrchestraSubagentSpawner? = nil,
        orchestraPlanHandler: OrchestraPlanHandler? = nil,
        orchestraSuperviseHandler: OrchestraSuperviseHandler? = nil,
        orchestraReplyToWorkerHandler: OrchestraReplyToWorkerHandler? = nil,
        orchestraAskSupervisorHandler: OrchestraAskSupervisorHandler? = nil,
        meshMessagingHandlers: MeshMessagingHandlers? = nil,
        monitorEventSink: MonitorEventSink? = nil,
        claudeCredentialBroker: ClaudeCredentialBroker? = nil,
        initialHistory: [AgentEvent] = [],
        now: @escaping @Sendable () -> Date = { Date() }
    ) {
        self.session = session
        self.backend = backend
        self.transcript = transcript
        self.worktreeManager = worktreeManager
        self.project = project
        self.worktree = worktree
        self.metadataStore = metadataStore
        self.conversational = conversational
        self.orchestraSubagentSpawner = orchestraSubagentSpawner
        self.blockingSubagentSpawner = blockingSubagentSpawner
        self.orchestraPlanHandler = orchestraPlanHandler
        self.orchestraSuperviseHandler = orchestraSuperviseHandler
        self.orchestraReplyToWorkerHandler = orchestraReplyToWorkerHandler
        self.orchestraAskSupervisorHandler = orchestraAskSupervisorHandler
        self.meshMessagingHandlers = meshMessagingHandlers
        self.monitorEventSink = monitorEventSink
        self.claudeCredentialBroker = claudeCredentialBroker
        self.history = initialHistory  // replayed transcript for a resumed session
        self.now = now
    }

    // MARK: - Observation

    /// A point-in-time view for the UI / sync summary list.
    public struct Snapshot: Sendable, Equatable {
        public let session: Session
        public let pendingApprovals: [ApprovalRequest]
    }

    public var snapshot: Snapshot {
        Snapshot(
            session: session,
            pendingApprovals: pendingApprovals.values.sorted { $0.createdAt < $1.createdAt })
    }

    /// Canonical events appended so far — replayed into a newly opened transcript
    /// view before attaching the live `subscribe()` stream (RUNTIME §6.2).
    public func transcriptSoFar() -> [AgentEvent] { history }

    /// A live fan-out of canonical events. Multiple subscribers are independent;
    /// each gets every event appended after it subscribes.
    public func subscribe() -> AsyncStream<AgentEvent> {
        let id = UUID()
        return AsyncStream { continuation in
            subscribers[id] = continuation
            continuation.onTermination = { [weak self] _ in
                Task { await self?.removeSubscriber(id) }
            }
        }
    }

    private func removeSubscriber(_ id: UUID) {
        subscribers[id] = nil
    }

    /// Await the current run's completion (the consume task finishing). Returns
    /// immediately if no run is in flight. Useful for tests and orderly shutdown.
    public func join() async {
        await runTask?.value
    }

    // MARK: - Lifecycle intents

    /// The session's worktree path (live worktree, else the persisted path).
    private var worktreePath: String { session.worktreePath ?? worktree?.path ?? "" }

    /// Whether this session runs on its own isolated worktree branch. Worktree-less
    /// sessions run directly in the project's main checkout on the chosen branch, so
    /// there is nothing to merge (and `integrate()` has no git context). Drives whether
    /// autoship has any branch to land.
    public var hasWorktree: Bool { worktree != nil }

    /// Host build/run opt-in (HOST_EXEC): only when the app-wide `host_exec` kill switch is on, the
    /// project is effectively sandboxed, AND host execution is allowed for the project. Drives whether
    /// the `host_exec` tool is exposed for the run — off means the tool is never advertised and the
    /// system prompt never mentions it exists. `hostExecEnabled` already implies the container service
    /// is on, so it subsumes the old `serviceEnabled` guard.
    private var allowsHostExec: Bool {
        guard ContainerServiceSettings.hostExecEnabled else { return false }
        guard let sandbox = project?.effectiveSandbox, sandbox.enabled else { return false }
        return sandbox.allowHostExec
    }

    /// macOS-VM opt-in: exposes the `mac_vm_exec` tool (an isolated per-session macOS VM) only when the
    /// user has opted sandboxed sessions in (`MacVMSettings.exposeByDefault`, which itself requires the
    /// service enabled), the session is sandboxed (so the agent otherwise lacks the Mac toolchain), and
    /// the host is Apple silicon. Off by default — a macOS VM is heavy — so enabling the service alone
    /// doesn't hand every session a real Mac. The per-agent-VM safe alternative to the shared-host
    /// `host_exec` for Mac-only builds/tests (docs/MACOS_VM.md).
    private var allowsMacVMExec: Bool {
        guard MacVMSettings.exposeByDefault, MacVMManager.isSupported else { return false }
        return isSandboxed
    }

    /// Computer-use opt-in: exposes the `mac_vm_computer` tool (GUI screenshots + mouse/keyboard over
    /// the session's macOS VM) when the user has opted in (`MacVMSettings.computerUseByDefault`), the
    /// session is sandboxed, and the host is Apple silicon. Separate from `allowsMacVMExec` because
    /// computer-use needs a base image provisioned for the GUI (auto-login, cliclick, TCC). See
    /// docs/MACOS_VM.md.
    private var allowsMacVMComputer: Bool {
        guard MacVMSettings.computerUseByDefault, MacVMManager.isSupported else { return false }
        return isSandboxed
    }

    /// Linux-VM opt-in: exposes `linux_vm_exec` (an isolated per-session Linux VM) when the user has
    /// enabled the Linux VM service and left sandboxed exposure on (`MacVMSettings.linuxExposeByDefault`),
    /// the session is sandboxed, and the host can run the VM runtime. The Linux VM is the lighter,
    /// faster-booting rung between the container and a macOS VM — for GUI/desktop dev a container can't
    /// do. See docs/LINUX_VM.md.
    private var allowsLinuxVMExec: Bool {
        guard MacVMSettings.linuxExposeByDefault, MacVMManager.isSupported else { return false }
        return isSandboxed
    }

    /// Linux-VM computer-use opt-in: exposes `linux_vm_computer` (GUI screenshots + mouse/keyboard over
    /// the session's Linux VM) when the user has opted in (`MacVMSettings.linuxComputerUseByDefault`),
    /// the session is sandboxed, and the host can run the VM runtime.
    private var allowsLinuxVMComputer: Bool {
        guard MacVMSettings.linuxComputerUseByDefault, MacVMManager.isSupported else { return false }
        return isSandboxed
    }

    /// Agent-container opt-in: available only inside a managed Control container.
    private var allowsAgentContainers: Bool {
        ContainerServiceSettings.agentContainersEnabled && isSandboxed
    }

    /// Whether this session's `claude` runs inside a container (the same gate `containerSpec()`
    /// uses, minus the side effects of building the spec). When false the agent runs on the host
    /// and already has the full toolchain, so no build-environment guidance is needed.
    private var isSandboxed: Bool {
        guard ContainerServiceSettings.serviceEnabled else { return false }
        guard let sandbox = project?.effectiveSandbox, sandbox.enabled else { return false }
        if let agent = ACPAgent.forBackend(session.backend), !agent.supportsContainerizedRuns {
            return false
        }
        return true
    }

    /// Build and platform-tool guidance exists only for managed container runs. Legacy host runs
    /// receive no Nucleic platform surface.
    private var environmentSystemPrompt: String? {
        guard isSandboxed else { return nil }
        return Self.sandboxBuildGuidance(
            allowHostExec: allowsHostExec, allowMacVMExec: allowsMacVMExec,
            allowMacVMComputer: allowsMacVMComputer,
            allowLinuxVMExec: allowsLinuxVMExec, allowLinuxVMComputer: allowsLinuxVMComputer,
            allowAgentContainers: allowsAgentContainers,
            defaultComputerUseVMType: MacVMSettings.defaultComputerUseVMType,
            maxConcurrentVMs: MacVMSettings.maxConcurrentVMs)
    }

    /// The effort actually sent to the backend. `orchestra` is a Nucleic-only orchestration
    /// mode, not an API effort level, so it resolves to its real effort (`xhigh`); every other
    /// level passes through. The resolved value never flows back into `session.effort`, so the
    /// UI keeps showing "Orchestra" (`OrchestrationMode`).
    private var resolvedEffort: String? { OrchestrationMode.resolvedEffort(session.effort) }

    /// Whether Orchestra is actually in effect this run: the user selected it AND the project
    /// is Nucleic Control. Orchestra is gated to Control projects (the hardened, sandboxed
    /// path), so a stray "orchestra" effort on a non-Control project — left over after a
    /// project leaves control, or arriving from a remote — is demoted here so it never silently
    /// grants fan-out consent off-control. (The effort still resolves to `xhigh`; only the
    /// consent is withheld.)
    private var orchestraActive: Bool {
        OrchestrationMode.isOrchestra(session.effort) && project?.isNucleicControlled == true
    }

    /// Whether this run is a machine-spawned Orchestra *worker*. The worker-side ask handler is
    /// injected only for a spawned worker (`AppStore.createSession` supplies it iff `spawnedBy !=
    /// nil`), so its presence is the discriminator — no persisted flag needed. A worker runs at
    /// `xhigh`, not the `orchestra` sentinel, so `orchestraActive` is false for it; the two never
    /// overlap.
    private var isOrchestraWorker: Bool { orchestraAskSupervisorHandler != nil }

    /// Which side of an Orchestra session this run is, driving the appended standing prompt and which
    /// channel tools the backend exposes: `.worker` for a spawned worker, `.supervisor` for an active
    /// Orchestra chat, `nil` otherwise. Worker takes precedence so the two are mutually exclusive.
    private var orchestraRole: OrchestrationMode.Role? {
        if isOrchestraWorker { return .worker }
        if orchestraActive { return .supervisor }
        return nil
    }

    /// The subagent spawner to hand the backend this turn: the injected `orchestraSubagentSpawner`
    /// only while Orchestra is active (selected, on a Control project), otherwise `nil` so the
    /// backend exposes no `nucleic_subagent` tool. Gated by `orchestraActive` for the same reason.
    private var activeOrchestraSubagentSpawner: OrchestraSubagentSpawner? {
        orchestraActive ? orchestraSubagentSpawner : nil
    }

    /// The **blocking** subagent spawner to hand the backend this turn — exposed as the
    /// `nucleic_subagent` tool (blocking shape, a native-Task drop-in) on any Nucleic Control run that
    /// is NOT an active Orchestra supervisor. That covers ordinary chats (so their subagents become
    /// observed Nucleic sessions in the Subagents panel) and worker sessions (so a worker's own nested
    /// subagents are managed too). `nil` for an active supervisor (it uses the non-blocking spawner
    /// with the supervise loop) and off-Control (native Task/Agent stay untouched there).
    private var activeBlockingSubagentSpawner: OrchestraSubagentSpawner? {
        guard project?.isNucleicControlled == true, !orchestraActive else { return nil }
        return blockingSubagentSpawner
    }

    /// The supervisor-side drive/reply handlers to hand the backend this turn — exposed only while
    /// Orchestra is active, alongside the subagent spawner.
    private var activeOrchestraPlanHandler: OrchestraPlanHandler? {
        orchestraActive ? orchestraPlanHandler : nil
    }
    private var activeOrchestraSuperviseHandler: OrchestraSuperviseHandler? {
        orchestraActive ? orchestraSuperviseHandler : nil
    }
    private var activeOrchestraReplyToWorkerHandler: OrchestraReplyToWorkerHandler? {
        orchestraActive ? orchestraReplyToWorkerHandler : nil
    }

    /// The system prompt appended to a run: the sandbox build guidance (when sandboxed), the
    /// nvrsion version-control guidance (when this chat edits the shared trunk), and — for an
    /// Orchestra run — the standing prompt for its role (the supervisor's consent or the worker's
    /// briefing). Re-applied on every start / resume because the CLI doesn't persist
    /// `--append-system-prompt`, which keeps these in effect as the per-turn reminders.
    private var appendedSystemPrompt: String? {
        // Build + version-control guidance both describe this run's environment; join them as the
        // `environment` paragraph(s) the orchestration combiner appends the role prompt after.
        let environment = [environmentSystemPrompt, nvrsionSystemPrompt]
            .compactMap { $0 }
            .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
            .joined(separator: "\n\n")
        return OrchestrationMode.combinedSystemPrompt(
            environment: environment.isEmpty ? nil : environment,
            role: orchestraRole)
    }

    /// Whether this chat edits the project's shared **nvrsion** trunk (NVRSION) rather than a
    /// private per-session worktree — its working dir *is* the trunk. Mirrors
    /// `AppStore.isNvrsionSession`: the mode is fixed at creation, so this reads the session's
    /// working dir, not the project's *current* nvrsion toggle (which only governs how *new* chats
    /// are made — NVRSION §7). So a worktree chat in an nvrsion project stays false, and an nvrsion
    /// chat whose project was later toggled off stays true — each keeps the model it was born with.
    private var isNvrsionSession: Bool {
        guard let project, let wt = session.worktreePath else { return false }
        return wt == project.resolvedTrunkPath
    }

    /// System-prompt guidance appended to an nvrsion run so the agent's version-control mental model
    /// matches reality: it edits a *shared, live-committed trunk*, not a private worktree it later
    /// commits. Without it the agent reasons in the ordinary per-branch git model and says wrong
    /// things ("your changes are only in the working tree", "you still need to commit / open a PR to
    /// ship") or takes harmful actions (a `git reset`/`commit`/`branch` on a tree other agents
    /// share). `nil` for non-nvrsion chats — the ordinary worktree model, where that reasoning holds.
    private var nvrsionSystemPrompt: String? {
        guard isNvrsionSession, let project else { return nil }
        return Self.nvrsionVersionControlGuidance(
            base: project.defaultBranch.value,
            trunkBranch: project.nvrsion?.trunkBranch ?? ProjectNvrsion.defaultTrunkBranch)
    }

    /// The build-environment guidance text. Separated from `environmentSystemPrompt` so it can be
    /// unit-tested without standing up a full controller. Describes the container's own toolchain
    /// (Node/npm, Python/pip, make/gcc, and — since narOS 26.07 — a Linux Swift toolchain at
    /// `/opt/swift`; see os/images/agent/Dockerfile) and routes *Apple-SDK* builds (SwiftUI/AppKit,
    /// `xcodebuild`, `codesign`) to the host via `host_exec` when that's permitted. The Swift split
    /// is the point: pure-Swift packages build in-container, anything importing an Apple framework
    /// cannot, because Linux has no Apple SDKs.
    static func sandboxBuildGuidance(
        allowHostExec: Bool, allowMacVMExec: Bool = false, allowMacVMComputer: Bool = false,
        allowLinuxVMExec: Bool = false, allowLinuxVMComputer: Bool = false,
        allowAgentContainers: Bool = false,
        defaultComputerUseVMType: ComputerUseVMType = .linux,
        maxConcurrentVMs: Int = MacVMSettings.defaultMaxConcurrentVMs
    ) -> String {
        // What to do with the in-container toolchain. When the agent can create its own containers,
        // the heavy work belongs in one of those rather than here — a big build in the session's own
        // container competes with the agent process itself and can take the session down with it.
        let toolchainClause =
            allowAgentContainers
            ? ", so JavaScript, Python and C/C++ build and test on Linux — but run the build or "
                + "test suite itself in a scratch `linux_container`, not in this session container "
                + "(see below)"
            : " — use these to build and test JavaScript, Python, and C/C++ code directly"
        let base = """
            Build environment: you are running inside a Linux container sandbox, not on the macOS \
            host. The container has Node/npm, Python 3 (python3/pip), and a C/C++ toolchain \
            (make, gcc, g++)\(toolchainClause). It also has a Linux Swift toolchain \
            (swiftc, SwiftPM, swift-testing, sourcekit-lsp), so a pure-Swift package builds and \
            tests in-container. What it does \
            NOT have is Xcode or any Apple SDK: a target that imports SwiftUI, AppKit, UIKit or \
            another Apple framework — and anything needing `xcodebuild`, `xcrun` or `codesign` — \
            cannot be built here, however much Swift is installed.
            """
        // macOS-VM guidance is appended (when the tool is exposed) after the host_exec/no-host_exec
        // body below, so the agent learns it has an isolated Mac available for end-to-end work.
        let macVM = allowMacVMExec ? "\n\n" + """
            You also have a `mac_vm_exec` tool: it runs a command inside THIS session's own isolated \
            macOS VM (a real, disposable Mac, separate from both this container and the shared host). \
            PREFER it over host_exec for Mac-only work — Xcode builds, `xcrun simctl` simulator runs, \
            running a built .app, codesign, or any true end-to-end test of a Mac/iOS build — because \
            each agent gets its own VM with its own Mac toolchain, so parallel Mac builds don't thrash \
            the one shared host the way host_exec does. Your repo is available in the VM at its \
            **original host path** (the same absolute path as on the host, so build artifacts that \
            hardcode it resolve); virtiofs is mounted directly there, and `mac_vm_exec` runs your \
            command *in* that directory by default — so you do NOT need to `cd` into it, and you must \
            NOT hunt for the repo under `/Volumes/My Shared Files` (a legacy automount path; globbing it \
            in the VM's zsh aborts the whole command). Each command must be non-interactive and exit on \
            its own: a build/test is fine, but don't launch a server or foreground a `.app` that never \
            returns\(allowMacVMComputer ? " (use `mac_vm_computer` to drive a GUI)" : ""), and don't \
            pipe a long, quiet build through \
            `| tail` — a non-follow `tail` withholds all output until the command finishes. A command \
            that never exits is killed after a timeout and you get its partial output plus a note back \
            (rather than an infinite hang). Like host_exec, calling the tool \
            IS how you request approval; pass a `reason` explaining why the task needs the macOS \
            toolchain. The first call boots the VM (a minute or two); later calls reuse it. \
            MANDATORY: the moment you are no longer ACTIVELY using the VM you MUST free it with \
            `mac_vm_control` (`suspend` to keep its state, `stop`/`kill` if you don't need it) — \
            macOS VMs are hard-capped across all agents, so never leave one idle.
            """ : ""
        let computer = allowMacVMComputer ? "\n\n" + """
            You can also SEE and OPERATE the macOS VM's screen with the `mac_vm_computer` tool — use \
            the VM as your own Mac. It's a computer-use loop: each call performs one action (screenshot, \
            click, type, key, launch_app, …) and returns a screenshot of the VM screen, so work \
            iteratively: screenshot → decide → act → screenshot. Reach for it to visually debug a \
            macOS/iOS app you built (launch it and look at / click through its window), drive the Xcode \
            or Simulator UI, or check how something renders — things a headless command can't show you. \
            Coordinates are the screen's real pixels (target exactly what you see). When the VM's \
            native agent is available, PREFER the semantic actions: `ax_dump` shows the frontmost \
            app's accessibility tree (every control's role/title/value/frame with a `ref`), then act \
            by identity with `ax_press`/`ax_set_value`/`ax_focus` — far more reliable than guessing \
            pixel coordinates, and it keeps working even when screenshots come back blank or \
            unavailable. Fall back to screenshot + pixel clicks when a control has no AX \
            action or the reply says the native agent is unavailable. These actions run in \
            the VM sandbox and are NOT individually approved, so you can move quickly. Treat this \
            VM as entirely your own sandbox: you have free rein to do whatever gets the job done — \
            install tools and packages, sign in to apps, change system settings and preferences, \
            edit files, or otherwise reconfigure it however you see fit. It's a disposable clone \
            that's reset at session end, so don't hold back or ask permission for changes inside \
            it — just make the environment whatever you need it to be to reach your goal. Prefer \
            `mac_vm_exec` for headless build/test; use `mac_vm_computer` when you need to look at or \
            operate the screen. When you have already SEEN every target and know the next few actions \
            (e.g. close several windows by clicking each close button, or click → type → return to \
            fill a field), fire them all at once with `mac_vm_computer_batch` instead of spending a \
            turn per action: pass `steps`, an ordered array of the same actions `mac_vm_computer` \
            takes; it runs them in order and returns a screenshot of the final state (set \
            `screenshot_each_step` for one per step, `step_delay_ms` to let the UI settle, and it \
            stops at the first failure unless you set `stop_on_error` false). Keep using the \
            single-action `mac_vm_computer` when you need to SEE a result before choosing the next \
            move. If you hit a GUI step only a human can clear — a CAPTCHA, a login or \
            two-factor prompt, an interactive OS dialog — do NOT get stuck: call \
            `mac_vm_request_operator` to ask the USER to take the wheel. Pass `instructions` (exactly \
            what they should do in the VM) and a `reason` (why a human is required); Nucleic shows \
            them the request and, if they accept, hands them an interactive viewer of this VM. The \
            call blocks until they finish and returns {completed, note} — read both, then \
            re-screenshot to confirm the state before continuing. If a notification banner or alert \
            slides in and obstructs part of the screen — covering a control you need or hiding what \
            you're reading — call `mac_vm_clear_notifications` to dismiss the on-screen notifications \
            (it kills the guest's NotificationCenter, which relaunches clean); it takes no arguments \
            and needs no approval, then re-screenshot. And the same MANDATORY rule applies to \
            computer-use: the moment you stop ACTIVELY driving the screen, free the VM with \
            `mac_vm_control` rather than leaving it idle.
            """ : ""
        // macOS-VM lifecycle: exposed whenever the macOS VM is usable (exec or computer-use), so the
        // agent can free resources or reset the VM it booted without ending the session. VMs are a
        // hard-capped resource (`maxConcurrentVMs`), so the guidance tells the agent to free its own
        // VM the moment it's done rather than leaving it idle and blocking other agents.
        let macVMControl = (allowMacVMExec || allowMacVMComputer) ? "\n\n" + """
            You can manage that macOS VM's lifecycle with the `mac_vm_control` tool (one `op`): \
            `status` reports whether it's running / suspended / stopped; `stop` powers it off and \
            frees its RAM (the next VM command reboots it); `suspend` saves the VM's state to disk and \
            **frees its slot** so another VM can boot in its place, and `resume` restores it from that \
            saved state (the next VM command auto-resumes too); `restart` reboots it in place to reclaim \
            RAM; `kill` deletes its disk clone so the next VM command boots a fresh one. These act \
            only on your own disposable VM, so they need NO approval. IMPORTANT — macOS VMs are a \
            scarce, hard-capped resource: at most \(maxConcurrentVMs) can run concurrently across ALL \
            agents, so a VM you leave running idle blocks other agents (and your own later work) from \
            booting one, and a fresh boot fails outright once the cap is hit. You MUST free your VM \
            the moment you are no longer ACTIVELY using it — `suspend` it as soon as it goes idle, \
            even if that is in \
            the middle of the very turn you booted it in, and `kill` it once you're finished with it \
            for good. Do not hold it "just in case" while you \
            read files, think, edit code, or run container work: those steps don't touch the Mac, so \
            free the slot before them and resume after. Suspend is the default: it saves the VM's full \
            state to disk, keeps all of it, and — unlike a plain freeze — frees the slot so another VM \
            can boot. When you need the Mac again, `resume` it (or just issue any VM command, which \
            auto-resumes) and pick up exactly where you left off; resume restores from disk, so it \
            takes a little longer than the boot-free thaw but preserves everything. Reach for `stop` \
            only when you're done with the Mac for a long while and want its RAM back; a later command \
            reboots it, losing in-VM state. Suspending between uses is what keeps a slot free for other \
            agents. The VM is also torn down for you \
            at session end, and `restart`/`kill` reset a wedged VM mid-session.
            """ : ""
        // Agent-created containers: the lightest isolation, cheaper than any VM. Introduced before the
        // Linux-VM block so the ladder reads container → Linux VM → macOS VM.
        //
        // The heavy-process rule is the load-bearing part. The agent process itself runs inside the
        // session container, so a build/test suite/dev server started there competes with it for that
        // container's CPU and memory — enough of them and the session dies mid-task. A scratch
        // container mounts the same worktree at the same absolute path (see
        // `handleLinuxContainerCall`), so moving the heavy command there costs nothing: it sees the
        // same files and writes its artifacts back to the same tree.
        let container = allowAgentContainers ? "\n\n" + """
            You can spin up your OWN throwaway Linux containers with the `linux_container` tool — the \
            LIGHTEST isolation available, far cheaper than any VM. Reach for it when you want \
            lightweight work in a clean, disposable environment kept separate from your main sandbox: \
            trying a risky install, testing a build in a fresh checkout, or running something you don't \
            want touching your working tree. It is a managed lifecycle — `op: "create"` starts a \
            container (optionally an `image`; defaults to your own sandbox image) and returns its \
            `name`, `op: "exec"` runs a `command` in that `name`, `op: "stop"`/`op: "remove"` tear it \
            down, and `op: "list"` shows the ones you created. Prefer a container over a Linux VM for \
            anything that doesn't need a real kernel, systemd, or a GUI — it covers almost all ordinary \
            build/test/scripting work, and you should reach for it before escalating to a VM. This is \
            also how you get root on your own: a `sudo` in your main sandbox is classified as \
            destructive and stops for user approval, so you cannot elevate unattended there. A \
            container you created is different — every `linux_container` op is scoped to this session \
            and needs NO approval, and `exec` already runs as root, so you can install packages and \
            change system files freely (you rarely even need to type `sudo`). When a step needs root, \
            create a container and run it there instead of waiting on an approval prompt.

            IMPORTANT — run HEAVY processes in a scratch container, never in this session container. \
            Your own agent process runs inside this session container and shares its CPU and memory, \
            so a build, a full test suite, a bundler, a long compile, a big install, or a \
            long-running server started here starves the session and can crash it mid-task, losing \
            your work in progress. The fix is cheap: `create` a container once, then `exec` the heavy \
            command in it. A scratch container mounts your worktree at the SAME absolute path and \
            starts in it, so the build sees exactly the files you just edited and its artifacts land \
            in the same tree — you do not copy anything in or out, and you can keep reusing that one \
            container for every build in the task. Keep in this session container only the light \
            work: reading and editing files, search, git, and short scripts that finish in a moment.
            """ : ""
        let linuxVM = allowLinuxVMExec ? "\n\n" + """
            You also have a `linux_vm_exec` tool: it runs a command inside THIS session's own isolated \
            Linux VM — a real, disposable Linux machine with its own kernel, separate from both this \
            container and any container you create. Use it ONLY for Linux dev work a container CANNOT \
            do: things that need a real kernel (custom kernel modules, systemd, nested virtualization, \
            low-level networking) or a full desktop/GUI. For ordinary build/test/scripting, \
            \(allowAgentContainers ? "a `linux_container` covers it" : "stay in this container") — \
            the VM is heavier. Like the other tools, calling it \
            IS how you request approval; pass a `reason` for why a container can't do this. The first \
            call boots the VM (a minute or two); later calls reuse it.
            """ : ""
        let linuxComputer = allowLinuxVMComputer ? "\n\n" + """
            You can also SEE and OPERATE the Linux VM's screen with `linux_vm_computer` — a \
            computer-use loop like `mac_vm_computer` (each call performs one action and returns a \
            screenshot): screenshot → decide → act → screenshot. Use it to drive a Linux desktop app \
            or GUI you can't test headlessly. Pixel actions only — click/type/key/scroll/launch_app at \
            the screen's real coordinates; the semantic `ax_*` actions are macOS-only. Actions run in \
            the VM sandbox and are NOT individually approved. Treat this VM as entirely your own \
            sandbox: you have free rein to do whatever gets the job done — install tools and \
            packages, sign in to apps, change settings, edit files, or otherwise reconfigure it \
            however you see fit. It's a disposable clone that's reset at session end, so don't hold \
            back or ask permission for changes inside it — just make the environment whatever you \
            need it to be to reach your goal. When you already know the next few actions from what \
            you've seen, batch them in one call with `linux_vm_computer_batch` (pass `steps`, an \
            ordered array of the same actions) instead of one turn per action; it returns a \
            screenshot of the final state (`screenshot_each_step`/`step_delay_ms`/`stop_on_error` \
            control per-step screenshots, settle delay, and failure handling).
            """ : ""
        // Linux-VM lifecycle: the Linux sibling of `mac_vm_control`, exposed whenever the Linux VM is
        // usable (exec or computer-use). Deliberately NOT under the macOS scarcity rule —
        // `maxConcurrentVMs` is a macOS-guest ceiling (`MacVMSettings`, see docs/MACOS_VM.md §10.1)
        // that Linux guests don't share, so telling the agent to race for a slot here would be false
        // and would buy nothing but suspend/resume churn.
        let linuxVMControl = (allowLinuxVMExec || allowLinuxVMComputer) ? "\n\n" + """
            You can manage that Linux VM's lifecycle with `linux_vm_control` — the sibling of \
            `mac_vm_control`, same ops (`status`, `stop`, `suspend`, `resume`, `restart`, `kill`) \
            acting on your own disposable Linux VM, no approval needed. Unlike macOS VMs, Linux VMs \
            are NOT capped across agents, so there is no slot to free and you can leave this one \
            running across a task without starving anyone. Still, when you're done with it for a \
            while, `suspend` it (saves its full state to disk; `resume`, or any VM command, brings it \
            back exactly where it left off) or `stop` it to give the host its RAM back. It's torn \
            down for you at session end; `restart`/`kill` reset it mid-session.
            """ : ""
        // The escalation ladder — a short policy that ties the rungs together, naming only the tools
        // actually exposed this run so it never points the agent at an unavailable tool.
        var rungs: [String] = [
            allowAgentContainers
                ? "a `linux_container` you create for ordinary build, test, and scripting (this "
                    + "session container only for light work — edits, search, git, short scripts)"
                : "this container for ordinary build, test, and scripting",
        ]
        if allowLinuxVMExec {
            rungs.append(
                "a Linux VM (`linux_vm_exec`) only for Linux work a container can't do — a real "
                    + "kernel, systemd, or a desktop/GUI")
        }
        if allowMacVMExec {
            rungs.append(
                "a macOS VM (`mac_vm_exec`) only for Mac-only work — Xcode, the simulators, codesign, "
                    + "or running/diagnosing a macOS/iOS build")
        }
        let ladder: String = {
            // Only worth stating the ladder when there's an actual choice to make (a VM rung exists).
            guard allowLinuxVMExec || allowMacVMExec else { return "" }
            var text = "\n\n" + "Choosing where to run work — always pick the LIGHTEST option that "
                + "can do the job, and only escalate when it genuinely can't: "
                + rungs.enumerated()
                    .map { "(\($0.offset + 1)) \($0.element)" }
                    .joined(separator: "; ") + "."
            if allowLinuxVMExec && allowMacVMExec {
                text += " Prefer a Linux VM over a macOS VM whenever the choice is open — it is "
                    + "lighter and boots faster. For a general computer-use task that could run on "
                    + "either desktop, default to the \(defaultComputerUseVMType.label) VM."
            }
            return text
        }()
        let tail = macVM + computer + macVMControl + container + linuxVM + linuxComputer
            + linuxVMControl + ladder
        if allowHostExec {
            return base + " " + """
                A `host_exec` tool runs a command on the macOS host (in this session's worktree) \
                where Swift and Xcode live. This is the normal, expected way to do Swift/macOS work \
                — building or running a Swift package, a SwiftUI/AppKit app, or an \
                `xcodebuild`/`codesign`/`xcrun` step. Use it freely for that: the tool is safe to \
                call because the user approves each invocation before it runs, so calling it is \
                never the wrong move when the task needs the host toolchain. Don't run \
                `xcodebuild`, or a `swift`/`make` build of an app or package that imports Apple \
                frameworks, directly in the container: the container's Swift is a Linux \
                toolchain with no Apple SDKs, so those fail on missing imports (or a missing \
                `xcodebuild`) rather than building. Run them via host_exec instead — and keep \
                using the container for Swift that really is portable.

                The one thing to remember: calling host_exec IS how you request permission. The \
                call itself surfaces the approval prompt to the user — nothing runs on the host \
                until they approve. So when the task needs the host, just CALL host_exec. Never end \
                your turn by asking in prose for permission or for the user to run the command \
                themselves; that stalls the task, because the tool call is the only thing that \
                actually asks. Give a clear `reason` on the call (the user sees it when approving) \
                and prefer one combined command, e.g. `swift build && swift test` (or the \
                project's Makefile targets, e.g. `make` / `make test`, which invoke swift). If the \
                user denies a call, adapt rather than re-requesting the same command.

                For everything else, Linux is already equipped and faster: it builds and \
                tests JavaScript/TypeScript, Python, and C/C++, and handles all ordinary shell, \
                file, search, and network work — do that in the sandbox\(allowAgentContainers
                    ? " (the heavy builds and test suites in a scratch `linux_container`, per below)"
                    : "") rather than on the host. Skip \
                host_exec for work the container can do, for installing software, or for \
                `git`/`gh` (host git/gh is blocked — it bypasses the shared-trunk version control; \
                run git in the container instead).
                """ + tail
        }
        return base + " " + """
            Host execution is disabled for this session, so no Apple toolchain is reachable: \
            macOS/iOS apps and any target importing SwiftUI, AppKit or UIKit cannot be built, \
            run, or tested here, and `xcodebuild` does not exist in the container. The \
            container's Linux Swift toolchain still builds and tests portable Swift packages, so \
            reach for it when the target really is platform-independent. If a task requires \
            building or running a macOS/iOS target, say so rather than guessing.
            """ + tail
    }

    /// The nvrsion version-control guidance text (NVRSION). Separated like `sandboxBuildGuidance`
    /// so it can be unit-tested without standing up a full controller. It teaches the one thing that
    /// makes nvrsion different from ordinary git — a *shared* trunk where every edit is committed the
    /// instant it lands — and the behavior that follows: don't hand-run history-mutating git on the
    /// shared tree, and the real branch is reached only by the user's promotion, not by the agent.
    static func nvrsionVersionControlGuidance(base: String, trunkBranch: String) -> String {
        """
        Version control in this session uses nvrsion, which differs from the ordinary per-branch \
        git model — keep this in mind whenever you describe or act on version control. Your working \
        directory IS a shared trunk checkout (branch `\(trunkBranch)`) that other agents edit live \
        and concurrently; it is NOT a private worktree or feature branch. Nucleic commits each \
        Edit/Write/MultiEdit into the trunk the instant it completes (one path-scoped commit per \
        edit), so your work is version-controlled on its own — it is never just "uncommitted \
        changes in the working tree" waiting to be saved, and you never commit it yourself: moments \
        after each edit completes it is already a commit on the trunk. Do not run \
        git commands that rewrite history or move the shared tree — no `commit`, `add`, `branch`, \
        `checkout`/`switch`, `reset`, `revert`, `stash`, `merge`, `rebase`, or `clean` — because the \
        tree is shared with other agents and your work is already committed for you, so those would \
        clobber siblings' concurrent edits or fight the live commit machinery; read-only inspection \
        (`git status`, `git log`, `git diff`, `git show`) is fine. Because the trunk is shared, a \
        file you read earlier may have been changed by another agent before your next edit — when \
        that happens you'll be told to re-read it, so don't assume an earlier read is still current. \
        Your edits reach the project's real `\(base)` branch only when the user runs the separate \
        "Integrate trunk → \(base)" promotion (which squashes the trunk's accumulated work into one \
        commit); you do not merge into `\(base)`, push, or open a pull request to ship — just make \
        the edits and they land on the trunk for the user to promote.
        """
    }

    /// Build the sandbox `ContainerSpec` for this session when its project is effectively
    /// sandboxed; `nil` → run `claude` on the host (default).
    ///
    /// Two container models, chosen by `Project.usesSharedControlContainer`:
    ///  • **Shared primary container** (Nucleic Control, no per-session opt-in): one container
    ///    for all such projects. Mounts the whole control base (every control repo + its
    ///    worktrees live under it) and the sessions dir (per-session claude-homes) at identical
    ///    canonical paths; `HOME` points at this session's own claude-home inside the mounted
    ///    sessions dir. Always uses the bundled default image (it serves many projects).
    ///  • **Per-session container** (non-control sandbox, or a future per-session opt-in):
    ///    today's model — mounts the repo root + worktree base + a per-session claude-home at
    ///    `/root/.claude`, honoring the project's image override.
    ///
    /// The per-session writable claude-home is seeded host-side (template copy on first use,
    /// credentials refreshed each turn) so both models share one code path. Git worktree links
    /// and the cwd-hash resolve because every host path is mounted at its identical canonical
    /// path (git stores symlink-resolved absolute paths, e.g. `/private/tmp/...`).
    private func containerSpec() -> ContainerSpec? {
        // The app-wide service switch gates everything (respected even for Nucleic Control):
        // with it off, sessions run on the host regardless of the project's sandbox state.
        guard ContainerServiceSettings.serviceEnabled else { return nil }
        guard let project, let sandbox = project.effectiveSandbox, sandbox.enabled else { return nil }
        if let agent = ACPAgent.forBackend(session.backend), !agent.supportsContainerizedRuns {
            return nil
        }

        let canon = GitWorktreeManager.canonical
        let shared = project.usesSharedControlContainer
        // Nucleic Control containers get the `git` interceptor shim (observe agent git ops
        // with certainty); a plain manually-sandboxed project does not.
        let installGitInterceptor = project.isNucleicControlled

        // Per-session writable claude-home (sibling of the session's transcript), seeded
        // host-side: copy the user's ~/.claude as a template the first time, and refresh the
        // login credentials every turn. On macOS the Claude Code login (OAuth) lives in the
        // Keychain, not a file, so it must be exported as `.credentials.json` (the file claude
        // reads on Linux) or the containerized claude reports "Not logged in". Never touches
        // the host's real ~/.claude.
        let sessionDir = (session.transcriptPath as NSString).deletingLastPathComponent
        let writableHost = claudeHomePath()
        let isNewHome = !FileManager.default.fileExists(atPath: writableHost)
        try? FileManager.default.createDirectory(
            atPath: writableHost, withIntermediateDirectories: true)
        // Per-agent auth seeding into the per-session writable home (the agent runs non-root and
        // owns this dir; its `$HOME` is set to it below). Claude: copy ~/.claude + export the
        // Keychain login into the dir itself (claude uses CLAUDE_CONFIG_DIR). Codex/Grok store auth
        // as plain files under `$HOME/.codex` / `$HOME/.grok`, so seed those subdirs from the host's
        // — simpler than Claude (no Keychain). API-key users are covered by the env forwarding below.
        // Codex ChatGPT auth is a rotating OAuth refresh token shared with the host; the API-key
        // escape hatch forwards a static `OPENAI_API_KEY` (below) and deliberately keeps that shared
        // token OUT of the container so host and container can't revoke each other's rotation.
        let codexAPIKey: String? = Self.codexControlUsesAPIKey ? CodexControlAPIKeyStore.load() : nil
        let external = ExternalAgentIntegrationSettings.enabledIntegrations(for: session.model)
        switch session.backend {
        case .claudeCode:
            if isNewHome { Self.seedClaudeHomeTemplate(into: writableHost, external: external) }
            Self.exportLoginCredentials(into: writableHost)
            Self.ensureClaudeConfigFile(into: writableHost)
        case .codex, .codexExec:
            let containerAuth = (writableHost as NSString).appendingPathComponent(".codex/auth.json")
            var skipped: Set<String> = ["sessions", "log", "logs"]
            if external.contains(.skills) == false { skipped.insert("skills") }
            if external.contains(.plugins) == false { skipped.insert("plugins") }
            if codexAPIKey != nil {
                // API-key auth: seed the rest of `~/.codex` (config, skills…) but never the OAuth
                // login, and drop any `auth.json` a prior OAuth turn left behind — else `codex` would
                // prefer the (rotating, revocable) ChatGPT token over the static key.
                skipped.insert("auth.json")
                Self.seedAgentHome(hostDir: ".codex", skipping: skipped, into: writableHost)
                try? FileManager.default.removeItem(atPath: containerAuth)
            } else {
                Self.seedAgentHome(hostDir: ".codex", skipping: skipped, into: writableHost)
                // Push the freshest host login into the container before it runs — the host may have
                // rotated the refresh token since this session was first seeded; the post-turn
                // `syncCodexLoginBack()` writes any in-container rotation back. (LOGIN SYNC.)
                let hostAuth = (NSHomeDirectory() as NSString).appendingPathComponent(".codex/auth.json")
                CodexAuthFile.copyIfNewer(from: hostAuth, to: containerAuth)
            }
        case .grok, .opencode, .openclaw, .hermes, .cursorAgent, .acp:
            // ACP wrapper agents store auth as plain files under `$HOME` (like Codex/Grok). Seed
            // each of the agent's known home dirs so a sandboxed run authenticates as the user.
            // Host-only in v1, so this path is exercised only if containers are enabled for them.
            if let agent = ACPAgent.forBackend(session.backend) {
                for dir in agent.containerHomeDirs {
                    var skipped: Set<String> = []
                    if external.contains(.skills) == false { skipped.insert("skills") }
                    if external.contains(.plugins) == false { skipped.insert("plugins") }
                    Self.seedAgentHome(hostDir: dir, skipping: skipped, into: writableHost)
                }
            }
        }

        let stagingPath = "/nucleic/host-claude"
        var mounts: [ContainerSpec.Mount] = []
        var env: [String: String] = [:]
        // Credential policy (mesh consistency): Claude and Codex agents authenticate ONLY with
        // credentials Nucleic itself mediates — the OAuth login seeded/refreshed above, or an API
        // key the user configured IN Nucleic (Control settings, applied below via
        // `ControlAPIKeyStore` / `codexAPIKey`). We deliberately do NOT inherit an ambient
        // ANTHROPIC_API_KEY / OPENAI_API_KEY / CODEX_API_KEY from the app's launchd environment:
        // Claude/Codex prefer an API key over OAuth, so a stray host key would silently override the
        // mediated login — and, never having been captured/sealed, it can't be reproduced on the
        // machines the mesh transfers credentials to. Managed keys flow through `CredentialProvider`.
        //
        // The ACP wrapper agents (Grok/OpenCode/Cursor/Hermes) still authenticate only via their own
        // env keys today (no in-Nucleic store yet), so those remain forwarded — outside the
        // Claude/Codex OAuth policy.
        for key in [
            "XAI_API_KEY", "GROK_CODE_XAI_API_KEY",
            "OPENCODE_API_KEY", "CURSOR_API_KEY", "HERMES_API_KEY", "NOUS_API_KEY",
        ] {
            if let value = ProcessInfo.processInfo.environment[key], !Self.isBlankAPIKey(value) {
                env[key] = value
            }
        }
        // Codex API-key auth mode: the stored key overrides whatever env forwarding found, and is why
        // the seeding above kept the OAuth login out. `OPENAI_API_KEY` covers the app-server backend;
        // `CODEX_API_KEY` covers `codex exec`.
        if let codexAPIKey {
            env["OPENAI_API_KEY"] = codexAPIKey
            env["CODEX_API_KEY"] = codexAPIKey
        }

        let name: String
        let image: String
        let writablePath: String
        // Two resource pools: the shared control container serves every control session at once
        // (more generous); a per-session container serves one.
        let cpus: Int
        let memoryGiB: Int
        if shared {
            let controlBase = canon(ProjectCloner.controlBase)
            try? FileManager.default.createDirectory(
                atPath: controlBase, withIntermediateDirectories: true)
            let sessionsDir = canon((sessionDir as NSString).deletingLastPathComponent)
            mounts.append(.init(host: controlBase, container: controlBase, readOnly: false))
            mounts.append(.init(host: sessionsDir, container: sessionsDir, readOnly: false))
            // A control project normally keeps its worktrees under the control base (covered by
            // the mount above). But a custom worktreeBase override can point outside it — mount
            // that too, or the session's workdir would be missing inside the container.
            let worktreeBase = canon(project.resolvedWorktreeBase)
            if worktreeBase != controlBase && !worktreeBase.hasPrefix(controlBase + "/") {
                mounts.append(.init(host: worktreeBase, container: worktreeBase, readOnly: false))
            }
            // The sessions dir is mounted at its identical path, so the writable claude-home is
            // visible inside the container at the very same path. Point claude at THIS session's
            // private claude-home via CLAUDE_CONFIG_DIR — that governs its settings, credentials,
            // and `projects/` transcripts (which `locateNativeTranscript` reads back from
            // `<claude-home>/projects`). HOME is set to the same dir below: the agent runs non-root
            // (the host uid), so `/root` is not writable — its `$HOME` must be a dir it owns.
            writablePath = writableHost
            env["CLAUDE_CONFIG_DIR"] = writableHost
            // Control container auth: by default reuse the host OAuth login (the
            // `.credentials.json` seeded above). If the user chose API-key auth for Control and
            // saved a key, forward it — claude prefers ANTHROPIC_API_KEY over OAuth, so it takes
            // over cleanly. (Blank/missing key → fall back to OAuth.)
            if ContainerServiceSettings.controlAuthMode == .apiKey,
               let apiKey = ControlAPIKeyStore.load() {
                env["ANTHROPIC_API_KEY"] = apiKey
            }
            // Pick the shared container by backend: with backend-splitting on, each agent family
            // lands in its own sandbox so they can't kill each other's processes. This is the stable
            // *logical* name (Claude → `nucleic-control-claude`, etc.); `ContainerManager` runs it
            // under a randomized, family-anonymous physical name so a sibling agent can't recognize or
            // target it. Off → every backend shares `nucleic-control`.
            name = ContainerManager.sharedContainerName(
                for: session.backend,
                split: ContainerServiceSettings.splitControlContainersByBackend)
            image = ProjectSandbox.defaultImage
            cpus = ContainerServiceSettings.controlContainerCPUs
            memoryGiB = ContainerServiceSettings.controlContainerMemoryGiB
        } else {
            let rootPath = canon(project.rootPath)
            let worktreeBase = canon(project.resolvedWorktreeBase)
            // Mount the writable claude-home at its identical host path (NOT under `/root`): the
            // agent runs non-root, and `/root` is mode 0700 — a claude-home under it would be
            // unreachable. At the host path it's owned by (and traversable for) the host uid the
            // agent runs as. Point both CLAUDE_CONFIG_DIR and HOME there.
            writablePath = writableHost
            mounts.append(.init(host: rootPath, container: rootPath, readOnly: false))
            mounts.append(.init(host: worktreeBase, container: worktreeBase, readOnly: false))
            mounts.append(.init(host: writableHost, container: writableHost, readOnly: false))
            env["CLAUDE_CONFIG_DIR"] = writablePath
            name = ContainerManager.containerName(for: session.id)
            image = sandbox.resolvedImage
            cpus = ContainerServiceSettings.sessionContainerCPUs
            memoryGiB = ContainerServiceSettings.sessionContainerMemoryGiB
        }

        // Run the agent as the *host* user (not container root), so it owns the bind-mounted
        // repo/worktree/claude-home (virtiofs presents the host owner on the shared mounts) while
        // the root-installed instrumentation — the git + command shims, the bash tracer,
        // `/etc/gitconfig` — stays read-only to it. `$HOME` therefore must be a dir the agent owns:
        // the writable claude-home, not `/root`. If the app itself runs as root (uid 0) this is a
        // no-op (can't drop below root, and the mounts are root-owned anyway).
        env["HOME"] = writablePath
        // The agent process is exec'd with a raw argv (`ContainerEngine.exec` sets
        // `environmentVariables` outright, and ContainerEngine ignores OCI image env), so unlike
        // Nucleic's own container commands — which go through `CommandInterceptor.agentShellArgv`
        // and pick this up from `/etc/profile.d/naros-env.sh` under `nash -l` — it never sources a
        // login shell. With no PATH set it fell back to the libc default, leaving the image's baked
        // toolchains (cargo/rustup, Go, the mise shims) invisible to every Bash tool call. Pin the
        // narOS agent-tier PATH here so all four containerized backends get it; host runs have no
        // ContainerSpec and keep `LoginShellPATH`'s host-derived value.
        //
        // `/usr/local/bin` leads — ahead of the mise shims, unlike naros-env.sh — so the
        // root-installed git/gh interceptor shims can never be shadowed by a managed toolchain of
        // the same name; that interception feeds the conflict coordinator and isn't optional.
        // Keep in sync with os/images/agent/files/etc/profile.d/naros-env.sh.
        env["PATH"] =
            "/usr/local/bin:/opt/mise/shims:/opt/cache/cargo/bin:/usr/local/go/bin"
            + ":/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin"
        #if os(Windows)
        // No POSIX identity to mirror: the host has no uid, and the guest runs under WSL rather
        // than under a mount owned by the host user. Use the uid/gid narOS bakes for its `agent`
        // user, so the passwd entry the image already carries matches and no entry has to be
        // appended (docs/NAROS.md §6.1, docs/WINDOWS_PORT.md §3.2).
        let hostUID = ContainerSpec.narosAgentUID
        let hostGID = ContainerSpec.narosAgentUID
        #else
        let hostUID = Int(getuid())
        let hostGID = Int(getgid())
        #endif

        // App-level Managed Git credentials: write the configured token / SSH key into this session's
        // bind-mounted claude-home and point the container's git/ssh at it (the files live in
        // `writableHost`, visible inside the container at `writablePath`). No-op when unset. A Control
        // session's shared container always gets it (it has no other way to reach GitHub); a
        // per-session sandbox gets it only when "Make available in sandbox" is on.
        if shared || GitHubCredentialSettings.availableInSandbox {
            env.merge(
                GitHubCredentialProvisioner.provision(
                    .current(), hostHomeDir: writableHost, containerHomeDir: writablePath)
            ) { _, new in new }
        }

        // Control plane: EVERY containerized run rides the vsock-relayed unix socket (no IP
        // listener → no macOS local-network prompts). The vsock control plane is mandatory — the
        // legacy gateway-TCP path is retired, for per-session containers too. A shared control
        // container meets the long-lived registry server at its container-stable path; a
        // per-session container gets its own socket (same app-owned runtime dir, keyed by its
        // unique name), served by its backend's own server. Requires the in-guest bridge —
        // sandbox image ≥ v4; a custom image must carry `node` + the bridge script, enforced by
        // the engine's fresh-clone preflight so a bridge-less image fails loudly at start.
        let controlSocketHostPath = ApprovalServerRegistry.controlSocketPath(for: name)
        // Claude token proxy (opt-in): relay a second socket so the backend can run its per-request
        // token-injecting proxy for this container. Only for Claude sessions and only when enabled —
        // otherwise nil, and the container behaves exactly as before (static token, no proxy relay).
        let proxySocketHostPath: String? =
            (session.backend == .claudeCode && ContainerServiceSettings.claudeTokenProxyEnabled)
            ? ApprovalServerRegistry.proxySocketPath(for: name) : nil

        return ContainerSpec(
            name: name,
            image: image,
            mounts: mounts,
            workdir: canon(worktreePath),
            env: env,
            idleTimeout: TimeInterval(sandbox.idleTimeoutSeconds),
            claudeHomeStaging: stagingPath,
            claudeHomeWritable: writablePath,
            installGitInterceptor: installGitInterceptor,
            cpus: cpus,
            memoryGiB: memoryGiB,
            runAsUID: hostUID,
            runAsGID: hostGID,
            controlSocketHostPath: controlSocketHostPath,
            proxySocketHostPath: proxySocketHostPath)
    }

    /// Build the turn's container spec, then let the shared Claude broker refresh and re-export
    /// subscription OAuth before the backend starts. Host runs and non-Claude backends are unchanged.
    private func preparedContainerSpec() async -> ContainerSpec? {
        let spec = containerSpec()
        guard session.backend == .claudeCode, let spec, let claudeCredentialBroker else {
            return spec
        }
        let preparation = await claudeCredentialBroker.prepare(container: spec)
        switch preparation {
        case .ready(let token):
            // Keep the rotating refresh token broker-only. Session CLIs receive the current access
            // token explicitly, so they cannot independently fork the refresh lineage.
            guard let token else { return spec }
            return spec.mergingEnvironment(["CLAUDE_CODE_OAUTH_TOKEN": token])
        case .refreshed(let token):
            return spec.mergingEnvironment(["CLAUDE_CODE_OAUTH_TOKEN": token])
        case .loginRequired, .unavailable:
            // Let Claude emit its normal authentication diagnostic; the transcript turns it into
            // Nucleic's native Log in row.
            return spec
        }
    }

    /// The per-session writable claude-home (sibling of the transcript), canonicalized to the same
    /// path the container mounts it at. Broker preparation uses this location as short-lived staging
    /// for the full OAuth credential and removes it before the user-controlled process starts.
    private func claudeHomePath() -> String {
        let sessionDir = (session.transcriptPath as NSString).deletingLastPathComponent
        return GitWorktreeManager.canonical(
            (sessionDir as NSString).appendingPathComponent("claude-home"))
    }

    /// Legacy crash-recovery fallback for homes created before the broker owned refresh. New brokered
    /// turns remove `.credentials.json` before launch, so this is normally a no-op; preserving it lets
    /// Nucleic adopt a newer credential left by an older app build.
    private func syncClaudeLoginBack() {
        guard session.backend == .claudeCode, isSandboxed else { return }
        let path = (claudeHomePath() as NSString).appendingPathComponent(".credentials.json")
        ClaudeLoginKeychain.writeBackIfNewer(fromContainerFile: path)
    }

    /// Whether Nucleic Control's Codex sessions authenticate with a stored OpenAI API key instead of
    /// the host ChatGPT login — true only when the mode is selected AND a key is actually saved, so a
    /// half-configured mode falls back to OAuth rather than breaking auth outright.
    static var codexControlUsesAPIKey: Bool {
        ContainerServiceSettings.codexControlAuthMode == .apiKey && CodexControlAPIKeyStore.hasKey
    }

    /// After a containerized Codex turn, propagate the sandbox's login back to the host's
    /// `~/.codex/auth.json` when it's newer — the containerized `codex` refreshes and *rotates* the
    /// shared ChatGPT refresh token as its access token expires; without this the rotated token stays
    /// trapped in the container copy while the host keeps a now-revoked one, so the next refresh on
    /// either side fails ("your refresh token was revoked"). The Codex counterpart to
    /// ``syncClaudeLoginBack()``; gated to sandboxed Codex OAuth sessions (host runs use `~/.codex`
    /// directly, and API-key mode has no refresh lineage to reconcile). See ``CodexAuthFile``.
    private func syncCodexLoginBack() {
        guard session.backend == .codex || session.backend == .codexExec,
            isSandboxed, !Self.codexControlUsesAPIKey else { return }
        let container = (claudeHomePath() as NSString).appendingPathComponent(".codex/auth.json")
        let host = (NSHomeDirectory() as NSString).appendingPathComponent(".codex/auth.json")
        CodexAuthFile.copyIfNewer(from: container, to: host)
    }

    /// Seed a per-session agent home for Codex/Grok: copy the host's `~/<dirName>` (`.codex` /
    /// `.grok` — plain-file auth dirs holding the login + config, unlike Claude's Keychain) into
    /// `<writableHome>/<dirName>`, so the containerized agent — whose `$HOME` is `writableHome` —
    /// authenticates as the user (`$HOME/.codex`, `$HOME/.grok`). Best-effort and host-side only:
    /// skipped when the source is absent or the dest already exists (so accumulated session state
    /// survives later turns); `skip` drops large/per-session subdirs (e.g. Codex `sessions`). The
    /// host dir is never mutated.
    private static func seedAgentHome(hostDir dirName: String, skipping skip: Set<String>, into writableHome: String) {
        let fm = FileManager.default
        let source = (NSHomeDirectory() as NSString).appendingPathComponent(dirName)
        let dest = (writableHome as NSString).appendingPathComponent(dirName)
        guard fm.fileExists(atPath: source), !fm.fileExists(atPath: dest),
            let entries = try? fm.contentsOfDirectory(atPath: source)
        else { return }
        try? fm.createDirectory(atPath: dest, withIntermediateDirectories: true)
        for entry in entries where !skip.contains(entry) {
            try? fm.copyItem(
                atPath: (source as NSString).appendingPathComponent(entry),
                toPath: (dest as NSString).appendingPathComponent(entry))
        }
        try? SecretFile.restrictToOwner(path: dest, isDirectory: true)
    }

    /// Seed a fresh per-session claude-home by copying the user's `~/.claude` into it as a
    /// template, so the containerized `claude` starts from the user's config (settings,
    /// plugins) while keeping a writable HOME it can persist session/transcript files into —
    /// the host `~/.claude` is never mutated. Best-effort and only on first use of a home
    /// (later turns skip it, preserving the container's accumulated session state). The
    /// per-session subdirs (`projects/`, `todos/`, `statsig/`) are skipped to keep the copy
    /// small and avoid leaking other projects' transcripts into the sandbox.
    private static func seedClaudeHomeTemplate(
        into claudeHome: String, external: Set<ExternalAgentIntegration>
    ) {
        let fm = FileManager.default
        let source = (NSHomeDirectory() as NSString).appendingPathComponent(".claude")
        guard let entries = try? fm.contentsOfDirectory(atPath: source) else { return }
        var skip: Set<String> = ["projects", "todos", "statsig", "shell-snapshots", ".credentials.json"]
        if external.contains(.skills) == false { skip.insert("skills") }
        if external.contains(.plugins) == false { skip.insert("plugins") }
        for entry in entries where !skip.contains(entry) {
            let from = (source as NSString).appendingPathComponent(entry)
            let to = (claudeHome as NSString).appendingPathComponent(entry)
            try? fm.copyItem(atPath: from, toPath: to)
        }
    }

    /// Ensure `<claudeHome>/.claude.json` exists — the top-level config file Claude Code reads
    /// from `CLAUDE_CONFIG_DIR`. The template seed copies the *contents* of `~/.claude/`, but
    /// `.claude.json` is a sibling of that directory (`~/.claude.json`), so it never lands in the
    /// per-session home. A Nucleic Control session points claude at the home via
    /// `CLAUDE_CONFIG_DIR`, and claude then exits 1 with "Claude configuration file not found":
    /// it auto-creates this file at the default `$HOME/.claude.json`, but errors when an explicit
    /// config dir lacks it. Seed it from the host's own `~/.claude.json` so onboarding/account
    /// state carries over, minus the per-cwd `projects` map (large, and would leak other
    /// projects' prompt history into the sandbox — claude recreates it). Written only when
    /// absent, so it never clobbers the config claude maintains across turns, and self-heals a
    /// home created before this seed existed. Best-effort, host-side, 0600 (carries account info).
    private static func ensureClaudeConfigFile(into claudeHome: String) {
        let dest = (claudeHome as NSString).appendingPathComponent(".claude.json")
        guard !FileManager.default.fileExists(atPath: dest) else { return }
        let source = (NSHomeDirectory() as NSString).appendingPathComponent(".claude.json")
        let payload = claudeConfigSeed(fromHostConfig: try? Data(contentsOf: URL(fileURLWithPath: source)))
        guard (try? payload.write(to: URL(fileURLWithPath: dest))) != nil else { return }
        try? SecretFile.restrictToOwner(path: dest, isDirectory: false)
    }

    /// The seed contents for a sandbox `.claude.json`, derived from the host's config `data`:
    /// the host config minus its per-cwd `projects` map, or a minimal valid config (onboarding
    /// marked complete) when there's no readable/parseable host file. Pure + testable.
    static func claudeConfigSeed(fromHostConfig data: Data?) -> Data {
        if let data, var object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] {
            object.removeValue(forKey: "projects")
            if let trimmed = try? JSONSerialization.data(withJSONObject: object) { return trimmed }
        }
        return Data(#"{"hasCompletedOnboarding":true}"#.utf8)
    }

    /// True when `value` is a present-but-blank `ANTHROPIC_API_KEY` — e.g. an empty var inherited
    /// from the launchd environment of a GUI-launched app. Claude Code prefers `ANTHROPIC_API_KEY`
    /// over the OAuth `.credentials.json`, so forwarding a blank one makes it attempt empty
    /// API-key auth and fail with "401 Invalid authentication credentials", overriding a perfectly
    /// good subscription login. nil (unset) is not blank — there's simply nothing to forward.
    public static func isBlankAPIKey(_ value: String?) -> Bool {
        guard let value else { return false }
        return value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
    }

    /// Reconcile and export the canonical Claude login into this session's writable home.
    private static func exportLoginCredentials(into claudeHome: String) {
        ClaudeLoginKeychain.reconcileAndExport(into: claudeHome)
    }

    /// Nucleic's owned Claude credential. The store may silently adopt a legacy Claude item when
    /// it is already authorized, but this call is never capable of showing Keychain UI.
    private static func readKeychainCredentials() -> String? {
        ClaudeLoginKeychain.read()
    }

    /// Begin a fresh run. Idempotent: a second call while running is a no-op.
    public func start(prompt: AgentInput) async {
        guard runTask == nil, !preparingTurn else { return }
        preparingTurn = true
        session.status = .running
        session.lastTurnDisposition = nil  // new turn — the prior turn's disposition is stale
        session.updatedAt = now()
        if prompt.plainText?.isEmpty == false { session.lastUserMessageAt = now() }
        let container = await preparedContainerSpec()
        preparingTurn = false
        guard runTask == nil, !session.status.isTerminal else { return }
        let run = RunSpec(
            sessionID: session.id,
            worktree: worktreePath,
            prompt: prompt,
            model: session.model,
            effort: resolvedEffort,
            autoApprove: session.auto,
            approvalPolicy: .interactive,
            container: container,
            allowHostExec: allowsHostExec,
            allowMacVMExec: allowsMacVMExec,
            allowMacVMComputer: allowsMacVMComputer,
            allowLinuxVMExec: allowsLinuxVMExec,
            allowLinuxVMComputer: allowsLinuxVMComputer,
            allowAgentContainers: allowsAgentContainers,
            orchestraActive: orchestraActive,
            orchestraSubagentSpawner: activeOrchestraSubagentSpawner,
            blockingSubagentSpawner: activeBlockingSubagentSpawner,
            orchestraPlanHandler: activeOrchestraPlanHandler,
            orchestraSuperviseHandler: activeOrchestraSuperviseHandler,
            orchestraReplyToWorkerHandler: activeOrchestraReplyToWorkerHandler,
            orchestraAskSupervisorHandler: orchestraAskSupervisorHandler,
            meshMessaging: meshMessagingHandlers,
            monitorEventSink: monitorEventSink,
            appendSystemPrompt: appendedSystemPrompt)
        // Snapshot routing provenance before the asynchronous stream task begins. A slider/model
        // change after launch must not rewrite what this prompt actually ran with.
        let userChunk = prompt.plainText.flatMap { $0.isEmpty ? nil : makeUserChunk($0) }
        consume(backend.start(run), injectingUserChunk: userChunk)
    }

    /// Reattach to the backend's native session and continue from `lastSeq + 1`.
    public func resume() async {
        guard runTask == nil, !preparingTurn,
              let backendSessionID = session.backendSessionID else { return }
        preparingTurn = true
        session.status = .running
        session.lastTurnDisposition = nil  // new turn — the prior turn's disposition is stale
        session.updatedAt = now()
        let container = await preparedContainerSpec()
        preparingTurn = false
        guard runTask == nil, !session.status.isTerminal else { return }
        let spec = ResumeSpec(
            sessionID: session.id,
            backendSessionID: backendSessionID,
            worktree: worktreePath,
            container: container,
            allowHostExec: allowsHostExec,
            allowMacVMExec: allowsMacVMExec,
            allowMacVMComputer: allowsMacVMComputer,
            allowLinuxVMExec: allowsLinuxVMExec,
            allowLinuxVMComputer: allowsLinuxVMComputer,
            allowAgentContainers: allowsAgentContainers,
            orchestraActive: orchestraActive,
            orchestraSubagentSpawner: activeOrchestraSubagentSpawner,
            blockingSubagentSpawner: activeBlockingSubagentSpawner,
            orchestraPlanHandler: activeOrchestraPlanHandler,
            orchestraSuperviseHandler: activeOrchestraSuperviseHandler,
            orchestraReplyToWorkerHandler: activeOrchestraReplyToWorkerHandler,
            orchestraAskSupervisorHandler: orchestraAskSupervisorHandler,
            meshMessaging: meshMessagingHandlers,
            monitorEventSink: monitorEventSink,
            appendSystemPrompt: appendedSystemPrompt)
        consume(backend.resume(spec), injectingUserChunk: nil)
    }

    private func consume(
        _ stream: AsyncThrowingStream<AgentEvent, Error>, injectingUserChunk userChunk: TextChunk?
    ) {
        runTask = Task {
            if let userChunk {
                await ingest(synthetic(.userText(userChunk)))
            }
            do {
                for try await event in stream {
                    await ingest(event)
                }
            } catch {
                // Stream failed before a terminal event — synthesize one so the
                // session reaches a terminal status and the transcript is complete.
                await ingest(synthetic(.error(AgentError(recoverable: false, message: "\(error)"))))
                await ingest(synthetic(.runFinished(RunFinished(outcome: .errored))))
            }
            await finishRun()
        }
    }

    /// Submit a user turn. The user's text is injected into the transcript first so
    /// chat history stays complete. In conversational mode each message is its own
    /// single-shot run: the first starts the session, later ones `resume` it (which
    /// also carries the message), so context is retained across turns.
    ///
    /// `display` carries the composer-facing form of the message (original text, before
    /// attachment path references are folded into `input`, plus its attachments) — used only
    /// when the message is queued, so the composer can show and cancel it individually. When the
    /// message sends immediately it's ignored; when omitted, a text-only item is synthesized.
    ///
    /// `echoToTranscript: false` sends `input` to the backend as a steering turn *without*
    /// synthesizing the `.userText` transcript event, so a machine-injected nudge (e.g. the
    /// dead-wait continuation) drives a fresh turn but never surfaces in chat as if the user typed
    /// it. Such a message also doesn't count as user activity (`lastUserMessageAt`) or show in the
    /// composer's queued list. Whether it sends immediately or is queued behind a closing turn, the
    /// echo is suppressed either way (see `finishRun`).
    public func sendInput(
        _ input: AgentInput, display: QueuedMessage? = nil, echoToTranscript: Bool = true
    ) async throws {
        session.lastTurnDisposition = nil  // a new user turn invalidates the prior disposition
        if echoToTranscript, input.plainText?.isEmpty == false { session.lastUserMessageAt = now() }

        if conversational {
            // A turn is still in flight (or wrapping up). The backend emits the turn's
            // terminal `runFinished` — which flips us to `awaitingInput` and re-enables the
            // composer — *before* its event stream actually closes and `runTask` clears. A
            // follow-up sent while a turn runs must NOT be dropped: queue it (as its own item,
            // alongside any already waiting) and keep the session `running` so it starts the
            // instant the current run finishes (see `finishRun`, which coalesces the queue into
            // one turn). The queued text is deliberately NOT injected into the transcript yet —
            // it's held in `pendingQueue` and injected only when it actually sends, so a queued
            // message never interleaves with the in-flight turn's streaming output.
            if runTask != nil || preparingTurn {
                let item = display ?? QueuedMessage(text: input.plainText ?? "")
                pendingQueue.append((input: input, display: item, silent: !echoToTranscript))
                refreshQueuedState()
                session.status = .running
                session.updatedAt = now()
                return
            }
            if echoToTranscript, let text = input.plainText, !text.isEmpty {
                await ingest(synthetic(.userText(makeUserChunk(text))))
            }
            await startConversationalTurn(input)
            return
        }

        if echoToTranscript, let text = input.plainText, !text.isEmpty {
            await ingest(synthetic(.userText(makeUserChunk(text))))
        }
        try await backend.send(input)
        if !session.status.isTerminal {
            session.status = .running
            session.updatedAt = now()
        }
    }

    /// Recompute the session's queued indicators from `pendingQueue`: the per-message list the
    /// composer renders, and the newline-joined text summary kept for the wire summary / iOS
    /// remote (which still shows the queue as a single string).
    private func refreshQueuedState() {
        // Silent (machine-injected) items drive a turn but are never user-visible, so they're
        // excluded from the composer's queued list and the plain-text summary.
        let visible = pendingQueue.filter { !$0.silent }
        session.queuedMessages = visible.map(\.display)
        let joined = visible.map { summaryLine(for: $0.display) }
            .filter { !$0.isEmpty }
            .joined(separator: "\n")
        session.queuedMessage = joined.isEmpty ? nil : joined
    }

    /// A one-line summary of a queued message for the plain-text indicator: its text, or — for
    /// an attachment-only message — the attachment names, so the indicator is never blank.
    private func summaryLine(for message: QueuedMessage) -> String {
        if !message.text.isEmpty { return message.text }
        return message.attachments.map(\.filename).joined(separator: ", ")
    }

    /// Drop every queued (not-yet-sent) follow-up, returning the joined text so the UI can refill
    /// the composer for editing. Returns `nil` if nothing is queued.
    @discardableResult
    public func cancelQueuedInput() -> String? {
        guard !pendingQueue.isEmpty else { return nil }
        let text = session.queuedMessage
        pendingQueue.removeAll()
        settleAfterQueueChange()
        return text
    }

    /// Drop a single queued follow-up by id, returning the removed item so the composer can
    /// refill its text and restore its attachment chips. Returns `nil` if no queued message has
    /// that id.
    @discardableResult
    public func cancelQueuedMessage(id: QueuedMessage.ID) -> QueuedMessage? {
        guard let index = pendingQueue.firstIndex(where: { $0.display.id == id }) else { return nil }
        let removed = pendingQueue.remove(at: index).display
        settleAfterQueueChange()
        return removed
    }

    /// Shared tail of a queue removal: refresh the indicators and, if the queue is now empty and
    /// no turn is actually in flight (the queue was only holding the session `running` past a
    /// finished run), settle back to `awaitingInput`. A genuinely in-flight turn is left alone.
    private func settleAfterQueueChange() {
        refreshQueuedState()
        if pendingQueue.isEmpty, runTask == nil, !preparingTurn, !session.status.isTerminal {
            session.status = .awaitingInput
        }
        session.updatedAt = now()
    }

    /// Start one conversational turn carrying `input`: resume the backend's native session
    /// if one exists (so context is retained), else start a fresh run. The user's text is
    /// already in the transcript (injected by the caller), so the stream is consumed with
    /// no re-injection.
    private func startConversationalTurn(_ input: AgentInput) async {
        preparingTurn = true
        session.status = .running
        session.updatedAt = now()
        let container = await preparedContainerSpec()
        preparingTurn = false
        guard runTask == nil, !session.status.isTerminal else { return }
        if let backendSessionID = session.backendSessionID {
            let spec = ResumeSpec(
                sessionID: session.id, backendSessionID: backendSessionID,
                worktree: worktreePath, prompt: input,
                model: session.model, effort: resolvedEffort, autoApprove: session.auto,
                container: container, allowHostExec: allowsHostExec,
                allowMacVMExec: allowsMacVMExec,
                allowMacVMComputer: allowsMacVMComputer,
                allowLinuxVMExec: allowsLinuxVMExec,
                allowLinuxVMComputer: allowsLinuxVMComputer,
                allowAgentContainers: allowsAgentContainers,
                orchestraActive: orchestraActive,
                orchestraSubagentSpawner: activeOrchestraSubagentSpawner,
                blockingSubagentSpawner: activeBlockingSubagentSpawner,
                orchestraPlanHandler: activeOrchestraPlanHandler,
                orchestraSuperviseHandler: activeOrchestraSuperviseHandler,
                orchestraReplyToWorkerHandler: activeOrchestraReplyToWorkerHandler,
                orchestraAskSupervisorHandler: orchestraAskSupervisorHandler,
                meshMessaging: meshMessagingHandlers,
                monitorEventSink: monitorEventSink,
                appendSystemPrompt: appendedSystemPrompt)
            consume(backend.resume(spec), injectingUserChunk: nil)
        } else {
            // No turn has run yet → this message starts the session.
            let run = RunSpec(
                sessionID: session.id, worktree: worktreePath, prompt: input,
                model: session.model, effort: resolvedEffort,
                autoApprove: session.auto, approvalPolicy: .interactive,
                container: container, allowHostExec: allowsHostExec,
                allowMacVMExec: allowsMacVMExec,
                allowMacVMComputer: allowsMacVMComputer,
                allowLinuxVMExec: allowsLinuxVMExec,
                allowLinuxVMComputer: allowsLinuxVMComputer,
                allowAgentContainers: allowsAgentContainers,
                orchestraActive: orchestraActive,
                orchestraSubagentSpawner: activeOrchestraSubagentSpawner,
                blockingSubagentSpawner: activeBlockingSubagentSpawner,
                orchestraPlanHandler: activeOrchestraPlanHandler,
                orchestraSuperviseHandler: activeOrchestraSuperviseHandler,
                orchestraReplyToWorkerHandler: activeOrchestraReplyToWorkerHandler,
                orchestraAskSupervisorHandler: orchestraAskSupervisorHandler,
                meshMessaging: meshMessagingHandlers,
                monitorEventSink: monitorEventSink,
                appendSystemPrompt: appendedSystemPrompt)
            consume(backend.start(run), injectingUserChunk: nil)
        }
    }

    private func makeUserChunk(_ text: String) -> TextChunk {
        userTurnCount += 1
        return TextChunk(
            messageID: "user-\(userTurnCount)", text: text, isPartial: false,
            routing: currentPromptRoutingDecision())
    }

    /// Build the immutable per-turn snapshot rendered under a user bubble. A manual model pick
    /// clears the session's routing fields, which deliberately makes this nil: in that case no
    /// classifier/router chose the model, so the transcript must not imply that one did.
    private func currentPromptRoutingDecision() -> PromptRoutingDecision? {
        guard let purpose = session.routedPurpose,
              let level = session.routedLevel,
              let model = session.model,
              let effort = resolvedEffort,
              let reason = session.routedReason
        else { return nil }
        return PromptRoutingDecision(
            purpose: purpose, level: level, model: model,
            effort: effort, reason: reason)
    }

    // MARK: - Revert (undo to a point)

    /// Revert the conversation to just before the user message at `seq`: drop that message
    /// and everything after it from the canonical transcript, and — best-effort — rewind the
    /// backend's native session so the next turn genuinely resumes from before it (the agent
    /// forgets the reverted exchanges, the same mechanism as Claude Code's own rewind). The
    /// worktree's files are left untouched. Returns the reverted message's text so the UI can
    /// drop it back into the composer for editing, or `nil` if `seq` isn't a user message or a
    /// turn is currently in flight (a transcript rewrite must never race a live run).
    public func revert(toSeq seq: UInt64) async -> String? {
        guard runTask == nil else { return nil }
        guard let target = history.first(where: { $0.seq == seq }),
            case .userText(let chunk) = target.kind
        else { return nil }

        let retained = history.filter { $0.seq < seq }
        let retainedUserTurns = retained.reduce(into: 0) { count, event in
            if case .userText = event.kind { count += 1 }
        }

        // Best-effort native rewind so the agent truly forgets the reverted turns. If the
        // session file can't be located or its prompt count doesn't line up, we keep the
        // native session as-is: the visual revert + edit still work; only the agent's memory
        // of the reverted turns survives.
        if retainedUserTurns == 0 {
            // Reverting the very first message: nothing to keep — let the next turn start a
            // fresh native session instead of resuming an emptied one.
            session.backendSessionID = nil
        } else if let nativePath = locateNativeTranscript() {
            _ = Self.truncateNativeTranscript(at: nativePath, keepingRealUserPrompts: retainedUserTurns)
        }

        // Bump the revert epoch before the rewrite: the seq counter resets below, so the seqs
        // being dropped will be *reissued* with different content — the epoch is how a device
        // holding a copy (mirror/offline cache) that missed this revert tells its overlap is
        // stale (SessionSummary.revertEpoch; mirror/cache heal on mismatch). Stamp it into the
        // transcript header in the same rewrite so the file itself carries it to mirrors and
        // across transfers; if the header can't be read the row still advances and the owner's
        // serve path re-stamps the header epoch from the row.
        session.revertEpoch += 1
        let header = (try? TranscriptReader(url: URL(fileURLWithPath: session.transcriptPath))
            .readHeader()).map { h in
                var h = h
                h.revertEpoch = session.revertEpoch
                return h
            }
        try? await transcript.truncate(to: retained, header: header)

        history = retained
        userTurnCount = retainedUserTurns
        pendingApprovals.removeAll()
        session.lastSeq = retained.last?.seq ?? 0
        session.lastTurnDisposition = nil
        session.status = .awaitingInput
        session.updatedAt = now()
        try? await metadataStore?.saveSession(session)
        return chunk.text
    }

    /// Locate the Claude Code native session JSONL for the current `backendSessionID`, trying
    /// the host `~/.claude` and (for a sandboxed project) the per-session writable claude-home,
    /// each against the raw and symlink-resolved worktree path (Claude names the project dir by
    /// replacing every non-alphanumeric character of its cwd with `-`). Returns the first that
    /// exists, else `nil`.
    /// The native agent transcript file for this session, if one exists on disk — for
    /// session-transfer memory carry (mesh P5). Wraps the private locator.
    public func nativeTranscriptURL() -> URL? {
        locateNativeTranscript().map { URL(fileURLWithPath: $0) }
    }

    private func locateNativeTranscript() -> String? {
        guard let sid = session.backendSessionID, !sid.isEmpty else { return nil }
        let fm = FileManager.default
        let canon = GitWorktreeManager.canonical

        var homes: [String] = []
        // A sandboxed session (shared or per-session container) writes its native transcript
        // into the per-session claude-home — the same effective-sandbox gate the spawn path uses.
        if ContainerServiceSettings.serviceEnabled,
           let sandbox = project?.effectiveSandbox, sandbox.enabled {
            let claudeHome = canon(
                ((session.transcriptPath as NSString).deletingLastPathComponent as NSString)
                    .appendingPathComponent("claude-home"))
            homes.append(claudeHome)
        }
        homes.append((NSHomeDirectory() as NSString).appendingPathComponent(".claude"))

        let cwds = Set([worktreePath, canon(worktreePath)].filter { !$0.isEmpty })
        for home in homes {
            for cwd in cwds {
                let path = "\(home)/projects/\(Self.claudeProjectDirName(cwd))/\(sid).jsonl"
                if fm.fileExists(atPath: path) { return path }
            }
        }
        return nil
    }

    /// Claude Code's project-directory name for a working directory: every character that
    /// isn't an ASCII letter or digit becomes `-` (e.g. `/Users/me/proj` → `-Users-me-proj`).
    static func claudeProjectDirName(_ cwd: String) -> String {
        String(
            cwd.unicodeScalars.map { scalar in
                let v = scalar.value
                let isAlnum =
                    (v >= 48 && v <= 57) || (v >= 65 && v <= 90) || (v >= 97 && v <= 122)
                return isAlnum ? Character(scalar) : "-"
            })
    }

    /// Truncate a Claude Code native session transcript so it ends just before its
    /// `(keep + 1)`-th real user prompt — dropping that turn and everything after, the clean
    /// turn boundary the next resume continues from. Tool-result and meta user records don't
    /// count as prompts. Returns `false` and leaves the file untouched if it can't be read or
    /// has fewer real prompts than expected (a mismatch we'd rather skip than corrupt).
    static func truncateNativeTranscript(at path: String, keepingRealUserPrompts keep: Int)
        -> Bool
    {
        let url = URL(fileURLWithPath: path)
        guard let data = try? Data(contentsOf: url) else { return false }
        let lines = data.split(separator: 0x0A, omittingEmptySubsequences: false)

        var prompts = 0
        var cutLineIndex: Int?
        for (i, line) in lines.enumerated() where !line.isEmpty {
            if isRealUserPrompt(Data(line)) {
                prompts += 1
                if prompts == keep + 1 { cutLineIndex = i; break }
            }
        }
        guard let cut = cutLineIndex else { return false }

        var out = Data()
        for line in lines[..<cut] where !line.isEmpty {
            out.append(contentsOf: line)
            out.append(0x0A)
        }
        do {
            try out.write(to: url, options: .atomic)
            return true
        } catch {
            return false
        }
    }

    /// Whether a native transcript line is a real user prompt (type `user`, carrying text)
    /// rather than a tool-result echo (Claude stores those as user-role records too) or an
    /// injected meta message.
    static func isRealUserPrompt(_ lineData: Data) -> Bool {
        guard let obj = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any],
            (obj["type"] as? String) == "user"
        else { return false }
        if (obj["isMeta"] as? Bool) == true { return false }
        guard let message = obj["message"] as? [String: Any] else { return false }
        if message["content"] is String { return true }
        if let blocks = message["content"] as? [[String: Any]] {
            if blocks.contains(where: { ($0["type"] as? String) == "tool_result" }) { return false }
            return blocks.contains { ($0["type"] as? String) == "text" }
        }
        return false
    }

    /// In conversational mode a turn's terminal `runFinished` means "ready for the
    /// next message", not "session over".
    private func derivedStatus(_ current: SessionStatus, _ kind: AgentEvent.Kind) -> SessionStatus {
        if conversational, case .runFinished(let finished) = kind {
            switch finished.outcome {
            case .completed, .interrupted, .maxTurns: return .awaitingInput
            case .errored: return .error
            }
        }
        return SessionStatus.transition(current, on: kind)
    }

    /// Resolve an outstanding approval. First responder wins inside the backend's
    /// coordinator; the resulting `approvalResolved` flows back through `ingest`. `responder`
    /// is the resolving device's `ResponderLabel`, recorded as `ApprovalResolved.decidedBy`.
    public func respondToApproval(_ id: ApprovalID, _ decision: Decision, by responder: String) async throws {
        try await backend.respond(to: id, decision, by: responder)
    }

    public func interrupt() async {
        await backend.interrupt()
    }

    /// Abandon a single stuck tool call (the user tapped **Skip** on it) — the agent gets a "skipped"
    /// result and continues the turn. A no-op for calls that aren't abandonable exec calls.
    public func skipToolCall(_ call: ToolCall) async {
        await backend.skipToolCall(call)
    }

    /// Resolve a "host command looks hung" alert the harness raised: `kill` tears down the command's
    /// whole process tree; otherwise the alert is dismissed and the command keeps running.
    public func resolveProcessStall(stallID: String, kill: Bool) async {
        await backend.resolveProcessStall(stallID: stallID, kill: kill)
    }

    // MARK: - Config (applies to subsequent turns)

    public func rename(_ title: String) async {
        let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else { return }
        session.title = trimmed
        session.updatedAt = now()
        try? await metadataStore?.saveSession(session)
    }

    /// Set the model (alias or full id; nil = CLI default) for the next turn.
    public func setModel(_ model: String?) async {
        session.model = model
        session.updatedAt = now()
        try? await metadataStore?.saveSession(session)
    }

    /// Set the reasoning effort (low/medium/high/xhigh/max; nil = default) for the next turn.
    ///
    /// Orchestra can be switched for the next turn, but only on a Nucleic Control project.
    /// The current model becomes its supervisor, which is what makes the slider's resisted
    /// overrun read as "this Max agent, plus managed subagents." Leaving Orchestra simply
    /// restores an ordinary effort. Enforced here so local and remote surfaces share the gate.
    public func setEffort(_ effort: String?) async {
        if OrchestrationMode.isOrchestra(effort), project?.isNucleicControlled != true {
            return
        }
        session.effort = effort
        session.updatedAt = now()
        try? await metadataStore?.saveSession(session)
    }

    /// Record why the Intelligence router picked this session's current model/effort (or
    /// clear it when the user switches to a manual pick). Transparency only — the routed
    /// fields never influence a run — so like `setKeywords` it doesn't bump `updatedAt`.
    public func setRoutingNote(_ note: RoutingNote?) async {
        guard session.routedPurpose != note?.purpose.rawValue
            || session.routedLevel != note?.level.rawValue
            || session.routedReason != note?.reason
        else { return }
        session.routedPurpose = note?.purpose.rawValue
        session.routedLevel = note?.level.rawValue
        session.routedReason = note?.reason
        try? await metadataStore?.saveSession(session)
    }

    /// Cache a generated summary on the session (persisted so it survives reopen).
    public func setSummary(_ summary: String) async {
        session.summary = summary
        session.updatedAt = now()
        try? await metadataStore?.saveSession(session)
    }

    /// Cache the intelligence provider's search keywords (comma-joined) for the session
    /// index (persisted so relaunch reuses them). Deliberately does NOT bump `updatedAt`:
    /// this runs as background backfill across many sessions on load, and touching
    /// `updatedAt` would reshuffle sidebar ordering and retrigger downstream activity for
    /// chats the user never touched.
    public func setKeywords(_ keywords: String) async {
        guard session.keywords != keywords else { return }
        session.keywords = keywords
        try? await metadataStore?.saveSession(session)
    }

    /// Record the Intelligence provider's classification of the last turn. Applied
    /// only while the session is idle at `.awaitingInput` (the state the disposition
    /// refines); ignored if a new turn has since started or the run reached a terminal
    /// state, so a slow/late classification can never clobber fresher state.
    public func setDisposition(_ disposition: TurnDisposition) async {
        guard session.status == .awaitingInput else { return }
        guard session.lastTurnDisposition != disposition else { return }
        session.lastTurnDisposition = disposition
        session.updatedAt = now()
        try? await metadataStore?.saveSession(session)
    }

    public func setAuto(_ auto: Bool) async {
        session.auto = auto
        // Autoship can't run without auto-approval (an agent that blocks on every tool
        // call never reaches a clean "done" to ship from); turning auto off turns it off.
        if !auto { session.autoShip = false }
        session.updatedAt = now()
        try? await metadataStore?.saveSession(session)
    }

    /// Toggle autoship. Enabling it also enables `auto` (shipping implies autonomous
    /// approval). Disabling leaves `auto` as-is. Enabling clears any prior failure
    /// marker — the user is opting back in, so the "needs attention" icon goes away.
    ///
    /// Autoship is gated on Nucleic Control: a session may only *enable* autoship when its
    /// project is Nucleic-controlled (the hardened, sandboxed, Nucleic-managed path).
    /// Enabling on a non-controlled project is a no-op here — the single runtime chokepoint
    /// that backs every enable surface — so the UI gate and this guard can't drift. Disabling
    /// always works (so a flag persisted true from an older build, or before a project moved
    /// out of control, can still be cleared).
    public func setAutoShip(_ autoShip: Bool) async {
        if autoShip, project?.isNucleicControlled != true { return }
        session.autoShip = autoShip
        if autoShip {
            session.auto = true
            // Re-arming is an explicit "I've handled it": clear both the hard-failure marker
            // and any pending merge-conflict flag so the chat starts clean.
            session.autoShipFailed = false
            session.autoShipConflict = false
        }
        session.updatedAt = now()
        try? await metadataStore?.saveSession(session)
    }

    /// Set (or clear) this session's autoship destination override. `nil` (or an empty/
    /// whitespace string) clears it, so the session inherits the project's autoship branch
    /// (which itself defaults to the project root repo's default branch).
    public func setShipBranch(_ branch: String?) async {
        let cleaned = branch?.trimmingCharacters(in: .whitespacesAndNewlines)
        session.shipBranch = (cleaned?.isEmpty ?? true) ? nil : cleaned
        session.updatedAt = now()
        try? await metadataStore?.saveSession(session)
    }

    /// Record that autoship aborted on a failure (merge conflict or merge error): turn
    /// autoship off and set the sticky failure marker that drives the sidebar's
    /// "autoship needs attention" icon. Distinct from a clean user toggle-off, which
    /// leaves the marker untouched. Cleared when the user re-enables autoship or opens
    /// the chat (and so sees the failure note in the transcript).
    public func markAutoShipFailed() async {
        session.autoShip = false
        session.autoShipFailed = true
        session.updatedAt = now()
        try? await metadataStore?.saveSession(session)
    }

    /// Clear the sticky autoship-failure marker because the user opened the chat and so
    /// saw the failure note in the transcript — the sidebar's "needs attention" icon has
    /// served its purpose. Like `setUnseenCompletion`, this is a view-state change and
    /// deliberately does not bump `updatedAt`, so seeing the error must not reorder the
    /// sidebar. Leaves `autoShip` off (the user must re-enable explicitly). A no-op when
    /// the marker isn't set.
    public func clearAutoShipFailed() async {
        guard session.autoShipFailed else { return }
        session.autoShipFailed = false
        try? await metadataStore?.saveSession(session)
    }

    /// Record that an autoship merge hit a *conflict* while autoship stays ARMED: set the
    /// sticky conflict marker (drives the same sidebar "needs attention" icon as
    /// `autoShipFailed`) but leave `autoShip` on, so the branch is left intact and the work
    /// re-ships on the next clean merge. Bumps `updatedAt` so the conflict surfaces in the
    /// sidebar's ordering, mirroring `markAutoShipFailed`. Distinct from a hard error, which
    /// disarms via `markAutoShipFailed`.
    public func markAutoShipConflict() async {
        session.autoShipConflict = true
        session.updatedAt = now()
        try? await metadataStore?.saveSession(session)
    }

    /// Clear the sticky autoship-conflict marker because the work finally landed (the merge
    /// queue's next merge, or the agent's own merge observed in-container). Like
    /// `clearAutoShipFailed`, this is a recovery/view-state change and deliberately does not
    /// bump `updatedAt`. A no-op when the marker isn't set.
    public func clearAutoShipConflict() async {
        guard session.autoShipConflict else { return }
        session.autoShipConflict = false
        try? await metadataStore?.saveSession(session)
    }

    /// Mark (or clear) the "completed but not yet opened" flag that drives the sidebar's
    /// unread dot. Set by the store when a turn completes off-screen; cleared on open.
    public func setUnseenCompletion(_ unseen: Bool) async {
        guard session.unseenCompletion != unseen else { return }
        session.unseenCompletion = unseen
        // Deliberately does not bump `updatedAt`: an unread marker is a view-state
        // change, and opening a chat to clear it must not reorder the sidebar.
        try? await metadataStore?.saveSession(session)
    }

    public func setFavorite(_ favorite: Bool) async {
        session.favorite = favorite
        session.updatedAt = now()
        try? await metadataStore?.saveSession(session)
    }

    public func setArchived(_ archived: Bool) async {
        session.archived = archived
        // Stamp when archiving (preserving an existing stamp if already archived), clear on
        // unarchive. The cleanup sweep measures "archived for longer than X" against this.
        session.archivedAt = archived ? (session.archivedAt ?? now()) : nil
        session.updatedAt = now()
        try? await metadataStore?.saveSession(session)
    }

    /// Re-target this session's parent (LOCKING §5.2 cascade) — the release target +
    /// update-on-grant source. `rootRef` (the lock domain) is unchanged.
    func setParent(ref: String?, sessionID: SessionID?) async {
        session.parentRef = ref
        session.parentSessionID = sessionID
        session.updatedAt = now()
        try? await metadataStore?.saveSession(session)
    }

    public func shutdown() async {
        runTask?.cancel()
        await backend.shutdown()
        if !session.status.isTerminal {
            session.status = .finished
            session.updatedAt = now()
        }
        try? await transcript.sync()
        try? await transcript.close()
        finishSubscribers()
    }

    // MARK: - Integration intents (delegate to the WorktreeManager)

    /// Refresh the live diff/status of the session's worktree.
    @discardableResult
    public func refreshStatus() async throws -> WorktreeStatus {
        let (manager, worktree) = try gitContext()
        let status = try await manager.status(of: worktree)
        session.diffStat = status.diffStat
        session.ahead = status.ahead
        session.behind = status.behind
        session.updatedAt = now()
        return status
    }

    /// Apply a diffstat computed outside the controller. An nvrsion session has no worktree to
    /// `refreshStatus` against (it shares the trunk, NVRSION §2), so the store measures its
    /// per-session trunk contribution (`NvrsionTrunk.sessionDiffStat`) and pushes it here to drive
    /// the sidebar's change-count — the worktree-less equivalent of `refreshStatus`'s diffstat update.
    func setLiveDiffStat(_ stat: DiffStat) {
        session.diffStat = stat
        session.updatedAt = now()
    }

    public func diff(_ options: DiffOptions = DiffOptions()) async throws -> Diff {
        let (manager, worktree) = try gitContext()
        return try await manager.diff(of: worktree, options)
    }

    /// The ref this session releases its locks against / merges into — its `parentRef`
    /// (LOCKING §3.3), falling back to the project default for legacy rows without one.
    private func parentTarget(_ project: Project) -> GitRef {
        session.parentRef.map { GitRef($0) } ?? project.defaultBranch
    }

    /// Repo-relative files this session has changed that aren't yet merged into its parent
    /// (`parentRef`, LOCKING §3.3; falls back to the project default) — its `check_conflict`
    /// footprint. Empties once the work lands. Returns `[]` for a worktree-less session.
    public func unmergedFiles() async throws -> [String] {
        guard let worktreeManager, let worktree, let project else { return [] }
        return try await worktreeManager.unmergedFiles(of: worktree, into: parentTarget(project))
    }

    /// Blob shas for `paths` on this session's **parent** ref — the landed check's "has the parent
    /// itself moved?" input (LOCKING §4.4). Absent from the map ⇒ the path isn't in the parent.
    public func parentBlobSHAs(_ paths: [String]) async throws -> [String: String] {
        guard let worktreeManager, let worktree, let project else { return [:] }
        return try await worktreeManager.blobSHAs(
            of: worktree, ref: parentTarget(project), paths: paths)
    }

    /// A multi-step git operation in progress in this session's worktree (rebase/merge/
    /// cherry-pick/revert), or nil when settled. Mid-operation the working tree is transient and
    /// must not drive lock release (LOCKING §4.4).
    public func worktreeOperationInProgress() async throws -> String? {
        guard let worktreeManager, let worktree else { return nil }
        return try await worktreeManager.operationInProgress(of: worktree)
    }

    /// Stat-level fingerprint of the on-disk state the §4.4 landed check depends on (worktree tip
    /// + index + parent tip + the held working files) — the reconcile poll's "did anything move
    /// since last pass?" probe. Nil for a worktree-less session, a path that isn't a real git
    /// worktree, or a directory (area) hold; callers must treat nil as "recompute"
    /// (`GitWorktreeManager.lockStateFingerprint`).
    public func lockStateFingerprint(held: [String]) -> String? {
        guard let worktree, let project else { return nil }
        return GitWorktreeManager.lockStateFingerprint(
            worktreePath: worktree.path, parentRef: parentTarget(project).value, heldPaths: held)
    }

    /// The branch this session merges into when it ships — the resolved ship destination
    /// (`Session.shipDestination`): the per-session override, then the project's autoship
    /// branch, else the legacy default (`parentRef` / project default branch). Distinct from
    /// `parentTarget`, which stays anchored to the fork base for locking/update-on-grant.
    private func shipTarget(_ project: Project) -> GitRef {
        session.shipDestination(in: project)
    }

    /// Repo-relative files this session has changed that aren't yet merged into its **ship
    /// destination** — the footprint autoship checks to decide whether there's anything to
    /// ship (and which empties once the work lands there). Equals `unmergedFiles` when the
    /// ship destination matches the fork base (the default), and diverges only when the user
    /// points autoship at a different branch. Returns `[]` for a worktree-less session.
    public func shippableFiles() async throws -> [String] {
        guard let worktreeManager, let worktree, let project else { return [] }
        return try await worktreeManager.unmergedFiles(of: worktree, into: shipTarget(project))
    }

    /// Finalize (commit) then land the session's branch into its ship destination. The hub
    /// the macOS "Integrate" flow and autoship both call (UX_MACOS).
    public func integrate(
        strategy: IntegrationStrategy, commit: CommitOption? = nil,
        onCommit: (@Sendable () async -> Void)? = nil,
        onStash: (@Sendable () async -> Void)? = nil,
        onSync: (@Sendable () async -> Void)? = nil,
        onSigned: (@Sendable (String) async -> Void)? = nil
    ) async throws -> IntegrationResult {
        let (manager, worktree) = try gitContext()
        guard let project else { throw BackendError.unsupported("no project configured") }
        let commitOption = commit ?? .auto(message: "Nucleic: \(session.title)")
        try await manager.finalize(worktree, commit: commitOption, onCommit: onCommit)
        return try await manager.integrate(
            worktree, in: project, into: shipTarget(project), strategy: strategy, message: nil,
            onStash: onStash, onSync: onSync, onSigned: onSigned)
    }

    /// `"<ref>:<path>"` → the parent-side blob sha this session was last re-grounded on. Makes the
    /// re-ground idempotent per parent content (LOCKING §4.5 "at most once per file per session")
    /// *independently of the lock staying held*: the §4.4 reconcile loop can release the lock
    /// between the re-ground (whose denied edit leaves the file matching the parent, so
    /// `unmergedFiles` drops it) and the agent's re-issue. The retried acquire then re-runs
    /// update-on-grant, and — when the merge dirty-refuses, so `HEAD` never advances past the
    /// detection diff — the same divergence would be re-detected forever, livelocking the agent in
    /// re-ground/release cycles. Detection is stateless; this memory is what terminates the loop.
    /// Mirrors `NvrsionTrunk.observed`, the trunk path's equivalent (NVRSION §3.2).
    private var regroundedParentBlobs: [String: String] = [:]

    /// Update-on-grant (LOCKING §4.5): bring the session's worktree up to date before it proceeds,
    /// reconciling against each ref in `updateOnGrantSources` in order — the fork base / cascade
    /// parent (`parentTarget`), then the ship destination (`shipTarget`) when it diverges — scoped to
    /// `requestedFiles` (the files it's acquiring). `.clean` when up to date, merged cleanly with none
    /// of those files touched, or worktree-less; `.changed` (lock kept) when a reconciled ref moved one
    /// of them past content this session was already re-grounded on — the host re-grounds the agent;
    /// `.conflicted` (lock kept, session paused) on the first real merge conflict (any earlier clean
    /// merge stays applied).
    func updateFromParent(requestedFiles: [String]) async -> LockUpdateResult {
        guard let (manager, worktree) = try? gitContext(), let project else { return .clean }
        var movedFiles: [String] = []
        var diffs: [String] = []
        for ref in updateOnGrantSources(project) {
            guard let update = try? await manager.updateFromParent(
                worktree, parentRef: ref, requestedFiles: requestedFiles)
            else { continue }
            if !update.conflicts.isEmpty { return .conflicted(update.conflicts.map(\.path)) }
            var fresh: [String] = []
            for file in update.changedFiles {
                let key = "\(ref.value):\(file)"
                let blob = update.changedBlobs[file] ?? ""
                guard regroundedParentBlobs[key] != blob else { continue }  // already re-grounded on it
                regroundedParentBlobs[key] = blob
                fresh.append(file)
            }
            movedFiles.append(contentsOf: fresh)
            // The diff is scoped to the ref's full changed set; surface it only when something
            // fresh survived (a wholly-filtered ref must read as clean, or the loop re-forms).
            if !fresh.isEmpty, !update.diff.isEmpty { diffs.append(update.diff) }
        }
        let moved = Array(Set(movedFiles)).sorted()
        if moved.isEmpty { return .clean }
        return .changed(files: moved, diff: diffs.joined(separator: "\n"))
    }

    /// The refs update-on-grant (LOCKING §4.5) brings into the worktree, in order: `parentTarget`
    /// — the fork base / cascade parent (§5.2), the anchor for the lock domain and release
    /// accounting, and the primary source — then `shipTarget` when the two **diverge** (a per-session
    /// `shipBranch` or the project's `autoShipBranch` is set). Reconciling against the ship
    /// destination closes the freshness gap where a sibling shipped its work there and released its
    /// lock, but the just-granted session — pulling only from `parentTarget` — could not see it (the
    /// staleness would otherwise surface only at this session's own ship time). It front-loads the
    /// same merge `WorktreeManager.resyncBranch` already runs *after* a ship. With no overrides the
    /// two coincide and this is the single historical `git merge <parentRef>`.
    private func updateOnGrantSources(_ project: Project) -> [GitRef] {
        let parent = parentTarget(project)
        let ship = shipTarget(project)
        return parent == ship ? [parent] : [parent, ship]
    }

    /// Inject a system-generated log line into the transcript (e.g. a user-triggered
    /// git action from the header). Appears in the chat as a muted note and is
    /// persisted/broadcast like any other event; never changes lifecycle state.
    public func note(_ text: String, icon: String? = nil, autoshipShipped: Bool = false, lockEvent: Bool = false, lock: NoteLock? = nil, toolCallID: String? = nil) async {
        await ingest(synthetic(.note(NoteEvent(text: text, icon: icon, autoshipShipped: autoshipShipped, lockEvent: lockEvent, lock: lock, toolCallID: toolCallID))))
    }

    public func discard(force: Bool) async throws {
        await shutdown()
        // A worktree-less session (ran in the main checkout) has nothing to remove.
        guard let worktreeManager, let worktree, let project else { return }
        try await worktreeManager.discard(worktree, in: project, force: force)
    }

    private func gitContext() throws -> (any WorktreeManaging, Worktree) {
        guard let worktreeManager, let worktree else {
            throw BackendError.unsupported("SessionController has no git context")
        }
        return (worktreeManager, worktree)
    }

    // MARK: - The single-writer ingest pipeline (RUNTIME §2)

    /// Event kinds that count as user-visible session activity — the ones that restamp
    /// `updatedAt` (the sidebar's "updated X ago", Recents and archived ordering).
    /// Streaming-progress kinds don't qualify: they arrive at token cadence and their only
    /// UI effect is the transcript, which never reads `updatedAt`. Account-level pushes
    /// (`rateLimit`, `codexUsage`) aren't *session* activity either — a quota window
    /// arriving out of band must not float an idle chat up the Recents list.
    private static func stampsActivity(_ kind: AgentEvent.Kind) -> Bool {
        switch kind {
        case .assistantText, .thinking, .toolCallStarted, .toolCallInputDelta,
            .toolCallCompleted, .toolResult, .fileChange, .usage, .raw,
            .rateLimit, .codexUsage:
            return false
        case .sessionStarted, .userText, .approvalRequested, .approvalResolved,
            .turnCompleted, .runFinished, .error, .note:
            return true
        }
    }

    private func ingest(_ event: AgentEvent) async {
        // 1. Canonical seq — the single serialization point (RUNTIME §2.1).
        let canonical: AgentEvent
        do {
            canonical = try await transcript.append(event)
        } catch {
            canonical = event  // degrade: keep the provisional event rather than drop it
        }

        // 2. Derived status + metadata.
        let statusBefore = session.status
        session.status = derivedStatus(session.status, canonical.kind)
        session.lastSeq = canonical.seq
        // Canonical freshness (`lastSeq`) and sidebar-visible freshness (`updatedAt`) are
        // deliberately separate (docs/MAIN_THREAD_PERFORMANCE_PLAN.md item 3): a streaming
        // delta advances `lastSeq` without restamping `updatedAt`, so a token burst no
        // longer rewrites the session record every event — which is what kept every
        // `SessionSummary` "changed" and re-invalidated the whole sidebar, Recents, and
        // dashboard at streaming cadence. Real activity (turn boundaries, user text,
        // approvals, errors, notes) and any status transition still restamp, so "updated
        // X ago", Recents order, and archived order stay honest at turn granularity.
        if Self.stampsActivity(canonical.kind) || session.status != statusBefore {
            session.updatedAt = now()
        }

        // Track whether the most recent thing to happen here was a *successful* autoship
        // merge: true only when the newest event is the merge-queue's success note, and
        // cleared by anything else — a later turn, a user message, or even a subsequent
        // failed/conflict autoship note (the marker reflects the latest outcome, not just
        // that autoship ran). Drives the sidebar's shipped-package icon.
        if case .note(let note) = canonical.kind {
            session.lastEventWasAutoship = note.autoshipShipped
        } else {
            session.lastEventWasAutoship = false
        }

        switch canonical.kind {
        case .sessionStarted(let started):
            session.backendSessionID = started.backendSessionID
            if session.model == nil { session.model = started.model }
            session.nativeTranscriptPath = started.nativeTranscriptPath
        case .approvalRequested(let request):
            pendingApprovals[request.id] = request
        case .approvalResolved(let resolved):
            pendingApprovals[resolved.id] = nil
        default:
            break
        }

        // Durability at meaningful boundaries (RUNTIME open-Q #2: batched fsync).
        switch canonical.kind {
        case .turnCompleted, .runFinished, .approvalRequested, .approvalResolved:
            try? await transcript.sync()
        default:
            break
        }

        // 3. Metadata persistence at meaningful boundaries (best-effort — a store
        //    failure must not break the canonical transcript or fan-out).
        if let metadataStore {
            switch canonical.kind {
            case .approvalRequested(let request):
                try? await metadataStore.saveApproval(request)
                try? await metadataStore.saveSession(session)
            case .approvalResolved(let resolved):
                try? await metadataStore.resolveApproval(resolved)
                try? await metadataStore.saveSession(session)
            case .sessionStarted, .turnCompleted, .runFinished, .error:
                try? await metadataStore.saveSession(session)
            default:
                break
            }
        }

        // 4. Fan-out to in-process subscribers.
        history.append(canonical)
        for continuation in subscribers.values {
            continuation.yield(canonical)
        }
    }

    /// Resolve every approval still pending now that the run has ended. With the backend
    /// stream closed there is no live waiter left to answer — the request cards are orphaned
    /// (their Allow/Deny buttons would call into a coordinator that no longer holds the
    /// continuation, so clicking changes nothing on screen). This happens when a run ends
    /// before an approval is answered: the agent deny-resolves at teardown but the
    /// `approvalResolved` races the stream close and is dropped, or the waiter is lost
    /// outright when the transport drops (e.g. the Mac sleeps mid-approval and the CLI gives
    /// up). Emit a synthetic `approvalResolved` (deny, by "system") for each so the normal
    /// `ingest` path dismisses the card, records the resolution, and tells any connected
    /// phone to dismiss it too. A no-op when nothing is pending; `ingest`'s per-id removal
    /// keeps this from double-resolving an approval whose real `approvalResolved` already
    /// arrived (that id is gone from the set, so it isn't iterated).
    private func resolveOrphanedApprovals(reason: String) async {
        guard !pendingApprovals.isEmpty else { return }
        for request in pendingApprovals.values.sorted(by: { $0.createdAt < $1.createdAt }) {
            await ingest(
                synthetic(
                    .approvalResolved(
                        ApprovalResolved(
                            id: request.id,
                            decision: .deny(reason: reason),
                            decidedBy: "system",
                            decidedAt: now()))))
        }
    }

    private func finishRun() async {
        runTask = nil
        // Dismiss any approval cards orphaned by this run ending — the single choke point
        // every run passes through, so a lost/raced `approvalResolved` can never leave a
        // dead Allow/Deny card on screen. Runs before any conversational follow-up turn
        // starts, so a stale approval can't bleed into the next turn.
        await resolveOrphanedApprovals(reason: "The run ended before this request was answered.")
        // Preserve legacy credential write-back for homes created by older builds. Brokered Claude
        // turns have no refresh token and make the Claude half a no-op; Codex still rotates in-run.
        syncClaudeLoginBack()
        syncCodexLoginBack()
        if conversational {
            // Follow-ups that arrived mid-close run now — never dropped. (Guarded on
            // non-terminal so messages queued just as the session is shutting down or
            // erroring out are discarded rather than resurrecting a dead session.) The whole
            // queue coalesces into one combined turn: the discrete items exist for the
            // composer's per-message display; the agent still sees a single follow-up.
            if !pendingQueue.isEmpty, !session.status.isTerminal {
                let combined = AgentInput(parts: pendingQueue.flatMap { $0.input.parts })
                // Silent (machine-injected) items steer the backend but are never echoed, so the
                // transcript reflects only what the user actually sent.
                let visibleText = pendingQueue
                    .filter { !$0.silent }
                    .compactMap { $0.input.plainText }
                    .filter { !$0.isEmpty }
                    .joined(separator: "\n")
                pendingQueue.removeAll()
                refreshQueuedState()
                // The queued messages weren't injected when they arrived (so they wouldn't
                // interleave with the just-finished turn). Inject the combined text now, as the
                // turn it actually drives begins.
                if !visibleText.isEmpty {
                    await ingest(synthetic(.userText(makeUserChunk(visibleText))))
                }
                await startConversationalTurn(combined)
                return
            }
            pendingQueue.removeAll()
            refreshQueuedState()
            // A turn ended; the session stays alive for the next message. Keep
            // subscribers attached. Status was set to awaitingInput/error by the
            // turn's runFinished; if the stream died without one, settle to awaiting.
            if !session.status.isTerminal && session.status != .awaitingInput {
                session.status = .awaitingInput
                session.updatedAt = now()
            }
            try? await transcript.sync()
            return
        }
        // One-shot: a well-behaved backend already emitted a terminal runFinished; if
        // not (backend died mid-turn), synthesize one so the session never lingers
        // non-terminal with the process gone.
        if !session.status.isTerminal {
            await ingest(synthetic(.runFinished(RunFinished(outcome: .interrupted))))
        }
        try? await transcript.sync()
        finishSubscribers()
    }

    private func finishSubscribers() {
        for continuation in subscribers.values { continuation.finish() }
        subscribers.removeAll()
    }

    /// Builds a synthetic event the adapter never produced (errors, lifecycle
    /// markers). Provisional seq is 0 — `TranscriptWriter` assigns the canonical one.
    private func synthetic(_ kind: AgentEvent.Kind) -> AgentEvent {
        AgentEvent(
            sessionID: session.id, seq: 0, at: now(),
            backend: session.backend, nativeType: nil, kind: kind)
    }
}
