Files
nucleic/Sources/NucleicCore/Backend.swift
T

825 lines
43 KiB
Swift

import Foundation
// Identifiers (SessionID/ApprovalID/BackendID/WorktreePath) and AgentInput now live in
// NucleicProtocol/CoreIdentifiers.swift so the sync wire layer shares them with iOS.
// NucleicCore re-exports NucleicProtocol (ProtocolExports.swift), so they stay in scope here.
// MARK: - Capabilities (BACKEND_PROTOCOL §2.1)
public struct BackendCapabilities: Sendable {
public var interactiveApprovals: Bool
public var allowAlwaysScopes: Set<AlwaysScope>
public var canModifyToolInput: Bool
public var partialMessageStreaming: Bool
public var emitsThinking: Bool
public var emitsFileChangeEvents: Bool
public var nativeResume: Bool
public var sandboxModes: [SandboxMode]
public var followUpWhileRunning: Bool
public init(
interactiveApprovals: Bool,
allowAlwaysScopes: Set<AlwaysScope>,
canModifyToolInput: Bool,
partialMessageStreaming: Bool,
emitsThinking: Bool,
emitsFileChangeEvents: Bool,
nativeResume: Bool,
sandboxModes: [SandboxMode],
followUpWhileRunning: Bool
) {
self.interactiveApprovals = interactiveApprovals
self.allowAlwaysScopes = allowAlwaysScopes
self.canModifyToolInput = canModifyToolInput
self.partialMessageStreaming = partialMessageStreaming
self.emitsThinking = emitsThinking
self.emitsFileChangeEvents = emitsFileChangeEvents
self.nativeResume = nativeResume
self.sandboxModes = sandboxModes
self.followUpWhileRunning = followUpWhileRunning
}
}
public enum SandboxMode: String, Sendable, Codable {
case readOnly = "read-only"
case workspaceWrite = "workspace-write"
case fullAccess = "danger-full-access"
}
/// Request to run a session's `claude` process inside an isolated Linux VM (built on Apple's
/// `containerization` framework) instead of on the host. Built by `SessionController` from
/// `Project.sandbox` and carried on `RunSpec`/`ResumeSpec`; consumed by `ClaudeCodeBackend` (which
/// `exec`s the invocation inside the running container VM via `ContainerManager`/`ContainerEngine`)
/// and `ContainerManager` (lifecycle). `nil` → host-spawned.
public struct ContainerSpec: Sendable, Equatable {
/// One bind mount: a host path made visible inside the container.
public struct Mount: Sendable, Equatable {
public let host: String
public let container: String
public let readOnly: Bool
public init(host: String, container: String, readOnly: Bool) {
self.host = host
self.container = container
self.readOnly = readOnly
}
}
/// Stable container name for this session (e.g. `nucleic-<sessionID.short>`).
public let name: String
/// Image reference to run (already resolved to the bundled default if no override).
public let image: String
/// Bind mounts. Repo root + worktree base are mounted at their identical host paths so
/// git worktree links and the cwd-hash resolve; `~/.claude` is mounted read-only at a
/// staging path and seeded into a writable claude-home.
public let mounts: [Mount]
/// Working directory inside the container (the session's worktree path).
public let workdir: String
/// Environment passed into the `claude` process (e.g. ANTHROPIC_API_KEY).
public let env: [String: String]
/// Stop the container after this much inactivity.
public let idleTimeout: TimeInterval
/// Read-only staging path where the host `~/.claude` is mounted inside the container.
public let claudeHomeStaging: String
/// Writable path seeded from the staging copy and used as the container's `~/.claude`.
public let claudeHomeWritable: String
/// Install Nucleic's `git` interceptor shim ahead of real git on PATH (Nucleic Control
/// containers). The shim execs real git and reports each invocation back to the host so
/// merges and other git ops are observed with certainty. The runtime callback URL + token
/// are injected into the exec env by `ClaudeCodeBackend` (gateway/port/token aren't known
/// until the container and approval server are up), so the spec only carries the flag.
public let installGitInterceptor: Bool
/// CPU count for the container VM (`container run --cpus`).
public let cpus: Int
/// Memory for the container VM in GiB (`container run --memory Ng`).
public let memoryGiB: Int
/// UID the agent (`claude` + its subprocesses) runs as inside the container, set on the exec's
/// `process.user`. `nil` → root (uid 0). Set to the *host* uid for a sandboxed session so the
/// agent owns the bind-mounted repo/worktree/claude-home (virtiofs presents the host owner on
/// the shared mounts) while the root-installed instrumentation (the git + command shims, the
/// bash tracer, `/etc/gitconfig`) stays read-only to it — the agent can run it but not tamper
/// with it. The container's own root process (`sleep infinity`) and the admin execs that install
/// the instrumentation still run as root; only the agent drops down.
public let runAsUID: Int?
/// GID paired with `runAsUID` (the host gid). `nil` → root's group.
public let runAsGID: Int?
/// Host path of the per-**container** control socket — the token-multiplexed approval server +
/// interceptor endpoint. When set, ``ContainerEngine`` relays it into the guest over vsock (the
/// framework's `UnixSocketConfiguration(.into)`), where it appears at ``controlSocketGuestPath``,
/// so the agent's MCP bridge and the git/gh/command shims reach the host with **no IP listener**
/// (hence no macOS incoming-connection / local-network prompts). The vsock control plane is
/// MANDATORY for session containers — `SessionController` sets this on every session spec
/// (shared and per-session), and backends refuse a containerized run without it. `nil` only for
/// agent-created throwaway containers (`linux_container`), which run no control plane. MUST be
/// short: AF_UNIX `sun_path` caps at ~104 bytes, so it lives under a short dir, never the long
/// Application-Support session path.
public let controlSocketHostPath: String?
/// Fixed in-guest path the relayed control socket appears at; the in-container control bridge
/// forwards to it. Paired with ``controlSocketHostPath`` on the host side.
public static let controlSocketGuestPath = "/run/nucleic/control.sock"
/// Fixed in-guest loopback port the control bridge listens on. The agent's MCP client and the
/// git/gh/command interceptor shims target `http://127.0.0.1:<this>` (loopback, inside the VM
/// netns — invisible to macOS); the bridge forwards to ``controlSocketGuestPath``. Must match
/// the default in `containers/nucleic-sandbox/control-bridge.js`.
public static let controlBridgePort: UInt16 = 9099
/// Host path of the per-container **Claude token proxy** socket (opt-in, see
/// ``ContainerServiceSettings/claudeTokenProxyEnabled``). When set, ``ContainerEngine`` relays it
/// into the guest at ``proxySocketGuestPath`` (a second `UnixSocketConfiguration(.into)`) and the
/// in-guest bridge forwards ``proxyBridgePort`` to it, so `claude` — pointed at
/// `http://127.0.0.1:<proxyBridgePort>` via `ANTHROPIC_BASE_URL` — reaches the host proxy, which
/// rewrites `Authorization` with a broker-fresh token per request (``ClaudeTokenProxy``). `nil`
/// (default) leaves the session on the static token alone. Same short-path constraint as
/// ``controlSocketHostPath`` (AF_UNIX `sun_path` cap).
public let proxySocketHostPath: String?
/// Fixed in-guest path the relayed proxy socket appears at; the bridge forwards to it. Paired
/// with ``proxySocketHostPath`` on the host side.
public static let proxySocketGuestPath = "/run/nucleic/proxy.sock"
/// Fixed in-guest loopback port the bridge exposes for the token proxy (distinct from
/// ``controlBridgePort``). `ANTHROPIC_BASE_URL` points `claude` at `http://127.0.0.1:<this>`.
/// Must match the `NUCLEIC_PROXY_BRIDGE_PORT` default in `control-bridge.js`.
public static let proxyBridgePort: UInt16 = 9098
/// The uid the narOS agent image bakes for its `agent` user (NAROS.md §6.1) — chosen to equal
/// `getuid()` on a default single-user Mac, the value `SessionController` passes as `runAsUID`,
/// so the baked passwd entry and host-side seeding agree without an appended entry. Keep in
/// lockstep with `os/images/agent/Dockerfile`'s `useradd --uid` (CI smoke asserts the image
/// side; `ContainerSpecTests` asserts this side). A host uid that differs (a second macOS
/// user account) still works — seeding appends a passwd entry for it, as it always has.
public static let narosAgentUID = 501
public init(
name: String,
image: String,
mounts: [Mount],
workdir: String,
env: [String: String],
idleTimeout: TimeInterval,
claudeHomeStaging: String,
claudeHomeWritable: String,
installGitInterceptor: Bool = false,
cpus: Int = ContainerServiceSettings.defaultContainerCPUs,
memoryGiB: Int = ContainerServiceSettings.defaultContainerMemoryGiB,
runAsUID: Int? = nil,
runAsGID: Int? = nil,
controlSocketHostPath: String? = nil,
proxySocketHostPath: String? = nil
) {
self.name = name
self.image = image
self.mounts = mounts
self.workdir = workdir
self.env = env
self.idleTimeout = idleTimeout
self.claudeHomeStaging = claudeHomeStaging
self.claudeHomeWritable = claudeHomeWritable
self.installGitInterceptor = installGitInterceptor
self.cpus = max(1, cpus)
self.memoryGiB = max(1, memoryGiB)
self.runAsUID = runAsUID
self.runAsGID = runAsGID
self.controlSocketHostPath = controlSocketHostPath
self.proxySocketHostPath = proxySocketHostPath
}
/// A copy with additional environment values. Used by the credential broker after the base
/// spec has already established mounts and the per-session home.
public func mergingEnvironment(_ additional: [String: String]) -> ContainerSpec {
ContainerSpec(
name: name, image: image, mounts: mounts, workdir: workdir,
env: env.merging(additional) { _, new in new },
idleTimeout: idleTimeout, claudeHomeStaging: claudeHomeStaging,
claudeHomeWritable: claudeHomeWritable, installGitInterceptor: installGitInterceptor,
cpus: cpus, memoryGiB: memoryGiB, runAsUID: runAsUID, runAsGID: runAsGID,
controlSocketHostPath: controlSocketHostPath,
proxySocketHostPath: proxySocketHostPath)
}
/// A copy of this spec under a different container `name`. `ContainerManager` runs shared control
/// containers under a randomized **physical** name (so an agent can't recognize or target a
/// sibling sandbox) while keeping the stable logical name as its public key — this renames the
/// spec at the engine boundary.
public func renamed(_ newName: String) -> ContainerSpec {
ContainerSpec(
name: newName, image: image, mounts: mounts, workdir: workdir, env: env,
idleTimeout: idleTimeout, claudeHomeStaging: claudeHomeStaging,
claudeHomeWritable: claudeHomeWritable, installGitInterceptor: installGitInterceptor,
cpus: cpus, memoryGiB: memoryGiB, runAsUID: runAsUID, runAsGID: runAsGID,
controlSocketHostPath: controlSocketHostPath,
proxySocketHostPath: proxySocketHostPath)
}
}
// MARK: - Nucleic-managed Orchestra subagents
/// A single Orchestra worker spawn, as requested by the agent's `nucleic_subagent` tool call.
/// `task` is the short label shown in the UI; `prompt` is the full instruction for the worker.
public struct OrchestraSubagentRequest: Sendable, Equatable {
public let task: String
public let prompt: String
public init(task: String, prompt: String) {
self.task = task
self.prompt = prompt
}
}
/// The result of a `nucleic_subagent` spawn, serialized back to the caller via ``wireJSON()``.
///
/// Two spawn shapes share this type:
/// - **Orchestra (supervisor)**: **non-blocking** — the tool returns `.spawned` as soon as the worker
/// is registered and started, so the supervisor gets a `worker_id` to track (and drive via
/// `nucleic_supervise` / `nucleic_reply_to_worker`) rather than the worker's final output; the
/// worker's completion, failure, and questions arrive later as ``OrchestraWorkerEvent``s.
/// - **Managed subagent (non-Orchestra, Control projects)**: **blocking** — the tool runs the worker
/// to completion and returns `.completed` with the worker's final reply inline, a drop-in for the
/// provider-native `Task`/`Agent` subagent (which Nucleic blocks so every subagent is an observed
/// session that shows in the Subagents panel).
///
/// `denied` means the spawn was refused (feature off / app unavailable / worker errored).
public enum OrchestraSubagentResult: Sendable, Equatable {
case denied(message: String)
case spawned(workerID: SessionID, title: String, model: String?)
/// A blocking subagent that ran to completion; `output` is the worker's final reply, returned
/// inline as the tool result so the caller reads it like a `Task` subagent's report.
case completed(workerID: SessionID, title: String, model: String?, output: String)
public func wireJSON() -> String {
switch self {
case .denied(let message):
return JSONValue.object([
"denied": .bool(true),
"message": .string(message),
]).canonicalString()
case .spawned(let workerID, let title, let model):
return JSONValue.object([
"ok": .bool(true),
"worker_id": .string(workerID.rawValue),
"status": .string("running"),
"title": .string(title),
"model": model.map(JSONValue.string) ?? .null,
]).canonicalString()
case .completed(let workerID, let title, let model, let output):
return JSONValue.object([
"ok": .bool(true),
"worker_id": .string(workerID.rawValue),
"status": .string("completed"),
"title": .string(title),
"model": model.map(JSONValue.string) ?? .null,
"output": .string(output),
]).canonicalString()
}
}
}
/// One thing a worker did that the supervisor must act on, surfaced through `nucleic_supervise`.
/// `question` is a live `nucleic_ask_supervisor` call awaiting a `nucleic_reply_to_worker`;
/// `completed`/`failed` are terminal (the worker's turn ended). `workerID` is the worker session id
/// the supervisor received from the spawn ack, so it can match events to dispatched work.
public enum OrchestraWorkerEvent: Sendable, Equatable {
case question(workerID: SessionID, task: String, question: String)
case completed(workerID: SessionID, task: String, model: String?, output: String)
case failed(workerID: SessionID, task: String, message: String)
public func jsonValue() -> JSONValue {
switch self {
case .question(let workerID, let task, let question):
return .object([
"worker_id": .string(workerID.rawValue),
"task": .string(task),
"kind": .string("question"),
"question": .string(question),
])
case .completed(let workerID, let task, let model, let output):
return .object([
"worker_id": .string(workerID.rawValue),
"task": .string(task),
"kind": .string("completed"),
"model": model.map(JSONValue.string) ?? .null,
"output": .string(output),
])
case .failed(let workerID, let task, let message):
return .object([
"worker_id": .string(workerID.rawValue),
"task": .string(task),
"kind": .string("failed"),
"message": .string(message),
])
}
}
}
/// The result of a `nucleic_supervise` drain: the batch of worker events since the last call plus
/// how many workers are still running. The supervisor loops `nucleic_supervise` until
/// `remainingRunning == 0` and it has collected every worker's terminal event.
public struct OrchestraSuperviseResult: Sendable, Equatable {
public let events: [OrchestraWorkerEvent]
public let remainingRunning: Int
public init(events: [OrchestraWorkerEvent], remainingRunning: Int) {
self.events = events
self.remainingRunning = remainingRunning
}
public func wireJSON() -> String {
JSONValue.object([
"events": .array(events.map { $0.jsonValue() }),
"remaining_running": .number(Double(remainingRunning)),
]).canonicalString()
}
}
/// A supervisor's answer to a worker's `nucleic_ask_supervisor`, addressed by `workerID`.
public struct OrchestraReplyRequest: Sendable, Equatable {
public let workerID: SessionID
public let reply: String
public init(workerID: SessionID, reply: String) {
self.workerID = workerID
self.reply = reply
}
}
/// The outcome of a `nucleic_reply_to_worker` call: `ok` when the reply reached a waiting worker,
/// `unknown` when no worker with that id is currently awaiting an answer (already answered, finished,
/// or a bad id).
public enum OrchestraReplyResult: Sendable, Equatable {
case ok
case unknown(message: String)
public func wireJSON() -> String {
switch self {
case .ok:
return JSONValue.object(["ok": .bool(true)]).canonicalString()
case .unknown(let message):
return JSONValue.object([
"ok": .bool(false),
"message": .string(message),
]).canonicalString()
}
}
}
/// The answer handed back to a worker's `nucleic_ask_supervisor`. `answered` carries the supervisor's
/// reply; `unavailable` means the supervisor's run ended before it answered (coordinator teardown), so
/// the worker is told to proceed autonomously rather than hang.
public enum OrchestraAskSupervisorResult: Sendable, Equatable {
case answered(String)
case unavailable(message: String)
public func wireJSON() -> String {
switch self {
case .answered(let answer):
return JSONValue.object([
"ok": .bool(true),
"answer": .string(answer),
]).canonicalString()
case .unavailable(let message):
return JSONValue.object([
"ok": .bool(false),
"supervisor_unavailable": .bool(true),
"answer": .string(message),
]).canonicalString()
}
}
}
/// Spawns a worker for the supervisor and returns immediately with a tracking id (non-blocking).
public typealias OrchestraSubagentSpawner =
@MainActor @Sendable (OrchestraSubagentRequest) async -> OrchestraSubagentResult
/// Supervisor side: blocks until the supervisor's workers report ≥1 event, then drains them.
public typealias OrchestraSuperviseHandler =
@MainActor @Sendable () async -> OrchestraSuperviseResult
/// Supervisor side: delivers a reply to a worker waiting in `nucleic_ask_supervisor`.
public typealias OrchestraReplyToWorkerHandler =
@MainActor @Sendable (OrchestraReplyRequest) async -> OrchestraReplyResult
/// Worker side: routes a worker's question up to its supervisor and blocks until the reply.
/// Injected only for machine-spawned worker sessions; its presence is what marks a run as a worker.
public typealias OrchestraAskSupervisorHandler =
@MainActor @Sendable (String) async -> OrchestraAskSupervisorResult
/// Delivers a fired `nucleic_monitor` event back into the session as a fresh user turn — the
/// cross-turn re-invocation the provider-native `Monitor` tool cannot do under Nucleic's
/// single-shot turn model (each turn is one foreground `claude -p` process that exits at end of
/// turn, with no harness loop to wake the agent later). The monitor's watch is owned by the
/// backend across turns; when its command emits output, the backend calls this sink and the host
/// injects the event via `SessionController.sendInput`, starting (or queuing) a new turn. The
/// injection is silent (`echoToTranscript: false`) and paired with a muted note, so the wake-up
/// stays auditable without impersonating a message the user typed.
/// `(description, event)` — `description` labels the watch; `event` is the coalesced output lines.
public typealias MonitorEventSink =
@MainActor @Sendable (_ description: String, _ event: String) async -> Void
// MARK: - Run / resume / input specs (BACKEND_PROTOCOL §2.2)
public enum ApprovalPolicy: Sendable {
case interactive
/// Non-interactive backends (Codex exec) or unattended runs: a fixed rule set,
/// fail-closed for anything unmatched (BACKEND_PROTOCOL §4.2).
case fixed(rules: [FixedRule])
public struct FixedRule: Sendable {
public enum Effect: Sendable { case allow, deny }
public let toolName: String
public let pattern: String?
public let effect: Effect
public init(toolName: String, pattern: String? = nil, effect: Effect) {
self.toolName = toolName
self.pattern = pattern
self.effect = effect
}
}
}
/// One provider-neutral decision policy applied after a backend routes a native approval to Nucleic.
enum NucleicApprovalPolicy {
static func shouldAutoApprove(
autoApprove: Bool, risk: Risk, requiresUserInput: Bool = false
) -> Bool {
autoApprove && !requiresUserInput && risk != .destructive && risk != .unknown
}
}
public struct RunSpec: Sendable {
public let sessionID: SessionID
public let worktree: WorktreePath
public let prompt: AgentInput
public let model: String?
public let effort: String?
/// Run with the backend's auto-approval mode. Claude and Codex use their native
/// classifiers; backends without one may implement equivalent local policy.
public let autoApprove: Bool
public let approvalPolicy: ApprovalPolicy
public let sandbox: SandboxMode?
/// When set, run `claude` inside this Apple `container` instead of on the host.
public let container: ContainerSpec?
/// Expose the `host_exec` tool so the (sandboxed) agent can request to build/run on the
/// host, each call approval-gated and never auto-approved (HOST_EXEC).
public let allowHostExec: Bool
/// Expose the `mac_vm_exec` tool so the agent can run Mac-only work (xcodebuild, simulators,
/// codesign) inside this session's isolated macOS VM instead of on the shared host. Each call is
/// approval-gated and never auto-approved. See docs/MACOS_VM.md.
public let allowMacVMExec: Bool
/// Expose the `mac_vm_computer` tool so the agent can drive the macOS VM's GUI (screenshots +
/// mouse/keyboard) as a Mac dev simulator. Actions run in the VM sandbox and are NOT individually
/// approval-gated. Needs a computer-use-provisioned base image. See docs/MACOS_VM.md.
public let allowMacVMComputer: Bool
/// Expose the `linux_vm_exec` tool so the agent can run desktop/GUI-adjacent dev work inside this
/// session's isolated Linux VM — the lighter, faster-booting sibling of the macOS VM. Each call is
/// approval-gated and never auto-approved. See docs/LINUX_VM.md.
public let allowLinuxVMExec: Bool
/// Expose the `linux_vm_computer` tool so the agent can drive the Linux VM's GUI (screenshots +
/// mouse/keyboard). Actions run in the VM sandbox and are NOT individually approval-gated. See
/// docs/LINUX_VM.md.
public let allowLinuxVMComputer: Bool
/// Expose the `linux_container` tool so the agent can spin up its own throwaway Linux containers
/// for lightweight work (create/exec/stop/remove) — the lightest rung below a VM. See
/// ``ContainerServiceSettings/agentContainersEnabled``.
public let allowAgentContainers: Bool
/// Orchestra (orchestration mode) is in effect for this run — the user selected it AND the
/// project is under Nucleic Control (`SessionController.orchestraActive`). The Claude backend
/// uses it to expose Nucleic's managed subagent tool; other backends ignore it. The fan-out
/// consent itself rides in separately on `appendSystemPrompt`.
public let orchestraActive: Bool
/// Nucleic-managed subagent spawner for Orchestra. When present, the backend exposes a
/// Nucleic MCP tool whose calls create ordinary observed Nucleic sessions using the configured
/// worker model, rather than letting the provider's opaque background subagent mechanism choose.
public let orchestraSubagentSpawner: OrchestraSubagentSpawner?
/// Nucleic-managed **blocking** subagent spawner, used outside Orchestra on Nucleic Control
/// projects. When present, the backend exposes the same `nucleic_subagent` MCP tool but in its
/// blocking shape (the call runs a worker session to completion and returns its final reply
/// inline) and blocks the provider-native `Task`/`Agent` tools — so every subagent is an observed
/// Nucleic session that shows in the Subagents panel. Mutually exclusive with
/// `orchestraSubagentSpawner` (a run is either an Orchestra supervisor or a blocking-subagent run).
public let blockingSubagentSpawner: OrchestraSubagentSpawner?
/// Supervisor-side Orchestra drive handler: exposes `nucleic_supervise` (drain worker events).
/// Present alongside `orchestraSubagentSpawner` for a supervisor run.
public let orchestraSuperviseHandler: OrchestraSuperviseHandler?
/// Supervisor-side Orchestra reply handler: exposes `nucleic_reply_to_worker`.
public let orchestraReplyToWorkerHandler: OrchestraReplyToWorkerHandler?
/// Worker-side Orchestra handler: exposes `nucleic_ask_supervisor`. Present *only* for a
/// machine-spawned worker run — its presence is what tells the backend this run is a worker.
public let orchestraAskSupervisorHandler: OrchestraAskSupervisorHandler?
/// Mesh messaging tool bridge (mesh casting "messages" channel). When present, the backend
/// exposes `nucleic_send_message` + `nucleic_subscribe_topic` so this session can exchange
/// app-level messages with sessions/devices across the mesh, host-mediated (provenance is
/// stamped host-side). Present only when the user enabled agent messaging in Settings.
public let meshMessaging: MeshMessagingHandlers?
/// Monitor event delivery bridge. When present, the backend exposes the `nucleic_monitor` tool
/// (a working replacement for the provider-native `Monitor`, which is blocked) and calls this
/// sink to re-invoke the session whenever an armed monitor fires. See `MonitorEventSink`.
public let monitorEventSink: MonitorEventSink?
public let mcpConfigPath: URL?
public let appendSystemPrompt: String?
public let extraEnv: [String: String]
public let extraArgs: [String]
public init(
sessionID: SessionID,
worktree: WorktreePath,
prompt: AgentInput,
model: String? = nil,
effort: String? = nil,
autoApprove: Bool = false,
approvalPolicy: ApprovalPolicy = .interactive,
sandbox: SandboxMode? = nil,
container: ContainerSpec? = nil,
allowHostExec: Bool = false,
allowMacVMExec: Bool = false,
allowMacVMComputer: Bool = false,
allowLinuxVMExec: Bool = false,
allowLinuxVMComputer: Bool = false,
allowAgentContainers: Bool = false,
orchestraActive: Bool = false,
orchestraSubagentSpawner: OrchestraSubagentSpawner? = nil,
blockingSubagentSpawner: OrchestraSubagentSpawner? = nil,
orchestraSuperviseHandler: OrchestraSuperviseHandler? = nil,
orchestraReplyToWorkerHandler: OrchestraReplyToWorkerHandler? = nil,
orchestraAskSupervisorHandler: OrchestraAskSupervisorHandler? = nil,
meshMessaging: MeshMessagingHandlers? = nil,
monitorEventSink: MonitorEventSink? = nil,
mcpConfigPath: URL? = nil,
appendSystemPrompt: String? = nil,
extraEnv: [String: String] = [:],
extraArgs: [String] = []
) {
self.sessionID = sessionID
self.worktree = worktree
self.prompt = prompt
self.model = model
self.effort = effort
self.autoApprove = autoApprove
self.approvalPolicy = approvalPolicy
self.sandbox = sandbox
self.container = container
self.allowHostExec = allowHostExec
self.allowMacVMExec = allowMacVMExec
self.allowMacVMComputer = allowMacVMComputer
self.allowLinuxVMExec = allowLinuxVMExec
self.allowLinuxVMComputer = allowLinuxVMComputer
self.allowAgentContainers = allowAgentContainers
self.orchestraActive = orchestraActive
self.orchestraSubagentSpawner = orchestraSubagentSpawner
self.blockingSubagentSpawner = blockingSubagentSpawner
self.orchestraSuperviseHandler = orchestraSuperviseHandler
self.orchestraReplyToWorkerHandler = orchestraReplyToWorkerHandler
self.orchestraAskSupervisorHandler = orchestraAskSupervisorHandler
self.meshMessaging = meshMessaging
self.monitorEventSink = monitorEventSink
self.mcpConfigPath = mcpConfigPath
self.appendSystemPrompt = appendSystemPrompt
self.extraEnv = extraEnv
self.extraArgs = extraArgs
}
}
public struct ResumeSpec: Sendable {
public let sessionID: SessionID
public let backendSessionID: String
public let worktree: WorktreePath
/// The follow-up user message for this resumed turn (nil = re-attach only).
public let prompt: AgentInput?
public let model: String?
public let effort: String?
public let autoApprove: Bool
public let fork: Bool
/// When set, resume `claude` inside this Apple `container` instead of on the host.
public let container: ContainerSpec?
/// Expose the `host_exec` tool on this resumed turn (HOST_EXEC). See `RunSpec`.
public let allowHostExec: Bool
/// Expose the `mac_vm_exec` tool on this resumed turn. Re-applied each turn (the CLI doesn't
/// persist `--allowedTools` across `--resume`). See `RunSpec`.
public let allowMacVMExec: Bool
/// Expose the `mac_vm_computer` tool on this resumed turn (re-applied each turn). See `RunSpec`.
public let allowMacVMComputer: Bool
/// Expose the `linux_vm_exec` tool on this resumed turn (re-applied each turn). See `RunSpec`.
public let allowLinuxVMExec: Bool
/// Expose the `linux_vm_computer` tool on this resumed turn (re-applied each turn). See `RunSpec`.
public let allowLinuxVMComputer: Bool
/// Expose the `linux_container` tool on this resumed turn (re-applied each turn). See `RunSpec`.
public let allowAgentContainers: Bool
/// Orchestra is in effect for this resumed turn — fan-out reaches Nucleic's managed
/// worker-session spawner. Re-applied each turn (the CLI doesn't persist `--allowedTools`
/// across `--resume`), since every follow-up Claude turn is a fresh single-shot `--resume` run.
/// See `RunSpec.orchestraActive`.
public let orchestraActive: Bool
/// Nucleic-managed subagent spawner for this resumed turn. See `RunSpec`.
public let orchestraSubagentSpawner: OrchestraSubagentSpawner?
/// Nucleic-managed **blocking** subagent spawner for this resumed turn, re-applied each turn (the
/// CLI doesn't persist `--allowedTools` across `--resume`). See `RunSpec.blockingSubagentSpawner`.
public let blockingSubagentSpawner: OrchestraSubagentSpawner?
/// Supervisor-side drive/reply handlers for this resumed turn, re-applied each turn. See `RunSpec`.
public let orchestraSuperviseHandler: OrchestraSuperviseHandler?
public let orchestraReplyToWorkerHandler: OrchestraReplyToWorkerHandler?
/// Worker-side ask handler for this resumed turn (present only for worker sessions). See `RunSpec`.
public let orchestraAskSupervisorHandler: OrchestraAskSupervisorHandler?
/// Mesh messaging tool bridge for this resumed turn, re-applied each turn (the CLI doesn't
/// persist `--allowedTools` across `--resume`). See `RunSpec.meshMessaging`.
public let meshMessaging: MeshMessagingHandlers?
/// Monitor event delivery bridge for this resumed turn, re-applied each turn (the CLI doesn't
/// persist `--allowedTools` across `--resume`). See `RunSpec.monitorEventSink`.
public let monitorEventSink: MonitorEventSink?
/// Re-applied every resumed turn (the CLI doesn't persist it across `--resume`), so
/// environment guidance like the sandbox build instructions stays in effect. See `RunSpec`.
public let appendSystemPrompt: String?
public init(
sessionID: SessionID, backendSessionID: String, worktree: WorktreePath,
prompt: AgentInput? = nil, model: String? = nil, effort: String? = nil,
autoApprove: Bool = false, fork: Bool = false, container: ContainerSpec? = nil,
allowHostExec: Bool = false, allowMacVMExec: Bool = false,
allowMacVMComputer: Bool = false, allowLinuxVMExec: Bool = false,
allowLinuxVMComputer: Bool = false, allowAgentContainers: Bool = false,
orchestraActive: Bool = false,
orchestraSubagentSpawner: OrchestraSubagentSpawner? = nil,
blockingSubagentSpawner: OrchestraSubagentSpawner? = nil,
orchestraSuperviseHandler: OrchestraSuperviseHandler? = nil,
orchestraReplyToWorkerHandler: OrchestraReplyToWorkerHandler? = nil,
orchestraAskSupervisorHandler: OrchestraAskSupervisorHandler? = nil,
meshMessaging: MeshMessagingHandlers? = nil,
monitorEventSink: MonitorEventSink? = nil,
appendSystemPrompt: String? = nil
) {
self.sessionID = sessionID
self.backendSessionID = backendSessionID
self.worktree = worktree
self.prompt = prompt
self.model = model
self.effort = effort
self.autoApprove = autoApprove
self.fork = fork
self.container = container
self.allowHostExec = allowHostExec
self.allowMacVMExec = allowMacVMExec
self.allowMacVMComputer = allowMacVMComputer
self.allowLinuxVMExec = allowLinuxVMExec
self.allowLinuxVMComputer = allowLinuxVMComputer
self.allowAgentContainers = allowAgentContainers
self.orchestraActive = orchestraActive
self.orchestraSubagentSpawner = orchestraSubagentSpawner
self.blockingSubagentSpawner = blockingSubagentSpawner
self.orchestraSuperviseHandler = orchestraSuperviseHandler
self.orchestraReplyToWorkerHandler = orchestraReplyToWorkerHandler
self.orchestraAskSupervisorHandler = orchestraAskSupervisorHandler
self.meshMessaging = meshMessaging
self.monitorEventSink = monitorEventSink
self.appendSystemPrompt = appendSystemPrompt
}
}
// AgentInput moved to NucleicProtocol/CoreIdentifiers.swift (now Codable for the wire).
// MARK: - The backend protocol (BACKEND_PROTOCOL §2)
public protocol AgentBackend: Sendable {
static var id: BackendID { get }
var capabilities: BackendCapabilities { get }
/// Begin a fresh run in a prepared worktree. Returns the live event stream.
func start(_ run: RunSpec) -> AsyncThrowingStream<AgentEvent, Error>
/// Reattach to an existing backend session (native resume).
func resume(_ resume: ResumeSpec) -> AsyncThrowingStream<AgentEvent, Error>
/// Queue a follow-up user turn on a running session.
func send(_ input: AgentInput) async throws
/// Answer an outstanding approval. `responder` is the resolving device's `ResponderLabel`,
/// recorded as `ApprovalResolved.decidedBy`. Throws if the backend lacks interactive approvals.
func respond(to approvalID: ApprovalID, _ decision: Decision, by responder: String) async throws
/// Cooperative interrupt of the current turn (keeps session resumable).
func interrupt() async
/// Abandon a single in-flight tool call that appears stuck (the user tapped **Skip** on it),
/// returning a synthetic "skipped" result to the agent so it stops blocking on that call and
/// moves on — without interrupting the rest of the turn. Only the long-running host/VM exec calls
/// support this; every other call (and any backend without the notion) is a no-op.
func skipToolCall(_ call: ToolCall) async
/// Resolve a "host command looks hung" alert the harness raised for an in-flight host command
/// (see `ProcessStallMonitor`). `kill` tears down that command's whole process tree — the running
/// `host_exec` then returns a real terminated result; otherwise the alert is dismissed and the
/// command keeps running. No-op for a `stallID` with no live alert (already resolved / wrong
/// backend).
func resolveProcessStall(stallID: String, kill: Bool) async
/// Terminate the process/connection and release resources.
func shutdown() async
}
extension AgentBackend {
/// Default: nothing to skip. Backends without abandonable in-flight tool calls (Codex, Grok)
/// inherit this no-op.
public func skipToolCall(_ call: ToolCall) async {}
/// Default: no host-command stall alerts to resolve. Backends that don't run host commands
/// inherit this no-op.
public func resolveProcessStall(stallID: String, kill: Bool) async {}
}
public enum BackendError: Error, Sendable {
case unsupported(String)
case notRunning
case spawnFailed(String)
case protocolViolation(String)
}
/// Shared diagnostics for the agent backends (Claude/Codex), so an abnormal CLI exit reads the
/// same way no matter which backend spawned it.
public enum BackendDiagnostics {
/// True for the exit codes that mean "killed by SIGKILL": `137 = 128 + 9`, or a bare `-9`
/// (Foundation reports a process killed by signal N as `-N` when the wrapper itself is signalled).
public static func isSIGKILL(_ exitCode: Int32) -> Bool { exitCode == 137 || exitCode == -9 }
/// Fallback message for an abnormal (non-zero) CLI exit when no container post-mortem is
/// available — host-spawned agents, or a containerized exit we couldn't probe. Deliberately
/// does NOT assert OOM: a SIGKILL has several causes (see `containerKillMessage`), so it only
/// names the signal. Containerized 137s should go through `containerKillMessage` instead, which
/// checks what actually happened.
public static func abnormalExitMessage(
tool: String, exitCode: Int32, stderrTail: String, containerized: Bool
) -> String {
let suffix = stderrSuffix(stderrTail, joiner: ": ", emptyEnd: "")
if isSIGKILL(exitCode) {
let lever = containerized
? " — the sandbox may have run out of memory or been stopped"
: " — possibly out of memory"
return "\(tool) was killed (SIGKILL, status 137)\(lever)\(suffix.isEmpty ? "." : suffix)"
}
return "\(tool) exited with status \(exitCode)\(suffix)"
}
/// Build the message for a containerized agent killed by SIGKILL, from a kill-time post-mortem
/// of the container (`ContainerEngine.diagnoseKill`). Reports what actually happened instead of
/// assuming OOM — because the symptom "all sessions in the container die at once with memory
/// low" is the container going *down*, not a per-process OOM:
/// • not registered → the app's own runtime stopped/restarted/removed it (kills every
/// session at once); not memory.
/// • probe unanswered → still registered but the VM won't answer even after a retry — it
/// crashed (e.g. guest kernel panic) or is wedged; also not a plain OOM.
/// • `oom_kill` > 0 → a real kernel OOM kill (memory can read low now — the kill freed it).
/// • memory near full → most likely OOM even without the counter.
/// • healthy + low → something *else* killed it (a competing agent, a brief spike); not memory.
public static func containerKillMessage(
tool: String, diagnosis: ContainerKillDiagnosis, stderrTail: String
) -> String {
let suffix = stderrSuffix(stderrTail, joiner: " — ", emptyEnd: "")
let head = "\(tool) was killed (SIGKILL, status 137)"
if !diagnosis.containerRunning {
return head + " — the sandbox container is no longer running: the app's container "
+ "runtime stopped, restarted, or removed it (an idle stop, a recovery restart, or "
+ "a teardown), which takes down every session inside it at once. This isn't an "
+ "out-of-memory problem — check the container lifecycle, not the memory limit." + suffix
}
if diagnosis.probeUnanswered {
return head + " — the sandbox container is still registered but its VM did not answer "
+ "a status probe, even after a retry. The VM most likely crashed (e.g. a guest "
+ "kernel panic) or is too wedged to respond — which takes down every session "
+ "inside it at once. This isn't a per-process out-of-memory kill; check the "
+ "container runtime and guest logs." + suffix
}
if let oom = diagnosis.oomKills, oom > 0 {
let n = oom == 1 ? "a process" : "\(oom) processes"
return head + " — the sandbox container's kernel has OOM-killed \(n), so it did hit its "
+ "memory limit" + memNote(diagnosis.sample, prefix: " (memory reads ", suffix: " now — "
+ "the kill already freed it)") + ". Raise the container's memory in Settings → "
+ "Sandbox, or run fewer sessions at once." + suffix
}
if let s = diagnosis.sample, s.memoryPercent >= 85 {
return head + " — the sandbox container's memory is nearly full"
+ memNote(s, prefix: " (", suffix: ")") + ", most likely out of memory. Raise the "
+ "container's memory in Settings → Sandbox, or run fewer sessions at once." + suffix
}
return head + " — but the sandbox container is still running and healthy"
+ memNote(diagnosis.sample, prefix: " (memory only ", suffix: ")") + ", so this is NOT an "
+ "out-of-memory kill. Something inside the sandbox killed the process — e.g. a competing "
+ "agent, or the OOM killer acting on a brief spike that has since cleared." + suffix
}
/// "1.2/8.0 GB, 15%" — a compact memory note, or "" when there's no usable sample.
private static func memNote(
_ sample: ContainerResourceSample?, prefix: String, suffix: String
) -> String {
guard let s = sample, s.memoryTotalBytes > 0 else { return "" }
func gb(_ b: UInt64) -> String { String(format: "%.1f", Double(b) / 1_073_741_824) }
return prefix + "\(gb(s.memoryUsedBytes))/\(gb(s.memoryTotalBytes)) GB, "
+ "\(Int(s.memoryPercent.rounded()))%" + suffix
}
/// Trimmed stderr tail joined onto a message, or "" when there's nothing to add.
private static func stderrSuffix(_ tail: String, joiner: String, emptyEnd: String) -> String {
let trimmed = tail.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? emptyEnd : joiner + trimmed
}
}