import Foundation

// Identifiers (SessionID/ApprovalID/BackendID/WorktreePath) and AgentInput now live in
// NucleicProtocol/CoreIdentifiers.swift so the sync wire layer shares them with iOS.
// NucleicCore re-exports NucleicProtocol (ProtocolExports.swift), so they stay in scope here.

// MARK: - Capabilities (BACKEND_PROTOCOL §2.1)

public struct BackendCapabilities: Sendable {
    public var interactiveApprovals: Bool
    public var allowAlwaysScopes: Set<AlwaysScope>
    public var canModifyToolInput: Bool
    public var partialMessageStreaming: Bool
    public var emitsThinking: Bool
    public var emitsFileChangeEvents: Bool
    public var nativeResume: Bool
    public var sandboxModes: [SandboxMode]
    public var followUpWhileRunning: Bool

    public init(
        interactiveApprovals: Bool,
        allowAlwaysScopes: Set<AlwaysScope>,
        canModifyToolInput: Bool,
        partialMessageStreaming: Bool,
        emitsThinking: Bool,
        emitsFileChangeEvents: Bool,
        nativeResume: Bool,
        sandboxModes: [SandboxMode],
        followUpWhileRunning: Bool
    ) {
        self.interactiveApprovals = interactiveApprovals
        self.allowAlwaysScopes = allowAlwaysScopes
        self.canModifyToolInput = canModifyToolInput
        self.partialMessageStreaming = partialMessageStreaming
        self.emitsThinking = emitsThinking
        self.emitsFileChangeEvents = emitsFileChangeEvents
        self.nativeResume = nativeResume
        self.sandboxModes = sandboxModes
        self.followUpWhileRunning = followUpWhileRunning
    }
}

public enum SandboxMode: String, Sendable, Codable {
    case readOnly = "read-only"
    case workspaceWrite = "workspace-write"
    case fullAccess = "danger-full-access"
}

/// Request to run a session's `claude` process inside an isolated Linux VM (built on Apple's
/// `containerization` framework) instead of on the host. Built by `SessionController` from
/// `Project.sandbox` and carried on `RunSpec`/`ResumeSpec`; consumed by `ClaudeCodeBackend` (which
/// `exec`s the invocation inside the running container VM via `ContainerManager`/`ContainerEngine`)
/// and `ContainerManager` (lifecycle). `nil` → host-spawned.
public struct ContainerSpec: Sendable, Equatable {
    /// One bind mount: a host path made visible inside the container.
    public struct Mount: Sendable, Equatable {
        public let host: String
        public let container: String
        public let readOnly: Bool
        public init(host: String, container: String, readOnly: Bool) {
            self.host = host
            self.container = container
            self.readOnly = readOnly
        }
    }

    /// Stable container name for this session (e.g. `nucleic-<sessionID.short>`).
    public let name: String
    /// Image reference to run (already resolved to the bundled default if no override).
    public let image: String
    /// Bind mounts. Repo root + worktree base are mounted at their identical host paths so
    /// git worktree links and the cwd-hash resolve; `~/.claude` is mounted read-only at a
    /// staging path and seeded into a writable claude-home.
    public let mounts: [Mount]
    /// Working directory inside the container (the session's worktree path).
    public let workdir: String
    /// Environment passed into the `claude` process (e.g. ANTHROPIC_API_KEY).
    public let env: [String: String]
    /// Stop the container after this much inactivity.
    public let idleTimeout: TimeInterval
    /// Read-only staging path where the host `~/.claude` is mounted inside the container.
    public let claudeHomeStaging: String
    /// Writable path seeded from the staging copy and used as the container's `~/.claude`.
    public let claudeHomeWritable: String
    /// Install Nucleic's `git` interceptor shim ahead of real git on PATH (Nucleic Control
    /// containers). The shim execs real git and reports each invocation back to the host so
    /// merges and other git ops are observed with certainty. The runtime callback URL + token
    /// are injected into the exec env by `ClaudeCodeBackend` (gateway/port/token aren't known
    /// until the container and approval server are up), so the spec only carries the flag.
    public let installGitInterceptor: Bool
    /// CPU count for the container VM (`container run --cpus`).
    public let cpus: Int
    /// Memory for the container VM in GiB (`container run --memory Ng`).
    public let memoryGiB: Int
    /// UID the agent (`claude` + its subprocesses) runs as inside the container, set on the exec's
    /// `process.user`. `nil` → root (uid 0). Set to the *host* uid for a sandboxed session so the
    /// agent owns the bind-mounted repo/worktree/claude-home (virtiofs presents the host owner on
    /// the shared mounts) while the root-installed instrumentation (the git + command shims, the
    /// bash tracer, `/etc/gitconfig`) stays read-only to it — the agent can run it but not tamper
    /// with it. The container's own root process (`sleep infinity`) and the admin execs that install
    /// the instrumentation still run as root; only the agent drops down.
    public let runAsUID: Int?
    /// GID paired with `runAsUID` (the host gid). `nil` → root's group.
    public let runAsGID: Int?
    /// Host path of the per-**container** control socket — the token-multiplexed approval server +
    /// interceptor endpoint. When set, ``ContainerEngine`` relays it into the guest over vsock (the
    /// framework's `UnixSocketConfiguration(.into)`), where it appears at ``controlSocketGuestPath``,
    /// so the agent's MCP bridge and the git/gh/command shims reach the host with **no IP listener**
    /// (hence no macOS incoming-connection / local-network prompts). The vsock control plane is
    /// MANDATORY for session containers — `SessionController` sets this on every session spec
    /// (shared and per-session), and backends refuse a containerized run without it. `nil` only for
    /// agent-created throwaway containers (`linux_container`), which run no control plane. MUST be
    /// short: AF_UNIX `sun_path` caps at ~104 bytes, so it lives under a short dir, never the long
    /// Application-Support session path.
    public let controlSocketHostPath: String?

