Files
nucleic/Sources/NucleicCore/Project.swift
T

1431 lines
84 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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** the app pulls
/// + unpacks at runtime (`ContainerEngine`), cached by this exact ref.
///
/// **narOS** (docs/NAROS.md, milestone N2): the default is now `naros-agent` — the Nucleic
/// Agent Runtime OS agent tier, built by `.github/workflows/naros.yml` from `os/` (mmdebstrap
/// trixie base + `os/images/agent/Dockerfile`). It supersedes `nucleic-sandbox:v7`
/// (there is no v8): same control bridge, agent CLIs, and Playwright/Chromium — CI's
/// parity sweep (`os/tests/parity-sweep.sh`) guards the tool inventory — plus narOS identity
/// (`ID=naros`), **nash forced as `/bin/sh`/`/bin/bash`** (NASH.md §7.1/M3), `naros-init` as
/// PID 1, the `agent` user (uid `ContainerSpec.narosAgentUID`), baked Rust/Go/mise toolchains,
/// and warm shared caches. The tag tracks `os/VERSION`; bump the pin when cutting a release
/// (single-constant lockstep, asserted in CI — NAROS.md §8 — replacing the old four-place bump).
///
/// **Rollback levers**, strongest first: repoint this pin to ``legacyImage`` (image-level
/// rollback — the v7 image stays published); or set `ContainerServiceSettings.legacyShell`
/// (shell-level only: execs use the preserved real bash and nash is bypassed via
/// `NUCLEIC_NASH_DISABLE`, no image change). A per-project override (`ProjectSandbox.image`)
/// rolls back one project.
///
/// 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/naros-agent:26.07"
/// The last pre-narOS sandbox image (`containers/nucleic-sandbox/Dockerfile` history: `v2`
/// build tools; `v3` gh+curl; `v4` Codex/Grok CLIs + the control bridge; `v5` openssh-client;
/// `v6` @latest CLI pins + multi-arch; `v7` Playwright+Chromium). Kept published as the
/// image-level rollback for the narOS swap — repoint ``defaultImage`` here to roll back.
public static let legacyImage = "ghcr.io/abkslm/nucleic-sandbox:v7"
/// 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(_: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,
operation: nonisolated(nonsending) () async throws -> R
) async rethrows -> R {
try await $defaultsBox.withValue(
DefaultsBox(defaults: store), operation: operation)
}
/// 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"
/// Hard per-session memory ceiling (GiB) inside the SHARED control container. 0 (default) = off:
/// sessions burst freely and a runaway one is contained only by its own cgroup's oom.group (each
/// session already has its own cgroup — see the vendored containerization patch #9). Set > 0 to
/// also cap each session's `memory.max`, so one session can't consume the whole container's memory
/// before *its own* OOM. Only applies to the shared control container (per-session sandbox
/// containers already run one session each).
public static let controlPerSessionMemoryGiBKey = "nucleic.container.control.perSessionMemoryGiB"
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/COVALENCE_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())
}
/// Hard per-session memory ceiling (GiB) in the shared control container; 0 = off. See the key doc.
public static var controlPerSessionMemoryGiB: Int {
resource(controlPerSessionMemoryGiBKey, default: 0)
}
/// 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)
}
/// The nash **rollback lever** (docs/NASH.md M3 / docs/NAROS.md N2). narOS images force nash as
/// the container's default shell (`/bin/sh`/`/bin/bash` diverted at image build); with this ON,
/// containerized execs run under the preserved real shell instead: `agentShellArgv` stops
/// preferring nash, and every hook env carries `NUCLEIC_NASH_DISABLE=1`, which makes any nash
/// still reached through the divert immediately re-exec the preserved `bash.real` (nash's M1
/// kill switch) — so shell-level regressions are isolatable from image regressions without
/// re-pulling an image. Default off. Takes effect for agent turns started after the change.
public static let legacyShellKey = "nucleic.container.legacyShell"
public static var legacyShell: Bool {
defaults.bool(forKey: legacyShellKey)
}
/// Route a Claude session's API traffic through Nucleic's in-process token-injecting proxy
/// (``ClaudeTokenProxy``) instead of handing the session a single static `CLAUDE_CODE_OAUTH_TOKEN`.
/// The proxy rewrites `Authorization` with a broker-fresh access token on every request, so a long
/// turn that outlives the ~1h token no longer 401s mid-turn — while the rotating refresh token
/// stays broker-only. **Opt-in (default off):** the host path is self-contained, but the
/// containerized path additionally relays a second unix socket + in-guest bridge that requires the
/// sandbox image/`naros-init` to launch it, so enable only on an image that carries the second
/// bridge. Takes effect for agent turns started after the change.
public static let claudeTokenProxyEnabledKey = "nucleic.claude.tokenProxy"
public static var claudeTokenProxyEnabled: Bool {
defaults.bool(forKey: claudeTokenProxyEnabledKey)
}
/// Whether the app keeps the sandbox **base image** up to date automatically. Two effects, both
/// gated on this and the container service being on: (1) a periodic registry check (a cheap
/// manifest-digest HEAD, `ContainerEngine.checkDefaultImageUpdate`) that surfaces a newer image in
/// Maintenance, and (2) auto-update on request — when a sandbox is next started and the registry
/// serves a newer digest than the cached rootfs, `ContainerEngine.ensureCachedRootfs` discards the
/// stale cache and pulls the new image instead of reusing the current one. Together they let a
/// refreshed image re-pushed to the pinned tag reach running sandboxes without an app release.
/// **Default on**; unset reads as `true`. An already-running container keeps its image until it's
/// re-created; a check/pull failure (offline, auth) leaves the cache in place.
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"
}
}
}
/// Settings for the **macOS host shell surface** (docs/NASH.md §7.7, §9.6): nash for the shells
/// *Nucleic itself spawns on the user's Mac* (`host_exec`, host monitors, Build/Run) — never a
/// divert, never the user's own shells. Deliberately its OWN namespace, not the
/// `nucleic.container.*` keys: a container rollback and a host rollback are different decisions.
///
/// Reads through ``ContainerServiceSettings/defaults`` for the same task-local test isolation.
public enum HostShellSettings {
private static var defaults: UserDefaults { ContainerServiceSettings.defaults }
/// `nash | zsh` — the shell for Nucleic's own non-interactive host spawns. **Default `zsh`
/// until M5.5's gates pass** (differential replay green + a clean `LoginShellEnv` dogfood
/// week, NASH.md §11): flipping is an explicit opt-in even after the code ships.
public static let shellKey = "nucleic.host.shell"
/// Whether host spawns should prefer the bundled nash: the shell setting says `nash` AND the
/// revert-everything lever is off. The binary's presence is a separate, per-call check
/// (`CommandInterceptor.hostShellArgv`) so a stripped bundle degrades silently to zsh.
public static var nashEnabled: Bool {
defaults.string(forKey: shellKey) == "nash" && !legacyShell
}
/// Host capture depth: `off | meta | tap`, default **`meta`** — the host filesystem is the
/// user's own machine, so data-flow previews (redirect/pipe/cmdsub) are opt-in here, inverting
/// the container default of `tap` (NASH.md §7.7.4).
public static let shellCaptureKey = "nucleic.host.shellCapture"
public static var shellCapture: String {
let raw = defaults.string(forKey: shellCaptureKey) ?? ""
return ["off", "meta", "tap"].contains(raw) ? raw : "meta"
}
/// The single revert-everything lever (NASH.md §9.6), mirroring the container's
/// `nucleic.container.legacyShell`: every host site back on `/bin/zsh -lc`, and host hook env
/// carries `NUCLEIC_NASH_DISABLE=1` so even a stray nash re-execs the fallback shell.
public static let legacyShellKey = "nucleic.host.legacyShell"
public static var legacyShell: Bool { defaults.bool(forKey: legacyShellKey) }
}
/// 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"
/// 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
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)
}
/// Apps/packages recorded from a base that's being **deleted** so the *next* base build can carry
/// them forward into the replacement — the durable stash behind "an OS-version update (delete +
/// reinstall) doesn't lose the apps the user had installed" (docs/MACOS_VM.md §9.1). The engine
/// writes these in `deleteBaseImage` from the outgoing base's `bundle.json`, unions them with the
/// live Settings selection when provisioning the new base, and clears them once that succeeds.
/// (A no-delete "Rebuild" carries the apps straight from the surviving base's own `bundle.json`, so
/// these keys stay empty on that path.)
public static let pendingBaseCarryAppsKey = "nucleic.macvm.pendingBaseCarryApps"
public static let pendingBaseCarryPackagesKey = "nucleic.macvm.pendingBaseCarryPackages"
public static var pendingBaseCarryApps: [String] {
defaults.stringArray(forKey: pendingBaseCarryAppsKey) ?? []
}
public static var pendingBaseCarryPackages: [String] {
defaults.stringArray(forKey: pendingBaseCarryPackagesKey) ?? []
}
/// Record what a base being deleted had installed, for the next build to restore. An empty/empty
/// pair clears the stash (nothing to carry).
public static func setPendingBaseCarry(apps: [String], packages: [String]) {
if apps.isEmpty { defaults.removeObject(forKey: pendingBaseCarryAppsKey) }
else { defaults.set(apps, forKey: pendingBaseCarryAppsKey) }
if packages.isEmpty { defaults.removeObject(forKey: pendingBaseCarryPackagesKey) }
else { defaults.set(packages, forKey: pendingBaseCarryPackagesKey) }
}
/// Clear the carry stash once the replacement base has been provisioned with it (the new base's
/// own `bundle.json` now records the set, so the stash has done its job).
public static func clearPendingBaseCarry() {
defaults.removeObject(forKey: pendingBaseCarryAppsKey)
defaults.removeObject(forKey: pendingBaseCarryPackagesKey)
}
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)
}
// 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"
/// The **kernel + initrd stay the externally-sourced Ubuntu arm64 artifacts** (26.04 LTS
/// "resolute"). Changing the kernel/initrd flow is a narOS non-goal (NAROS.md §non-goals: "the VM
/// continues to use the externally-sourced kernel/initrd flow… narOS owns the *rootfs*") — so N4
/// swaps only the rootfs, below. The rootfs's kernel *modules* still match this Ubuntu kernel
/// (``defaultLinuxModulesManifestURL``), independent of the rootfs userland.
///
/// 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"
/// Plain-URL rootfs override (escape hatch). **Empty by default** — the default rootfs is the
/// narOS OCI artifact below, not a URL. When set, ``resolveLinuxArtifacts`` downloads this instead.
public static let defaultLinuxRootfsURL = ""
/// The **narOS VM rootfs** the two-phase build boots, published by `naros.yml` as a single-blob OCI
/// artifact (same channel as the guest agents / the pre-unwrapped kernel). Default: the **desktop
/// flavor** (`naros-vm-desktop`, N5) — GNOME 50 / Mutter baked from a pinned Debian forky snapshot,
/// for computer-use + the AT-SPI semantic agent (it replaces the retired Ubuntu guest). The headless
/// flavor (`naros-vm`, N4 — exec-only, trixie) is available by overriding the key. Override the ref
/// via the key; empty → the pinned default. Bump the tag with `os/VERSION`.
public static let linuxRootfsImageKey = "nucleic.linuxvm.rootfsImage"
public static let defaultLinuxRootfsImage = "ghcr.io/abkslm/naros-vm-desktop:26.07"
/// The cloud-image manifest pinning the **external kernel's** `linux-modules-<ver>-generic` version
/// (`scripts/fetch-kernel-modules.sh`). Tied to the Ubuntu kernel/initrd above — the modules must
/// match the kernel we boot — and therefore kept **independent of the (narOS) rootfs source**.
public static let linuxModulesManifestURLKey = "nucleic.linuxvm.modulesManifestURL"
public static let defaultLinuxModulesManifestURL =
"https://cloud-images.ubuntu.com/releases/resolute/release/"
+ "ubuntu-26.04-server-cloudimg-arm64.manifest"
/// The OS version the Linux VM base reports (stamped into `bundle.json`, shown in Settings). Bump
/// with `os/VERSION`.
public static let defaultLinuxOSVersion = "26.07"
// The **Linux guest agents** (`nucleic-linux-agent`, the static-C vsock control agent; and
// `nucleic-a11y-agent`, the Rust AT-SPI semantic agent) have no settings here on purpose. They are
// versioned with the rootfs and baked into it from the narOS apt pool
// (os/mkimage/profiles/vm*.naros-pkgs) — the same debs published to the hosted repo. The host once
// also pulled them from `ghcr.io/abkslm/nucleic-{linux,a11y}-agent:latest` and had the phase-1
// bootstrap overlay them onto the unpacked rootfs, so a VM could ship one build of the agent and
// run another (`:latest` moved independently of the rootfs tag). Pinning `linuxRootfsImage` now
// pins the agents too.
/// 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)
}
/// Debug switch for **Linux base image builds** (docs/LINUX_VM.md §Debugging a failed base build).
/// When on: the provision boot drops `quiet` for a verbose console (journald forwarded to the
/// serial log), and the payload bakes `/etc/nucleic-debug` into the rootfs so the in-guest
/// `nucleic-bootdebug` unit emits its full snapshot even on healthy boots. A failed provision
/// always writes `debug-report.txt` into the bundle, debug switch or not.
public static let linuxBaseBuildDebugKey = "nucleic.linuxvm.baseBuildDebug"
public static var linuxBaseBuildDebug: Bool { defaults.bool(forKey: linuxBaseBuildDebugKey) }
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) }
public static var linuxRootfsImage: String { linuxURL(linuxRootfsImageKey, default: defaultLinuxRootfsImage) }
public static var linuxModulesManifestURL: String {
linuxURL(linuxModulesManifestURLKey, default: defaultLinuxModulesManifestURL)
}
/// 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
}
}
/// Covalence runner settings (docs/COVALENCE_RUNNER.md §7): the **max runners** cap the user sets
/// in Settings ▸ Covalence ▸ Covalence Cloud. It configures the runner *pool* (the Mac pushes it
/// to the control plane via `PATCH /v1/pool/settings`, which clamps it server-side too); it
/// mirrors the macOS VM cap pattern (`MacVMSettings` maxConcurrent + `MacVMManager.ensureRunning`
/// enforcement) onto the cloud pool — the ceiling on how many independent runner peers the pool
/// scales up to on demand.
public enum RunnerSettings {
private static var defaults: UserDefaults { ContainerServiceSettings.defaults }
/// The most concurrent runner peers the pool may run. Each runner is a full `nucleicd` host
/// with its own mesh identity; the fleet scales up on demand (a parked mesh dispatch boots
/// one more, up to this cap) and reaps to zero. Clamped to ``maxRunnersRange``; the pool
/// re-clamps on `PATCH /settings` so a stale client can't exceed it. Mirrors
/// `nucleic.macvm.maxConcurrent`.
public static let maxRunnersKey = "nucleic.runner.maxRunners"
/// 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 defaultMaxRunners = 4
/// Same 1…16 bounds the Worker enforces (`MAX_RUNNERS_RANGE` in pool.ts), so the stepper
/// and the server agree.
public static let maxRunnersRange = 1...16
/// Whether the runner service is enabled app-wide.
public static var serviceEnabled: Bool { defaults.bool(forKey: serviceEnabledKey) }
/// The configured max-runners cap, clamped to ``maxRunnersRange``. Unset ⇒
/// ``defaultMaxRunners``.
public static var maxRunners: Int {
let stored = defaults.object(forKey: maxRunnersKey) as? Int ?? defaultMaxRunners
return min(maxRunnersRange.upperBound, max(maxRunnersRange.lowerBound, stored))
}
/// 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
}
/// How a runner replaces Apple Foundation Models (docs/COVALENCE_RUNNER.md §5, item 6).
/// Persisted under ``intelligenceModeKey``; `NUCLEIC_RUNNER_INTELLIGENCE_MODE` overrides it
/// per boot (the container path, where env is the config surface).
public static let intelligenceModeKey = "nucleic.runner.intelligenceMode"
/// The configured intelligence mode. Unset ⇒ ``RunnerIntelligenceMode/mesh`` — free (no
/// agent tokens), private (E2EE to the user's own devices), and it degrades to heuristics
/// by itself when no capable device is connected.
public static var intelligenceMode: RunnerIntelligenceMode {
RunnerIntelligenceMode(rawValue: defaults.string(forKey: intelligenceModeKey) ?? "")
?? .mesh
}
}
/// The runner's `IntelligenceProviding` choice (COVALENCE_RUNNER §5): `agent` runs a small
/// SKU of the user's authenticated agent behind strict templates, `mesh` delegates to
/// Apple-Intelligence-capable devices on the mesh, `heuristic` skips models entirely. Modes
/// 12 always terminate in the heuristic fallback when they can't serve. Mirrors the
/// `IntelligenceMode` union in `cloud/nucleic-runner/src/pool.ts` (keep in lockstep).
public enum RunnerIntelligenceMode: String, CaseIterable, Sendable {
case agent
case mesh
case heuristic
public var displayName: String {
switch self {
case .agent: return "Agent (small model)"
case .mesh: return "Your devices (Apple Intelligence)"
case .heuristic: return "Off (heuristics only)"
}
}
}
/// 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
}
}