import Foundation

/// User-configured provider resources that may be made available to an agent. The settings are
/// model-scoped rather than backend-scoped: two models served by the same CLI can deliberately have
/// different tool surfaces. Skills default to all models to preserve historical agent-home seeding;
/// external MCP servers and plugins remain opt-in because Claude has historically
/// launched with strict MCP configuration.
public enum ExternalAgentIntegration: String, CaseIterable, Sendable, Codable, Hashable {
    case mcp
    case skills
    case plugins

    public var defaultsKey: String { "nucleic.agents.external.\(rawValue).models" }
    public var defaultModelSelection: String { self == .skills ? "*" : "" }
}

/// App-wide, model-scoped external-agent integration policy. Values are stored as either `*` (all
/// models) or a JSON array of model SKUs. Keeping the parser in Core makes launch decisions and the
/// Settings UI share one tolerant representation.
public enum ExternalAgentIntegrationSettings {
    public static func models(from raw: String) -> Set<String> {
        if raw.trimmingCharacters(in: .whitespacesAndNewlines) == "*" { return ["*"] }
        guard let data = raw.data(using: .utf8),
            let values = try? JSONDecoder().decode([String].self, from: data)
        else { return [] }
        return Set(values.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })
    }

    public static func encodedModels(_ models: Set<String>) -> String {
        if models.contains("*") { return "*" }
        return (try? String(data: JSONEncoder().encode(models.sorted()), encoding: .utf8)) ?? "[]"
    }

    public static func allows(
        _ integration: ExternalAgentIntegration,
        model: String?,
        defaults: UserDefaults = .standard
    ) -> Bool {
        guard let model, !model.isEmpty else { return false }
        let raw = defaults.string(forKey: integration.defaultsKey)
            ?? integration.defaultModelSelection
        let selected = models(from: raw)
        return selected.contains("*") || selected.contains(model)
    }

    public static func enabledIntegrations(
        for model: String?, defaults: UserDefaults = .standard
    ) -> Set<ExternalAgentIntegration> {
        Set(ExternalAgentIntegration.allCases.filter { allows($0, model: model, defaults: defaults) })
    }
}

// MARK: - Project domain model (RUNTIME §4, PLAN "Core domain model")
// ProjectID moved to NucleicProtocol/CoreIdentifiers.swift (shared with the iOS client).

/// A git ref: a branch name, tag, or commit-ish. A thin wrapper so call sites read
/// intentionally (`GitRef("main")`) rather than passing bare strings everywhere.
public struct GitRef: Hashable, Sendable, Codable, CustomStringConvertible, ExpressibleByStringLiteral {
    public let value: String
    public init(_ value: String) { self.value = value }
    public init(stringLiteral value: String) { self.value = value }
    public var description: String { value }
}

/// Setup-script failure policy (WORKTREE_MANAGER §5).
public enum SetupPolicy: String, Sendable, Codable {
    /// Session can't start if setup fails (default — don't run agents against a broken env).
    case block
    /// Proceed but flag the failure.
    case warn
}

/// Per-project execution-sandbox settings. When enabled, the project's sessions run
/// `claude` inside an isolated Linux VM (built on Apple's `containerization` framework) instead of
/// on the host, with the worktree bind-mounted (CONTAINER_SANDBOX). `nil` on a Project means
/// "off" — the default, host-spawned behavior.
public struct ProjectSandbox: Sendable, Codable, Equatable {
    public var enabled: Bool
    /// Image reference to run. `nil` → the bundled default image (built on first use).
    public var image: String?
    /// Stop the per-session container after this many seconds of inactivity; the next
    /// turn transparently restarts it.
    public var idleTimeoutSeconds: Int
    /// Allow the sandboxed agent to build and run executables on the *host* machine via the
    /// `host_exec` MCP tool, escaping the Linux container (e.g. to compile/run a macOS binary
    /// the container can't). On by default for newly configured sandboxes; every host command
    /// is approval-gated and is never auto-approved (HOST_EXEC). Only meaningful when `enabled`
    /// is true. (Sandboxes persisted before this field existed still decode to `false` below.)
    public var allowHostExec: Bool

    /// Per-session sandbox containers. When false (the default), a Nucleic Control project's
    /// sessions all share Nucleic's single **primary** managed container; when true, each
    /// session gets its own container (today's per-session model). The choice is fixed at
    /// project creation. NOT YET USER-SETTABLE — surfaced as a disabled placeholder toggle in
    /// a control project's Settings; the creation-time migration that lets a project opt into
    /// per-session containers is deferred. Only meaningful for Nucleic Control projects.
    public var perSessionContainers: Bool

    /// The default sandbox image, used when `image` is nil — a **registry reference** to the
    /// prebuilt `nucleic-sandbox` image (built in CI from `containers/nucleic-sandbox/Dockerfile`
    /// and pushed to GHCR; must be public for anonymous pulls). `ContainerEngine` pulls + unpacks it
    /// and caches the rootfs by this exact ref, so bump the version tag whenever the Dockerfile
    /// changes — the new tag has no cache yet, so the next session pulls it fresh and launch-time
    /// reconcile prunes the superseded cache. (`v2` added the cross-language build tools:
    /// build-essential/make, python3/pip. `v3` added the GitHub CLI `gh` plus `curl`. `v4` added the
    /// Codex + Grok CLIs and `control-bridge.js` — the latter is REQUIRED by the now-mandatory vsock
    /// control plane, so every image from `v4` on must keep shipping it. `v5` added
    /// `openssh-client` for `ssh-keygen`, so the agent can sign commits with `gpg.format=ssh`. `v6`
    /// pins the Codex/Claude Code CLIs to `@latest` at build time so a rebuild refreshes them — the
    /// image ships the current Codex, which unlocks its newest models.)
    /// Keep in lockstep with `.github/workflows/sandbox-image.yml`'s `IMAGE_TAG`.
    ///
    /// Two runtime escape hatches avoid waiting for an app release when only the CLIs need refreshing:
    /// **Force re-creation** (Settings ▸ Control) re-pulls this exact ref from the registry — evicting
    /// the local rootfs *and* image-store caches — so a tag re-pushed with a newer Codex is fetched
    /// fresh; **Check for updates** `npm install -g …@latest` upgrades Codex/Claude Code in place
    /// inside the running container with no re-pull. See `ContainerManager.updateAgentCLIs`.
    public static let defaultImage = "ghcr.io/abkslm/nucleic-sandbox:v6"

    /// Where the VM's Linux kernel is fetched from when it isn't already on disk. Published as a GHCR
    /// **package** — an OCI artifact carrying the single `vmlinux-arm64` blob, pushed by
    /// `.github/workflows/kernel-image.yml` (NOT a GitHub release asset, which would inherit the repo's
    /// visibility). A GHCR package's visibility is independent of the repo's, so this can be made
    /// **public for anonymous pulls** while the repo stays private — exactly like the sandbox image
    /// (`defaultImage`). When the package is private the app authenticates the pull with the user's
    /// GitHub token (`read:packages`), host-scoped by the same `ContainerEngine.registryAuth` rule.
    /// `ContainerEngine` first reuses a local/CLI kernel if present, so machines with Apple's
    /// `container` installed download nothing. Bump the tag when the kernel version changes (and
    /// publish the matching package).
    public static let kernelImage = "ghcr.io/abkslm/nucleic-kernel:6.18.15-186"

    public static let defaultIdleTimeoutSeconds = 900

    public init(
        enabled: Bool = false,
        image: String? = nil,
        idleTimeoutSeconds: Int = ProjectSandbox.defaultIdleTimeoutSeconds,
        allowHostExec: Bool = true,
        perSessionContainers: Bool = false
    ) {
        self.enabled = enabled
        self.image = image
        self.idleTimeoutSeconds = idleTimeoutSeconds
        self.allowHostExec = allowHostExec
        self.perSessionContainers = perSessionContainers
    }

    // Tolerant decode: rows persisted before `allowHostExec` / `perSessionContainers` existed
    // decode with the default rather than failing the whole blob — the store decodes sandbox
    // config with `try?`, so a strict miss would silently drop the entire sandbox config.
    public init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? false
        image = try c.decodeIfPresent(String.self, forKey: .image)
        idleTimeoutSeconds =
            try c.decodeIfPresent(Int.self, forKey: .idleTimeoutSeconds)
            ?? ProjectSandbox.defaultIdleTimeoutSeconds
        allowHostExec = try c.decodeIfPresent(Bool.self, forKey: .allowHostExec) ?? false
        perSessionContainers =
            try c.decodeIfPresent(Bool.self, forKey: .perSessionContainers) ?? false
    }

    /// The image actually used to run containers (resolved override or bundled default).
    public var resolvedImage: String {
        if let image, !image.trimmingCharacters(in: .whitespaces).isEmpty { return image }
        return ProjectSandbox.defaultImage
    }
}

