Files
nucleic/Sources/NucleicCore/Project.swift
T

888 lines
50 KiB
Swift

import Foundation
// 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-default vsock
/// control plane, so `v4` must ship before `vsockControlPlaneEnabled` defaults on. `v5` added
/// `openssh-client` for `ssh-keygen`, so the agent can sign commits with `gpg.format=ssh`.)
/// Keep in lockstep with `.github/workflows/sandbox-image.yml`'s `IMAGE_TAG`.
public static let defaultImage = "ghcr.io/abkslm/nucleic-sandbox:v5"
/// 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 `claude`.
public enum ControlAuthMode: String, CaseIterable, Sendable {
/// Reuse the host's Claude subscription login — the macOS Keychain OAuth credentials,
/// exported into the per-session claude-home as `.credentials.json`. The default.
case oauth
/// Use a user-supplied Anthropic API key (stored in the Keychain via `ControlAPIKeyStore`),
/// forwarded as `ANTHROPIC_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.
public static var serviceEnabled: Bool {
defaults.bool(forKey: serviceEnabledKey)
}
/// 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
}
/// 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)
}
/// When on, a shared Nucleic Control container's control plane (the approval MCP server + the
/// git/gh/command interceptor endpoint) runs over a **vsock-relayed unix socket** instead of
/// TCP/HTTP on the VM gateway — so macOS raises no incoming-connection / local-network prompts.
/// The agent and the interceptor shims reach the host through an in-container loopback bridge
/// that forwards to the relayed socket (see `docs/VSOCK_CONTROL_PLANE.md`). **On by default** as
/// of sandbox image `v4` (which ships `control-bridge.js`); set the key to `false` to fall back to
/// the legacy gateway-TCP path. Because it requires the bridge in the image, the default must move
/// in lockstep with `ProjectSandbox.defaultImage` being a bridge-bearing tag (≥ `v4`).
public static let vsockControlPlaneEnabledKey = "nucleic.container.vsockControlPlane"
/// Defaults to `true` when unset (so a fresh install gets the vsock control plane); an explicit
/// stored `false` still wins. `object(forKey:)` distinguishes "unset" from a stored `false`,
/// which `UserDefaults.bool(forKey:)` (always `false` when absent) cannot.
public static var vsockControlPlaneEnabled: Bool {
defaults.object(forKey: vsockControlPlaneEnabledKey) as? Bool ?? true
}
/// 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)
}
/// 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
}
}
/// 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"
/// Advanced/diagnostic: when on, the guest-screen **monitor** is opened automatically for every
/// per-session macOS VM the instant it boots — no matter what launched it (`mac_vm_exec`,
/// `mac_vm_computer`, or provisioning). Normally the monitor is opened on demand (the Control
/// panel's "Observe"); this forces it always-on, which is handy for Nucleic developers or anyone
/// diagnosing a VM that won't behave. OFF by default. Only meaningful while the service is on.
public static let alwaysOpenGuestDisplayKey = "nucleic.macvm.alwaysOpenGuestDisplay"
/// 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"
/// 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
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 guest-screen monitor should auto-open for every session VM as it boots (see
/// ``alwaysOpenGuestDisplayKey``). Only meaningful while the service is on.
public static var alwaysOpenGuestDisplay: Bool {
serviceEnabled && defaults.bool(forKey: alwaysOpenGuestDisplayKey)
}
/// 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
}
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)
}
}
/// 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
}
}