Files
nucleic/Sources/NucleicCore/Backend.swift
T

1363 lines
68 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 (default on, with
/// ``ContainerServiceSettings/claudeTokenProxyEnabled`` as a rollback lever). 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``). A `nil`
/// field leaves that container spec without a proxy relay. 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)
}
/// A copy with `extra` merged over `env` (new keys win). The Windows control plane uses
/// this to bake the gateway-TCP endpoint (`NUCLEIC_CONTROL_HOST`/`NUCLEIC_CONTROL_PORT`)
/// into the spec BEFORE `ensureRunning` — the in-guest bridge reads it at container
/// create, where macOS relays a unix socket instead (docs/WINDOWS_PORT.md §5).
public func withEnvironment(merging extra: [String: String]) -> ContainerSpec {
ContainerSpec(
name: name, image: image, mounts: mounts, workdir: workdir,
env: env.merging(extra) { _, new in new },
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
/// One file an Orchestra worker is allowed to modify, and the part of it the supervisor's plan
/// assigns to that worker (LOCKING §11).
///
/// Orchestra locks are held by the **supervisor**, not the worker, so sibling workers never contend
/// with one another — which is precisely what makes it possible for several of them to edit one file
/// at the same time. Nothing else keeps them off each other's lines, so the plan has to: `path` is
/// the file (worktree-relative), and `region` is the supervisor's description of the worker's slice
/// of it ("the `arbitrate` function", "the CLI-flags section", "imports only").
///
/// `path` is machine-enforced at the edit gate — a worker with a declared scope is denied any write
/// outside it. `region` is prose, so it is *communicated* (it rides into the worker's opening prompt
/// and back out in the denial text) rather than checked: no static rule can tell "the `arbitrate`
/// function" from its neighbor, and inventing one would either be wrong or reject legal edits.
public struct OrchestraFileScope: Sendable, Equatable {
/// Worktree-relative path the worker may modify.
public let path: String
/// What in that file this worker owns, in the supervisor's words. `nil` = the whole file.
public let region: String?
public init(path: String, region: String? = nil) {
self.path = path
self.region = region
}
/// One line of the scope block handed to the worker, e.g. `- Sources/App/Store.swift — the
/// `arbitrate` function only`.
public var briefingLine: String {
region.map { "- \(path)\($0)" } ?? "- \(path) — the whole file"
}
}
/// 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.
/// `batch` names the group this worker belongs to, so the supervisor can wait on — and act on — one
/// group's results while the rest keep running (`nucleic_supervise`'s `batch` argument). `nil` for a
/// worker dispatched outside any batch. `files` is the worker's write scope (see
/// ``OrchestraFileScope``); empty means the supervisor named no scope, and the worker is trusted
/// with the whole tree exactly as it was before scopes existed.
public struct OrchestraSubagentRequest: Sendable, Equatable {
public let task: String
public let prompt: String
public let batch: String?
public let files: [OrchestraFileScope]
/// An existing worker chat to continue instead of creating a new one. Orchestra supervisors use
/// this after a host crash or supervisor-chat restart: the worker keeps its native conversation
/// context and transcript, but is registered in the supervisor's new live coordinator before its
/// next turn starts. `nil` is the ordinary fresh-spawn path.
public let rebindWorkerID: SessionID?
public init(
task: String, prompt: String, batch: String? = nil, files: [OrchestraFileScope] = [],
rebindWorkerID: SessionID? = nil
) {
self.task = task
self.prompt = prompt
self.batch = batch
self.files = files
self.rebindWorkerID = rebindWorkerID
}
}
// MARK: - Orchestra delegation plans
/// One delegated task in an Orchestra plan: the same (label, instructions) pair a single
/// `nucleic_subagent` call carries, submitted as part of a batch rather than one call at a time.
public struct OrchestraPlanTask: Sendable, Equatable {
public let task: String
public let prompt: String
/// The files (and the region of each) this task's worker may modify — see ``OrchestraFileScope``.
/// Empty when the supervisor named none.
public let files: [OrchestraFileScope]
public init(task: String, prompt: String, files: [OrchestraFileScope] = []) {
self.task = task
self.prompt = prompt
self.files = files
}
}
/// A group of delegated tasks the supervisor can act on as a unit. The point of a batch is *early
/// consumption*: the supervisor waits for this group with `nucleic_supervise(batch:)` and can plan
/// its next move from these results alone, while tasks in other batches are still running. Typically
/// an explore/research batch whose findings shape the implementation batch that follows.
public struct OrchestraPlanBatch: Sendable, Equatable {
public let name: String
/// Free-form note on what this batch is for ("explore", "implement", "verify") — carried into the
/// plan's transcript note for the user, never interpreted.
public let purpose: String?
public let tasks: [OrchestraPlanTask]
public init(name: String, purpose: String? = nil, tasks: [OrchestraPlanTask]) {
self.name = name
self.purpose = purpose
self.tasks = tasks
}
}
/// How one worker session sits inside an independent delegation-plan batch. Stored with the
/// session so the Subagents panel can reconstruct a plan's parallel groups after relaunch,
/// rather than relying on the supervisor's transient tool result.
public struct OrchestraBatchPlacement: Sendable, Codable, Equatable {
/// Unique per submitted batch; display names are intentionally allowed to repeat across plans.
public let id: String
public let name: String
/// The supervisor's stated reason for this batch, surfaced beside its name in the panel.
public let purpose: String?
public init(id: String, name: String, purpose: String? = nil) {
self.id = id
self.name = name
self.purpose = purpose
}
}
/// An ordered chain of delegated tasks. Unlike a batch (whose tasks are independent and all run at
/// once), a sequence runs one worker at a time and hands each worker's terminal report to the next
/// worker before that next turn starts. Ordering is the dependency graph, so chains can be any
/// length without ids, cycle detection, or provider-specific graph syntax.
public struct OrchestraPlanSequence: Sendable, Equatable {
public let name: String
/// Free-form note on what the chain accomplishes, surfaced alongside its name in the UI.
public let purpose: String?
public let tasks: [OrchestraPlanTask]
public init(name: String, purpose: String? = nil, tasks: [OrchestraPlanTask]) {
self.name = name
self.purpose = purpose
self.tasks = tasks
}
}
/// The supervisor's delegation plan, submitted in one `nucleic_delegate_plan` call: the prose plan
/// plus every task it decomposes into, grouped into parallel batches or ordered sequences. This tool
/// call *is* the structured output the orchestrator produces, so it works the same on every provider
/// without depending on provider-specific structured-output support.
public struct OrchestraPlanRequest: Sendable, Equatable {
/// The plan itself, in the orchestrator's words — surfaced to the user as a transcript note.
public let plan: String
public let batches: [OrchestraPlanBatch]
/// Ordered dependency chains. Different sequences (and ordinary batches) still fan out in
/// parallel; only adjacent tasks within one sequence wait for and consume one another.
public let sequences: [OrchestraPlanSequence]
public init(
plan: String, batches: [OrchestraPlanBatch] = [],
sequences: [OrchestraPlanSequence] = []
) {
self.plan = plan
self.batches = batches
self.sequences = sequences
}
/// Every task in the plan, paired with its group name — the complete spawn list. The historical
/// tuple label stays `batch` for source compatibility, but sequence tasks are included too.
public var allTasks: [(batch: String, task: OrchestraPlanTask)] {
batches.flatMap { batch in batch.tasks.map { (batch: batch.name, task: $0) } }
+ sequences.flatMap { sequence in
sequence.tasks.map { (batch: sequence.name, task: $0) }
}
}
}
/// How one worker session sits inside a plan sequence. Stored with the session so the Subagents
/// panel can reconstruct the chain after relaunch, rather than relying on a transient tool result.
public struct OrchestraSequencePlacement: Sendable, Codable, Equatable {
/// Unique per submitted sequence; display names are intentionally allowed to repeat.
public let id: String
public let name: String
public let purpose: String?
/// One-based position, for direct display and stable sorting.
public let step: Int
public let count: Int
/// The immediately preceding worker. Nil only for the first step.
public let dependsOnWorkerID: SessionID?
public init(
id: String, name: String, purpose: String? = nil, step: Int, count: Int,
dependsOnWorkerID: SessionID? = nil
) {
self.id = id
self.name = name
self.purpose = purpose
self.step = step
self.count = count
self.dependsOnWorkerID = dependsOnWorkerID
}
}
public enum OrchestraPlanGroupMode: String, Sendable, Equatable {
case parallel
case sequence
}
/// One worker Nucleic actually spawned for a plan.
public struct OrchestraDispatchedWorker: Sendable, Equatable {
public let workerID: SessionID
public let task: String
/// The preceding worker whose report will be injected into this worker's prompt.
public let dependsOnWorkerID: SessionID?
public init(workerID: SessionID, task: String, dependsOnWorkerID: SessionID? = nil) {
self.workerID = workerID
self.task = task
self.dependsOnWorkerID = dependsOnWorkerID
}
}
/// A task in the plan that could not be spawned (empty prompt, session creation failed, …). Reported
/// per batch so the supervisor learns which pieces of its plan are missing rather than silently
/// waiting for workers that never existed.
public struct OrchestraRejectedTask: Sendable, Equatable {
public let task: String
public let message: String
public init(task: String, message: String) {
self.task = task
self.message = message
}
}
/// What Nucleic did with one batch of a submitted plan.
public struct OrchestraDispatchedBatch: Sendable, Equatable {
public let name: String
/// `.parallel` for a legacy batch; `.sequence` for an ordered dependency chain. The enclosing
/// result keeps the historical `batches` wire key for compatibility and marks each group.
public let mode: OrchestraPlanGroupMode
public let workers: [OrchestraDispatchedWorker]
public let rejected: [OrchestraRejectedTask]
public init(
name: String, mode: OrchestraPlanGroupMode = .parallel,
workers: [OrchestraDispatchedWorker], rejected: [OrchestraRejectedTask] = []
) {
self.name = name
self.mode = mode
self.workers = workers
self.rejected = rejected
}
public func jsonValue() -> JSONValue {
var object: [String: JSONValue] = [
"batch": .string(name),
"mode": .string(mode.rawValue),
"spawned": .number(Double(workers.count)),
"workers": .array(
workers.map {
var worker: [String: JSONValue] = [
"worker_id": .string($0.workerID.rawValue),
"task": .string($0.task),
]
if let dependency = $0.dependsOnWorkerID {
worker["depends_on_worker_id"] = .string(dependency.rawValue)
}
return .object(worker)
}),
]
if !rejected.isEmpty {
object["rejected"] = .array(
rejected.map {
.object(["task": .string($0.task), "message": .string($0.message)])
})
}
return .object(object)
}
}
/// The result of submitting a delegation plan. `dispatched` reports what was spawned per batch (all
/// of it at once — the worker cap queues the excess rather than withholding a spawn), so the
/// supervisor can immediately start waiting on its first batch.
public enum OrchestraPlanResult: Sendable, Equatable {
case denied(message: String)
case dispatched(batches: [OrchestraDispatchedBatch])
public func wireJSON() -> String {
switch self {
case .denied(let message):
return JSONValue.object([
"denied": .bool(true),
"message": .string(message),
]).canonicalString()
case .dispatched(let batches):
let spawned = batches.reduce(0) { $0 + $1.workers.count }
return JSONValue.object([
"ok": .bool(true),
"total_spawned": .number(Double(spawned)),
"batches": .array(batches.map { $0.jsonValue() }),
]).canonicalString()
}
}
}
/// 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, batch: String?, question: String)
case completed(workerID: SessionID, task: String, batch: String?, model: String?, output: String)
case failed(workerID: SessionID, task: String, batch: String?, message: String)
/// The batch the reporting worker was dispatched in, or `nil` for an unbatched worker.
public var batch: String? {
switch self {
case .question(_, _, let batch, _), .completed(_, _, let batch, _, _),
.failed(_, _, let batch, _):
return batch
}
}
/// Whether this event ends the worker's run (as opposed to a question, which pauses it).
public var isTerminal: Bool {
switch self {
case .question: return false
case .completed, .failed: return true
}
}
public func jsonValue() -> JSONValue {
// `batch` is emitted only when the worker was dispatched in one, so an unbatched fan-out's
// events keep exactly the shape they had before batches existed.
func base(_ workerID: SessionID, _ task: String, _ batch: String?, _ kind: String)
-> [String: JSONValue]
{
var object: [String: JSONValue] = [
"worker_id": .string(workerID.rawValue),
"task": .string(task),
"kind": .string(kind),
]
if let batch { object["batch"] = .string(batch) }
return object
}
switch self {
case .question(let workerID, let task, let batch, let question):
var object = base(workerID, task, batch, "question")
object["question"] = .string(question)
return .object(object)
case .completed(let workerID, let task, let batch, let model, let output):
var object = base(workerID, task, batch, "completed")
object["model"] = model.map(JSONValue.string) ?? .null
object["output"] = .string(output)
return .object(object)
case .failed(let workerID, let task, let batch, let message):
var object = base(workerID, task, batch, "failed")
object["message"] = .string(message)
return .object(object)
}
}
}
/// How one batch of dispatched workers is doing, reported on every `nucleic_supervise` drain so the
/// supervisor can see which groups are finished, which are still working, and therefore what it can
/// act on now. Counts are cumulative for the batch's whole life (they survive the drain that prunes
/// terminal workers), so `completed + failed` is every worker of the batch that has reported.
public struct OrchestraBatchProgress: Sendable, Equatable {
public let name: String
public let running: Int
public let completed: Int
public let failed: Int
public init(name: String, running: Int, completed: Int, failed: Int) {
self.name = name
self.running = running
self.completed = completed
self.failed = failed
}
/// No workers of this batch are still running — every one has reported completed or failed.
public var isComplete: Bool { running == 0 }
public func jsonValue() -> JSONValue {
.object([
"batch": .string(name),
"running": .number(Double(running)),
"completed": .number(Double(completed)),
"failed": .number(Double(failed)),
"complete": .bool(isComplete),
])
}
}
/// The result of a `nucleic_supervise` drain: the worker events since the last call, how many workers
/// are still running, and per-batch progress. The supervisor loops `nucleic_supervise` until
/// `remainingRunning == 0` and it has collected every worker's terminal event.
///
/// When the call named a batch (`awaitedBatch`), the wait ends as soon as that batch is complete — or
/// sooner, if a worker asks a question that must be answered for anything to progress. Either way the
/// drain returns *every* pending event, not just the awaited batch's: withholding a sibling batch's
/// result would strand it (events are delivered exactly once), and the supervisor is never worse off
/// for learning something early. `awaitedBatchComplete` is what it should branch on.
public struct OrchestraSuperviseResult: Sendable, Equatable {
public let events: [OrchestraWorkerEvent]
public let remainingRunning: Int
public let batches: [OrchestraBatchProgress]
/// The batch this call waited on, echoed back; `nil` when the call waited on any event at all.
public let awaitedBatch: String?
/// Whether that batch has fully reported. `nil` when no batch was awaited.
public let awaitedBatchComplete: Bool?
public init(
events: [OrchestraWorkerEvent],
remainingRunning: Int,
batches: [OrchestraBatchProgress] = [],
awaitedBatch: String? = nil,
awaitedBatchComplete: Bool? = nil
) {
self.events = events
self.remainingRunning = remainingRunning
self.batches = batches
self.awaitedBatch = awaitedBatch
self.awaitedBatchComplete = awaitedBatchComplete
}
public func wireJSON() -> String {
var object: [String: JSONValue] = [
"events": .array(events.map { $0.jsonValue() }),
"remaining_running": .number(Double(remainingRunning)),
]
// Omitted entirely for an unbatched fan-out, so its result shape is unchanged.
if !batches.isEmpty {
object["batches"] = .array(batches.map { $0.jsonValue() })
}
if let awaitedBatch {
object["awaited_batch"] = .string(awaitedBatch)
object["awaited_batch_complete"] = .bool(awaitedBatchComplete ?? false)
}
return JSONValue.object(object).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 outcome of a supervisor's `archive_subsession` call. Orchestra workers remain visible and
/// resumable after their turn reaches Done; this explicit acknowledgement is one of the two ways
/// they leave the active set (the other is archiving their parent chat).
public enum OrchestraArchiveSubsessionResult: Sendable, Equatable {
case archived(workerID: SessionID)
case alreadyArchived(workerID: SessionID)
case denied(message: String)
public func wireJSON() -> String {
switch self {
case .archived(let workerID):
return JSONValue.object([
"ok": .bool(true),
"worker_id": .string(workerID.rawValue),
"archived": .bool(true),
]).canonicalString()
case .alreadyArchived(let workerID):
return JSONValue.object([
"ok": .bool(true),
"worker_id": .string(workerID.rawValue),
"archived": .bool(true),
"already_archived": .bool(true),
]).canonicalString()
case .denied(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()
}
}
}
/// The outcome of a `nucleic_ask_user` call — Nucleic's provider-neutral counterpart to Claude Code's
/// built-in `AskUserQuestion`. `answered` maps each question's exact text to the label(s) the user
/// picked (one for a single-select, several for a `multiSelect`, or their free-text "Other" answer).
/// `declined` means the user dismissed the card without choosing; `unavailable` means no interactive
/// surface could carry the question (a malformed payload, or a run with no user attached).
public enum AskUserResult: Sendable, Equatable {
case answered(answers: [(question: String, labels: [String])])
case declined(message: String)
case unavailable(message: String)
public static func == (lhs: AskUserResult, rhs: AskUserResult) -> Bool {
switch (lhs, rhs) {
case (.answered(let l), .answered(let r)):
return l.count == r.count
&& zip(l, r).allSatisfy { $0.question == $1.question && $0.labels == $1.labels }
case (.declined(let l), .declined(let r)): return l == r
case (.unavailable(let l), .unavailable(let r)): return l == r
default: return false
}
}
public func wireJSON() -> String {
switch self {
case .answered(let answers):
// Answers are echoed keyed by question text — the same shape `AskUserQuestion` folds into
// `updatedInput` — plus a flat `answered` list so a model that struggles with dynamic keys
// can read them positionally.
var byQuestion: [String: JSONValue] = [:]
for answer in answers {
byQuestion[answer.question] = answer.labels.count == 1
? .string(answer.labels[0])
: .array(answer.labels.map(JSONValue.string))
}
return JSONValue.object([
"ok": .bool(true),
"answers": .object(byQuestion),
"answered": .array(
answers.map {
.object([
"question": .string($0.question),
"labels": .array($0.labels.map(JSONValue.string)),
])
}),
]).canonicalString()
case .declined(let message):
return JSONValue.object([
"ok": .bool(false),
"declined": .bool(true),
"message": .string(message),
]).canonicalString()
case .unavailable(let message):
return JSONValue.object([
"ok": .bool(false),
"user_unavailable": .bool(true),
"message": .string(message),
]).canonicalString()
}
}
}
/// The outcome returned to an agent blocked in Nucleic's provider-neutral plan-review tool.
/// `revise` is intentionally distinct from `denied`: revision tells the agent to update and
/// resubmit the plan, while denial withholds authorization and ends this review cycle.
public enum PlanReviewResult: Sendable, Equatable {
case approved
case revise(feedback: String)
case denied(message: String)
public func wireJSON() -> String {
switch self {
case .approved:
return JSONValue.object([
"ok": .bool(true),
"approved": .bool(true),
"decision": .string("approved"),
"message": .string("The user approved the plan. You may begin implementation."),
]).canonicalString()
case .revise(let feedback):
return JSONValue.object([
"ok": .bool(true),
"approved": .bool(false),
"decision": .string("revise"),
"feedback": .string(feedback),
"message": .string(
"Revise the plan using the user's feedback and submit it for approval again. Do not implement yet."),
]).canonicalString()
case .denied(let message):
return JSONValue.object([
"ok": .bool(true),
"approved": .bool(false),
"decision": .string("denied"),
"message": .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
/// Submits a whole delegation plan: Nucleic spawns every task in every batch and reports the
/// worker ids per batch. Non-blocking, like the single spawner.
public typealias OrchestraPlanHandler =
@MainActor @Sendable (OrchestraPlanRequest) async -> OrchestraPlanResult
/// Supervisor side: blocks until the supervisor's workers report ≥1 event — or, when a batch name is
/// passed, until that batch has fully reported — then drains every pending event.
public typealias OrchestraSuperviseHandler =
@MainActor @Sendable (String?) async -> OrchestraSuperviseResult
/// Supervisor side: delivers a reply to a worker waiting in `nucleic_ask_supervisor`.
public typealias OrchestraReplyToWorkerHandler =
@MainActor @Sendable (OrchestraReplyRequest) async -> OrchestraReplyResult
/// Supervisor side: archives one of its settled worker subsessions after incorporating the result.
public typealias OrchestraArchiveSubsessionHandler =
@MainActor @Sendable (SessionID) async -> OrchestraArchiveSubsessionResult
/// 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 {
/// Tools whose purpose is to collect a deliberate user decision rather than merely request
/// permission for an action. They must bypass both remembered allow rules and Auto mode.
static func requiresExplicitUserDecision(toolName: String) -> Bool {
toolName == AskUserQuestion.toolName || PlanReview.isPlanTool(toolName)
}
static func shouldAutoApprove(
autoApprove: Bool, risk: Risk, requiresExplicitUserDecision: Bool = false
) -> Bool {
autoApprove && !requiresExplicitUserDecision && 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 plan handler: exposes `nucleic_delegate_plan`, which takes the
/// orchestrator's whole batched task list in one call and spawns a worker per task. Present
/// alongside `orchestraSubagentSpawner` for a supervisor run.
public let orchestraPlanHandler: OrchestraPlanHandler?
/// 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?
/// Supervisor-side explicit worker retirement: exposes `archive_subsession`.
public let orchestraArchiveSubsessionHandler: OrchestraArchiveSubsessionHandler?
/// 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,
orchestraPlanHandler: OrchestraPlanHandler? = nil,
orchestraSuperviseHandler: OrchestraSuperviseHandler? = nil,
orchestraReplyToWorkerHandler: OrchestraReplyToWorkerHandler? = nil,
orchestraArchiveSubsessionHandler: OrchestraArchiveSubsessionHandler? = 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.orchestraPlanHandler = orchestraPlanHandler
self.orchestraSuperviseHandler = orchestraSuperviseHandler
self.orchestraReplyToWorkerHandler = orchestraReplyToWorkerHandler
self.orchestraArchiveSubsessionHandler = orchestraArchiveSubsessionHandler
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 plan handler for this resumed turn, re-applied each turn. See `RunSpec`.
public let orchestraPlanHandler: OrchestraPlanHandler?
/// Supervisor-side drive/reply handlers for this resumed turn, re-applied each turn. See `RunSpec`.
public let orchestraSuperviseHandler: OrchestraSuperviseHandler?
public let orchestraReplyToWorkerHandler: OrchestraReplyToWorkerHandler?
public let orchestraArchiveSubsessionHandler: OrchestraArchiveSubsessionHandler?
/// 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,
orchestraPlanHandler: OrchestraPlanHandler? = nil,
orchestraSuperviseHandler: OrchestraSuperviseHandler? = nil,
orchestraReplyToWorkerHandler: OrchestraReplyToWorkerHandler? = nil,
orchestraArchiveSubsessionHandler: OrchestraArchiveSubsessionHandler? = 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.orchestraPlanHandler = orchestraPlanHandler
self.orchestraSuperviseHandler = orchestraSuperviseHandler
self.orchestraReplyToWorkerHandler = orchestraReplyToWorkerHandler
self.orchestraArchiveSubsessionHandler = orchestraArchiveSubsessionHandler
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
}
}