/// Per-project **nvrsion** settings (NVRSION, Beta). When active a control project's sessions
/// share a single `nucleic/trunk` checkout and land each edit into it immediately — instead of
/// each session forking an isolated worktree/branch held until ship (LOCKING). `nil` on a Project
/// (or `enabled == false`) means off — the default, classic per-session-worktree behavior.
///
/// Persisted as `nvrsion_config` JSON on the `project` row (mirrors `ProjectSandbox`), so new
/// fields are additive without a migration. Only meaningful for Nucleic Control projects that use
/// the shared control container — see `Project.nvrsionActive` (NVRSION §10).
public struct ProjectNvrsion: Sendable, Codable, Equatable {
    /// Master switch for the mode. Default off (Beta, opt-in).
    public var enabled: Bool
    /// The shared trunk branch all the project's nvrsion sessions edit and land into.
    public var trunkBranch: String
    /// Optional fast validation command run after an edit is written and **before** it lands in
    /// trunk (NVRSION §5). Receives the edited paths; non-zero exit rejects the edit back to the
    /// agent (kept warm, not committed). `nil`/empty → land immediately (the default).
    public var prelandHook: String?
    /// Keep-warm idle window in seconds (NVRSION §4): a held file untouched this long is released
    /// mid-turn so a waiter can take it. Lower = faster hand-off, more re-reads.
    public var keepWarmIdleSeconds: Int

    public static let defaultTrunkBranch = "nucleic/trunk"
    public static let defaultKeepWarmIdleSeconds = 4

    public init(
        enabled: Bool = false,
        trunkBranch: String = ProjectNvrsion.defaultTrunkBranch,
        prelandHook: String? = nil,
        keepWarmIdleSeconds: Int = ProjectNvrsion.defaultKeepWarmIdleSeconds
    ) {
        self.enabled = enabled
        self.trunkBranch = trunkBranch
        self.prelandHook = prelandHook
        self.keepWarmIdleSeconds = keepWarmIdleSeconds
    }

    // Tolerant decode: rows persisted before a field existed decode to its default rather than
    // failing the whole blob (the store decodes with `try?`, so a strict miss drops the config).
    public init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? false
        trunkBranch =
            try c.decodeIfPresent(String.self, forKey: .trunkBranch).flatMap {
                $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0
            } ?? ProjectNvrsion.defaultTrunkBranch
        prelandHook = try c.decodeIfPresent(String.self, forKey: .prelandHook)
        keepWarmIdleSeconds =
            try c.decodeIfPresent(Int.self, forKey: .keepWarmIdleSeconds)
            ?? ProjectNvrsion.defaultKeepWarmIdleSeconds
    }

    /// The pre-land hook to actually run, or `nil` when unset/blank (→ land immediately).
    public var resolvedPrelandHook: String? {
        guard let h = prelandHook?.trimmingCharacters(in: .whitespaces), !h.isEmpty else { return nil }
        return h
    }
}

/// App-wide container preferences, persisted in `UserDefaults`. These gate and seed the
/// *per-project* `ProjectSandbox` config above — they never sandbox sessions on their own.
/// Surfaced in Settings → General; read here so `NucleicCore` (e.g. `SessionController`,
/// `AppStore`) and the SwiftUI layer agree on the same keys.
/// How a Nucleic Control container session authenticates its agent. Shared by Claude
/// (``ContainerServiceSettings/controlAuthMode``) and Codex
/// (``ContainerServiceSettings/codexControlAuthMode``); the concrete credential store and env var
/// differ per provider.
public enum ControlAuthMode: String, CaseIterable, Sendable {
    /// Reuse the host's subscription login — Claude's macOS Keychain OAuth credentials (exported as
    /// `.credentials.json`) or Codex's `~/.codex/auth.json`, kept coherent host↔container by the
    /// login-sync path. The default.
    case oauth
    /// Use a user-supplied API key (stored in the Keychain via `ControlAPIKeyStore` /
    /// `CodexControlAPIKeyStore`), forwarded as `ANTHROPIC_API_KEY` / `OPENAI_API_KEY`. For when the
    /// host OAuth login doesn't work in the container, or the user would rather bill an API key.
    case apiKey
}

/// How aggressively a running container's VM hands unused memory back to the host. The engine
/// attaches a virtio memory balloon to each VM and periodically retargets it from the guest's live
/// working set; this level chooses how much headroom to leave, how low it may shrink, and how often
/// it adjusts — single descriptive words rather than raw numeric knobs (those live in
/// ``BalloonPolicy``). Replaces the old "the VM never returns freed memory; restart to reclaim"
/// limitation with automatic reclamation.
public enum MemoryManagementLevel: String, CaseIterable, Sendable {
    /// The default — reclaim gently: generous headroom, a high floor, slow cadence. Safest against
    /// in-guest memory pressure; frees the least.
    case conservative
    /// Reclaim steadily with a comfortable safety margin.
    case balanced
    /// Reclaim tightly and react quickly — frees the most host memory, likeliest to squeeze a
    /// spiky workload.
    case aggressive

    /// User-facing label (single word, capitalized).
    public var label: String { rawValue.capitalized }

    /// The concrete autoballoon parameters this level maps to.
    public var policy: BalloonPolicy {
        switch self {
        case .conservative:
            return BalloonPolicy(
                enabled: true, headroom: 2.0, reserveBytes: 3 * 1_073_741_824 / 2,
                floorBytes: 2 * 1_073_741_824, cadenceSeconds: 30, hysteresisBytes: 256 * 1_048_576)
        case .balanced:
            return BalloonPolicy(
                enabled: true, headroom: 1.5, reserveBytes: 1_073_741_824,
                floorBytes: 1_073_741_824, cadenceSeconds: 10, hysteresisBytes: 128 * 1_048_576)
        case .aggressive:
            return BalloonPolicy(
                enabled: true, headroom: 1.25, reserveBytes: 3 * 1_073_741_824 / 4,
                floorBytes: 512 * 1_048_576, cadenceSeconds: 5, hysteresisBytes: 64 * 1_048_576)
        }
    }
}

/// The concrete memory-balloon parameters a ``MemoryManagementLevel`` maps to. A pure value type so
/// the target math is unit-testable without booting a VM.
public struct BalloonPolicy: Sendable, Equatable {
    /// `false` means no balloon is attached and memory is never reclaimed. Every current
    /// ``MemoryManagementLevel`` enables it; the flag remains so a policy can still be built disabled.
    public let enabled: Bool
    /// Multiplier applied to the working set when picking the target (>1 leaves slack so the guest
    /// isn't squeezed the instant it allocates again). Always ≥ 1, so the target never drops below
    /// the working set — inflating a balloon below resident memory would force guest reclaim/OOM.
    public let headroom: Double
    /// Absolute bytes always kept available **on top of** the working set. The working set we measure
    /// is the per-*container* cgroup figure, but the target is applied to the *whole VM*, which also
    /// holds the guest kernel, vminitd, the init, the virtio overhead and the virtiofs page cache —
    /// none of which are in that cgroup. Reserving a fixed whole-VM margin keeps the balloon from
    /// inflating below the guest's true footprint (the bug that drove a swapless guest into a
    /// direct-reclaim CPU spin). Sized to comfortably cover the kernel + vminitd + overhead + cache.
    public let reserveBytes: UInt64
    /// Never shrink the guest below this many bytes, however idle it looks — a floor against
    /// thrashing the page cache or starving the kernel.
    public let floorBytes: UInt64
    /// Seconds between adjustment passes.
    public let cadenceSeconds: UInt64
    /// Only move the target when it changes by at least this much, to avoid churning the balloon.
    public let hysteresisBytes: UInt64

    public init(
        enabled: Bool, headroom: Double, reserveBytes: UInt64, floorBytes: UInt64,
        cadenceSeconds: UInt64, hysteresisBytes: UInt64
    ) {
        self.enabled = enabled
        self.headroom = headroom
        self.reserveBytes = reserveBytes
        self.floorBytes = floorBytes
        self.cadenceSeconds = cadenceSeconds
        self.hysteresisBytes = hysteresisBytes
    }

    /// Desired balloon target (guest memory in bytes) for a live container: leave the larger of a
    /// `headroom` multiple of the working set and the working set plus a fixed whole-VM `reserveBytes`
    /// margin — but never below `floorBytes` nor above the VM's configured `ceilingBytes`. The
    /// additive reserve is what keeps the whole-VM target above the guest's real footprint when the
    /// cgroup working set is small (where a pure multiplier would leave too little for the kernel/
    /// page-cache and force reclaim).
    public func target(workingSetBytes: UInt64, ceilingBytes: UInt64) -> UInt64 {
        let scaledDouble = Double(workingSetBytes) * headroom
        // Saturating cast: a huge product clamps to ceiling below anyway.
        let scaled = scaledDouble >= Double(UInt64.max) ? UInt64.max : UInt64(scaledDouble)
        let reserved = workingSetBytes.addingReportingOverflow(reserveBytes)
        let withReserve = reserved.overflow ? UInt64.max : reserved.partialValue
        return min(max(max(scaled, withReserve), floorBytes), ceilingBytes)
    }
}