    /// Fixed in-guest path the relayed control socket appears at; the in-container control bridge
    /// forwards to it. Paired with ``controlSocketHostPath`` on the host side.
    public static let controlSocketGuestPath = "/run/nucleic/control.sock"

    /// Fixed in-guest loopback port the control bridge listens on. The agent's MCP client and the
    /// git/gh/command interceptor shims target `http://127.0.0.1:<this>` (loopback, inside the VM
    /// netns — invisible to macOS); the bridge forwards to ``controlSocketGuestPath``. Must match
    /// the default in `containers/nucleic-sandbox/control-bridge.js`.
    public static let controlBridgePort: UInt16 = 9099

    /// Host path of the per-container **Claude token proxy** socket (opt-in, see
    /// ``ContainerServiceSettings/claudeTokenProxyEnabled``). When set, ``ContainerEngine`` relays it
    /// into the guest at ``proxySocketGuestPath`` (a second `UnixSocketConfiguration(.into)`) and the
    /// in-guest bridge forwards ``proxyBridgePort`` to it, so `claude` — pointed at
    /// `http://127.0.0.1:<proxyBridgePort>` via `ANTHROPIC_BASE_URL` — reaches the host proxy, which
    /// rewrites `Authorization` with a broker-fresh token per request (``ClaudeTokenProxy``). `nil`
    /// (default) leaves the session on the static token alone. Same short-path constraint as
    /// ``controlSocketHostPath`` (AF_UNIX `sun_path` cap).
    public let proxySocketHostPath: String?

    /// Fixed in-guest path the relayed proxy socket appears at; the bridge forwards to it. Paired
    /// with ``proxySocketHostPath`` on the host side.
    public static let proxySocketGuestPath = "/run/nucleic/proxy.sock"

    /// Fixed in-guest loopback port the bridge exposes for the token proxy (distinct from
    /// ``controlBridgePort``). `ANTHROPIC_BASE_URL` points `claude` at `http://127.0.0.1:<this>`.
    /// Must match the `NUCLEIC_PROXY_BRIDGE_PORT` default in `control-bridge.js`.
    public static let proxyBridgePort: UInt16 = 9098

    /// The uid the narOS agent image bakes for its `agent` user (NAROS.md §6.1) — chosen to equal
    /// `getuid()` on a default single-user Mac, the value `SessionController` passes as `runAsUID`,
    /// so the baked passwd entry and host-side seeding agree without an appended entry. Keep in
    /// lockstep with `os/images/agent/Dockerfile`'s `useradd --uid` (CI smoke asserts the image
    /// side; `ContainerSpecTests` asserts this side). A host uid that differs (a second macOS
    /// user account) still works — seeding appends a passwd entry for it, as it always has.
    public static let narosAgentUID = 501

