import Foundation

/// AgentBackend over the Claude Code CLI: spawns
/// `claude -p --output-format stream-json --input-format stream-json --verbose`,
/// normalizes stdout into `AgentEvent`s, and bridges interactive approvals
/// through an in-process MCP server + `--permission-prompt-tool`
/// (ADAPTERS §1; BACKEND_PROTOCOL §7.1).
public actor ClaudeCodeBackend: AgentBackend {
    public static let id = BackendID.claudeCode

    public nonisolated let capabilities = BackendCapabilities(
        interactiveApprovals: true,
        allowAlwaysScopes: [.session, .toolName, .toolNameWithPattern],
        canModifyToolInput: true,
        partialMessageStreaming: true,
        emitsThinking: true,
        emitsFileChangeEvents: false,  // synthesized from Edit/Write tool calls
        nativeResume: true,
        sandboxModes: [],
        followUpWhileRunning: false)

    public struct Configuration: Sendable {
        /// Executable name (resolved via PATH) or absolute path. Tests point this
        /// at the fake-claude stub (OBSERVABILITY B.4).
        public var executable: String
        public var includePartialMessages: Bool
        /// Pre-allowed via --allowedTools so cheap read-only tools never
        /// round-trip through the approval server (ADAPTERS §1.1).
        public var allowedTools: [String]
        /// Single-shot runs close stdin right after the prompt so the CLI exits
        /// at end of turn; interactive sessions keep it open for follow-ups.
        public var closeStdinAfterPrompt: Bool
        /// Pass `--strict-mcp-config` so the child uses ONLY our `--mcp-config`
        /// (the approval server) and ignores the host's global MCP servers/plugins.
        /// The M0 live capture showed the spawned CLI otherwise inherits the user's
        /// Cloudflare/Stripe/etc. plugin tools — undesirable for a hermetic Nucleic
        /// session (M0 finding 7). Default on.
        public var strictMCPConfig: Bool
        /// Optional curated settings (inline JSON or a file path) passed via
        /// `--settings`, to further isolate the child from user-level config.
        public var settings: String?
        /// Directory for the optional raw native-stream capture
        /// (OBSERVABILITY A.5); nil = off.
        public var captureDirectory: URL?
        public var now: @Sendable () -> Date

        public init(
            executable: String = "claude",
            includePartialMessages: Bool = false,
            allowedTools: [String] = ["Read", "Glob", "Grep"],
            closeStdinAfterPrompt: Bool = true,
            strictMCPConfig: Bool = true,
            settings: String? = nil,
            captureDirectory: URL? = nil,
            now: @escaping @Sendable () -> Date = { Date() }
        ) {
            self.executable = executable
            self.includePartialMessages = includePartialMessages
            self.allowedTools = allowedTools
            self.closeStdinAfterPrompt = closeStdinAfterPrompt
            self.strictMCPConfig = strictMCPConfig
            self.settings = settings
            self.captureDirectory = captureDirectory
            self.now = now
        }
    }

    /// MCP timeouts for the spawned CLI. Two knobs with deliberately different values:
    ///
    /// * `MCP_TOOL_TIMEOUT` — per *tool call*. Our gated calls (`approve`, `AskUserQuestion`,
    ///   `check_conflict`, `host_exec`) SUSPEND until a human responds, which may be many minutes
    ///   if they step away. The CLI's default would abandon the suspended call and let the model
    ///   proceed *without* an answer (observed as questions/approvals "timing out"), so this stays
    ///   pinned to Node's max delay (~24.8 days, the largest `setTimeout` accepts before it wraps).
    ///   The unbounded per-call wait does NOT strand server-side work when the run dies first:
    ///   `teardownRun()`'s token unregister (and any connection close) cancels the approval
    ///   server's in-flight handler tasks — most importantly parked/running `nucleic_subagent`
    ///   spawns, which would otherwise hold Orchestra worker slots for a dead supervisor.
    ///
    /// * `MCP_TIMEOUT` — the initial server *connection/handshake*, which involves no human. Pinning
    ///   it as high as the tool timeout meant an unreachable approval server would wedge startup for
    ///   ~24.8 days instead of failing fast, so it's bounded to a generous-but-finite 60s. The
    ///   approval server is in-process and already listening before the child execs, so a real
    ///   connection completes in milliseconds; 60s only ever bounds a genuine failure.
    ///
    /// An explicit `RunSpec.extraEnv` still wins.
    static let mcpTimeoutEnv: [String: String] = [
        "MCP_TIMEOUT": "60000",
        "MCP_TOOL_TIMEOUT": "2147483647",
    ]

    /// `CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS` — at the end of a `-p` turn whose stdin is closed, the
    /// CLI blocks waiting for any still-running background subagents/tasks so their results fold into
    /// the turn's final output (which is exactly how a fan-out's work gets *received and acted upon*),
    /// but only up to this ceiling. The CLI default is 600000 ms = 10 minutes (`claude` 2.1.185); once
    /// it elapses the run prints "Background tasks still running … terminating", KILLS those tasks, and
    /// exits — their work is abandoned, never folded in, so the parent never sees it.
    ///
    /// Nucleic closes stdin on every turn (``Configuration/closeStdinAfterPrompt``), which is the
    /// `inputClosed` state that arms that wind-down, and its Orchestra fan-out parents routinely spawn
    /// subagents that run far longer than 10 minutes. So we pin this to `0` (wait indefinitely) — the
    /// value the CLI's own message recommends. A runaway subagent is instead bounded by the user's Stop
    /// (SIGINT→SIGKILL) and the container idle timeout, mirroring how `MCP_TOOL_TIMEOUT` above is pinned
    /// to Node's max for the human-in-the-loop calls that suspend just as long. An explicit
    /// ``RunSpec/extraEnv`` still wins (merged after, at both spawn sites).
    static let printBackgroundWaitEnv: [String: String] = [
        "CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS": "0",
    ]

    /// `CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT` — separate from the wall-clock `MCP_TOOL_TIMEOUT` above, the
    /// CLI (v2.1.187+) also aborts an MCP tool call that produces NO response and NO progress
    /// notification for an *idle* window: 5 minutes for HTTP/SSE/WebSocket servers, 30 minutes for
    /// stdio servers. Our approval server is a streamable-HTTP MCP server, so its window is 5 minutes.
    ///
    /// The gated calls it serves (`approve`, `AskUserQuestion`, …) are exactly the case this kills:
    /// they suspend awaiting a human and emit no progress in the meantime, so a human who takes longer
    /// than 5 minutes to answer would see the call aborted even though `MCP_TOOL_TIMEOUT` is pinned to
    /// Node's max. We pin the idle timeout to `0` (disable the check) for the same reason we pin that
    /// wall-clock timeout high — a human-in-the-loop call legitimately suspends indefinitely, bounded
    /// instead by the user's Stop and the container idle timeout. An explicit ``RunSpec/extraEnv`` still
    /// wins (merged after, at both spawn sites).
    static let mcpToolIdleTimeoutEnv: [String: String] = [
        "CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT": "0",
    ]

    /// The full environment pinned onto every spawned `claude` (host spawn or container exec): the MCP
    /// timeouts (connection, tool wall-clock, and tool idle) plus the background-wait ceiling. Both
    /// spawn sites start from this and then merge ``RunSpec/extraEnv`` (and, host-side, the Managed Git
    /// env) on top, so a per-run override wins.
    static var pinnedChildEnv: [String: String] {
        mcpTimeoutEnv
            .merging(printBackgroundWaitEnv) { _, new in new }
            .merging(mcpToolIdleTimeoutEnv) { _, new in new }
    }

    /// How long a *containerized* run may produce NO stdout before it's declared a stalled spawn.
    /// The first event (`system/init`) is emitted within seconds of launch, before any model call,
    /// so a containerized run silent this long has hit a stdio-transport failure: the agent is
    /// running (it reached the approval server) but its stdout/stdin isn't getting through the
    /// guest⇄host vsock relay. Generous enough never to false-fire on a slow cold container start;
    /// host runs are exempt (a host pipe either works at once or the spawn throws). See
    /// ``reportStalledSpawnIfSilent(stderrTail:)``.
    static let firstOutputTimeoutNanos: UInt64 = 60 * 1_000_000_000

    private let configuration: Configuration
    private let processHost: ProcessHost
    /// The per-backend approval server — used for host runs and per-session sandbox containers.
    /// Shared-control-container runs use the registry's container-scoped server instead (resolved
    /// per run into ``runServer``).
    private let approvalServer: MCPApprovalServer
    /// Vends the shared, token-multiplexed server for a Nucleic Control container, so every session
    /// in that shared box reaches us at the container's single relayed control socket. `nil` (tests,
    /// host-only setups) → always use ``approvalServer``.
    private let approvalServerRegistry: ApprovalServerRegistry?
    /// The approval server the current run uses (the shared one for a control container, else
    /// ``approvalServer``). Set at run start; teardown/shutdown unregister this run's token from it.
    private var runServer: MCPApprovalServer?
    /// The server to act on for this run's token lifecycle (the resolved run server, or the
    /// per-backend default before/after a run).
    private var activeServer: MCPApprovalServer { runServer ?? approvalServer }
    public let approvals: ApprovalCoordinator
    /// Optional sandbox orchestrator. `nil` → always spawn on the host (default). When a
    /// `RunSpec.container` is present and this is set, the run executes inside the container.
    private let containerManager: ContainerManager?
    /// Optional macOS-VM orchestrator. When set and `RunSpec.allowMacVMExec` is on, the `mac_vm_exec`
    /// tool is exposed so the agent runs Mac-only work in this session's own isolated macOS VM (see
    /// docs/MACOS_VM.md). `nil` (tests / no service) → the tool is never advertised.
    private let macVMManager: MacVMManager?
    /// Optional conflict coordinator. When set, the `check_conflict` MCP tool is exposed and
    /// the agent is instructed to consult it before editing (internal project management).
    private let conflictCoordinator: ConflictCoordinator?

    private var handle: (any ProcessHandle)?
    /// Name of the container the current run executes in (for the `finished` callback).
    private var activeContainerName: String?
    private var continuation: AsyncThrowingStream<AgentEvent, Error>.Continuation?
    private var sessionID: SessionID?
    private var serverToken: String?
    private var provisionalSeq: UInt64 = 0
    private var interruptRequested = false
    private var terminating = false
    private var sawRunFinished = false
    /// True once the agent's first stdout line reaches the decode loop. The containerized spawn
    /// watchdog disarms on this; while it's false past the deadline the run is a stalled spawn.
    private var sawFirstOutput = false
    /// Set when the spawn watchdog fired (no first output in time), so the post-loop exit handling
    /// settles the run as a stalled-spawn error and doesn't also report the watchdog's own SIGKILL
    /// as an abnormal/OOM exit.
    private var spawnStalled = false
    /// Set once this run's stderr showed the `claude -p` background-wait cutoff (see
    /// ``printBackgroundWaitEnv``), so the visible note is emitted at most once per run.
    private var reportedBackgroundWaitCutoff = false
    /// Whether the current run is in auto-approve mode (set per run). Edit tools are routed
    /// through us even in auto mode for a conflict pre-check; on no-conflict they auto-allow.
    private var autoApprove = false
    /// During Orchestra, native Claude Task/Agent spawns are blocked so worker creation goes through
    /// Nucleic's managed `nucleic_subagent` tool, where model selection and lifecycle are enforced.
    private var blockNativeSubagentSpawns = false
    /// Set once the user picks "Allow for this session" on a `host_exec` prompt, so further host
    /// commands this session run without re-prompting (HOST_EXEC). Auto-approve never sets this —
    /// only an explicit user choice does. Persists across turns (actor lives for the session).
    private var hostExecSessionGranted = false
    /// As ``hostExecSessionGranted``, for `mac_vm_exec` — set when the user picks "Allow for this
    /// session" on a macOS-VM prompt, so further in-VM commands run without re-prompting.
    private var macVMExecSessionGranted = false
    /// Whether this session opted into computer use (`run.allowMacVMComputer`). Drives whether the
    /// session's VM boots **computer-use-capable** (main queue + a host-side surface for SIP-free
    /// capture/input) — set for BOTH the exec and computer specs so the VM boots consistently
    /// regardless of which tool touches it first.
    private var macVMComputerAllowed = false
    /// Name of the macOS VM the current session booted for `mac_vm_exec` (for the `finished` callback).
    private var activeMacVMName: String?
    /// As ``macVMExecSessionGranted``, for `linux_vm_exec`.
    private var linuxVMExecSessionGranted = false
    /// Whether this session opted into Linux-VM computer use (`run.allowLinuxVMComputer`) — set for
    /// both the Linux exec and computer specs so the Linux VM boots computer-use-capable consistently.
    private var linuxVMComputerAllowed = false
    /// Name of the Linux VM the current session booted (for the `finished` callback).
    private var activeLinuxVMName: String?
    /// Set when the user picks "Allow for this session" on a `linux_container` prompt, so further
    /// container ops run without re-prompting.
    private var linuxContainerSessionGranted = false
    /// The throwaway containers this session created via `linux_container`, keyed by logical name,
    /// so `list` can enumerate them and `exec` can re-ensure one that idle-stopped between calls.
    private var agentContainerSpecs: [String: ContainerSpec] = [:]
    public private(set) var backendSessionID: String?

    public init(
        configuration: Configuration = Configuration(),
        processHost: ProcessHost = ProcessHost(),
        approvalServer: MCPApprovalServer = MCPApprovalServer(),
        approvals: ApprovalCoordinator = ApprovalCoordinator(),
        containerManager: ContainerManager? = nil,
        macVMManager: MacVMManager? = nil,
        conflictCoordinator: ConflictCoordinator? = nil,
        approvalServerRegistry: ApprovalServerRegistry? = nil
    ) {
        self.configuration = configuration
        self.processHost = processHost
        self.approvalServer = approvalServer
        self.approvals = approvals
        self.containerManager = containerManager
        self.macVMManager = macVMManager
        self.conflictCoordinator = conflictCoordinator
        self.approvalServerRegistry = approvalServerRegistry
    }

    // MARK: - AgentBackend

    public nonisolated func start(_ run: RunSpec) -> AsyncThrowingStream<AgentEvent, Error> {
        makeStream(run: run, resumeArgs: [])
    }

    public nonisolated func resume(_ resume: ResumeSpec) -> AsyncThrowingStream<AgentEvent, Error> {
        var args = ["--resume", resume.backendSessionID]
        if resume.fork { args.append("--fork-session") }
        let run = RunSpec(
            sessionID: resume.sessionID,
            worktree: resume.worktree,
            prompt: resume.prompt ?? AgentInput(parts: []),
            model: resume.model,
            effort: resume.effort,
            autoApprove: resume.autoApprove,
            container: resume.container,
            allowHostExec: resume.allowHostExec,
            allowMacVMExec: resume.allowMacVMExec,
            allowMacVMComputer: resume.allowMacVMComputer,
            allowLinuxVMExec: resume.allowLinuxVMExec,
            allowLinuxVMComputer: resume.allowLinuxVMComputer,
            allowAgentContainers: resume.allowAgentContainers,
            orchestraActive: resume.orchestraActive,
            orchestraSubagentSpawner: resume.orchestraSubagentSpawner,
            orchestraSuperviseHandler: resume.orchestraSuperviseHandler,
            orchestraReplyToWorkerHandler: resume.orchestraReplyToWorkerHandler,
            orchestraAskSupervisorHandler: resume.orchestraAskSupervisorHandler,
            appendSystemPrompt: resume.appendSystemPrompt)
        return makeStream(run: run, resumeArgs: args)
    }

    public func send(_ input: AgentInput) async throws {
        guard let handle else { throw BackendError.notRunning }
        try handle.writeLine(Self.userMessageLine(for: input))
    }

    public func respond(to approvalID: ApprovalID, _ decision: Decision, by responder: String) async throws {
        let outcome = await approvals.resolve(approvalID, decision, by: responder)
        if outcome == .unknownID {
            throw BackendError.protocolViolation("No outstanding approval \(approvalID)")
        }
    }

    public func interrupt() async {
        interruptRequested = true
        // SIGINT lets `claude` end the turn cleanly (it emits an interrupted result); the escalation
        // SIGKILLs the process group if it ignores the interrupt, so Stop always takes effect even
        // on a wedged agent. The session stays resumable — a follow-up turn re-spawns the process.
        handle?.interruptThenKill()
    }

    public func shutdown() async {
        terminating = true
        await approvals.cancelOutstanding(reason: "Session terminated")
        // Release this backend's OWN approval server — its loopback TCP listener (host runs) or
        // its per-session control socket (per-session containers), which would otherwise linger
        // on disk after the session ends. Never the registry's shared server (`runServer` on a
        // control-container run): other sessions in that container are still using it, and a
        // future run of this backend re-`start()`s idempotently either way.
        await approvalServer.stop()
        guard let handle else { return }
        handle.closeStdin()
        await handle.terminate()
        self.handle = nil
        if let serverToken {
            await activeServer.unregister(token: serverToken)
        }
    }

    // MARK: - Run loop

    private nonisolated func makeStream(run: RunSpec, resumeArgs: [String])
        -> AsyncThrowingStream<AgentEvent, Error>
    {
        AsyncThrowingStream { continuation in
            let task = Task {
                await self.runLoop(run: run, resumeArgs: resumeArgs, continuation: continuation)
            }
            continuation.onTermination = { _ in task.cancel() }
        }
    }

    private func runLoop(
        run: RunSpec,
        resumeArgs: [String],
        continuation: AsyncThrowingStream<AgentEvent, Error>.Continuation
    ) async {
        self.continuation = continuation
        self.sessionID = run.sessionID
        self.sawRunFinished = false
        self.interruptRequested = false
        self.sawFirstOutput = false
        self.spawnStalled = false
        self.reportedBackgroundWaitCutoff = false
        self.autoApprove = run.autoApprove
        self.blockNativeSubagentSpawns = run.orchestraActive && run.orchestraSubagentSpawner != nil

        do {
            // 0. Resolve the approval server for this run. Sessions that share a Nucleic Control
            // container share ONE token-multiplexed server (so they can share that container's
            // single relayed control socket); host runs and per-session sandboxes keep the
            // per-backend server. The bearer token disambiguates sessions on the shared server.
            let server: MCPApprovalServer
            if let cspec = run.container,
                ContainerManager.allSharedControlContainerNames.contains(cspec.name),
                let registry = approvalServerRegistry
            {
                server = await registry.server(for: cspec.name)
            } else {
                server = approvalServer
            }
            runServer = server

            // 0b. Sandbox: if this run is containerized, bring the container up first (its init
            // also launches the in-guest control bridge the child reaches us through).
            let sandbox = (run.container != nil) ? containerManager : nil
            if let sandbox, let cspec = run.container {
                let (name, _) = try await sandbox.ensureRunning(cspec)
                activeContainerName = name
            }

            // 1. Approval bridge: per-session bearer token → handler.
            let token = UUID().uuidString
            serverToken = token
            // Transport: a containerized child ALWAYS rides the vsock control plane — we serve
            // ONLY on the relayed unix socket, no IP listener at all, so macOS raises no
            // incoming-connection / local-network prompts. The agent + interceptor shims reach us
            // via the in-guest loopback bridge (which forwards to the relayed socket), so the
            // control endpoint is `127.0.0.1:<bridge port>` inside the VM. A host (non-container)
            // run keeps the loopback TCP listener; the gateway-TCP path for containers is retired.
            let port: UInt16
            let controlHost: String
            if let controlSock = run.container?.controlSocketHostPath {
                try await server.start(unixSocketPath: controlSock)
                controlHost = "127.0.0.1"
                port = ContainerSpec.controlBridgePort
                // Mirror the TCP `port != 0` guard below: assert we're actually listening on the
                // control socket. A desynced/unbound socket would otherwise surface only as the CLI's
                // opaque "Available MCP tools: none" and — because this server is shared by the whole
                // container — poison every session in the box. Fail the run loudly and retryably instead.
                guard await server.isUnixSocketListening(atPath: controlSock) else {
                    throw BackendError.spawnFailed(
                        "approval control socket \(controlSock) is not listening "
                            + "(control endpoint unreachable)")
                }
            } else if run.container != nil {
                // Every containerized spec carries a control socket (SessionController sets it
                // unconditionally). A spec without one would silently fall back to a TCP endpoint
                // the guest can't reach — enforce the invariant loudly instead.
                throw BackendError.spawnFailed(
                    "containerized run has no control socket — the vsock control plane is mandatory")
            } else {
                port = try await server.start(host: "127.0.0.1")
                controlHost = "127.0.0.1"
                // A bound listener always reports a non-zero ephemeral port; a 0 here means the
                // approval server didn't actually bind, so the agent could never reach us and would
                // silently stall on its first gated tool. Fail the run loudly instead.
                guard port != 0 else {
                    throw BackendError.spawnFailed(
                        "approval server did not bind a port (control endpoint unreachable)")
                }
            }
            await server.register(token: token) { [weak self] call in
                guard let self else { return .deny(message: "Session terminated") }
                return await self.handleApprovalCall(call)
            }
            // Conflict-check bridge: the agent's `check_conflict` call suspends here until
            // detection (and, if needed, the user) resolves it (internal project management).
            if let conflictCoordinator {
                let sid = run.sessionID
                await server.registerConflict(token: token) { call in
                    // `check_conflict` is the agent's pre-edit probe — the lock for these files isn't
                    // held yet, so update-on-grant hasn't run and `refreshNotice` is moot here; pass
                    // the resolution through (the re-ground notice rides the actual-edit gate below).
                    let outcome = await conflictCoordinator.arbitrate(
                        sessionID: sid, task: call.task, files: call.files)
                    return MCPApprovalServer.ConflictReply(resolution: outcome.resolution)
                }
            }
            // Host-exec bridge (HOST_EXEC): when this run opts in, expose the `host_exec`
            // tool. Its handler is the sole gate — every call surfaces an approval (never
            // auto-approved) and, once granted, runs on the host outside the container.
            if run.allowHostExec {
                let workdir = run.container?.workdir ?? run.worktree
                await server.registerHostExec(token: token) { [weak self] call in
                    guard let self else { return .denied(message: "Session terminated") }
                    return await self.handleHostExecCall(call, workdir: workdir)
                }
            }
            // macOS-VM bridge: when this run opts in AND a VM manager is wired, expose `mac_vm_exec`.
            // Its handler is the sole gate (every call surfaces an approval, never auto-approved) and,
            // once granted, boots/reuses this session's isolated macOS VM and runs the command inside
            // it over SSH — the Mac-native, per-agent alternative to the shared-host `host_exec`.
            if run.allowMacVMExec, macVMManager != nil {
                let hostWorktree = run.worktree
                await server.registerMacVMExec(token: token) { [weak self] call in
                    guard let self else { return .denied(message: "Session terminated") }
                    return await self.handleMacVMExecCall(call, hostWorktree: hostWorktree)
                }
            }
            // macOS-VM computer-use bridge: when opted in, expose `mac_vm_computer` so the agent can
            // see and drive the VM's GUI. Actions run in the VM sandbox and are NOT per-action gated.
            macVMComputerAllowed = run.allowMacVMComputer
            if run.allowMacVMComputer, macVMManager != nil {
                let hostWorktree = run.worktree
                await server.registerMacVMComputer(token: token) { [weak self] call in
                    guard let self else { return .denied(message: "Session terminated") }
                    return await self.handleMacVMComputerCall(call, hostWorktree: hostWorktree)
                }
                // macOS-VM operator-assist bridge: rides on computer-use (the interactive host-side
                // surface only exists for a computer-use VM). Its handler is the sole gate — every
                // `mac_vm_request_operator` call surfaces an approval (never auto-approved) and, once
                // the user accepts, hands them an interactive viewer to operate the VM by hand.
                await server.registerMacVMOperator(token: token) { [weak self] call in
                    guard let self else { return .denied(message: "Session terminated") }
                    return await self.handleMacVMOperatorCall(call, hostWorktree: hostWorktree)
                }
                // macOS-VM notification-clearing bridge: rides on computer-use (only meaningful when the
                // agent is looking at the VM's screen). Not gated — a fixed `killall NotificationCenter`
                // against the agent's own disposable VM.
                await server.registerMacVMClearNotifications(token: token) { [weak self] _ in
                    guard let self else { return .denied(message: "Session terminated") }
                    return await self.handleMacVMClearNotificationsCall(hostWorktree: hostWorktree)
                }
            }
            // macOS-VM lifecycle bridge: expose `mac_vm_control` whenever this session can use the
            // macOS VM at all (exec or computer-use), so the agent can stop/suspend/resume/restart/kill
            // the VM it booted. Not gated — it only manages the agent's own disposable VM.
            if (run.allowMacVMExec || run.allowMacVMComputer), macVMManager != nil {
                await server.registerMacVMControl(token: token) { [weak self] call in
                    guard let self else { return .denied(message: "Session terminated") }
                    return await self.handleMacVMControlCall(call)
                }
            }
            // Linux-VM bridge: exposes `linux_vm_exec` — this session's own isolated Linux VM (the
            // lighter, faster-booting sibling of the macOS VM), for Linux dev work a container can't do.
            if run.allowLinuxVMExec, macVMManager != nil {
                let hostWorktree = run.worktree
                await server.registerLinuxVMExec(token: token) { [weak self] call in
                    guard let self else { return .denied(message: "Session terminated") }
                    return await self.handleLinuxVMExecCall(call, hostWorktree: hostWorktree)
                }
            }
            // Linux-VM computer-use bridge: exposes `linux_vm_computer` (GUI over the Linux VM).
            linuxVMComputerAllowed = run.allowLinuxVMComputer
            if run.allowLinuxVMComputer, macVMManager != nil {
                let hostWorktree = run.worktree
                await server.registerLinuxVMComputer(token: token) { [weak self] call in
                    guard let self else { return .denied(message: "Session terminated") }
                    return await self.handleLinuxVMComputerCall(call, hostWorktree: hostWorktree)
                }
            }
            // Linux-VM lifecycle bridge: the Linux sibling of `mac_vm_control` above.
            if (run.allowLinuxVMExec || run.allowLinuxVMComputer), macVMManager != nil {
                await server.registerLinuxVMControl(token: token) { [weak self] call in
                    guard let self else { return .denied(message: "Session terminated") }
                    return await self.handleLinuxVMControlCall(call)
                }
            }
            // Agent-container bridge: exposes `linux_container` so the agent can spin up its own
            // throwaway Linux containers (the lightest rung). Needs a container manager to run them.
            if run.allowAgentContainers, containerManager != nil {
                let sessionImage = run.container?.image ?? ProjectSandbox.defaultImage
                let hostWorktree = run.worktree
                await server.registerLinuxContainer(token: token) { [weak self] call in
                    guard let self else { return .denied(message: "Session terminated") }
                    return await self.handleLinuxContainerCall(
                        call, defaultImage: sessionImage, hostWorktree: hostWorktree)
                }
            }
            // Orchestra worker bridge: the fan-out tool creates Nucleic sessions instead of opaque
            // provider-native background tasks, so Nucleic enforces the configured worker model and
            // observes the worker lifecycle/transcript like any other chat.
            if run.orchestraActive, let spawner = run.orchestraSubagentSpawner {
                await server.registerOrchestraSubagent(token: token) { call in
                    let request = OrchestraSubagentRequest(task: call.task, prompt: call.prompt)
                    return await spawner(request)
                }
            }
            // Supervisor drive loop: `nucleic_supervise` (block for worker events) and
            // `nucleic_reply_to_worker` (answer a worker's question). Present for an active supervisor
            // alongside the spawner.
            if run.orchestraActive, let supervise = run.orchestraSuperviseHandler {
                await server.registerOrchestraSupervise(token: token) { await supervise() }
            }
            if run.orchestraActive, let replyToWorker = run.orchestraReplyToWorkerHandler {
                await server.registerOrchestraReplyToWorker(token: token) { call in
                    let request = OrchestraReplyRequest(
                        workerID: SessionID(rawValue: call.workerID), reply: call.reply)
                    return await replyToWorker(request)
                }
            }
            // Worker-side bridge: `nucleic_ask_supervisor`. Present ONLY for a machine-spawned worker
            // (the app injects the handler iff this run is a worker), so it's the run's worker marker.
            if let askSupervisor = run.orchestraAskSupervisorHandler {
                await server.registerOrchestraAskSupervisor(token: token) { call in
                    await askSupervisor(call.question)
                }
            }
            // Git-interceptor bridge (Nucleic Control containers): the in-container `git` shim
            // reports each mutating git op to `POST /git-event`. Fire-and-forget — record it and
            // return immediately so a report never blocks the agent's git call.
            if run.container?.installGitInterceptor == true {
                await server.registerGitReport(token: token) { [weak self] call in
                    Task { await self?.handleGitReport(call) }
                }
                // The sibling `gh` shim reports agentic GitHub actions (PR/release/API) to
                // `POST /gh-event`. Same fire-and-forget contract as the git report.
                await server.registerGhReport(token: token) { [weak self] call in
                    Task { await self?.handleGhReport(call) }
                }
                // The non-git command interceptor (shims + bash tracer) reports to `/command-event`
                // under the same gate/token — every command the agent's shell ran, for the GUI feed
                // and logs. Also fire-and-forget so a report never blocks the agent.
                await server.registerCommandReport(token: token) { [weak self] call in
                    Task { await self?.handleCommandReport(call) }
                }
            }
            let mcpConfig = server.mcpConfigJSON(host: controlHost, port: port, token: token)

            // 2. Spawn.
            var args = [
                "-p",
                "--output-format", "stream-json",
                "--input-format", "stream-json",
                "--verbose",
            ]
            if configuration.includePartialMessages {
                args.append("--include-partial-messages")
            }
            args += ["--permission-prompt-tool", MCPApprovalServer.qualifiedToolName]
            // Pin the mode explicitly: the host machine's defaultMode would otherwise
            // leak in (M0 live-capture finding, 2026-06-12). With conflict coordination we
            // force `default` so EVERY tool call flows through our permission server (the
            // only way to conflict-check edits — Claude's `auto` mode auto-accepts edits
            // before we ever see them, confirmed empirically); we then reproduce auto-approve
            // ourselves in `handleApprovalCall`. Without coordination, defer to Claude's own
            // `auto` classifier as before.
            if case .interactive = run.approvalPolicy {
                let mode = (conflictCoordinator == nil && run.autoApprove) ? "auto" : "default"
                args += ["--permission-mode", mode]
            }
            args += ["--mcp-config", mcpConfig]
            // Hermetic child: only our approval server, none of the host's global
            // MCP servers/plugins (M0 finding 7).
            // Only an explicit MCP opt-in lifts that isolation. Skills and plugins are discovered
            // from the selectively copied config home and do not broaden the global MCP surface.
            let allowsExternalMCP = ExternalAgentIntegrationSettings.allows(.mcp, model: run.model)
            if configuration.strictMCPConfig && !allowsExternalMCP {
                args.append("--strict-mcp-config")
            }
            if let settings = configuration.settings {
                args += ["--settings", settings]
            }
            // Pre-allow `check_conflict` so an agent that consults it directly never
            // round-trips through the approval prompt (it's a query, not a gated action).
            var allowedTools = configuration.allowedTools
            if conflictCoordinator != nil {
                allowedTools.append(MCPApprovalServer.qualifiedConflictToolName)
            }
            // Pre-allow `host_exec` so the call reaches our handler directly instead of
            // Claude's permission path — our handler is the sole gate, so `auto` mode can
            // never auto-approve it (HOST_EXEC).
            if run.allowHostExec {
                allowedTools.append(MCPApprovalServer.qualifiedHostExecToolName)
            }
            // Pre-allow `mac_vm_exec` for the same reason (our handler is the sole gate).
            if run.allowMacVMExec, macVMManager != nil {
                allowedTools.append(MCPApprovalServer.qualifiedMacVMExecToolName)
            }
            // Pre-allow `mac_vm_computer` so computer-use actions reach our handler with no per-action
            // prompt (the VM sandbox is the safety boundary).
            if run.allowMacVMComputer, macVMManager != nil {
                allowedTools.append(MCPApprovalServer.qualifiedMacVMComputerToolName)
                // Pre-allow `mac_vm_request_operator` too (rides on computer-use); our handler gates it.
                allowedTools.append(MCPApprovalServer.qualifiedMacVMOperatorToolName)
                // Pre-allow `mac_vm_clear_notifications` (rides on computer-use; not gated — it only
                // touches the agent's own disposable VM).
                allowedTools.append(MCPApprovalServer.qualifiedMacVMClearNotificationsToolName)
            }
            // Pre-allow the Linux-VM tools for the same reason (our handler is the sole gate).
            if run.allowLinuxVMExec, macVMManager != nil {
                allowedTools.append(MCPApprovalServer.qualifiedLinuxVMExecToolName)
            }
            if run.allowLinuxVMComputer, macVMManager != nil {
                allowedTools.append(MCPApprovalServer.qualifiedLinuxVMComputerToolName)
            }
            // Pre-allow the VM lifecycle tools so their ops reach our handler directly (no per-op
            // prompt — they only manage the agent's own disposable VM). Exposed alongside the VM.
            if (run.allowMacVMExec || run.allowMacVMComputer), macVMManager != nil {
                allowedTools.append(MCPApprovalServer.qualifiedMacVMControlToolName)
            }
            if (run.allowLinuxVMExec || run.allowLinuxVMComputer), macVMManager != nil {
                allowedTools.append(MCPApprovalServer.qualifiedLinuxVMControlToolName)
            }
            // Pre-allow `linux_container` so lifecycle ops reach our handler directly (the sole gate).
            if run.allowAgentContainers, containerManager != nil {
                allowedTools.append(MCPApprovalServer.qualifiedLinuxContainerToolName)
            }
            // Orchestra: pre-allow only Nucleic's managed subagent tool. Native Task/Agent are
            // deliberately not pre-allowed here, so worker spawning flows through Nucleic where the
            // app can enforce the configured worker model and observe the worker lifecycle.
            if run.orchestraActive, run.orchestraSubagentSpawner != nil {
                allowedTools.append(MCPApprovalServer.qualifiedOrchestraSubagentToolName)
            }
            // Supervisor drive/reply tools — pre-allowed so they reach our handlers directly, like the
            // spawner (they suspend/mutate the worker channel, never the permission path).
            if run.orchestraActive, run.orchestraSuperviseHandler != nil {
                allowedTools.append(MCPApprovalServer.qualifiedOrchestraSuperviseToolName)
            }
            if run.orchestraActive, run.orchestraReplyToWorkerHandler != nil {
                allowedTools.append(MCPApprovalServer.qualifiedOrchestraReplyToWorkerToolName)
            }
            // Worker-side ask tool — pre-allowed for a machine-spawned worker.
            if run.orchestraAskSupervisorHandler != nil {
                allowedTools.append(MCPApprovalServer.qualifiedOrchestraAskSupervisorToolName)
            }
            if !allowedTools.isEmpty {
                args += ["--allowedTools", allowedTools.joined(separator: ",")]
            }
            if let model = run.model { args += ["--model", model] }
            if let effort = run.effort { args += ["--effort", effort] }
            if let appendSystemPrompt = run.appendSystemPrompt {
                args += ["--append-system-prompt", appendSystemPrompt]
            }
            args += resumeArgs
            args += run.extraArgs

            // Host-run (unsandboxed) agent: pin its git (every remote) to the Managed Git key for the
            // run's lifetime — shredded when this `do` block exits, i.e. after the decode loop below
            // ends (the agent process exited). A containerized run gets the key via `provision`
            // (cspec.env) instead. No-op when no Managed Git credential is configured.
            let isHostRun = !(sandbox != nil && run.container != nil && activeContainerName != nil)
            var hostManagedGitEnv: [String: String] = [:]
            let hostManagedGitCleanup: @Sendable () -> Void
            if isHostRun {
                let (e, c) = GitHubCredentialProvisioner.provisionHost()
                hostManagedGitEnv = e
                hostManagedGitEnv.removeValue(forKey: "GH_CONFIG_DIR")
                hostManagedGitCleanup = c
            } else {
                hostManagedGitCleanup = {}
            }
            defer { hostManagedGitCleanup() }

            // Spawn on the host, or exec the same `claude` invocation inside the container VM.
            let handle: any ProcessHandle
            if let sandbox, let cspec = run.container, let name = activeContainerName {
                var env = Self.pinnedChildEnv
                    .merging(cspec.env) { _, new in new }
                    .merging(run.extraEnv) { _, new in new }
                // Tell the in-container `git` shim where to report (the host approval server,
                // reached over the in-guest control bridge) and under which per-session bearer
                // token. The env is inherited by every git subprocess the agent spawns.
                if cspec.installGitInterceptor {
                    // Point the in-container git/gh/command interceptor at the host control endpoint
                    // (the loopback control bridge, forwarding to the relayed socket). Shared helper
                    // so every containerized backend wires the interceptor identically.
                    env.merge(
                        CommandInterceptor.hookEnv(
                            host: controlHost, port: port, token: token, sessionID: run.sessionID,
                            commandTracing: ContainerServiceSettings.commandTracingEnabled)
                    ) { _, new in new }
                }
                // Exec inside the running container VM; the returned handle streams the guest
                // process's stdio over vsock and satisfies the same `ProcessHandle` contract as a
                // host spawn, so the decode loop below is identical in-container and on the host.
                handle = try await sandbox.exec(
                    name: name, workdir: cspec.workdir, env: env,
                    argv: [configuration.executable] + args,
                    uid: cspec.runAsUID, gid: cspec.runAsGID)
            } else {
                let spec = ProcessSpec(
                    executable: configuration.executable,
                    args: args,
                    cwd: run.worktree,
                    env: Self.pinnedChildEnv
                        .merging(run.extraEnv) { _, new in new }
                        .merging(hostManagedGitEnv) { _, new in new },
                    stdinMode: .pipe)
                handle = try await processHost.launch(spec)
            }
            self.handle = handle

            // 3. Initial prompt over stdin (NDJSON user message).
            if !run.prompt.parts.isEmpty {
                try handle.writeLine(Self.userMessageLine(for: run.prompt))
            }
            if configuration.closeStdinAfterPrompt {
                handle.closeStdin()
            }

            // 4. stderr tail for diagnostics.
            let stderrTail = StderrTail()
            let stderrTask = Task { [weak self] in
                for try await line in handle.stderrLines {
                    let text = String(decoding: line, as: UTF8.self)
                    stderrTail.append(text)
                    // Turn the otherwise-silent background-wait cutoff (see `printBackgroundWaitEnv`)
                    // into a visible note. Unreachable while we pin the ceiling to 0, but a finite
                    // override (RunSpec.extraEnv) or a future CLI default would otherwise drop the
                    // abandoned subagents' work without a trace.
                    if Self.isBackgroundWaitCutoff(text) {
                        await self?.reportBackgroundWaitCutoff()
                    }
                    if ProcessInfo.processInfo.environment["NUCLEIC_DEBUG_STDERR"] != nil {
                        FileHandle.standardError.write(Data(("claude-stderr: " + text + "\n").utf8))
                    }
                }
            }

            // 5. Optional raw native capture (OBSERVABILITY A.5).
            let capture = try makeCaptureFile()

            // 5b. Spawn watchdog (containerized runs only). The first event lands within seconds of
            // launch, so if NO stdout reaches the decode loop within `firstOutputTimeoutNanos`, the
            // agent started but its stdio transport stalled — surface a retryable error and kill the
            // wedged process so the run settles, instead of spinning "Working…" forever. Disarmed by
            // the first decoded line below; cancelled unconditionally after the loop.
            let watchdog: Task<Void, Never>? = (run.container != nil)
                ? Task { [weak self] in
                    try? await Task.sleep(nanoseconds: Self.firstOutputTimeoutNanos)
                    await self?.reportStalledSpawnIfSilent(stderrTail: stderrTail)
                }
                : nil

            // 6. Decode loop — the single consuming task of the native stream.
            let decoder = ClaudeStreamDecoder()
            // The exact host directory a `host_exec` command runs in for this run — the same
            // `workdir` the host-exec handler uses. Recorded onto each host_exec tool call below so
            // the transcript's in-chat host card resolves `$PWD` to precisely the directory the
            // command ran in, identically to the approval card. Nil when host_exec isn't exposed.
            let hostExecWorkdir = run.allowHostExec ? (run.container?.workdir ?? run.worktree) : nil
            for try await line in handle.stdoutLines {
                if !sawFirstOutput {
                    sawFirstOutput = true
                    watchdog?.cancel()  // output flows → the spawn isn't stalled
                }
                capture?.append(line: line)
                for decoded in decoder.decode(line: line) {
                    if case .sessionStarted(let started) = decoded.kind {
                        backendSessionID = started.backendSessionID
                    }
                    var kind = Self.recordingHostExecCwd(decoded.kind, workdir: hostExecWorkdir)
                    if case .runFinished(let finished) = kind {
                        sawRunFinished = true
                        // A SIGINT (manual Stop) makes the CLI emit an `is_error`
                        // result, which the decoder maps to `.errored`. That's a
                        // deliberate stop, not a failure — remap it so the session
                        // settles as interrupted ("Run stopped"), not errored.
                        if interruptRequested, finished.outcome == .errored {
                            kind = .runFinished(
                                RunFinished(
                                    outcome: .interrupted,
                                    finalText: finished.finalText,
                                    totalUsage: finished.totalUsage,
                                    durationMs: finished.durationMs))
                        }
                    }
                    emit(kind, nativeType: decoded.nativeType)
                }
            }

            // 7. Exit handling: if the CLI died without a result event, synthesize
            //    the run outcome (ADAPTERS §1.5).
            watchdog?.cancel()
            let exitCode = await handle.wait()
            stderrTask.cancel()
            capture?.close()
            if !sawRunFinished {
                // A nonzero exit is only an error if we didn't ask the process to stop
                // (interrupt or session shutdown both end it deliberately). A stalled spawn already
                // emitted its own error and SIGKILLed the process, so don't also report that kill as
                // an abnormal/OOM exit.
                if exitCode != 0 && !interruptRequested && !terminating && !spawnStalled {
                    let message: String
                    // A SIGKILL inside the sandbox is ambiguous (real OOM vs. the whole container
                    // going down vs. another process) — so probe the container now and report what
                    // actually happened, rather than assuming "out of memory".
                    if BackendDiagnostics.isSIGKILL(exitCode),
                        let name = activeContainerName, let containerManager
                    {
                        let diagnosis = await containerManager.diagnoseContainerKill(name: name)
                        message = BackendDiagnostics.containerKillMessage(
                            tool: "claude", diagnosis: diagnosis, stderrTail: stderrTail.joined())
                    } else {
                        message = BackendDiagnostics.abnormalExitMessage(
                            tool: "claude", exitCode: exitCode, stderrTail: stderrTail.joined(),
                            containerized: run.container != nil)
                    }
                    emit(.error(AgentError(recoverable: false, message: message)), nativeType: nil)
                }
                let outcome: RunFinished.Outcome =
                    spawnStalled ? .errored
                    : interruptRequested ? .interrupted
                    : (terminating || exitCode == 0) ? .completed : .errored
                emit(.runFinished(RunFinished(outcome: outcome)), nativeType: nil)
            }
            await approvals.cancelOutstanding(reason: "Run ended")
            // Tear down this run's per-instance state BEFORE finishing the stream: the
            // consumer (SessionController) starts the next turn the moment the stream
            // closes, and start/resume reuse this same actor. Cleaning up first means a
            // follow-up turn can never overlap and clobber the previous run's handle,
            // bearer token, or container reference.
            await teardownRun()
            continuation.finish()
        } catch {
            // A SIGINT interrupt (Stop) or a shutdown can abort an in-flight stdout
            // read mid-turn — that's a deliberate stop, not a run failure. Finish the
            // stream cleanly with the right terminal outcome so a conversational
            // session settles to `awaitingInput` (resumable) instead of being thrown
            // as an error, which `SessionController.consume` would turn into a terminal
            // `.error` and lock the composer.
            if interruptRequested || terminating || spawnStalled {
                if !sawRunFinished {
                    let outcome: RunFinished.Outcome =
                        spawnStalled ? .errored
                        : interruptRequested ? .interrupted : .completed
                    emit(.runFinished(RunFinished(outcome: outcome)), nativeType: nil)
                }
                await approvals.cancelOutstanding(reason: "Run interrupted")
                await teardownRun()
                continuation.finish()
            } else {
                // A genuine failure still has to release any approval waiter blocked on this
                // run, or its MCP serve task (and the connection it holds) leaks until the
                // whole session shuts down. Deny-resolving it returns the agent's blocked
                // call; the UI side is dismissed independently by the consumer at run end.
                await approvals.cancelOutstanding(reason: "Run failed")
                await teardownRun()
                continuation.finish(throwing: error)
            }
        }
    }

    /// Release everything the just-finished run held: the approval-server bearer token,
    /// the sandbox container (so its idle timer can arm), and the process handle.
    private func teardownRun() async {
        if let serverToken {
            // Unregister only this run's token; a shared control-container server keeps serving the
            // other sessions in the box. The server itself is never stopped here.
            await activeServer.unregister(token: serverToken)
            self.serverToken = nil
        }
        runServer = nil
        if let name = activeContainerName {
            await containerManager?.finished(name: name)
            activeContainerName = nil
        }
        self.handle = nil
    }

    /// Spawn-watchdog handler (containerized runs). Fires `firstOutputTimeoutNanos` after launch; if
    /// the agent STILL hasn't produced any stdout, the spawn has stalled — the agent is running (it
    /// reached the approval server) but its stdout/stdin isn't crossing the guest⇄host vsock relay,
    /// so it sits idle forever and the UI spins "Working…" with nothing to show. Convert that dead
    /// end into an actionable, *recoverable* error (the session stays resumable) and SIGKILL the
    /// wedged process group so its stdio closes, the decode loop ends, and the run settles. No-op if
    /// output has since arrived or the run is already interrupting/terminating/finished.
    private func reportStalledSpawnIfSilent(stderrTail: StderrTail) async {
        guard !sawFirstOutput, !sawRunFinished, !interruptRequested, !terminating,
            let handle
        else { return }
        spawnStalled = true
        var message =
            "The agent started inside the container but produced no output within "
            + "\(Self.firstOutputTimeoutNanos / 1_000_000_000)s. It reached the approval server, so "
            + "it is running, but its output never streamed back — the container's stdio transport "
            + "stalled. This is usually transient; run the turn again."
        let tail = stderrTail.joined()
        if !tail.isEmpty { message += "\n\nLast container stderr:\n\(tail)" }
        if let name = activeContainerName, let containerManager {
            let diagnosis = await containerManager.diagnoseContainerKill(name: name)
            message += diagnosis.containerRunning
                ? "\n\n(The container is still running — this is a stdio relay stall, not an "
                    + "out-of-memory kill.)"
                : "\n\n(The container is no longer reachable — it may have stopped or crashed.)"
        }
        emit(.error(AgentError(recoverable: true, message: message)), nativeType: nil)
        // Force the wedged process group down, then finish the line streams host-side so the decode
        // loop ends (EOF) and the run settles even if the guest's wait/stdio RPC is itself stalled.
        handle.sendSignal(SIGKILL)
        handle.forceCloseStreams()
    }

    /// Whether a stderr line is the `claude -p` background-wait cutoff warning — the CLI announcing it
    /// gave up waiting on still-running background subagents/tasks and is terminating them (so their
    /// work is lost). Matched on the two stable fragments of the CLI's message ("Background tasks still
    /// running …; terminating.") rather than the whole sentence, so a reword of the surrounding text
    /// (e.g. the env-var hint) doesn't silently disable detection. See ``printBackgroundWaitEnv``.
    static func isBackgroundWaitCutoff(_ line: String) -> Bool {
        line.contains("Background tasks still running") && line.contains("terminating")
    }

    /// Surface the `claude -p` background-wait cutoff as a *recoverable* error in the transcript: the
    /// CLI stopped waiting on still-running background subagents/tasks and killed them, so their work
    /// never made it into this turn. Recoverable (the session stays resumable) and lifecycle-neutral —
    /// like ``reportStalledSpawnIfSilent(stderrTail:)``, the sibling out-of-band surfacing helper.
    /// Should be unreachable while we pin the ceiling to 0 (wait indefinitely); it exists so a finite
    /// ``RunSpec/extraEnv`` override — or a future CLI default — can't drop the work without a trace.
    /// Emitted at most once per run.
    private func reportBackgroundWaitCutoff() {
        guard !reportedBackgroundWaitCutoff else { return }
        reportedBackgroundWaitCutoff = true
        emit(
            .error(
                AgentError(
                    recoverable: true,
                    message:
                        "A background subagent or task was still running when this turn ended, and "
                        + "Claude stopped waiting for it — so its work was not incorporated into this "
                        + "turn. Re-run the turn (or raise CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS) to let "
                        + "long-running subagents finish.")),
            nativeType: nil)
    }

    // MARK: - Approval bridge

    private func handleApprovalCall(_ call: MCPApprovalServer.ApprovalCall) async
        -> MCPApprovalServer.Reply
    {
        if blockNativeSubagentSpawns, call.toolName == "Task" || call.toolName == "Agent" {
            return .deny(message:
                "Orchestra worker spawning must go through Nucleic's `nucleic_subagent` tool so "
                    + "Nucleic can manage the worker lifecycle and enforce the configured worker model. "
                    + "Call `nucleic_subagent` with a short task label and complete worker prompt.")
        }

        // Structured-edit enforcement: a raw shell command that mutates a workspace file
        // (`sed -i`, `echo > file`, `tee`, …) bypasses the fileChange→trunk-commit + conflict
        // path, so deny it and steer the agent to the structured edit tool. Runs ahead of the
        // always-rule cache and the auto-approve blanket so neither an "allow always" rule nor
        // auto mode can slip an untracked mutation through. Truly throwaway output (/tmp,
        // /dev/null) is allowed; the residue-capture backstop covers anything this misses.
        if call.toolName == "Bash", let command = call.input["command"]?.stringValue,
            let reason = WorktreeMutationGuard.denialReason(command: command)
        {
            return .deny(message: reason)
        }

        // Conflict pre-check (internal project management): edit-class tools are routed here
        // even in auto mode. Before the edit lands, ask Nucleic whether the target file
        // overlaps another active agent's work. Runs ahead of the always-rule cache so an
        // "always allow" rule can't slip a conflicting edit past.
        let editPaths = RiskClassifier.editedPaths(toolName: call.toolName, input: call.input)
        if let conflictCoordinator, !editPaths.isEmpty {
            // Arbitrate EVERY edit-class call so each touched file acquires its own lock — not
            // just the first edit of the turn. (A prior per-turn `conflictOverridden` latch
            // suppressed arbitration after the first grant, leaving every later file unlocked
            // and free for a parallel session to edit, which surfaced as autoship merge
            // conflicts.) Re-arbitrating is cheap: a file this session already holds re-grants
            // instantly via the LockManager fast-path, and a contended file correctly blocks.
            let outcome = await conflictCoordinator.arbitrate(
                sessionID: sessionID ?? SessionID(rawValue: "unknown"), task: "", files: editPaths,
                toolUseID: call.toolUseID)
            switch outcome.resolution {
            case .deferred:
                return .deny(message:
                    "This edit conflicts with another active Nucleic agent and was added to the "
                        + "to-do list. Stop now and do not edit — tell the user it was deferred.")
            case .cancelled:
                return .deny(message:
                    "This edit conflicts with another active Nucleic agent and the user cancelled "
                        + "it. Stop now and do not edit.")
            case .proceed, .noConflict:
                // Lock granted (or nothing to lock). If update-on-grant pulled a parent change into a
                // file this edit targets, block THIS edit once with the re-ground notice (the scoped
                // diff) so the agent re-issues against fresh content; the lock is held, so the retry
                // re-grants instantly (no merge) and proceeds (LOCKING §4.5). Otherwise let it through.
                if let notice = outcome.refreshNotice { return .deny(message: notice) }
                break
            }
        }

        // `AskUserQuestion` is not a gate — it *collects* the user's choices, which we must
        // fold into the reply's `updatedInput` (see `AskUserQuestion`). Any short-circuit that
        // replies `allow` with the input unchanged makes the CLI report "the user did not
        // answer the questions." So it must always surface (the UI renders it as a picker, not
        // allow/deny) — never auto-answered by the always-rule cache or the auto-approve
        // blanket below.
        let collectsUserInput = call.toolName == AskUserQuestion.toolName

        // Always-rule short-circuit: auto-answer without surfacing (RUNTIME §5).
        if !collectsUserInput,
            let cached = await approvals.cachedDecision(toolName: call.toolName, input: call.input)
        {
            return ClaudeDecisionMapping.reply(for: cached.decision, originalInput: call.input)
        }

        // Nucleic-managed auto-approve: conflict coordination forces `default` mode (every
        // call routes here), so we reproduce auto mode ourselves — approve everything except
        // destructive actions, which still surface for explicit approval. The edit conflict
        // pre-check above already ran, so an auto-approved edit is conflict-clean.
        if !collectsUserInput, conflictCoordinator != nil, autoApprove,
            RiskClassifier.classify(toolName: call.toolName, input: call.input) != .destructive
        {
            return ClaudeDecisionMapping.reply(for: .allow(), originalInput: call.input)
        }

        let request = ApprovalRequest(
            id: .generate(),
            sessionID: sessionID ?? SessionID(rawValue: "unknown"),
            toolCallID: call.toolUseID,
            toolName: call.toolName,
            input: call.input,
            title: RiskClassifier.title(toolName: call.toolName, input: call.input),
            risk: RiskClassifier.classify(toolName: call.toolName, input: call.input),
            createdAt: configuration.now())

        emit(.approvalRequested(request), nativeType: "mcp/tools/call")
        let resolved = await approvals.waitForDecision(request)
        emit(.approvalResolved(resolved), nativeType: nil)

        if case .cancelRun = resolved.decision {
            interruptRequested = true
            handle?.sendSignal(SIGINT)
        }
        return ClaudeDecisionMapping.reply(for: resolved.decision, originalInput: call.input)
    }

    // MARK: - Host exec bridge (HOST_EXEC)

    /// Gate + run a `host_exec` call. This path NEVER consults the auto-approve blanket or
    /// the always-rule cache: it surfaces an explicit approval every time, unless the user
    /// previously chose "Allow for this session" (which only an explicit choice can set).
    private func handleHostExecCall(_ call: MCPApprovalServer.HostExecCall, workdir: String) async
        -> MCPApprovalServer.HostExecReply
    {
        // Programmatic resistance (HostExecPolicy): host_exec escapes the sandbox onto the shared
        // macOS host, so it is a last resort for the macOS-only toolchain — not a general shell.
        // Sort the command before doing anything else.
        let decision = HostExecPolicy.classify(call.command)

        // Reading a `.env` secrets file is forbidden outright — denied here, before anything else,
        // and no session grant can ever wave it through.
        if case .secretFileBlocked = decision {
            return .denied(message: HostExecPolicy.secretFileBlockedDenial())
        }

        // A command that plainly runs in the container is refused right here, with a pointer back
        // to the container — it never reaches an approval prompt.
        if case .containerRunnable(let program) = decision {
            return .denied(message: HostExecPolicy.containerRunnableDenial(program: program))
        }

        // Every accepted call must justify why the container can't do it. Empty/vague is refused
        // (the agent retries with a real reason), keeping the escape hatch deliberate.
        guard HostExecPolicy.isAcceptableReason(call.reason) else {
            return .denied(message: HostExecPolicy.missingReasonMessage())
        }

        // "Allow for this session" only ever blankets genuine host-only builds. An ambiguous
        // command (make, bash -c "…", a bare script path) can't be shown to need the host, and a
        // read-only inspection command — where a `-exec`/`-o`/`!cmd` escape could hide inside an
        // otherwise-benign tool — is shown in full each time too; both ALWAYS re-prompt, so a prior
        // session grant can never wave either through unseen.
        let forceFreshPrompt: Bool
        switch decision {
        case .ambiguous, .readOnly:
            forceFreshPrompt = true
        case .hostOnly, .containerRunnable, .secretFileBlocked:
            forceFreshPrompt = false
        }

        if forceFreshPrompt || !hostExecSessionGranted {
            let request = ApprovalRequest(
                id: .generate(),
                sessionID: sessionID ?? SessionID(rawValue: "unknown"),
                toolCallID: nil,
                toolName: MCPApprovalServer.qualifiedHostExecToolName,
                input: .object([
                    "command": .string(call.command),
                    "reason": .string(call.reason),
                    // The host cwd this command runs in, so the approval card can resolve `$PWD`.
                    "cwd": .string(workdir),
                ]),
                title: "Run on host: \(Self.oneLine(call.command))",
                risk: .hostExec,
                createdAt: configuration.now())

            emit(.approvalRequested(request), nativeType: "mcp/tools/call")
            let resolved = await approvals.waitForDecision(request)
            emit(.approvalResolved(resolved), nativeType: nil)

            switch resolved.decision {
            case .deny(let reason):
                return .denied(message: reason ?? "The user denied host execution.")
            case .cancelRun:
                interruptRequested = true
                handle?.sendSignal(SIGINT)
                return .denied(message: "The user cancelled the run.")
            case .allowAlways:
                // "Allow for this session" — but only a host-only build earns the blanket; an
                // ambiguous command stays gated per-call (see forceFreshPrompt above).
                if !forceFreshPrompt { hostExecSessionGranted = true }
            case .allow:
                break
            }
        }

        // Host-command concurrency gate (HOST_EXEC §concurrency): before running on the shared host,
        // claim a run slot so two sessions don't fire *similar* commands (e.g. two builds) at once —
        // which thrash the host toolchain, and for nvrsion sessions sharing one trunk collide on the
        // same build directory. On a detected conflict the user gets an in-chat prompt naming the
        // other session with three choices: "Run anyway" overrides a false positive, "Wait" queues
        // behind the running twin and auto-runs once it's clear, "Cancel" skips. The slot is held
        // only for the command's duration. Fail-open: with no coordinator (host-only/test setups),
        // just run, exactly as before.
        guard let coordinator = conflictCoordinator, let sid = sessionID else {
            return await runOnHost(command: call.command, workdir: workdir)
        }
        var override = false
        while true {
            switch await coordinator.beginHostCommand(
                sessionID: sid, command: call.command, override: override)
            {
            case .clear:
                let reply = await runOnHost(command: call.command, workdir: workdir)
                await coordinator.endHostCommand(sessionID: sid)
                return reply
            case .conflict(let conflicts):
                switch await surfaceHostCommandConflict(command: call.command, conflicts: conflicts) {
                case .runAnyway:
                    override = true  // user dismissed it as a false positive — re-claim, forcing past it
                    continue
                case .wait:
                    // FIFO wait: queue behind the running twin(s), poll until it's our turn, then run.
                    // A nil reply means the run was interrupted (Stop) before the slot freed.
                    if let reply = await waitForHostCommandSlot(
                        sessionID: sid, command: call.command, workdir: workdir,
                        conflicts: conflicts, coordinator: coordinator)
                    {
                        return reply
                    }
                    return .denied(message: "The run was cancelled while waiting for the host to free up.")
                case .skip:
                    return .denied(message: Self.hostConflictDenial(conflicts))
                }
            }
        }
    }

    /// Drop the one-time "VM opened" transcript note when a guest becomes usable *anew* for this
    /// session, so the chat gets a clickable "Open VM Monitor" affordance (the flagged note the
    /// transcript renders with a button). Fires on a cold open — it wasn't live (a fresh clone, or a
    /// reboot from a stopped / idle-stopped clone) — and on a thaw from suspension — it was live but
    /// paused, which `ensureRunning` (or an explicit `mac_vm_control resume`) transparently resumes.
    /// Both flags are the VM's state *before* the boot, since a suspended VM still reads as running
    /// (it stays in the live registry, just paused) and would otherwise slip past. A plain reuse of an
    /// already-running, unpaused VM adds nothing. `label` names the guest OS in the copy.
    private func announceVMOpened(wasRunning: Bool, wasPaused: Bool, label: String) {
        guard !wasRunning || wasPaused else { return }
        let verb = wasPaused ? "Resumed" : "Opened"
        emit(
            .note(NoteEvent(
                text: "\(verb) the \(label) VM. Open the VM Monitor to watch its screen.",
                icon: "display",
                vmMonitor: true)),
            nativeType: nil)
    }

    /// Narrate a `mac_vm_control` / `linux_vm_control` power-state transition into the transcript, so a
    /// suspend / shutdown / restart / kill shows up in chat the way the "Booting the … VM …" note does
    /// for a cold start. Progress-style (trailing "…") and emitted just before the (possibly slow)
    /// engine op runs; the tool reply still carries the settled-state message the agent reads. Callers
    /// gate this on the VM actually being in a state the op will change, so a no-op isn't narrated.
    private func announceVMTransition(_ text: String, icon: String) {
        // `narratesTool` marks this as the pretty stand-in for the `*_vm_control` tool card, so the
        // transcript drops that redundant card and shows only this line (TranscriptProjection).
        emit(.note(NoteEvent(text: text, icon: icon, narratesTool: true)), nativeType: nil)
    }

    /// Format an error thrown while booting/reusing a session VM into an agent-facing message. When the
    /// boot was refused because the configured **maximum number of VMs is already running**
    /// (``MacVMError/concurrencyLimit``), surface that error's own clear, VM-type-neutral text so the
    /// agent is told the cap is reached (and to wait or free a VM) rather than reading it as an opaque
    /// failure. Any other error keeps the `<kind> VM error:` prefix so its origin stays obvious.
    private static func vmBootErrorMessage(_ error: Error, kind: String) -> String {
        if case MacVMError.concurrencyLimit = error {
            return String(describing: error)
        }
        return "\(kind) VM error: \(String(describing: error))"
    }

    /// Gate + run a `mac_vm_exec` call: it boots or reuses **this session's own** isolated macOS VM and
    /// runs the command inside it over SSH. `hostWorktree` is shared into the VM so the build sees the
    /// repo (guest path `/Volumes/My Shared Files/workspace`). Because each session gets a distinct VM,
    /// there is no shared-host concurrency gate — parallel agents don't collide the way they do on
    /// `host_exec`. For the same reason the VM is disposable and can't escape onto the shared host,
    /// **Auto mode approves every call without prompting**; interactive sessions surface an explicit
    /// approval unless the user already chose "Allow for this session".
    private func handleMacVMExecCall(
        _ call: MCPApprovalServer.MacVMExecCall, hostWorktree: String
    ) async -> MCPApprovalServer.MacVMExecReply {
        guard let manager = macVMManager else {
            return .denied(message: "The macOS VM service is not available.")
        }
        // Every accepted call must justify why it needs the macOS VM; empty/vague is refused (reuse the
        // host_exec reason gate — same "deliberate escape hatch" intent).
        guard HostExecPolicy.isAcceptableReason(call.reason) else {
            return .denied(message: HostExecPolicy.missingReasonMessage())
        }

        // Auto mode waves every macOS-VM command through: the VM is isolated and disposable and can't
        // reach the shared host, so there's nothing a per-command prompt would protect.
        if !autoApprove, !macVMExecSessionGranted {
            let request = ApprovalRequest(
                id: .generate(),
                sessionID: sessionID ?? SessionID(rawValue: "unknown"),
                toolCallID: nil,
                toolName: MCPApprovalServer.qualifiedMacVMExecToolName,
                input: .object([
                    "command": .string(call.command),
                    "reason": .string(call.reason),
                ]),
                title: "Run in macOS VM: \(Self.oneLine(call.command))",
                risk: .hostExec,
                createdAt: configuration.now())
            emit(.approvalRequested(request), nativeType: "mcp/tools/call")
            let resolved = await approvals.waitForDecision(request)
            emit(.approvalResolved(resolved), nativeType: nil)
            switch resolved.decision {
            case .deny(let reason):
                return .denied(message: reason ?? "The user denied running in the macOS VM.")
            case .cancelRun:
                interruptRequested = true
                handle?.sendSignal(SIGINT)
                return .denied(message: "The user cancelled the run.")
            case .allowAlways:
                macVMExecSessionGranted = true
            case .allow:
                break
            }
        }

        guard let sid = sessionID else {
            return .denied(message: "No active session for the macOS VM.")
        }
        let vmName = MacVMManager.vmName(for: sid)
        let shareName = "workspace"
        let sharePath = "/Volumes/My Shared Files/\(shareName)"
        // Run from the re-created **identical host path** (so build artifacts that hardcode it resolve),
        // falling back to the share path since host-path re-creation is best-effort — see
        // MacVMEngine+HostPaths. The command is wrapped with a resilient `cd`, so the spec/run `workdir`
        // is left nil (remoteScript's strict `cd || exit 1` would abort if the identical path were
        // absent). The identical path is what we advertise as the guest working directory.
        let guestWorkdir = hostWorktree
        let cdPrefix = MacVMSpec.workdirPrefix([guestWorkdir, sharePath])
        // Honor the user's configured per-session VM resources + SSH account (Settings ▸ Virtual
        // Machines), not the bare `MacVMSpec` defaults.
        let spec = MacVMSpec(
            name: vmName,
            cpus: MacVMSettings.vmCPUs,
            memoryGiB: MacVMSettings.vmMemoryGiB,
            mounts: [MacVMSpec.Mount(host: hostWorktree, name: shareName, readOnly: false)],
            workdir: guestWorkdir,
            sshUser: MacVMSettings.sshUser,
            computerUse: macVMComputerAllowed)
        do {
            // Narrate the wait so a cold start doesn't read as a hang (mirrors the host-command wait
            // notes): a fresh clone's first boot can take a couple of minutes — see
            // MacVMEngine.performBoot. Only announce the boot when the VM isn't already up; a reuse is
            // quick and needs no note.
            let alreadyRunning = await manager.isRunning(name: vmName)
            // Read paused state before the boot: `ensureRunning` thaws a suspended VM, and a suspended
            // VM already reads as running, so capture it now to tell a resume from a plain reuse.
            let wasPaused = await manager.isPaused(name: vmName)
            if !alreadyRunning {
                emit(
                    .note(NoteEvent(
                        text: "Booting the macOS VM (a first boot can take a couple of minutes)…",
                        icon: "hourglass")),
                    nativeType: nil)
            }
            // First call boots the VM (a fresh clone can take a couple of minutes); later calls reuse it.
            let (name, _) = try await manager.ensureRunning(spec)
            activeMacVMName = name
            defer { Task { await manager.finished(name: name) } }  // re-arm the idle timer after the turn
            announceVMOpened(wasRunning: alreadyRunning, wasPaused: wasPaused, label: "macOS")
            emit(
                .note(NoteEvent(
                    text: "Running in the macOS VM: \(Self.oneLine(call.command))",
                    icon: "play.fill")),
                nativeType: nil)
            let result = try await manager.run(
                name: name, command: cdPrefix + call.command, workdir: nil)
            return .ran(exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr)
        } catch {
            return .denied(message: Self.vmBootErrorMessage(error, kind: "macOS"))
        }
    }

    /// Perform one `mac_vm_computer` computer-use action against this session's macOS VM and return the
    /// post-action screenshot (as an MCP image the model sees) plus a short summary. Boots/reuses the
    /// SAME VM `mac_vm_exec` uses. Unlike `host_exec`/`mac_vm_exec`, actions are NOT individually
    /// approval-gated — a per-click prompt would make a navigation loop unusable, and the blast radius
    /// is the agent's own disposable VM. Exposure (the tool being advertised at all) is the opt-in gate.
    private func handleMacVMComputerCall(
        _ call: MCPApprovalServer.MacVMComputerCall, hostWorktree: String
    ) async -> MCPApprovalServer.MacVMComputerReply {
        guard let manager = macVMManager else {
            return .denied(message: "The macOS VM service is not available.")
        }
        guard let sid = sessionID else {
            return .denied(message: "No active session for the macOS VM.")
        }
        let vmName = MacVMManager.vmName(for: sid)
        let shareName = "workspace"
        let spec = MacVMSpec(
            name: vmName,
            cpus: MacVMSettings.vmCPUs,
            memoryGiB: MacVMSettings.vmMemoryGiB,
            mounts: [MacVMSpec.Mount(host: hostWorktree, name: shareName, readOnly: false)],
            workdir: hostWorktree,  // the re-created identical host path (see MacVMEngine+HostPaths)
            sshUser: MacVMSettings.sshUser,
            computerUse: macVMComputerAllowed)
        do {
            let alreadyRunning = await manager.isRunning(name: vmName)
            // Read paused state before the boot: `ensureRunning` thaws a suspended VM, and a suspended
            // VM already reads as running, so capture it now to tell a resume from a plain reuse.
            let wasPaused = await manager.isPaused(name: vmName)
            let (name, _) = try await manager.ensureRunning(spec)
            activeMacVMName = name
            defer { Task { await manager.finished(name: name) } }
            announceVMOpened(wasRunning: alreadyRunning, wasPaused: wasPaused, label: "macOS")
            let (image, summary) = try await manager.performComputerAction(
                name: name, action: call.action, x: call.x, y: call.y, text: call.text,
                ref: call.ref, value: call.value,
                scrollDirection: call.scrollDirection, scrollAmount: call.scrollAmount,
                durationMs: call.durationMs)
            return .ok(text: summary, imageBase64: image)
        } catch {
            return .denied(message: Self.vmBootErrorMessage(error, kind: "macOS"))
        }
    }

    /// Handle a `mac_vm_clear_notifications` call: dismiss the macOS VM's on-screen notifications by
    /// killing the guest's NotificationCenter (which relaunches clean). Not approval-gated — like the
    /// computer-use actions it only touches the agent's own disposable VM. Boots/reuses the SAME VM
    /// `mac_vm_exec`/`mac_vm_computer` use, then runs the fixed command over the exec channel.
    private func handleMacVMClearNotificationsCall(
        hostWorktree: String
    ) async -> MCPApprovalServer.MacVMExecReply {
        guard let manager = macVMManager else {
            return .denied(message: "The macOS VM service is not available.")
        }
        guard let sid = sessionID else {
            return .denied(message: "No active session for the macOS VM.")
        }
        let vmName = MacVMManager.vmName(for: sid)
        let shareName = "workspace"
        let spec = MacVMSpec(
            name: vmName,
            cpus: MacVMSettings.vmCPUs,
            memoryGiB: MacVMSettings.vmMemoryGiB,
            mounts: [MacVMSpec.Mount(host: hostWorktree, name: shareName, readOnly: false)],
            workdir: hostWorktree,  // the re-created identical host path (see MacVMEngine+HostPaths)
            sshUser: MacVMSettings.sshUser,
            computerUse: macVMComputerAllowed)
        do {
            let alreadyRunning = await manager.isRunning(name: vmName)
            // Read paused state before the boot: `ensureRunning` thaws a suspended VM, and a suspended
            // VM already reads as running, so capture it now to tell a resume from a plain reuse.
            let wasPaused = await manager.isPaused(name: vmName)
            let (name, _) = try await manager.ensureRunning(spec)
            activeMacVMName = name
            defer { Task { await manager.finished(name: name) } }
            announceVMOpened(wasRunning: alreadyRunning, wasPaused: wasPaused, label: "macOS")
            emit(
                .note(NoteEvent(
                    text: "Clearing notifications in the macOS VM…", icon: "bell.slash")),
                nativeType: nil)
            // `killall` exits non-zero when NotificationCenter isn't running (nothing to clear) — that's
            // a success for our purposes, so swallow it and report exit 0 with an empty banner list.
            let result = try await manager.run(
                name: name, command: "killall NotificationCenter", workdir: nil)
            return .ran(exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr)
        } catch {
            return .denied(message: Self.vmBootErrorMessage(error, kind: "macOS"))
        }
    }

    /// Gate a `mac_vm_request_operator` call: the agent asks the *user* to operate this session's macOS
    /// VM by hand for a step only a human can clear (CAPTCHA / login / interactive dialog). Modeled on
    /// `handleHostExecCall` — surface an approval carrying the agent's (unverified) `reason` +
    /// `instructions`, and suspend until the user finishes or declines. The VM is booted/kept alive for
    /// the whole hand-off so the interactive host-side surface exists when the user opens the viewer; the
    /// in-app card + walkthrough popup drive the viewer and resolve this approval. Never auto-approved.
    private func handleMacVMOperatorCall(
        _ call: MCPApprovalServer.MacVMOperatorCall, hostWorktree: String
    ) async -> MCPApprovalServer.MacVMOperatorReply {
        guard let manager = macVMManager else {
            return .denied(message: "The macOS VM service is not available.")
        }
        guard let sid = sessionID else {
            return .denied(message: "No active session for the macOS VM.")
        }
        // The user needs to know what to do — an empty instruction set is refused (the agent retries
        // with real instructions), and the reason must justify why a human is required (reuse the
        // host_exec reason gate — same "deliberate, well-explained" intent).
        let instructions = call.instructions.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !instructions.isEmpty else {
            return .denied(
                message: "Provide `instructions`: exactly what the user should do in the VM.")
        }
        guard HostExecPolicy.isAcceptableReason(call.reason) else {
            return .denied(message: HostExecPolicy.missingReasonMessage())
        }

        // Boot/reuse this session's VM with the computer-use surface so the viewer the user opens is
        // the real, interactive guest. `ensureRunning` marks the turn busy (disarming the idle timer);
        // the matching `finished` runs only when this handler returns — i.e. after the user is done — so
        // the VM stays alive for the whole hand-off.
        let vmName = MacVMManager.vmName(for: sid)
        let shareName = "workspace"
        let spec = MacVMSpec(
            name: vmName,
            cpus: MacVMSettings.vmCPUs,
            memoryGiB: MacVMSettings.vmMemoryGiB,
            mounts: [MacVMSpec.Mount(host: hostWorktree, name: shareName, readOnly: false)],
            workdir: hostWorktree,  // the re-created identical host path (see MacVMEngine+HostPaths)
            sshUser: MacVMSettings.sshUser,
            computerUse: macVMComputerAllowed)
        let runningName: String
        do {
            let alreadyRunning = await manager.isRunning(name: vmName)
            // Read paused state before the boot: `ensureRunning` thaws a suspended VM, and a suspended
            // VM already reads as running, so capture it now to tell a resume from a plain reuse.
            let wasPaused = await manager.isPaused(name: vmName)
            let (name, _) = try await manager.ensureRunning(spec)
            runningName = name
            activeMacVMName = name
            announceVMOpened(wasRunning: alreadyRunning, wasPaused: wasPaused, label: "macOS")
        } catch {
            return .denied(message: Self.vmBootErrorMessage(error, kind: "macOS"))
        }
        defer { Task { await manager.finished(name: runningName) } }

        let request = ApprovalRequest(
            id: .generate(),
            sessionID: sid,
            toolCallID: nil,
            toolName: MCPApprovalServer.qualifiedMacVMOperatorToolName,
            input: .object([
                "reason": .string(call.reason),
                "instructions": .string(instructions),
                // The VM the viewer should open, so the in-app card can present the right guest.
                "vm_name": .string(runningName),
            ]),
            title: "VM help needed: \(Self.oneLine(instructions))",
            // Reuse the host-exec risk so the request is always surfaced, never auto-approved, and
            // offers no "Allow Always" — each hand-off is a one-off. The card is chosen by toolName.
            risk: .hostExec,
            createdAt: configuration.now())

        emit(.approvalRequested(request), nativeType: "mcp/tools/call")
        let resolved = await approvals.waitForDecision(request)
        emit(.approvalResolved(resolved), nativeType: nil)

        switch resolved.decision {
        case .deny(let reason):
            return .denied(message: reason ?? "The user declined to operate the VM.")
        case .cancelRun:
            interruptRequested = true
            handle?.sendSignal(SIGINT)
            return .denied(message: "The user cancelled the run.")
        case .allow(let updatedInput):
            // The walkthrough popup resolves with {completed, note}; default to completed when absent
            // (a plain Allow with no payload reads as "the user handled it").
            let completed = updatedInput?["completed"]?.boolValue ?? true
            let note = updatedInput?["note"]?.stringValue ?? ""
            return .resolved(completed: completed, note: note)
        case .allowAlways:
            return .resolved(completed: true, note: "")
        }
    }

    /// Gate + run a `linux_vm_exec` call: boots/reuses this session's isolated **Linux** VM (the
    /// lighter, faster-booting sibling of the macOS VM) and runs the command inside it over the vsock
    /// agent. The repo is shared in over virtiofs; because the Linux base has no automount, the handler
    /// mounts the share on demand and runs from it. Gated like `mac_vm_exec` — approved per call (or per
    /// session), never auto-approved through Claude's path; Auto mode waves it through (the VM is
    /// isolated and disposable).
    private func handleLinuxVMExecCall(
        _ call: MCPApprovalServer.MacVMExecCall, hostWorktree: String
    ) async -> MCPApprovalServer.MacVMExecReply {
        guard let manager = macVMManager else {
            return .denied(message: "The Linux VM service is not available.")
        }
        guard HostExecPolicy.isAcceptableReason(call.reason) else {
            return .denied(message: HostExecPolicy.missingReasonMessage())
        }
        if !autoApprove, !linuxVMExecSessionGranted {
            let request = ApprovalRequest(
                id: .generate(),
                sessionID: sessionID ?? SessionID(rawValue: "unknown"),
                toolCallID: nil,
                toolName: MCPApprovalServer.qualifiedLinuxVMExecToolName,
                input: .object([
                    "command": .string(call.command),
                    "reason": .string(call.reason),
                ]),
                title: "Run in Linux VM: \(Self.oneLine(call.command))",
                risk: .hostExec,
                createdAt: configuration.now())
            emit(.approvalRequested(request), nativeType: "mcp/tools/call")
            let resolved = await approvals.waitForDecision(request)
            emit(.approvalResolved(resolved), nativeType: nil)
            switch resolved.decision {
            case .deny(let reason):
                return .denied(message: reason ?? "The user denied running in the Linux VM.")
            case .cancelRun:
                interruptRequested = true
                handle?.sendSignal(SIGINT)
                return .denied(message: "The user cancelled the run.")
            case .allowAlways:
                linuxVMExecSessionGranted = true
            case .allow:
                break
            }
        }
        guard let sid = sessionID else {
            return .denied(message: "No active session for the Linux VM.")
        }
        let vmName = MacVMManager.linuxVMName(for: sid)
        let shareName = "workspace"
        // The Linux base does NOT automount virtiofs (its fstab carries only root — see
        // guest/linux-base/bootstrap/init), so mount the session share on demand and run from it.
        // `nucleicshare` is the fixed virtiofs tag both sides agree on (MacVMEngine+LinuxConfig).
        let guestMountRoot = "/mnt/nucleic"
        let sharePath = "\(guestMountRoot)/\(shareName)"
        // Run from the re-created **identical host path** — host-path re-creation bind-mounts the share
        // there at boot (see MacVMEngine+HostPaths), so artifacts that hardcode it resolve — falling
        // back to the share path then the mount root, since that re-creation is best-effort.
        let guestWorkdir = hostWorktree
        let spec = MacVMSpec(
            name: vmName,
            os: .linux,
            cpus: MacVMSettings.vmCPUs,
            memoryGiB: MacVMSettings.vmMemoryGiB,
            mounts: [MacVMSpec.Mount(host: hostWorktree, name: shareName, readOnly: false)],
            workdir: guestWorkdir,
            sshUser: MacVMSettings.sshUser,
            computerUse: linuxVMComputerAllowed)
        do {
            let alreadyRunning = await manager.isRunning(name: vmName)
            // Read paused state before the boot: `ensureRunning` thaws a suspended VM, and a suspended
            // VM already reads as running, so capture it now to tell a resume from a plain reuse.
            let wasPaused = await manager.isPaused(name: vmName)
            if !alreadyRunning {
                emit(
                    .note(NoteEvent(
                        text: "Booting the Linux VM (a first boot can take a couple of minutes)…",
                        icon: "hourglass")),
                    nativeType: nil)
            }
            let (name, _) = try await manager.ensureRunning(spec)
            activeLinuxVMName = name
            defer { Task { await manager.finished(name: name) } }
            announceVMOpened(wasRunning: alreadyRunning, wasPaused: wasPaused, label: "Linux")
            emit(
                .note(NoteEvent(
                    text: "Running in the Linux VM: \(Self.oneLine(call.command))",
                    icon: "play.fill")),
                nativeType: nil)
            let mountPrep =
                "mkdir -p \(guestMountRoot); mountpoint -q \(guestMountRoot) 2>/dev/null || "
                + "mount -t virtiofs nucleicshare \(guestMountRoot) 2>/dev/null || true; "
            let wrapped = mountPrep
                + MacVMSpec.workdirPrefix([guestWorkdir, sharePath, guestMountRoot])
                + call.command
            let result = try await manager.run(name: name, command: wrapped, workdir: nil)
            return .ran(exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr)
        } catch {
            return .denied(message: Self.vmBootErrorMessage(error, kind: "Linux"))
        }
    }

    /// Perform one `linux_vm_computer` action against this session's Linux VM and return the
    /// post-action screenshot. Boots/reuses the SAME VM `linux_vm_exec` uses. Not per-action gated —
    /// exposure is the opt-in boundary (the blast radius is the agent's own disposable VM).
    private func handleLinuxVMComputerCall(
        _ call: MCPApprovalServer.MacVMComputerCall, hostWorktree: String
    ) async -> MCPApprovalServer.MacVMComputerReply {
        guard let manager = macVMManager else {
            return .denied(message: "The Linux VM service is not available.")
        }
        guard let sid = sessionID else {
            return .denied(message: "No active session for the Linux VM.")
        }
        let vmName = MacVMManager.linuxVMName(for: sid)
        let shareName = "workspace"
        let spec = MacVMSpec(
            name: vmName,
            os: .linux,
            cpus: MacVMSettings.vmCPUs,
            memoryGiB: MacVMSettings.vmMemoryGiB,
            mounts: [MacVMSpec.Mount(host: hostWorktree, name: shareName, readOnly: false)],
            workdir: hostWorktree,  // the re-created identical host path (see MacVMEngine+HostPaths)
            sshUser: MacVMSettings.sshUser,
            computerUse: linuxVMComputerAllowed)
        do {
            let alreadyRunning = await manager.isRunning(name: vmName)
            // Read paused state before the boot: `ensureRunning` thaws a suspended VM, and a suspended
            // VM already reads as running, so capture it now to tell a resume from a plain reuse.
            let wasPaused = await manager.isPaused(name: vmName)
            let (name, _) = try await manager.ensureRunning(spec)
            activeLinuxVMName = name
            defer { Task { await manager.finished(name: name) } }
            announceVMOpened(wasRunning: alreadyRunning, wasPaused: wasPaused, label: "Linux")
            let (image, summary) = try await manager.performComputerAction(
                name: name, action: call.action, x: call.x, y: call.y, text: call.text,
                ref: call.ref, value: call.value,
                scrollDirection: call.scrollDirection, scrollAmount: call.scrollAmount,
                durationMs: call.durationMs)
            return .ok(text: summary, imageBase64: image)
        } catch {
            return .denied(message: Self.vmBootErrorMessage(error, kind: "Linux"))
        }
    }

    /// Handle a `mac_vm_control` call: apply one lifecycle op to this session's macOS VM.
    private func handleMacVMControlCall(
        _ call: MCPApprovalServer.VMControlCall
    ) async -> MCPApprovalServer.VMControlReply {
        guard let manager = macVMManager else {
            return .denied(message: "The macOS VM service is not available.")
        }
        guard let sid = sessionID else {
            return .denied(message: "No active session for the macOS VM.")
        }
        return await performVMControl(
            op: call.op, vmName: MacVMManager.vmName(for: sid), label: "macOS", manager: manager)
    }

    /// Handle a `linux_vm_control` call: the Linux-VM sibling of ``handleMacVMControlCall(_:)``.
    private func handleLinuxVMControlCall(
        _ call: MCPApprovalServer.VMControlCall
    ) async -> MCPApprovalServer.VMControlReply {
        guard let manager = macVMManager else {
            return .denied(message: "The Linux VM service is not available.")
        }
        guard let sid = sessionID else {
            return .denied(message: "No active session for the Linux VM.")
        }
        return await performVMControl(
            op: call.op, vmName: MacVMManager.linuxVMName(for: sid), label: "Linux", manager: manager)
    }

    /// Shared core for `mac_vm_control` / `linux_vm_control`: apply one lifecycle `op` to the named
    /// per-session VM. Not approval-gated — the blast radius is the agent's own disposable VM (exposure
    /// is the opt-in boundary, like the computer-use tools), and these ops only change that VM's power
    /// state. `label` is the guest-OS word ("macOS" / "Linux") used in the notes and messages.
    private func performVMControl(
        op rawOp: String, vmName: String, label: String, manager: MacVMManager
    ) async -> MCPApprovalServer.VMControlReply {
        let op = rawOp.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
        switch op {
        case "status":
            if await manager.isRunning(name: vmName) {
                let state = await manager.isPaused(name: vmName) ? "suspended" : "running"
                return .ok(state: state, message: "The \(label) VM is \(state).")
            }
            let exists = await manager.exists(name: vmName)
            return .ok(
                state: exists ? "stopped" : "absent",
                message: exists
                    ? "The \(label) VM is stopped; its disk is kept, so the next command reboots it."
                    : "No \(label) VM exists yet for this session.")

        case "stop":
            // Narrate an actual shutdown; stopping an already-down VM changes nothing.
            if await manager.isRunning(name: vmName) {
                announceVMTransition("Shutting down the \(label) VM…", icon: "power")
            }
            await manager.stop(name: vmName)
            return .ok(
                state: "stopped",
                message: "Stopped the \(label) VM (freed its RAM; the next command reboots it).")

        case "suspend":
            // Narrate only when there's a live, un-paused VM to freeze (a suspended VM still reads as
            // running, so exclude the already-paused case).
            if await manager.isRunning(name: vmName), await !manager.isPaused(name: vmName) {
                announceVMTransition("Suspending the \(label) VM…", icon: "pause.circle")
            }
            let paused = await manager.suspend(name: vmName)
            return paused
                ? .ok(
                    state: "suspended",
                    message: "Suspended the \(label) VM (frozen in RAM; `resume` — or the next "
                        + "command — thaws it instantly).")
                : .denied(message: "The \(label) VM isn't running, so there's nothing to suspend.")

        case "resume":
            // Narrate only when there's a suspended VM to thaw.
            if await manager.isPaused(name: vmName) {
                announceVMTransition("Resuming the \(label) VM…", icon: "play.circle")
            }
            let resumed = await manager.resume(name: vmName)
            // An explicit thaw re-opens the guest's live screen — surface the monitor link, same as the
            // transparent resume a reusing exec/computer call triggers.
            if resumed { announceVMOpened(wasRunning: true, wasPaused: true, label: label) }
            return resumed
                ? .ok(state: "running", message: "Resumed the \(label) VM.")
                : .denied(message: "The \(label) VM isn't suspended.")

        case "restart":
            guard await manager.exists(name: vmName) else {
                return .denied(
                    message: "No \(label) VM exists yet to restart — run a command to boot one first.")
            }
            announceVMTransition("Restarting the \(label) VM…", icon: "arrow.clockwise")
            await manager.restart(name: vmName)
            return .ok(state: "running", message: "Restarted the \(label) VM.")

        case "kill", "remove":
            // Narrate only when there's a clone to delete.
            if await manager.exists(name: vmName) {
                announceVMTransition("Killing the \(label) VM…", icon: "trash")
            }
            let removed = await manager.remove(name: vmName)
            return .ok(
                state: "removed",
                message: removed
                    ? "Killed the \(label) VM and deleted its disk clone (the next command boots a "
                        + "fresh one)."
                    : "No \(label) VM clone remained to kill.")

        default:
            return .denied(
                message: "Unknown op '\(rawOp)'. Use status, stop, suspend, resume, restart, or kill.")
        }
    }

    /// Handle a `linux_container` call: the agent manages its own throwaway Linux containers. `list` is
    /// a free query; `create`/`exec`/`stop`/`remove` are approval-gated (reuse the host_exec reason
    /// gate). Containers run under a session-scoped `nucleic-scr-<short>-…` name; the idle timer stops
    /// them and launch-time disk GC reaps their clones, so a forgotten container costs nothing durable.
    private func handleLinuxContainerCall(
        _ call: MCPApprovalServer.ContainerToolCall, defaultImage: String, hostWorktree: String
    ) async -> MCPApprovalServer.ContainerToolReply {
        guard let manager = containerManager else {
            return .denied(message: "Agent-managed containers are not available.")
        }
        guard let sid = sessionID else {
            return .denied(message: "No active session for containers.")
        }
        let op = call.op.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()

        // `list` is a read-only query — no approval.
        if op == "list" {
            let names = agentContainerSpecs.keys.sorted()
            return .ok(
                name: "", names: names,
                message: names.isEmpty
                    ? "You have not created any containers this session."
                    : "\(names.count) container(s).")
        }
        guard ["create", "exec", "stop", "remove"].contains(op) else {
            return .denied(
                message: "Unknown op '\(call.op)'. Use create, exec, stop, remove, or list.")
        }
        // Gate the mutating ops behind a reason + approval (like host_exec / mac_vm_exec).
        guard HostExecPolicy.isAcceptableReason(call.reason) else {
            return .denied(message: HostExecPolicy.missingReasonMessage())
        }
        if !autoApprove, !linuxContainerSessionGranted {
            let title: String
            switch op {
            case "create": title = "Create Linux container" + (call.image.map { " (\($0))" } ?? "")
            case "exec": title = "Run in Linux container: \(Self.oneLine(call.command ?? ""))"
            case "stop": title = "Stop Linux container \(call.name ?? "")"
            default: title = "Remove Linux container \(call.name ?? "")"
            }
            let request = ApprovalRequest(
                id: .generate(),
                sessionID: sid,
                toolCallID: nil,
                toolName: MCPApprovalServer.qualifiedLinuxContainerToolName,
                input: .object([
                    "op": .string(op),
                    "name": .string(call.name ?? ""),
                    "image": .string(call.image ?? ""),
                    "command": .string(call.command ?? ""),
                    "reason": .string(call.reason),
                ]),
                title: title,
                risk: .hostExec,
                createdAt: configuration.now())
            emit(.approvalRequested(request), nativeType: "mcp/tools/call")
            let resolved = await approvals.waitForDecision(request)
            emit(.approvalResolved(resolved), nativeType: nil)
            switch resolved.decision {
            case .deny(let reason):
                return .denied(message: reason ?? "The user denied the container op.")
            case .cancelRun:
                interruptRequested = true
                handle?.sendSignal(SIGINT)
                return .denied(message: "The user cancelled the run.")
            case .allowAlways:
                linuxContainerSessionGranted = true
            case .allow:
                break
            }
        }

        switch op {
        case "create":
            let logical = agentContainerName(for: sid, label: call.name)
            if agentContainerSpecs[logical] != nil {
                return .denied(
                    message: "A container named \(logical) already exists — exec into it, or remove "
                        + "it first.")
            }
            let image = call.image.flatMap { $0.isEmpty ? nil : $0 } ?? defaultImage
            let spec = ContainerSpec(
                name: logical,
                image: image,
                mounts: [ContainerSpec.Mount(
                    host: hostWorktree, container: hostWorktree, readOnly: false)],
                workdir: hostWorktree,
                env: [:],
                idleTimeout: TimeInterval(ProjectSandbox.defaultIdleTimeoutSeconds),
                claudeHomeStaging: "",
                claudeHomeWritable: "")
            do {
                _ = try await manager.ensureRunning(spec)
                await manager.finished(name: logical)  // arm the idle timer; the agent execs later
                agentContainerSpecs[logical] = spec
                return .ok(
                    name: logical, names: agentContainerSpecs.keys.sorted(),
                    message: "Created container \(logical) from \(image).")
            } catch {
                return .denied(message: "Container error: \(String(describing: error))")
            }

        case "exec":
            guard let logical = call.name.flatMap({ $0.isEmpty ? nil : $0 }),
                let spec = agentContainerSpecs[logical]
            else {
                return .denied(
                    message: "Unknown container. Create one first, or check `list` for the name.")
            }
            guard let command = call.command.flatMap({ $0.isEmpty ? nil : $0 }) else {
                return .denied(message: "Provide a `command` to exec.")
            }
            do {
                _ = try await manager.ensureRunning(spec)  // restart it if it idle-stopped
                defer { Task { await manager.finished(name: logical) } }
                let handle = try await manager.exec(
                    name: logical, workdir: call.workdir ?? spec.workdir, env: [:],
                    argv: ["/bin/sh", "-lc", command])
                let (code, out, err) = await drainContainerExec(handle)
                return .ran(exitCode: code, stdout: out, stderr: err)
            } catch {
                return .denied(message: "Container error: \(String(describing: error))")
            }

        case "stop":
            guard let logical = call.name.flatMap({ $0.isEmpty ? nil : $0 }),
                agentContainerSpecs[logical] != nil
            else {
                return .denied(message: "Unknown container. Check `list` for the name.")
            }
            await manager.stopAgentContainer(name: logical)
            return .ok(
                name: logical, names: agentContainerSpecs.keys.sorted(),
                message: "Stopped \(logical) (its disk is kept; exec restarts it).")

        default:  // "remove"
            guard let logical = call.name.flatMap({ $0.isEmpty ? nil : $0 }),
                agentContainerSpecs[logical] != nil
            else {
                return .denied(message: "Unknown container. Check `list` for the name.")
            }
            let removed = await manager.removeAgentContainer(name: logical)
            agentContainerSpecs[logical] = nil
            return .ok(
                name: logical, names: agentContainerSpecs.keys.sorted(),
                message: removed ? "Removed \(logical)." : "Removed \(logical) (was not running).")
        }
    }

    /// Drain a container `exec` handle to completion: collect stdout/stderr concurrently and await the
    /// exit code. The line streams strip the trailing newline, so re-add one per line. `nonisolated`
    /// so the two `async let` collectors run off-actor (and don't serialize on this actor).
    private nonisolated func drainContainerExec(
        _ handle: any ProcessHandle
    ) async -> (exitCode: Int32, stdout: String, stderr: String) {
        func collect(_ stream: AsyncThrowingStream<Data, Error>) async -> String {
            var acc = Data()
            do {
                for try await line in stream {
                    acc.append(line)
                    acc.append(0x0a)
                }
            } catch {}
            return String(decoding: acc, as: UTF8.self)
        }
        async let out = collect(handle.stdoutLines)
        async let err = collect(handle.stderrLines)
        let code = await handle.wait()
        return (code, await out, await err)
    }

    /// A session-scoped logical name for an agent-created container. `label` (if given) is sanitized to
    /// `[a-z0-9-]`; otherwise a `scratch-N` name is generated. Carries the build-channel suffix so
    /// launch-time disk GC (`ContainerManager.ownsContainer`) reaps only this channel's clones.
    private func agentContainerName(for sid: SessionID, label: String?) -> String {
        let base = "nucleic-scr-\(sid.short.lowercased())"
        let suffix = ContainerManager.channelSuffix
        if let label, !label.isEmpty {
            let cleaned = label.lowercased().map { $0.isLetter || $0.isNumber ? $0 : Character("-") }
            let safe = String(cleaned.prefix(24))
            let trimmed = safe.trimmingCharacters(in: CharacterSet(charactersIn: "-"))
            if !trimmed.isEmpty { return "\(base)-\(trimmed)\(suffix)" }
        }
        var n = 1
        while agentContainerSpecs["\(base)-scratch\(n)\(suffix)"] != nil { n += 1 }
        return "\(base)-scratch\(n)\(suffix)"
    }

    /// How the user answered a host-command conflict prompt (HOST_EXEC §concurrency).
    private enum HostConflictResolution {
        /// "Run anyway" — a false positive; override the detector and claim the slot now.
        case runAnyway
        /// "Wait" — join the FIFO queue and run once the conflicting command finishes.
        case wait
        /// "Cancel" — don't run; the agent is told to wait-and-retry itself.
        case skip
    }

    /// After the user chooses **Wait** on a host-command conflict (HOST_EXEC §concurrency): join the
    /// FIFO wait queue and poll until this session reaches the head and no similar command is still
    /// running, then run on the host and release the slot. Returns the host reply once it runs, or
    /// `nil` if the run was interrupted (Stop) before the slot freed — the caller turns that into a
    /// cancellation result. The queue keeps our place across polls, so waiters proceed in the order
    /// they chose Wait.
    ///
    /// Narrates the wait into the transcript so a parked run doesn't read as a hang: one muted note
    /// when we start waiting (naming the conflicting session), and another when the slot frees and the
    /// command actually starts. Mirrors the lock-event notes.
    private func waitForHostCommandSlot(
        sessionID sid: SessionID, command: String, workdir: String,
        conflicts: [HostCommandConflict], coordinator: ConflictCoordinator
    ) async -> MCPApprovalServer.HostExecReply? {
        await coordinator.enqueueHostCommandWait(sessionID: sid, command: command)
        emit(
            .note(NoteEvent(
                text: "Waiting for \(Self.conflictSessionNames(conflicts)) to finish a similar "
                    + "host command, then running: \(Self.oneLine(command))",
                icon: "hourglass")),
            nativeType: nil)
        while true {
            // Bail the moment the user stops the run — don't leave a phantom in the queue.
            if interruptRequested {
                await coordinator.dequeueHostCommandWait(sessionID: sid)
                return nil
            }
            switch await coordinator.claimQueuedHostCommand(sessionID: sid, command: command) {
            case .clear:
                emit(
                    .note(NoteEvent(
                        text: "The host is free — now running: \(Self.oneLine(command))",
                        icon: "play.fill")),
                    nativeType: nil)
                let reply = await runOnHost(command: command, workdir: workdir)
                await coordinator.endHostCommand(sessionID: sid)
                return reply
            case .conflict:
                // Still blocked — the twin is running (or an earlier waiter is ahead). Sleep, then
                // re-poll; the wait queue holds our FIFO place across the gap. A cancelled sleep
                // (Task teardown) just loops back to the interrupt check above.
                try? await Task.sleep(nanoseconds: Self.hostCommandWaitPollInterval)
            }
        }
    }

    /// How often a queued host command re-checks whether its slot has freed (HOST_EXEC §concurrency).
    /// One second trades negligible latency for a near-idle poll — a build the waiter is parked behind
    /// runs for far longer than this.
    private static let hostCommandWaitPollInterval: UInt64 = 1_000_000_000

    /// Surface the in-chat override prompt when a host command would collide with a *similar* command
    /// already running in another session (HOST_EXEC §concurrency). The approval carries a
    /// `host_command_conflict` payload (which session, its command, why) that the GUI renders as a
    /// dedicated card with **Run anyway** / **Wait** / **Cancel**. Deliberately one-shot — a false
    /// positive now isn't remembered, so the next overlap is re-evaluated on its own merits. "Wait"
    /// and "Run anyway" both arrive as non-deny `.allow`; they're told apart by the
    /// `HostCommandConflictSignal.waitInputKey` flag the Wait button sets in `updatedInput`.
    private func surfaceHostCommandConflict(command: String, conflicts: [HostCommandConflict]) async
        -> HostConflictResolution
    {
        let request = ApprovalRequest(
            id: .generate(),
            sessionID: sessionID ?? SessionID(rawValue: "unknown"),
            toolCallID: nil,
            toolName: MCPApprovalServer.qualifiedHostExecToolName,
            input: .object([
                "command": .string(command),
                "host_command_conflict": .object([
                    "with": .array(conflicts.map { c in
                        .object([
                            "session": .string(c.sessionTitle),
                            "command": .string(c.command),
                            "reason": .string(c.reason),
                        ])
                    })
                ]),
            ]),
            title: "Possible conflict with \(Self.conflictSessionNames(conflicts)): "
                + "a similar host command is already running",
            risk: .hostExec,
            createdAt: configuration.now())

        emit(.approvalRequested(request), nativeType: "mcp/tools/call")
        let resolved = await approvals.waitForDecision(request)
        emit(.approvalResolved(resolved), nativeType: nil)

        switch resolved.decision {
        case .allow(let updatedInput):
            // "Wait" and "Run anyway" both come back as `.allow`; the Wait button flags itself in
            // `updatedInput`, so a present-and-true flag means queue, otherwise override now.
            if updatedInput?[HostCommandConflictSignal.waitInputKey]?.boolValue == true {
                return .wait
            }
            return .runAnyway
        case .allowAlways:
            return .runAnyway  // a blanket allow → override the detector
        case .deny:
            return .skip
        case .cancelRun:
            interruptRequested = true
            handle?.sendSignal(SIGINT)
            return .skip
        }
    }

    /// Name the conflicting session(s) for the prompt title / denial message: "session 'A'",
    /// "sessions 'A' and 'B'", or "sessions 'A', 'B', and 1 more".
    private static func conflictSessionNames(_ conflicts: [HostCommandConflict]) -> String {
        let titles = conflicts.map { "'\($0.sessionTitle)'" }
        switch titles.count {
        case 0: return "another session"
        case 1: return "session \(titles[0])"
        case 2: return "sessions \(titles[0]) and \(titles[1])"
        default:
            return "sessions \(titles.prefix(2).joined(separator: ", ")), and \(titles.count - 2) more"
        }
    }

    /// The tool-result text when the user skips a conflicting host command: name the session(s) and
    /// frame it as "wait and retry," not a hard failure, so the agent coordinates rather than gives up.
    private static func hostConflictDenial(_ conflicts: [HostCommandConflict]) -> String {
        let detail = conflicts.first.map { " (\($0.sessionTitle) is running `\(oneLine($0.command))`)" } ?? ""
        return "Not run: a similar host command is already in progress in "
            + "\(conflictSessionNames(conflicts))\(detail). To avoid two host commands colliding "
            + "(e.g. simultaneous builds sharing the host, or a shared trunk's build directory), the "
            + "user chose not to run both at once. Wait for the other to finish and retry, or run a "
            + "non-overlapping command."
    }

    /// Run `command` on the host via the login shell in the session's worktree, capturing
    /// stdout/stderr (each capped) and the exit code. Runs OUTSIDE the container — this is
    /// the deliberate sandbox escape, reached only after explicit approval.
    private func runOnHost(command: String, workdir: String) async
        -> MCPApprovalServer.HostExecReply
    {
        // Pin the git/gh the agent runs on the host (`git push`, `gh pr create`, a signed `git
        // commit`, …) to the app's Managed Git credential — the same hermetic env Nucleic's own
        // host-side git uses (`provisionHost`: GIT_SSH_COMMAND on the configured key, IdentitiesOnly /
        // IdentityAgent=none / -F /dev/null, plus the SSH-signing config). Without it, the login shell
        // below inherits the user's ssh-agent and `~/.ssh` (e.g. 1Password), so the agent's push
        // authenticates with the user's *personal* key — never the Managed Git key — and prompts for
        // authorization. GIT_SSH_COMMAND pins git's ssh to the Managed Git key for *every* remote, not
        // just github.com; it governs only git, so a standalone `ssh somehost` the agent runs is
        // unaffected. No-op when no Managed Git credential is configured. GH_CONFIG_DIR is dropped: it
        // carries only `git_protocol: ssh` (no gh auth), and forcing it would break gh's *API* auth when
        // no token is set (gh resolves auth from GH_TOKEN or the host gh login instead); SSH is pinned.
        let (gitEnv, gitCleanup) = GitHubCredentialProvisioner.provisionHost()
        defer { gitCleanup() }
        var env = gitEnv
        env.removeValue(forKey: "GH_CONFIG_DIR")
        let spec = ProcessSpec(
            executable: "/bin/zsh",
            args: ["-lc", command],
            cwd: workdir,
            env: env,
            stdinMode: .closed)
        do {
            let handle = try await processHost.launch(spec)
            // Drain both pipes concurrently so a full stderr buffer can't deadlock stdout.
            async let out = Self.collect(handle.stdoutLines)
            async let err = Self.collect(handle.stderrLines)
            let code = await handle.wait()
            let (stdout, stderr) = await (out, err)
            return .ran(exitCode: code, stdout: stdout, stderr: stderr)
        } catch {
            return .ran(
                exitCode: -1, stdout: "",
                stderr: "Failed to launch host command: \(error)")
        }
    }

    /// Collect a line stream into one string, capped so a runaway build log can't blow up
    /// the tool result the agent reads back.
    private static func collect(_ stream: AsyncThrowingStream<Data, Error>, cap: Int = 64 * 1024)
        async -> String
    {
        var data = Data()
        var truncated = false
        do {
            for try await line in stream {
                if data.count < cap {
                    data.append(line)
                    data.append(0x0A)
                } else {
                    truncated = true
                }
            }
        } catch {}
        var text = String(decoding: data, as: UTF8.self)
        if truncated { text += "\n…(output truncated)" }
        return text
    }

    /// Records the run's host `cwd` onto a `host_exec` tool call event, so the transcript's in-chat
    /// host card resolves `$PWD`-style substitutions to the exact directory the command ran in — the
    /// same value the approval card uses (the agent's raw tool call carries only `command`/`reason`).
    /// A no-op for every other event/tool, when host_exec isn't exposed (`workdir == nil`), or when a
    /// `cwd` is already present — so it's idempotent across the started/completed pair for one call.
    static func recordingHostExecCwd(_ kind: AgentEvent.Kind, workdir: String?) -> AgentEvent.Kind {
        guard let workdir else { return kind }
        func enrich(_ call: ToolCall) -> ToolCall {
            guard call.name == MCPApprovalServer.qualifiedHostExecToolName,
                  var fields = call.input.objectValue, fields["cwd"] == nil
            else { return call }
            fields["cwd"] = .string(workdir)
            return ToolCall(
                toolCallID: call.toolCallID, name: call.name,
                input: .object(fields), parentToolCallID: call.parentToolCallID)
        }
        switch kind {
        case .toolCallStarted(let call): return .toolCallStarted(enrich(call))
        case .toolCallCompleted(let call): return .toolCallCompleted(enrich(call))
        default: return kind
        }
    }

    private static func oneLine(_ command: String, max: Int = 80) -> String {
        let flat = command.replacingOccurrences(of: "\n", with: " ")
            .trimmingCharacters(in: .whitespaces)
        return flat.count <= max ? flat : String(flat.prefix(max - 1)) + "…"
    }

    // MARK: - Helpers

    private func emit(_ kind: AgentEvent.Kind, nativeType: String?) {
        provisionalSeq += 1
        let event = AgentEvent(
            sessionID: sessionID ?? SessionID(rawValue: "unknown"),
            seq: provisionalSeq,
            at: configuration.now(),
            backend: .claudeCode,
            nativeType: nativeType,
            kind: kind)
        continuation?.yield(event)
    }

    /// Forward a ground-truth git op reported by the in-container interceptor shim (hopped onto
    /// this actor from the fire-and-forget `/git-event` handler) to the host via the shared
    /// `ConflictCoordinator` — the same seam `check_conflict` uses. `AppStore` owns the
    /// semantics: releasing file locks the moment a merge lands ([[git-event]] → the lock
    /// system) and recording an agent-performed merge as an autoship ship. Best-effort: dropped
    /// when there's no coordinator (pipeline tests).
    private func handleGitReport(_ call: MCPApprovalServer.GitReportCall) async {
        guard let sessionID, let conflictCoordinator else { return }
        await conflictCoordinator.observeGitOp(
            sessionID: sessionID, argv: call.argv, exitCode: call.exitCode)
    }

    /// Forward a ground-truth `gh` action reported by the in-container interceptor shim to the host
    /// via the shared `ConflictCoordinator` — the same seam as `handleGitReport`. `AppStore` records
    /// the agentic GitHub action (open/merge a PR, cut a release, …) in the Control activity feed.
    /// Best-effort: dropped when there's no coordinator (pipeline tests).
    private func handleGhReport(_ call: MCPApprovalServer.GhReportCall) async {
        guard let sessionID, let conflictCoordinator else { return }
        await conflictCoordinator.observeGhOp(
            sessionID: sessionID, argv: call.argv, exitCode: call.exitCode)
    }

    /// Forward a non-git command the interceptor reported (shim or bash tracer) to the host via
    /// the shared `ConflictCoordinator` — the same seam `handleGitReport` uses. `AppStore` owns the
    /// semantics: classify, gate to Nucleic Control sessions, and record into the command-activity
    /// feed + structured log. Best-effort: dropped when there's no coordinator (pipeline tests).
    private func handleCommandReport(_ call: MCPApprovalServer.CommandReportCall) async {
        guard let sessionID, let conflictCoordinator else { return }
        await conflictCoordinator.observeCommand(sessionID: sessionID, call: call)
    }

    /// NDJSON user-message envelope (ADAPTERS §1.3 — pinned by the M0 live capture).
    static func userMessageLine(for input: AgentInput) -> Data {
        var blocks: [JSONValue] = []
        for part in input.parts {
            switch part {
            case .text(let text):
                blocks.append(.object(["type": .string("text"), "text": .string(text)]))
            case .context(let label, let body):
                blocks.append(
                    .object(["type": .string("text"), "text": .string("[\(label)]\n\(body)")]))
            case .attachment(let attachment):
                // Attachments are normally materialized to disk and folded into the text before a
                // turn reaches any backend (`AppStore.materializeRemoteAttachments`), so this is a
                // defensive fallback: reference the file by name rather than drop it silently.
                blocks.append(.object([
                    "type": .string("text"),
                    "text": .string("[Attached file: \(attachment.filename)]")]))
            }
        }
        let line = JSONValue.object([
            "type": .string("user"),
            "message": .object(["role": .string("user"), "content": .array(blocks)]),
        ])
        return (try? line.encodedData()) ?? Data()
    }

    private func makeCaptureFile() throws -> CaptureFile? {
        guard let directory = configuration.captureDirectory else { return nil }
        try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
        return try CaptureFile(url: directory.appendingPathComponent("native.ndjson"))
    }
}

/// Ring of recent stderr lines, kept for the synthesized error message when the
/// CLI exits abnormally.
private final class StderrTail: @unchecked Sendable {
    private let lock = NSLock()
    private var lines: [String] = []

    func append(_ line: String) {
        lock.lock()
        defer { lock.unlock() }
        lines.append(line)
        if lines.count > 50 { lines.removeFirst(lines.count - 50) }
    }

    func joined() -> String {
        lock.lock()
        defer { lock.unlock() }
        return lines.suffix(10).joined(separator: "\n")
    }
}

private final class CaptureFile: @unchecked Sendable {
    private let handle: FileHandle
    private let lock = NSLock()

    init(url: URL) throws {
        FileManager.default.createFile(atPath: url.path, contents: nil)
        handle = try FileHandle(forWritingTo: url)
    }

    func append(line: Data) {
        lock.lock()
        defer { lock.unlock() }
        var framed = line
        framed.append(0x0A)
        try? handle.write(contentsOf: framed)
    }

    func close() {
        lock.lock()
        defer { lock.unlock() }
        try? handle.close()
    }
}