public enum ContainerServiceSettings {
    /// Carries a UserDefaults across the task-local boundary: the type is documented thread-safe
    /// but the SDK withholds its Sendable conformance, so the box vouches for it.
    private struct DefaultsBox: @unchecked Sendable { let defaults: UserDefaults }
    @TaskLocal private static var defaultsBox = DefaultsBox(defaults: .standard)

    /// The store every accessor reads and `enableService()` writes. Production always resolves
    /// to `.standard`; tests bind a fresh isolated suite via ``withDefaults(_:isolation:operation:)``
    /// so they can flip these switches without ever mutating the process-global domain. Task-local
    /// because swift-testing runs suites in parallel: a global (even save/restored) override of
    /// `serviceEnabledKey` is visible to every concurrently running test, and a leaked `true`
    /// sends SessionController tests down the real sandbox-provisioning path — seeding a
    /// claude-home from the developer's `~/.claude` and blocking forever on the login-Keychain
    /// ACL prompt when run headless.
    public static var defaults: UserDefaults { defaultsBox.defaults }

    /// Run `operation` with every ContainerServiceSettings accessor backed by `store`. The
    /// binding is task-local: it flows into child tasks and `Task {}`s created inside, and is
    /// invisible to concurrent tasks — the isolation seam tests bind a per-test suite through.
    public static func withDefaults<R>(
        _ store: UserDefaults,
        isolation: isolated (any Actor)? = #isolation,
        operation: () async throws -> R
    ) async rethrows -> R {
        try await $defaultsBox.withValue(
            DefaultsBox(defaults: store), operation: operation, isolation: isolation)
    }

    /// Master switch. Off → the per-project "run sessions in a sandbox container" option is
    /// unavailable and no session runs in a container, regardless of its stored setting.
    /// Turning it on only enables the *ability* to opt a project in; it does not sandbox
    /// anything by itself. Off by default — the container runtime adds 2+ GB of memory
    /// overhead per running session.
    public static let serviceEnabledKey = "nucleic.container.serviceEnabled"

    /// When on, newly added projects start with sandboxing enabled. Only takes effect while
    /// the container service (above) is also on. Off by default.
    public static let sandboxByDefaultKey = "nucleic.container.sandboxByDefault"

    /// When on, newly *cloned* projects default to Nucleic Control (cloned under
    /// `~/.nucleic/control/`, managed by Nucleic — shared sandbox, git interceptor,
    /// autoship-eligible). Off by default. The Add-Project sheet seeds its Nucleic Control
    /// switch from this.
    public static let controlByDefaultKey = "nucleic.container.controlByDefault"

    /// When on, new Nucleic Control projects start with **nvrsion** enabled (NVRSION) — the
    /// shared-trunk versioning mode. Beta, off by default. Only meaningful for Control projects with
    /// the container service on; a per-project toggle still overrides it.
    public static let nvrsionByDefaultKey = "nucleic.container.nvrsionByDefault"

    /// CPU count and memory (GiB) for Nucleic container VMs, split into two pools: the shared
    /// primary Nucleic Control container serves *every* control session at once (so it wants
    /// more), while a per-session container serves one. Applied via `container run --cpus N
    /// --memory Ng`; `0`/unset → the sensible defaults. (`container`'s own defaults are 4 / 1 GiB.)
    public static let controlContainerCPUsKey = "nucleic.container.control.cpus"
    public static let controlContainerMemoryGiBKey = "nucleic.container.control.memoryGiB"
    public static let sessionContainerCPUsKey = "nucleic.container.session.cpus"
    public static let sessionContainerMemoryGiBKey = "nucleic.container.session.memoryGiB"
    /// Per-session defaults (one session per VM) — also the `ContainerSpec` fallback.
    public static let defaultContainerCPUs = 4
    public static let defaultContainerMemoryGiB = 4
    /// Shared primary control-container defaults (serves every control session) — more generous.
    /// The memory constant is the documented floor; the *effective* default is host-aware (see
    /// `recommendedControlContainerMemoryGiB`).
    public static let defaultControlContainerCPUs = 8
    public static let defaultControlContainerMemoryGiB = 8

    /// Username for pulling the sandbox image from a private registry. The image is pulled with
    /// HTTP Basic credentials = (this username, a token). On GHCR the username can be the GitHub
    /// account (or any non-empty value with a valid token); when empty, the engine falls back to the
    /// image-reference owner (e.g. `abkslm` from `ghcr.io/abkslm/…`). The token itself is the
    /// app's GitHub token (`GitHubCredentialStore`, needs `read:packages` for GHCR) unless overridden
    /// by the `NUCLEIC_REGISTRY_USER`/`NUCLEIC_REGISTRY_TOKEN` env. Public images (incl. Apple's
    /// vminitd) are pulled anonymously regardless.
    public static let registryUsernameKey = "nucleic.container.registry.username"

    /// Configured private-registry username (UserDefaults), or `nil`/empty when unset.
    public static var registryUsername: String? {
        let v = (defaults.string(forKey: registryUsernameKey) ?? "")
            .trimmingCharacters(in: .whitespacesAndNewlines)
        return v.isEmpty ? nil : v
    }

    /// Default memory (GiB) for the shared Nucleic Control container: the host's *total* physical
    /// RAM. It serves every Control session's `claude` process at once, so a fixed 8 GiB is the
    /// most OOM-prone limit in the app — the in-VM Linux OOM-killer SIGKILLs a process the moment
    /// the combined RSS hits the ceiling ("exited with status 137"). `--memory` is a *ceiling*, not
    /// a reservation: Apple's `container` VM only consumes what it actually uses, so setting it to
    /// the full system RAM just stops the in-VM OOM-killer from firing prematurely. The trade-off
    /// (Apple's docs): the VM doesn't relinquish freed pages to the host while running, so a
    /// long-lived busy container's footprint only ratchets up — reclaim it with `restartShared`
    /// (Settings → Control → "Restart container"). The default argument reads the live host, so
    /// call sites use `()`; tests pass `physicalBytes` explicitly.
    public static func recommendedControlContainerMemoryGiB(
        physicalBytes: UInt64 = ProcessInfo.processInfo.physicalMemory
    ) -> Int {
        let physGiB = Int(physicalBytes / 1_073_741_824)
        // = total system memory; fall back to the documented floor only on a bogus (0) reading.
        return physGiB > 0 ? physGiB : defaultControlContainerMemoryGiB
    }

    /// Whether the container service is enabled app-wide.
    ///
    /// Always false on Linux: the Apple-containerization service exists only on macOS, and on a
    /// runner the surrounding container IS the sandbox (docs/ANTIMATTER_RUNNER.md §4 tier 0) —
    /// sessions run in place (`RunSpec.container == nil`) and tier-1 isolation arrives through
    /// the RunnerPool acquire seam, not this switch. Without this gate, a controlled project
    /// (the runner's default project shape) would pin the service on via
    /// `reconcileContainerService` and every dispatch would die in the `ContainerManager` stub.
    public static var serviceEnabled: Bool {
        #if os(Linux)
        return false
        #else
        return defaults.bool(forKey: serviceEnabledKey)
        #endif
    }

    /// Set once the app has finished its first-launch bootstrap, so the one-time remnant scan
    /// (`AppStore.reconcileContainerService`) runs only on a genuinely fresh install and never
    /// again. `bool(forKey:)` is false when absent, which is exactly "haven't launched yet".
    public static let didCompleteFirstLaunchKey = "nucleic.firstLaunchCompleted"

    /// Force the container-service master switch on. Called when a Nucleic Control project
    /// exists — registered, or a leftover remnant on disk from a prior install — because Control
    /// cannot function with the service off, so its presence pins the switch. Idempotent.
    public static func enableService() {
        defaults.set(true, forKey: serviceEnabledKey)
    }

    /// Whether new projects should default to sandboxing on. Only meaningful — and only
    /// reported true — while the container service is enabled.
    public static var sandboxByDefault: Bool {
        serviceEnabled && defaults.bool(forKey: sandboxByDefaultKey)
    }

    /// Whether newly cloned projects default to Nucleic Control. Nucleic Control requires the
    /// container service (its whole point is the shared sandbox container + git interceptor), so
    /// this is only reported true while the service is on.
    public static var controlByDefault: Bool {
        serviceEnabled && defaults.bool(forKey: controlByDefaultKey)
    }

    /// Whether new Nucleic Control projects should start with nvrsion on (NVRSION). Beta; only
    /// reported true while the container service is enabled (nvrsion needs the shared control
    /// container). A per-project setting still overrides this default.
    public static var nvrsionByDefault: Bool {
        serviceEnabled && defaults.bool(forKey: nvrsionByDefaultKey)
    }

    /// A stored integer for `key`, clamped to ≥ 1, falling back when unset/invalid.
    private static func resource(_ key: String, default fallback: Int) -> Int {
        let v = defaults.integer(forKey: key)
        return v > 0 ? v : fallback
    }

