825 lines
43 KiB
Swift
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
|
|
}
|
|
}
|