    public init(
        name: String,
        image: String,
        mounts: [Mount],
        workdir: String,
        env: [String: String],
        idleTimeout: TimeInterval,
        claudeHomeStaging: String,
        claudeHomeWritable: String,
        installGitInterceptor: Bool = false,
        cpus: Int = ContainerServiceSettings.defaultContainerCPUs,
        memoryGiB: Int = ContainerServiceSettings.defaultContainerMemoryGiB,
        runAsUID: Int? = nil,
        runAsGID: Int? = nil,
        controlSocketHostPath: String? = nil,
        proxySocketHostPath: String? = nil
    ) {
        self.name = name
        self.image = image
        self.mounts = mounts
        self.workdir = workdir
        self.env = env
        self.idleTimeout = idleTimeout
        self.claudeHomeStaging = claudeHomeStaging
        self.claudeHomeWritable = claudeHomeWritable
        self.installGitInterceptor = installGitInterceptor
        self.cpus = max(1, cpus)
        self.memoryGiB = max(1, memoryGiB)
        self.runAsUID = runAsUID
        self.runAsGID = runAsGID
        self.controlSocketHostPath = controlSocketHostPath
        self.proxySocketHostPath = proxySocketHostPath
    }

    /// A copy with additional environment values. Used by the credential broker after the base
    /// spec has already established mounts and the per-session home.
    public func mergingEnvironment(_ additional: [String: String]) -> ContainerSpec {
        ContainerSpec(
            name: name, image: image, mounts: mounts, workdir: workdir,
            env: env.merging(additional) { _, new in new },
            idleTimeout: idleTimeout, claudeHomeStaging: claudeHomeStaging,
            claudeHomeWritable: claudeHomeWritable, installGitInterceptor: installGitInterceptor,
            cpus: cpus, memoryGiB: memoryGiB, runAsUID: runAsUID, runAsGID: runAsGID,
            controlSocketHostPath: controlSocketHostPath,
            proxySocketHostPath: proxySocketHostPath)
    }

    /// A copy of this spec under a different container `name`. `ContainerManager` runs shared control
    /// containers under a randomized **physical** name (so an agent can't recognize or target a
    /// sibling sandbox) while keeping the stable logical name as its public key — this renames the
    /// spec at the engine boundary.
    public func renamed(_ newName: String) -> ContainerSpec {
        ContainerSpec(
            name: newName, image: image, mounts: mounts, workdir: workdir, env: env,
            idleTimeout: idleTimeout, claudeHomeStaging: claudeHomeStaging,
            claudeHomeWritable: claudeHomeWritable, installGitInterceptor: installGitInterceptor,
            cpus: cpus, memoryGiB: memoryGiB, runAsUID: runAsUID, runAsGID: runAsGID,
            controlSocketHostPath: controlSocketHostPath,
            proxySocketHostPath: proxySocketHostPath)
    }

    /// 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

/// 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.
public struct OrchestraSubagentRequest: Sendable, Equatable {
    public let task: String
    public let prompt: String
    public let batch: String?

    public init(task: String, prompt: String, batch: String? = nil) {
        self.task = task
        self.prompt = prompt
        self.batch = batch
    }
}

// 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

    public init(task: String, prompt: String) {
        self.task = task
        self.prompt = prompt
    }
}

/// 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
    }
}

/// The supervisor's delegation plan, submitted in one `nucleic_delegate_plan` call: the prose plan
/// plus every task it decomposes into, grouped into batches. This tool call *is* the structured
/// output the orchestrator produces — the MCP input schema is the contract, 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]

    public init(plan: String, batches: [OrchestraPlanBatch]) {
        self.plan = plan
        self.batches = batches
    }

    /// Every task across every batch, paired with its batch name — the spawn list.
    public var allTasks: [(batch: String, task: OrchestraPlanTask)] {
        batches.flatMap { batch in batch.tasks.map { (batch: batch.name, task: $0) } }
    }
}

/// One worker Nucleic actually spawned for a plan.
public struct OrchestraDispatchedWorker: Sendable, Equatable {
    public let workerID: SessionID
    public let task: String

    public init(workerID: SessionID, task: String) {
        self.workerID = workerID
        self.task = task
    }
}

/// 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
    public let workers: [OrchestraDispatchedWorker]
    public let rejected: [OrchestraRejectedTask]

    public init(
        name: String, workers: [OrchestraDispatchedWorker], rejected: [OrchestraRejectedTask] = []
    ) {
        self.name = name
        self.workers = workers
        self.rejected = rejected
    }

    public func jsonValue() -> JSONValue {
        var object: [String: JSONValue] = [
            "batch": .string(name),
            "spawned": .number(Double(workers.count)),
            "workers": .array(
                workers.map {
                    .object([
                        "worker_id": .string($0.workerID.rawValue),
                        "task": .string($0.task),
                    ])
                }),
        ]
        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 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()
        }
    }
}

/// 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

/// 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 || toolName == ExitPlanMode.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?
    /// 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,
        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.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?
    /// 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,
        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.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
    }
}