    /// CPUs / memory (GiB) for the shared primary Nucleic Control container.
    public static var controlContainerCPUs: Int {
        resource(controlContainerCPUsKey, default: defaultControlContainerCPUs)
    }
    public static var controlContainerMemoryGiB: Int {
        // Unset → the host-aware recommendation (not the flat floor), so the OOM-prone shared
        // container gets headroom by default on capable hosts.
        resource(controlContainerMemoryGiBKey, default: recommendedControlContainerMemoryGiB())
    }

    /// CPUs / memory (GiB) for a per-session container.
    public static var sessionContainerCPUs: Int {
        resource(sessionContainerCPUsKey, default: defaultContainerCPUs)
    }
    public static var sessionContainerMemoryGiB: Int {
        resource(sessionContainerMemoryGiBKey, default: defaultContainerMemoryGiB)
    }

    /// How Nucleic Control container sessions authenticate `claude` — the host subscription
    /// login (OAuth, default) or a user-supplied API key (`ControlAPIKeyStore`). App-wide for
    /// now; a per-project override is planned (see the hint in project settings).
    public static let controlAuthModeKey = "nucleic.container.control.authMode"

    public static var controlAuthMode: ControlAuthMode {
        ControlAuthMode(rawValue: defaults.string(forKey: controlAuthModeKey) ?? "")
            ?? .oauth
    }

    /// How Nucleic Control container sessions authenticate `codex` — the host ChatGPT-subscription
    /// login (OAuth, default; kept in sync host↔container by ``CodexAuthFile``) or a user-supplied
    /// OpenAI API key (`CodexControlAPIKeyStore`, forwarded as `OPENAI_API_KEY`). The API-key mode is
    /// the escape hatch when the shared OAuth refresh token keeps getting revoked across environments.
    public static let codexControlAuthModeKey = "nucleic.container.control.codex.authMode"

    public static var codexControlAuthMode: ControlAuthMode {
        ControlAuthMode(rawValue: defaults.string(forKey: codexControlAuthModeKey) ?? "")
            ?? .oauth
    }

    /// When on, each agent family gets its *own* shared Nucleic Control container instead of all
    /// piling into the one `nucleic-control`. The model families can otherwise turn competitive in
    /// a shared sandbox — killing each other's processes ("agenticide") — so this isolates them into
    /// `nucleic-control-claude` (Claude), `nucleic-control-codex` (GPT/Codex), and
    /// `nucleic-control-xai` (xAI/Grok). Off by default; only consulted for shared-container control
    /// sessions (per-session containers are already isolated).
    /// See `ContainerManager.sharedContainerName(for:split:)`.
    public static let splitControlContainersByBackendKey = "nucleic.container.control.splitByBackend"

    public static var splitControlContainersByBackend: Bool {
        defaults.bool(forKey: splitControlContainersByBackendKey)
    }

    // The vsock control plane (the approval MCP server + interceptor endpoint over a
    // vsock-relayed unix socket, `docs/VSOCK_CONTROL_PLANE.md`) is MANDATORY — always on for
    // every containerized run, shared and per-session alike. The old
    // `nucleic.container.vsockControlPlane` toggle and the legacy gateway-TCP fallback are
    // retired: every shipping sandbox image (≥ `v4`) carries `control-bridge.js`, and a custom
    // image without it fails loudly at container start (the engine's fresh-clone preflight).

    /// Whether the in-container **bash command tracer** is active. The tracer installs a `DEBUG`/
    /// `EXIT` trap (sourced via `BASH_ENV`) that records EVERY command the agent runs inside a Bash
    /// tool call and batch-posts the metadata to the GUI command feed. Because the `DEBUG` trap fires
    /// before every simple command, it adds per-command shell overhead during build/test-heavy turns
    /// — so it is **opt-in (default off)**. This gates only the command tracer: the git/gh
    /// interception the conflict-detection / merge-queue / autoship system relies on is always on.
    /// Gated at the env level (the `BASH_ENV`/command-hook vars are injected only when this is on),
    /// so toggling takes effect for agent turns started after the change — no container rebuild.
    public static let commandTracingEnabledKey = "nucleic.container.commandTracing"

    public static var commandTracingEnabled: Bool {
        defaults.bool(forKey: commandTracingEnabledKey)
    }

    /// Whether the app periodically checks the registry for a newer sandbox **base image** than the
    /// one it has unpacked (a cheap manifest-digest HEAD, `ContainerEngine.checkDefaultImageUpdate`),
    /// so a refreshed image re-pushed to the pinned tag can be surfaced — and applied via Force
    /// re-creation — without waiting for an app release. **Default on**; unset reads as `true`. The
    /// check only resolves a digest (no download) and only runs while the container service is on.
    public static let autoCheckImageUpdatesKey = "nucleic.container.autoCheckImageUpdates"

    public static var autoCheckImageUpdates: Bool {
        defaults.object(forKey: autoCheckImageUpdatesKey) == nil
            ? true
            : defaults.bool(forKey: autoCheckImageUpdatesKey)
    }

    /// How aggressively running container VMs reclaim unused memory to the host (via each VM's
    /// virtio memory balloon, driven by `ContainerEngine`'s autoballoon loop). Defaults to
    /// `.conservative`.
    ///
    /// Conservative by default — the gentlest reclaiming level (generous headroom, a high floor, slow
    /// cadence). Ballooning was historically off out of the box because the balloon's target is
    /// derived from the per-**container** cgroup working set but applied to the **whole VM**
    /// (`targetVirtualMachineMemorySize`), which also hosts the guest kernel, vminitd, init and the
    /// virtiofs page cache. With no guest swap, a turn that allocated past the (cadence-stale) target
    /// sent the guest into a perpetual direct-reclaim spin — a guest vCPU pegged at ~100% and the
    /// agent wedged mid-allocation (output froze and never recovered, ending in OOM-137).
    /// ``BalloonPolicy/target(workingSetBytes:ceilingBytes:)`` now reserves a whole-VM margin so every
    /// level clears the guest's true footprint, so the default reclaims conservatively rather than not
    /// at all. Unset/invalid → the default.
    public static let memoryManagementKey = "nucleic.container.memoryManagement"

    public static var memoryManagement: MemoryManagementLevel {
        MemoryManagementLevel(rawValue: defaults.string(forKey: memoryManagementKey) ?? "")
            ?? .conservative
    }

    /// Whether a sandboxed agent may spin up its OWN throwaway Linux containers (the `linux_container`
    /// tool) for lightweight work it wants isolated from its main sandbox — a fresh copy of the same
    /// image, managed by the agent (create/exec/stop/remove). Containers are the lightest rung of the
    /// virtualization ladder (far cheaper than a Linux or macOS VM), so this is ON by default whenever
    /// the container service is on. Turning it off hides the tool without disabling the agent's own
    /// primary sandbox container.
    public static let agentContainersEnabledKey = "nucleic.container.agentContainersEnabled"

    /// Whether sandboxed agents are offered the `linux_container` tool (defaults to `true` when unset;
    /// an explicit stored `false` still wins). Only meaningful while the container service is on.
    public static var agentContainersEnabled: Bool {
        serviceEnabled && (defaults.object(forKey: agentContainersEnabledKey) as? Bool ?? true)
    }

    /// App-wide master switch for the `host_exec` tool — the agent's escape hatch to run commands on
    /// the macOS host, OUTSIDE the sandbox container. ON by default whenever the container service is
    /// on; turning it off hides the tool entirely — it is never advertised to the agent, and the
    /// system prompt never mentions it exists — regardless of any per-project "Allow host build/run"
    /// opt-in. A hard kill switch that sits above the per-project setting.
    public static let hostExecEnabledKey = "nucleic.container.hostExecEnabled"

    /// Whether sandboxed agents may be offered the `host_exec` tool at all (defaults to `true` when
    /// unset; an explicit stored `false` still wins). Only meaningful while the container service is on.
    public static var hostExecEnabled: Bool {
        serviceEnabled && (defaults.object(forKey: hostExecEnabledKey) as? Bool ?? true)
    }
}

/// Which guest a *general* (OS-agnostic) computer-use task should default to when both the Linux and
/// macOS VM services are available. Mac-only work (driving Xcode, a macOS/iOS app) always uses the
/// macOS VM regardless; this only settles the tie for tasks that could run on either desktop. Linux
/// is the default because a Linux guest is lighter and boots faster (docs/LINUX_VM.md). Surfaced in
/// Settings ▸ Virtual Machines and fed into the agent's build guidance so it reaches for the right VM.
public enum ComputerUseVMType: String, CaseIterable, Sendable, Codable {
    case linux
    case macOS

    /// Human label for the settings picker and prompt guidance.
    public var label: String {
        switch self {
        case .linux: return "Linux"
        case .macOS: return "macOS"
        }
    }
}

