1363 lines
68 KiB
Swift
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
|
|
}
|
|
}
|