/// App-wide settings for the **macOS-guest VM** service (Apple `Virtualization` framework) — the
/// Mac-native sibling of ``ContainerServiceSettings``. Where the Linux container gives agents the
/// cross-platform toolchain, a macOS VM gives them a real, isolated Mac (Xcode, the simulators,
/// codesign) for end-to-end testing — one per session, so several agents run Mac work at once
/// without colliding on the single shared host the way `host_exec` does.
///
/// Reads through ``ContainerServiceSettings/defaults`` so the same task-local test-suite binding
/// (`ContainerServiceSettings.withDefaults`) isolates these switches too, and so production always
/// resolves to `.standard`.
public enum MacVMSettings {
    private static var defaults: UserDefaults { ContainerServiceSettings.defaults }

    /// Master switch. Off → the `mac_vm_exec` tool is never exposed and no macOS VM is booted,
    /// regardless of any per-session setting. Off by default: a macOS guest is heavy (several GB of
    /// host RAM each, plus a large one-time base-image install), so it's strictly opt-in.
    public static let serviceEnabledKey = "nucleic.macvm.serviceEnabled"

    /// When on, sandboxed sessions expose `mac_vm_exec` (their own isolated macOS VM) by default.
    /// Only meaningful while the macOS-VM service (above) is on. On by default.
    public static let exposeByDefaultKey = "nucleic.macvm.exposeByDefault"

    /// When on, sandboxed sessions also expose `mac_vm_computer` — GUI computer-use (screenshots +
    /// mouse/keyboard) driving the VM as a Mac dev simulator. A SEPARATE opt-in from `exposeByDefault`.
    /// Capture + input run **host-side** through the guest's virtual display + virtual HID
    /// (docs/MACOS_VM.md §12), so no in-guest permissions and no SIP are required. On by default;
    /// only meaningful while the service is on.
    public static let computerUseByDefaultKey = "nucleic.macvm.computerUseByDefault"

    /// When on, the base image ALSO installs the optional in-guest **semantic AX agent**
    /// (`NucleicVMAgent`): Accessibility-based control (act on a UI element by identity rather than
    /// screen coordinates). It needs the guest's TCC grants, which are written automatically during the
    /// base build (docs/MACOS_VM.md §12.5). OFF by default: the DEFAULT computer-use path is host-side
    /// virtual IO, which needs neither the agent nor those grants. Only meaningful while the service is on.
    public static let axAgentEnabledKey = "nucleic.macvm.axAgentEnabled"

    /// When on, the in-chat **VM Monitor** panel is opened automatically in the right column whenever
    /// the open chat has a VM running, and retracted again the moment the user switches to a chat that
    /// has no VM — so the guest screen follows the conversation without manually adding the panel.
    /// OFF by default. Only meaningful while a guest service (macOS or Linux) is on.
    public static let autoOpenVMMonitorsKey = "nucleic.macvm.autoOpenVMMonitors"

    /// When on, the open chat's VM screen is shown in a small **Picture-in-Picture** window that floats
    /// above every other window — even other apps — while that chat has a VM running, following the
    /// conversation the same way ``autoOpenVMMonitorsKey`` follows the right column. Like every VM
    /// interface surface it is look-only (the read-only monitor, no `VZVirtualMachineView`), so it never
    /// captures the host's mouse or keyboard. ON by default. Only meaningful while a guest service
    /// (macOS or Linux) is on.
    public static let pictureInPictureVMMonitorsKey = "nucleic.macvm.pipVMMonitors"

    /// Optional path to a **prebuilt** golden base VM bundle the user produced out-of-band. When set
    /// and present, the engine clones it directly and skips the install+provision path entirely. Empty
    /// → the engine builds its own golden base under the storage root (install from the restore image,
    /// then provision the toolchain).
    public static let basePrebuiltPathKey = "nucleic.macvm.basePrebuiltPath"

    /// Optional path to a local macOS restore `.ipsw`. Empty → the engine fetches
    /// `VZMacOSRestoreImage.latestSupported` over the network (~14 GB) the first time it builds a base.
    /// The `.ipsw` you supply pins the guest macOS version (e.g. a macOS 27 restore image).
    public static let restoreImagePathKey = "nucleic.macvm.restoreImagePath"

    /// Optional **remote** `.ipsw` URL to pin a specific guest macOS version without pre-downloading it
    /// by hand — e.g. a macOS 27 UniversalMac restore image. The engine downloads it,
    /// caches it keyed by filename (so switching versions doesn't collide), and installs from it.
    /// Takes precedence over `latestSupported` but not over an explicit local `restoreImagePath`. Empty
    /// → use `latestSupported` (the newest the host can run; see docs/MACOS_VM.md §guest versions).
    public static let restoreImageURLKey = "nucleic.macvm.restoreImageURL"

    /// UI-only: which entry is selected in the guest-image picker (a stable id from the hardcoded
    /// IPSW catalog, or "" for “latest supported”). The engine reads `restoreImageURL` /
    /// `restoreImagePath`; this just remembers the picker position so a chosen entry stays selected
    /// across launches (including placeholder entries whose `.ipsw` isn't published yet).
    public static let restoreImageChoiceKey = "nucleic.macvm.restoreImageChoice"

    /// Per-session guest resource ceilings. Applied to the cloned VM at boot; `0`/unset → the defaults.
    public static let vmCPUsKey = "nucleic.macvm.cpus"
    public static let vmMemoryGiBKey = "nucleic.macvm.memoryGiB"
    /// Base-disk size (GiB) the golden base is installed into; clones inherit it copy-on-write.
    public static let baseDiskGiBKey = "nucleic.macvm.baseDiskGiB"
    /// Ceiling on concurrently *running* macOS guests. macOS has historically capped simultaneous
    /// macOS-guest VMs (2 on recent releases); the manager enforces this so a fan-out of agents
    /// queues rather than failing an over-limit `VZVirtualMachine.start`. Adjust if a future OS lifts it.
    public static let maxConcurrentVMsKey = "nucleic.macvm.maxConcurrent"
    /// How long (seconds) a session's VM boot waits in the admission queue for a slot to free when the
    /// concurrent-VM ceiling is hit and no *done* chat's VM can be reclaimed, before giving up with the
    /// concurrency-limit error. Bounds the wait so a tool call can't hang indefinitely behind actively
    /// working peers. Only reached when every occupied slot is a busy chat (a done chat's VM is evicted
    /// first, never queued behind). See ``MacVMEngine/ensureRunning(_:)``.
    public static let vmQueueTimeoutSecondsKey = "nucleic.macvm.queueTimeoutSeconds"
    /// How long (seconds) a guest gets to power itself off after Nucleic asks it to, before its virtual
    /// power is cut. Applies wherever the on-disk clone *survives* the stop and is rebooted later — the
    /// idle timer, the agent's explicit `stop`, a `restart`, and eviction under the concurrent-VM
    /// ceiling. See ``MacVMEngine/shutdown(name:timeout:)``.
    public static let gracefulShutdownTimeoutSecondsKey = "nucleic.macvm.gracefulShutdownTimeoutSeconds"
    /// The in-guest account Nucleic SSHes in as (baked into the base by provisioning).
    public static let sshUserKey = "nucleic.macvm.sshUser"

    public static let defaultVMCPUs = 4
    /// macOS guests want more than a Linux container: 8 GiB keeps Xcode/simulator responsive.
    public static let defaultVMMemoryGiB = 8
    public static let defaultBaseDiskGiB = 96
    public static let defaultIdleTimeoutSeconds = 900
    public static let defaultMaxConcurrentVMs = 2
    /// Default admission-queue wait (see ``vmQueueTimeoutSecondsKey``): three minutes — long enough to
    /// ride out a peer's turn wrapping up, short enough that a full house surfaces the ceiling error
    /// rather than stalling the tool call indefinitely.
    public static let defaultVMQueueTimeoutSeconds = 180
    /// Default shutdown grace (see ``gracefulShutdownTimeoutSecondsKey``): 20 seconds. An idle guest
    /// unmounts and halts well inside this; overrunning it means the guest is wedged, and cutting power
    /// is then no worse than the hard stop this replaced. Comfortably inside
    /// ``defaultVMQueueTimeoutSeconds`` so an eviction can't stall a queued boot into its ceiling error.
    public static let defaultGracefulShutdownTimeoutSeconds = 20
    public static let defaultSSHUser = "agent"

    /// Whether the macOS-VM service is enabled app-wide.
    public static var serviceEnabled: Bool { defaults.bool(forKey: serviceEnabledKey) }

    /// Whether sandboxed sessions expose `mac_vm_exec` by default (only while the service is on).
    /// Defaults to `true` when unset; an explicit stored `false` still wins (`object(forKey:)`
    /// distinguishes "unset" from a stored `false`, which `bool(forKey:)` cannot).
    public static var exposeByDefault: Bool {
        serviceEnabled && (defaults.object(forKey: exposeByDefaultKey) as? Bool ?? true)
    }

    /// Whether sandboxed sessions expose `mac_vm_computer` (GUI computer-use) by default (only while
    /// the service is on). Host-side virtual IO, so any installed base can serve it — no provisioning.
    /// Defaults to `true` when unset; an explicit stored `false` still wins.
    public static var computerUseByDefault: Bool {
        serviceEnabled && (defaults.object(forKey: computerUseByDefaultKey) as? Bool ?? true)
    }

    /// Whether the base image installs the optional in-guest semantic AX agent (the by-identity
    /// control add-on; see ``axAgentEnabledKey``). Builds on virtual computer use, so it requires
    /// both the service and ``computerUseByDefault`` to be on.
    public static var axAgentEnabled: Bool {
        computerUseByDefault && defaults.bool(forKey: axAgentEnabledKey)
    }

    /// Whether the in-chat VM Monitor should follow the open chat — auto-opening in the right column
    /// while that chat has a VM up and closing when it doesn't (see ``autoOpenVMMonitorsKey``). Only
    /// meaningful while a guest service (macOS or Linux) is on.
    public static var autoOpenVMMonitors: Bool {
        (serviceEnabled || linuxServiceEnabled) && defaults.bool(forKey: autoOpenVMMonitorsKey)
    }

    /// Whether the open chat's VM screen should float over everything as a Picture-in-Picture window
    /// while that chat has a VM up (see ``pictureInPictureVMMonitorsKey``). Defaults to `true` when
    /// unset; an explicit stored `false` still wins. Only meaningful while a guest service is on.
    public static var pictureInPictureVMMonitors: Bool {
        (serviceEnabled || linuxServiceEnabled)
            && (defaults.object(forKey: pictureInPictureVMMonitorsKey) as? Bool ?? true)
    }

    /// Configured prebuilt-base path, or `nil`/empty when unset.
    public static var basePrebuiltPath: String? {
        let v = (defaults.string(forKey: basePrebuiltPathKey) ?? "")
            .trimmingCharacters(in: .whitespacesAndNewlines)
        return v.isEmpty ? nil : v
    }

    /// Configured local restore-image path, or `nil`/empty when unset.
    public static var restoreImagePath: String? {
        let v = (defaults.string(forKey: restoreImagePathKey) ?? "")
            .trimmingCharacters(in: .whitespacesAndNewlines)
        return v.isEmpty ? nil : v
    }

    /// Configured remote restore-image `.ipsw` URL (version pin), or `nil`/empty when unset.
    public static var restoreImageURL: String? {
        let v = (defaults.string(forKey: restoreImageURLKey) ?? "")
            .trimmingCharacters(in: .whitespacesAndNewlines)
        return v.isEmpty ? nil : v
    }

    /// Configured SSH account name, or the default.
    public static var sshUser: String {
        let v = (defaults.string(forKey: sshUserKey) ?? "")
            .trimmingCharacters(in: .whitespacesAndNewlines)
        return v.isEmpty ? defaultSSHUser : v
    }

    /// Optional password for the in-guest `agent` account, used by the macOS-27 **declarative
    /// first-boot provisioning** path (`VZMacGuestProvisioningOptions` — the guest account,
    /// auto-login, and Remote Login are created unattended when host AND guest are macOS 27+).
    /// Empty → the engine generates a random password and persists it next to the host SSH key
    /// (`macvms/ssh/agent-password`), so the fully-unattended path needs no configuration. The
    /// guest is a NAT-isolated, disposable VM — this is a lab credential, not a secret vault.
    public static let agentPasswordKey = "nucleic.macvm.agentPassword"

    /// Configured agent-account password, or `nil`/empty when unset (engine then generates one).
    public static var agentPassword: String? {
        let v = (defaults.string(forKey: agentPasswordKey) ?? "")
            .trimmingCharacters(in: .whitespacesAndNewlines)
        return v.isEmpty ? nil : v
    }

    /// Absolute host paths to user-provided `.app` bundles to bake into the base image's
    /// `/Applications`. Chosen in Settings ("Base image" → "Included apps", drag-drop or browse) and
    /// applied during the base build/re-provision: the engine stages each into the provisioning share
    /// and the guest provisioner copies them into `/Applications` (docs/MACOS_VM.md §4.4). Stored as a
    /// plain `[String]` (UserDefaults persists string arrays natively).
    public static let bundledAppPathsKey = "nucleic.macvm.bundledAppPaths"

    /// Configured `.app` bundle paths to include in the base, filtered to those that still exist on
    /// disk and end in `.app` (a bundle the user moved/deleted since selecting it is silently dropped).
    public static var bundledAppPaths: [String] {
        let fm = FileManager.default
        return (defaults.stringArray(forKey: bundledAppPathsKey) ?? []).filter { path in
            path.hasSuffix(".app") && fm.fileExists(atPath: path)
        }
    }

    /// Persist the list of `.app` bundle paths to include in the base image (see ``bundledAppPaths``).
    public static func setBundledAppPaths(_ paths: [String]) {
        defaults.set(paths, forKey: bundledAppPathsKey)
    }

    /// Ids of the **common packages** (``MacVMPackage``) the operator opted into installing in the base
    /// image — Settings ("macOS virtual machines" → "Common packages"). Unlike ``bundledAppPaths``
    /// (local `.app` bundles the user supplies), these are well-known tools the guest fetches and
    /// installs itself during provisioning (e.g. the latest Chrome). Stored as a plain `[String]`.
    public static let selectedPackagesKey = "nucleic.macvm.selectedPackages"

    /// Selected common-package ids, filtered to catalog entries that are actually installable (an
    /// unknown or not-yet-supported/grayed-out id is silently dropped — a placeholder can never be
    /// selected). Order follows the stored selection.
    public static var selectedPackageIDs: [String] {
        let installable = Set(MacVMPackage.catalog.filter { $0.isInstallable }.map { $0.id })
        return (defaults.stringArray(forKey: selectedPackagesKey) ?? []).filter { installable.contains($0) }
    }

    /// Persist the selected common-package ids (see ``selectedPackageIDs``).
    public static func setSelectedPackageIDs(_ ids: [String]) {
        defaults.set(ids, forKey: selectedPackagesKey)
    }

    private static func resource(_ key: String, default fallback: Int) -> Int {
        let v = defaults.integer(forKey: key)
        return v > 0 ? v : fallback
    }

    public static var vmCPUs: Int { resource(vmCPUsKey, default: defaultVMCPUs) }
    public static var vmMemoryGiB: Int { resource(vmMemoryGiBKey, default: defaultVMMemoryGiB) }
    public static var baseDiskGiB: Int { resource(baseDiskGiBKey, default: defaultBaseDiskGiB) }
    public static var maxConcurrentVMs: Int {
        resource(maxConcurrentVMsKey, default: defaultMaxConcurrentVMs)
    }
    public static var vmQueueTimeoutSeconds: Int {
        resource(vmQueueTimeoutSecondsKey, default: defaultVMQueueTimeoutSeconds)
    }
    public static var gracefulShutdownTimeoutSeconds: Int {
        resource(gracefulShutdownTimeoutSecondsKey, default: defaultGracefulShutdownTimeoutSeconds)
    }

    // MARK: - Linux guests

    /// Master switch for the **Linux** VM service — the sibling of ``serviceEnabledKey``. Off by
    /// default: a Linux guest is a separate, heavy opt-in (its own multi-GB base build). When off, no
    /// Linux VM is booted and the Linux base build is unavailable, regardless of per-session settings.
    public static let linuxServiceEnabledKey = "nucleic.linuxvm.serviceEnabled"
    /// When on, sandboxed sessions expose exec against their own isolated **Linux** VM by default (only
    /// while the Linux service is on). On by default.
    public static let linuxExposeByDefaultKey = "nucleic.linuxvm.exposeByDefault"
    /// When on, sandboxed Linux-VM sessions also expose computer-use (host-side virtio-gpu capture +
    /// USB HID). On by default; only meaningful while the Linux service is on.
    public static let linuxComputerUseByDefaultKey = "nucleic.linuxvm.computerUseByDefault"

    /// When on, the Linux base ALSO enables the in-guest **semantic (AT-SPI) agent** — element-level
    /// UI understanding (act on a control by identity rather than screen coordinates; see
    /// docs/LINUX_VM_SEMANTIC_AGENT.md). The sibling of ``axAgentEnabledKey``. Builds on Linux
    /// kernel-level computer use, so it requires both the service and computer-use to be on. OFF by
    /// default: the default computer-use path is host-side virtio IO, which needs neither.
    public static let linuxAxAgentEnabledKey = "nucleic.linuxvm.axAgentEnabled"

    /// URLs for the Linux boot artifacts. The kernel is an arm64 `Image`; if the source is a gzip
    /// `vmlinuz` (Ubuntu ships these), the engine gunzips it host-side before boot. Empty → the pinned
    /// Ubuntu arm64 defaults below, so a Linux base builds with zero configuration.
    public static let linuxKernelURLKey = "nucleic.linuxvm.kernelURL"
    public static let linuxInitrdURLKey = "nucleic.linuxvm.initrdURL"
    public static let linuxRootfsURLKey = "nucleic.linuxvm.rootfsURL"
    /// Optional path to a **prebuilt** Linux base bundle (kernel + initrd + provisioned rootfs). When
    /// set and complete, the engine clones it directly and skips the download+build path.
    public static let linuxBasePrebuiltPathKey = "nucleic.linuxvm.basePrebuiltPath"

    /// Pinned Ubuntu arm64 defaults (**26.04 LTS "resolute"**). Cloud-image kernel + initrd
    /// (self-consistent with the rootfs's kernel modules) and the cloud root filesystem tarball. The
    /// latest LTS: long support window, and it ships **GNOME 50 / Mutter** on Wayland — the compositor
    /// the semantic (accessibility) agent targets (docs/LINUX_VM_SEMANTIC_AGENT.md §3, §Phase 3). A
    /// GNOME bump is a deliberate, scoped port.
    ///
    /// Kernel note: modern Ubuntu arm64 `vmlinuz` is an **EFI-zboot** self-decompressing PE (zstd
    /// payload), which `VZLinuxBootLoader` cannot boot — it needs a raw arm64 `Image`. The artifact
    /// pipeline unwraps zboot host-side (``MacVMEngine.prepareBootableKernel``). To skip that (e.g. to
    /// avoid the host `zstd` dependency), override ``linuxKernelURLKey`` with a **pre-unwrapped** raw
    /// Image — see `scripts/publish-linux-vm-kernel.sh` for producing/publishing one.
    public static let defaultLinuxKernelURL =
        "https://cloud-images.ubuntu.com/releases/resolute/release/unpacked/"
        + "ubuntu-26.04-server-cloudimg-arm64-vmlinuz-generic"
    public static let defaultLinuxInitrdURL =
        "https://cloud-images.ubuntu.com/releases/resolute/release/unpacked/"
        + "ubuntu-26.04-server-cloudimg-arm64-initrd-generic"
    public static let defaultLinuxRootfsURL =
        "https://cloud-images.ubuntu.com/releases/resolute/release/"
        + "ubuntu-26.04-server-cloudimg-arm64-root.tar.xz"

    /// Whether the Linux-VM service is enabled app-wide.
    public static var linuxServiceEnabled: Bool { defaults.bool(forKey: linuxServiceEnabledKey) }

    /// Whether sandboxed sessions expose a Linux VM by default (only while the Linux service is on).
    public static var linuxExposeByDefault: Bool {
        linuxServiceEnabled && (defaults.object(forKey: linuxExposeByDefaultKey) as? Bool ?? true)
    }

    /// Whether sandboxed Linux-VM sessions expose computer-use by default (only while on).
    public static var linuxComputerUseByDefault: Bool {
        linuxServiceEnabled && (defaults.object(forKey: linuxComputerUseByDefaultKey) as? Bool ?? true)
    }

    /// Whether the Linux base enables the in-guest semantic (AT-SPI) agent (see
    /// ``linuxAxAgentEnabledKey``). Builds on Linux kernel-level computer use, so it requires both the
    /// service and ``linuxComputerUseByDefault`` to be on.
    public static var linuxAxAgentEnabled: Bool {
        linuxComputerUseByDefault && defaults.bool(forKey: linuxAxAgentEnabledKey)
    }

    private static func linuxURL(_ key: String, default fallback: String) -> String {
        let v = (defaults.string(forKey: key) ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
        return v.isEmpty ? fallback : v
    }

    public static var linuxKernelURL: String { linuxURL(linuxKernelURLKey, default: defaultLinuxKernelURL) }
    public static var linuxInitrdURL: String { linuxURL(linuxInitrdURLKey, default: defaultLinuxInitrdURL) }
    public static var linuxRootfsURL: String { linuxURL(linuxRootfsURLKey, default: defaultLinuxRootfsURL) }

    /// Configured prebuilt Linux base path, or `nil`/empty when unset.
    public static var linuxBasePrebuiltPath: String? {
        let v = (defaults.string(forKey: linuxBasePrebuiltPathKey) ?? "")
            .trimmingCharacters(in: .whitespacesAndNewlines)
        return v.isEmpty ? nil : v
    }

    // MARK: - Default computer-use guest

    /// Which guest a *general* computer-use task defaults to (``ComputerUseVMType``). Only settles the
    /// tie for OS-agnostic desktop work — Mac-only computer use always uses the macOS VM. Empty/invalid
    /// → Linux (the lighter, faster-booting default).
    public static let defaultComputerUseVMTypeKey = "nucleic.vm.defaultComputerUseType"

    /// The configured default computer-use guest, or ``ComputerUseVMType/linux`` when unset/invalid.
    public static var defaultComputerUseVMType: ComputerUseVMType {
        ComputerUseVMType(rawValue: defaults.string(forKey: defaultComputerUseVMTypeKey) ?? "")
            ?? .linux
    }
}

/// How the runner pool's load balancer places a new session sandbox
/// (docs/ANTIMATTER_RUNNER.md §7). Raw-string-backed so a future policy round-trips through an
/// older client's Settings without throwing. Mirrors the `BalancerPolicy` union in
/// `cloud/nucleic-runner/src/pool.ts`.
public struct RunnerBalancerPolicy: RawRepresentable, Sendable, Equatable, Hashable {
    public let rawValue: String
    public init(rawValue: String) { self.rawValue = rawValue }

    /// Place on the sandbox with the fewest live sessions (default) — evens load out.
    public static let leastSessions = RunnerBalancerPolicy(rawValue: "least-sessions")
    /// Rotate through live sandboxes in id order — predictable spread regardless of load.
    public static let roundRobin = RunnerBalancerPolicy(rawValue: "round-robin")

    public var displayName: String {
        switch rawValue {
        case "least-sessions": return "Least sessions"
        case "round-robin": return "Round robin"
        default: return rawValue.capitalized
        }
    }
}

/// Antimatter runner settings (docs/ANTIMATTER_RUNNER.md §7): the **max containers** cap and the
/// **load balancer** policy the user sets in Settings ▸ Antimatter ▸ Runner. These configure the
/// runner *pool* (the Mac pushes them to the control plane via `PATCH /v1/pool/settings`, which
/// clamps them server-side too); they mirror the macOS VM cap pattern (`MacVMSettings`
/// maxConcurrent + `MacVMManager.ensureRunning` enforcement) onto the cloud pool.
public enum RunnerSettings {
    private static var defaults: UserDefaults { ContainerServiceSettings.defaults }

    /// Total containers the pool may run at once (host-of-record included). 1 ⇒ every session
    /// runs in place in the host container — the shared-container model, no fan-out. Clamped to
    /// ``maxContainersRange``; the pool re-clamps on `PATCH /settings` so a stale client can't
    /// exceed it. Mirrors `nucleic.macvm.maxConcurrent`.
    public static let maxContainersKey = "nucleic.runner.maxContainers"
    /// The load-balancer policy (``RunnerBalancerPolicy``).
    public static let balancerPolicyKey = "nucleic.runner.balancerPolicy"
    /// Whether the runner is enabled at all (the "Enable runner" switch). Off by default; a
    /// runner only provisions when the user turns it on, like every other service switch.
    public static let serviceEnabledKey = "nucleic.runner.serviceEnabled"
    /// The runner control-plane base URL (dev/self-host override). Empty ⇒ the production
    /// `runner.nucleic.blakeslee.xyz`.
    public static let controlURLKey = "nucleic.runner.controlURL"

    public static let defaultMaxContainers = 4
    /// Same 1…16 bounds the Worker enforces (`MAX_CONTAINERS_RANGE` in pool.ts), so the stepper
    /// and the server agree.
    public static let maxContainersRange = 1...16

    /// Whether the runner service is enabled app-wide.
    public static var serviceEnabled: Bool { defaults.bool(forKey: serviceEnabledKey) }

    /// The configured max-container cap, clamped to ``maxContainersRange``. Unset ⇒
    /// ``defaultMaxContainers``.
    public static var maxContainers: Int {
        let stored = defaults.object(forKey: maxContainersKey) as? Int ?? defaultMaxContainers
        return min(maxContainersRange.upperBound, max(maxContainersRange.lowerBound, stored))
    }

    /// The configured balancer policy, or ``RunnerBalancerPolicy/leastSessions`` when unset.
    public static var balancerPolicy: RunnerBalancerPolicy {
        switch defaults.string(forKey: balancerPolicyKey) {
        case RunnerBalancerPolicy.roundRobin.rawValue: return .roundRobin
        default: return .leastSessions
        }
    }

    /// Configured control-plane URL, or `nil`/empty when unset (⇒ production).
    public static var controlURL: String? {
        let v = (defaults.string(forKey: controlURLKey) ?? "")
            .trimmingCharacters(in: .whitespacesAndNewlines)
        return v.isEmpty ? nil : v
    }
}

/// A registered git repository plus per-project configuration.
public struct Project: Identifiable, Sendable, Codable, Equatable {
    public let id: ProjectID
    public var name: String
    public var rootPath: String
    public var defaultBranch: GitRef
    /// The branch autoship merges this project's sessions into. `nil` → the project root
    /// repo's `defaultBranch` (the default). A per-session override (`Session.shipBranch`)
    /// takes precedence over this. See `resolvedAutoShipBranch`.
    public var autoShipBranch: GitRef?
    public var defaultBackend: BackendID?
    /// Override for where session worktrees are created. `nil` → the default
    /// sibling dir on the same volume (WORKTREE_MANAGER §2).
    public var worktreeBase: String?
    public var setupScript: String?
    public var setupPolicy: SetupPolicy
    /// Execution-sandbox settings. `nil` → sessions run on the host (default).
    public var sandbox: ProjectSandbox?
    /// nvrsion (Beta) settings. `nil`/`enabled == false` → classic per-session-worktree behavior
    /// (the default). See `nvrsionActive` for the full gate (NVRSION §10).
    public var nvrsion: ProjectNvrsion?
    public var createdAt: Date
    /// When the project was archived, or `nil` if it's active. Archiving hides the project
    /// from the active list and stops its live sessions, but leaves all files and records in
    /// place so it can be restored (see `AppStore.setProjectArchived`).
    public var archivedAt: Date?

    public init(
        id: ProjectID = .generate(),
        name: String,
        rootPath: String,
        defaultBranch: GitRef,
        autoShipBranch: GitRef? = nil,
        defaultBackend: BackendID? = .claudeCode,
        worktreeBase: String? = nil,
        setupScript: String? = nil,
        setupPolicy: SetupPolicy = .block,
        sandbox: ProjectSandbox? = nil,
        nvrsion: ProjectNvrsion? = nil,
        createdAt: Date = Date(),
        archivedAt: Date? = nil
    ) {
        self.id = id
        self.name = name
        self.rootPath = rootPath
        self.defaultBranch = defaultBranch
        self.autoShipBranch = autoShipBranch
        self.defaultBackend = defaultBackend
        self.worktreeBase = worktreeBase
        self.setupScript = setupScript
        self.setupPolicy = setupPolicy
        self.sandbox = sandbox
        self.nvrsion = nvrsion
        self.createdAt = createdAt
        self.archivedAt = archivedAt
    }

    /// Whether this project is currently archived (hidden from the active list).
    public var isArchived: Bool { archivedAt != nil }

    /// The branch autoship merges this project's sessions into, resolved: the configured
    /// `autoShipBranch` if set, else the project root repo's `defaultBranch`.
    public var resolvedAutoShipBranch: GitRef { autoShipBranch ?? defaultBranch }

    /// Whether this repo lives under Nucleic's control directory (`~/.nucleic/control/`) —
    /// a repo Nucleic clones and manages itself, kept out of iCloud and away from manual
    /// edits. Derived from `rootPath`, so a move into/out of the control dir flips it.
    public var isNucleicControlled: Bool {
        ProjectCloner.isControlled(rootPath)
    }

    /// The sandbox config that actually governs this project's sessions, resolving the Nucleic
    /// Control default: a **controlled** project is sandboxed project-wide by default — even
    /// with no stored `sandbox`, and even if a stored one is disabled — so it always runs in
    /// Nucleic's managed container (its `image`/`idleTimeout`/`perSessionContainers` carry
    /// through when set). A non-controlled project keeps the explicit opt-in: its stored
    /// `sandbox` when enabled, else `nil` (host-spawned).
    ///
    /// This is the single source of truth both the spawn gate (`SessionController`) and the
    /// teardown paths (`AppStore`) read, so they can't drift. It is still **subordinate to**
    /// the app-wide `ContainerServiceSettings.serviceEnabled` master switch, which callers
    /// check first — so with the container service off, even a control project runs on the host.
    public var effectiveSandbox: ProjectSandbox? {
        if isNucleicControlled {
            var s = sandbox ?? ProjectSandbox()
            s.enabled = true
            return s
        }
        if let sandbox, sandbox.enabled { return sandbox }
        return nil
    }

    /// Whether this project's sandboxed sessions share Nucleic's single **primary** managed
    /// container rather than each getting their own. True only for a Nucleic Control project
    /// that hasn't opted into per-session containers — the default for control projects.
    public var usesSharedControlContainer: Bool {
        isNucleicControlled && !(effectiveSandbox?.perSessionContainers ?? false)
    }

    /// Whether **nvrsion** (Beta) governs this project's sessions — the single gate both the spawn
    /// path and teardown read so they can't drift (NVRSION §10). Requires a Nucleic Control project,
    /// the mode enabled, and the shared control container (v0 doesn't support per-session
    /// containers). Like `effectiveSandbox` it stays subordinate to the app-wide container-service
    /// master switch, which callers check first.
    public var nvrsionActive: Bool {
        isNucleicControlled && (nvrsion?.enabled ?? false) && usesSharedControlContainer
    }

    /// Host path of the shared nvrsion trunk checkout: `<repoRoot>/.nucleic/trunk` — kept inside the
    /// project under `.nucleic/` (git-excluded) on the same volume, beside the worktrees dir.
    public var resolvedTrunkPath: String {
        let nucleicDir = (rootPath as NSString).appendingPathComponent(".nucleic")
        return (nucleicDir as NSString).appendingPathComponent("trunk")
    }

    /// `<repoSlug>` used in the worktree path and branch derivation.
    public var repoSlug: String {
        let last = (rootPath as NSString).lastPathComponent
        return Slug.sanitize(last, fallback: "repo")
    }

    /// Resolved base directory for this project's session worktrees:
    /// `<override>/<repoSlug>` or the default `<repoRoot>/.nucleic/worktrees` — kept
    /// inside the project under `.nucleic/` (git-excluded so the primary checkout never
    /// sees it) on the same volume (WORKTREE_MANAGER §2).
    public var resolvedWorktreeBase: String {
        if let worktreeBase {
            return (worktreeBase as NSString).appendingPathComponent(repoSlug)
        }
        let nucleicDir = (rootPath as NSString).appendingPathComponent(".nucleic")
        return (nucleicDir as NSString).appendingPathComponent("worktrees")
    }
}

/// A compact, `Sendable` view of a project *anywhere in the mesh* for the sidebar — the project
/// analogue of `SessionSummary`. Local projects and projects mirrored from peer Macs both map
/// into this one shape, so the sidebar renders a single list through a single code path and
/// differentiates origin only by a globe badge. Semantics a local `Project` *derives* from its
/// paths/defaults (`isNucleicControlled`, `nvrsionActive`) are carried as plain values here,
/// because a remote project's truth rides the wire, not this Mac's filesystem.
public struct ProjectSummary: Identifiable, Sendable, Equatable, Hashable {
    public let id: ProjectID
    public var name: String
    public var isNucleicControlled: Bool
    public var nvrsionActive: Bool
    /// Whether sessions run in a sandbox container (drives the shield badge). Always false for
    /// remote projects — the wire doesn't carry it, and the shield describes *this* Mac's runtime.
    public var sandboxed: Bool
    /// The `HostID` of the peer Mac this project lives on, or `nil` for a project on this Mac.
    public var hostID: String?
    /// The owning Mac's friendly name (resolved when the summary is built), for the globe
    /// badge's tooltip. `nil` for local projects.
    public var hostLabel: String?

    public var isRemote: Bool { hostID != nil }

    /// List-row identity, origin-qualified for the same reason as `SessionSummary.sidebarRowID`.
    public var rowID: String { "\(hostID ?? "local")-\(id.rawValue)" }

    public init(
        id: ProjectID, name: String, isNucleicControlled: Bool = false,
        nvrsionActive: Bool = false, sandboxed: Bool = false,
        hostID: String? = nil, hostLabel: String? = nil
    ) {
        self.id = id
        self.name = name
        self.isNucleicControlled = isNucleicControlled
        self.nvrsionActive = nvrsionActive
        self.sandboxed = sandboxed
        self.hostID = hostID
        self.hostLabel = hostLabel
    }

    public init(_ project: Project) {
        self.init(
            id: project.id, name: project.name,
            isNucleicControlled: project.isNucleicControlled,
            nvrsionActive: project.nvrsionActive,
            sandboxed: project.sandbox?.enabled == true)
    }
}

// MARK: - Slug derivation (WORKTREE_MANAGER §2)

public enum Slug {
    /// Lowercased, kebab-cased, alphanumerics + dashes only, collapsed and trimmed.
    public static func sanitize(_ raw: String, fallback: String = "session") -> String {
        var out = ""
        var lastWasDash = false
        for scalar in raw.lowercased().unicodeScalars {
            if scalar == "-" || scalar == "_" || scalar == " " || scalar == "." || scalar == "/" {
                if !lastWasDash && !out.isEmpty { out.append("-"); lastWasDash = true }
            } else if scalar.properties.isAlphabetic || ("0"..."9").contains(scalar) {
                out.unicodeScalars.append(scalar)
                lastWasDash = false
            }
            // anything else is dropped
        }
        while out.hasSuffix("-") { out.removeLast() }
        return out.isEmpty ? fallback : out
    }
}
