import Foundation

/// `AgentBackend` over xAI Grok's **ACP** mode — `grok agent stdio` (BACKEND_PROTOCOL §7).
/// ACP (the Agent Client Protocol) is standards JSON-RPC 2.0 over the process's stdio, baked
/// directly into the `grok` binary, so this backend is a near-twin of
/// [`CodexAppServerBackend`](../Codex/CodexAppServerBackend.swift): it speaks JSON-RPC via the
/// shared [`JSONRPCConnection`](../JSONRPCConnection.swift) (with the `"jsonrpc"` header on, as
/// ACP requires), normalizes `session/update` notifications into `AgentEvent`s
/// ([`GrokACPDecoder`](GrokACPDecoder.swift)), and answers interactive approvals — which the agent
/// sends as `session/request_permission` server→client *requests* — through the shared
/// `ApprovalCoordinator`, the same suspension seam Claude's MCP bridge and Codex's JSON-RPC use.
///
/// This **replaces** the previous `grok -p --output-format streaming-json` + generated-PreToolUse-
/// hook design (and its `/grok-hook` HTTP bridge): ACP gives native streaming and a native
/// permission request, so the inferred streaming-json vocab and the entire hook bridge are gone.
///
/// **Turn model (v1).** Per-turn process, like the Codex backends: `start` spawns the agent and
/// opens a session; each follow-up `resume` spawns a fresh process and `session/load`s the same
/// session by id (falling back to a fresh session if the agent doesn't advertise `loadSession`).
/// `followUpWhileRunning = false`. Runs on the host by default; when a run carries a vsock control
/// container (Nucleic Control, control plane enabled) it execs inside it — stdio over vsock, with the
/// git/gh/command interceptor wired to the shared per-container server. (`sandboxModes = []` refers to
/// Codex-style in-process sandbox levels, which ACP doesn't expose — distinct from the container.)
public actor GrokACPBackend: AgentBackend {
    public static let id = BackendID.grok

    public nonisolated let capabilities = BackendCapabilities(
        interactiveApprovals: true,                       // session/request_permission bridge
        allowAlwaysScopes: [.session, .toolName, .toolNameWithPattern],  // via our ApprovalCoordinator cache
        canModifyToolInput: false,                        // ACP permission can't rewrite tool input
        partialMessageStreaming: true,                    // agent_message_chunk / agent_thought_chunk
        emitsThinking: true,                              // agent_thought_chunk
        emitsFileChangeEvents: false,                     // synthesized from tool-call locations
        nativeResume: true,                               // session/load
        sandboxModes: [],                                 // host-only in v1
        followUpWhileRunning: false)                      // single-shot per turn; resume next turn

    public struct Configuration: Sendable {
        /// Executable name (resolved via PATH) or absolute path. Tests point this at the
        /// fake-grok stub (OBSERVABILITY B.4).
        public var executable: String
        public var clientName: String
        public var clientVersion: String
        public var now: @Sendable () -> Date

        public init(
            executable: String = "grok",
            clientName: String = "Nucleic",
            clientVersion: String = "0.1",
            now: @escaping @Sendable () -> Date = { Date() }
        ) {
            self.executable = executable
            self.clientName = clientName
            self.clientVersion = clientVersion
            self.now = now
        }
    }

    /// ACP protocol version we negotiate (`grok agent stdio` speaks v1).
    static let protocolVersion = 1

    private let configuration: Configuration
    private let processHost: ProcessHost
    public let approvals: ApprovalCoordinator
    /// Optional conflict coordinator. When set, edit-class permission requests are arbitrated so
    /// Grok sessions join the lock system + Nucleic Control autoship, exactly like Claude.
    private let conflictCoordinator: ConflictCoordinator?
    /// Optional sandbox orchestrator. When set and a run carries a vsock control container, the
    /// agent execs inside it (stdio over vsock) instead of on the host — the same per-family control
    /// container isolation Claude gets. `nil` → always host.
    private let containerManager: ContainerManager?
    /// Name of the container the current run execs in (for the teardown `finished` callback).
    private var activeContainerName: String?
    /// Vends the shared, token-multiplexed approval server for a control container — Grok uses it
    /// only for the git/gh/command interceptor report routes (its approvals are native ACP, not
    /// MCP). `nil` → no interceptor reporting (host runs, or tests).
    private let approvalServerRegistry: ApprovalServerRegistry?
    /// The interceptor server + token for the current containerized run (for teardown unregister).
    private var interceptorServer: MCPApprovalServer?
    private var interceptorToken: String?

    private var handle: (any ProcessHandle)?
    private var rpc: JSONRPCConnection?
    private var decoder = GrokACPDecoder()
    private var continuation: AsyncThrowingStream<AgentEvent, Error>.Continuation?
    private var sessionID: SessionID?
    private var provisionalSeq: UInt64 = 0
    private var interruptRequested = false
    private var terminating = false
    private var sawRunFinished = false
    private var autoApprove = false
    /// The open ACP session's id (kept across turns so `resume` reattaches the same session).
    private var acpSessionID: String?
    /// Permission handlers run as detached tasks (the transport dispatches inbound requests off the
    /// read loop), so they can still be mid-flight when the `session/prompt` response settles the
    /// turn — ACP's terminal is decoupled from the permission round-trip. We count them and drain to
    /// zero before finishing, so every `approvalRequested` gets its `approvalResolved` into the
    /// stream ahead of `runFinished` (and its reply written before teardown closes the connection).
    private var inflightApprovals = 0
    private var approvalsDrained: CheckedContinuation<Void, Never>?
    public private(set) var backendSessionID: String?

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

    // MARK: - AgentBackend

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

    public nonisolated func resume(_ resume: ResumeSpec) -> AsyncThrowingStream<AgentEvent, Error> {
        let run = RunSpec(
            sessionID: resume.sessionID,
            worktree: resume.worktree,
            prompt: resume.prompt ?? AgentInput(parts: []),
            model: resume.model,
            effort: resume.effort,
            autoApprove: resume.autoApprove,
            appendSystemPrompt: resume.appendSystemPrompt)
        return makeStream(run: run, resumeSessionID: resume.backendSessionID)
    }

    public func send(_ input: AgentInput) async throws {
        // ACP turns are single-shot per process in v1; conversational follow-ups arrive via
        // resume() (which session/loads the same session), as for the Codex backends.
        throw BackendError.unsupported("Grok ACP follow-ups resume a new turn; send() is unused in v1")
    }

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

    public func interrupt() async {
        interruptRequested = true
        await cancelCurrentSession()
    }

    public func shutdown() async {
        terminating = true
        await approvals.cancelOutstanding(reason: "Session terminated")
        await teardownTurn()
    }

    // MARK: - Run loop

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

    private func runLoop(
        run: RunSpec,
        resumeSessionID: String?,
        continuation: AsyncThrowingStream<AgentEvent, Error>.Continuation
    ) async {
        self.continuation = continuation
        self.sessionID = run.sessionID
        self.sawRunFinished = false
        self.interruptRequested = false
        self.autoApprove = run.autoApprove
        self.decoder = GrokACPDecoder()

        // A re-attach-only resume (no prompt) needs no process: the session id we'd reattach is
        // already known and the UI history comes from our transcript. Settle and return.
        if run.prompt.parts.isEmpty {
            if let resumeSessionID { acpSessionID = resumeSessionID; backendSessionID = resumeSessionID }
            finishRun(outcome: .completed)
            continuation.finish()
            return
        }

        let stderrTail = GrokStderrTail()
        do {
            // 1. Spawn `grok agent stdio` — inside the shared control container (stdio over vsock)
            // when this run carries a vsock control socket, else on the host. The container path is
            // gated on that signal (vsock control plane on + a shared control container), so default
            // behavior is unchanged. (Requires `grok` + its auth in the sandbox image.)
            // 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 — after the agent process ends). A
            // containerized run gets the key via `provision` (cspec.env). No-op when unset.
            let isHostRun = !(containerManager != nil && run.container?.controlSocketHostPath != 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() }

            let handle: any ProcessHandle
            if let containerManager, let cspec = run.container, let controlSock = cspec.controlSocketHostPath {
                let (name, _) = try await containerManager.ensureRunning(cspec)
                activeContainerName = name
                var env = cspec.env.merging(run.extraEnv) { _, new in new }
                // Git/gh/command interceptor: Grok's in-container shims POST observed ops to the
                // shared per-container approval server (over the relayed socket, via the in-guest
                // bridge), which forwards them to the conflict coordinator (locks + autoship) — the
                // same observation Claude gets. Grok's approvals stay native ACP, so only the report
                // routes are registered, not the MCP approve tool.
                if cspec.installGitInterceptor, conflictCoordinator != nil,
                    let registry = approvalServerRegistry
                {
                    let server = await registry.server(for: cspec.name)
                    try await server.start(unixSocketPath: controlSock)
                    let token = UUID().uuidString
                    interceptorServer = server
                    interceptorToken = token
                    await server.registerGitReport(token: token) { [weak self] call in
                        Task { await self?.handleGitReport(call) }
                    }
                    await server.registerGhReport(token: token) { [weak self] call in
                        Task { await self?.handleGhReport(call) }
                    }
                    await server.registerCommandReport(token: token) { [weak self] call in
                        Task { await self?.handleCommandReport(call) }
                    }
                    env.merge(
                        CommandInterceptor.hookEnv(
                            host: "127.0.0.1", port: ContainerSpec.controlBridgePort,
                            token: token, sessionID: run.sessionID,
                            commandTracing: ContainerServiceSettings.commandTracingEnabled)
                    ) { _, new in new }
                }
                handle = try await containerManager.exec(
                    name: name, workdir: cspec.workdir, env: env,
                    argv: [configuration.executable, "agent", "stdio"],
                    uid: cspec.runAsUID, gid: cspec.runAsGID)
            } else {
                let spec = ProcessSpec(
                    executable: configuration.executable,
                    args: ["agent", "stdio"],
                    cwd: run.worktree,
                    env: run.extraEnv.merging(hostManagedGitEnv) { _, new in new },
                    stdinMode: .pipe)
                handle = try await processHost.launch(spec)
            }
            self.handle = handle

            let stderrTask = Task {
                for try await line in handle.stderrLines {
                    let text = String(decoding: line, as: UTF8.self)
                    stderrTail.append(text)
                    if ProcessInfo.processInfo.environment["NUCLEIC_DEBUG_STDERR"] != nil {
                        FileHandle.standardError.write(Data(("grok-stderr: " + text + "\n").utf8))
                    }
                }
            }

            // 2. JSON-RPC client (ACP: header on) + inbound (permission) request handler.
            let rpc = JSONRPCConnection(handle: handle, includeVersionHeader: true)
            self.rpc = rpc
            await rpc.start { [weak self] request in
                await self?.handleInboundRequest(request)
            }

            // 3. Handshake: initialize → (authenticate if required) → session/new | session/load.
            let initResult = try await rpc.request("initialize", params: .object([
                "protocolVersion": .number(Double(Self.protocolVersion)),
                "clientCapabilities": .object([
                    "fs": .object(["readTextFile": .bool(false), "writeTextFile": .bool(false)]),
                    "terminal": .bool(false),
                ]),
                "clientInfo": .object([
                    "name": .string(configuration.clientName),
                    "version": .string(configuration.clientVersion),
                ]),
            ]))
            let agentCaps = initResult["agentCapabilities"] ?? .object([:])
            let supportsLoad = agentCaps["loadSession"]?.boolValue ?? false
            // If the agent advertises auth methods, it isn't authenticated yet — pick the first
            // (best-effort; grok with a prior login or XAI_API_KEY reports none).
            if let methodID = initResult["authMethods"]?.arrayValue?.first?["id"]?.stringValue {
                _ = try? await rpc.request("authenticate", params: .object(["methodId": .string(methodID)]))
            }

            let sessionID = try await openSession(
                rpc: rpc, run: run, resumeSessionID: resumeSessionID, supportsLoad: supportsLoad)
            acpSessionID = sessionID
            backendSessionID = sessionID
            emit(
                .sessionStarted(
                    SessionStarted(
                        backendSessionID: sessionID,
                        model: run.model ?? "",
                        cwd: run.worktree,
                        toolNames: [],
                        nativeTranscriptPath: nil)),
                nativeType: "session/new")

            // 4. Drain session/update notifications concurrently with the prompt turn. The decoder
            //    stays actor-isolated (only `self` + `rpc`, both Sendable, cross into the Task).
            let drainTask = Task { [rpc] in
                for await note in rpc.notifications {
                    self.handleNotification(note)
                }
            }

            // 5. Run the turn. ACP's terminal is the session/prompt *response* (a stopReason), not
            //    a notification — so await it, then end the notification stream and let the drain
            //    deliver every buffered update before we settle (no trailing chunk dropped).
            let promptResult = try? await rpc.request(
                "session/prompt",
                params: .object([
                    "sessionId": .string(sessionID),
                    "prompt": Self.promptContent(for: run.prompt),
                ]))
            await rpc.endNotifications()
            await drainTask.value

            // 6. Resolve + drain any still-outstanding approval BEFORE settling. ACP's terminal (the
            //    prompt response) can arrive with a permission request still suspended (e.g. an
            //    interrupt/cancel); resolving it here and waiting for its handler to finish keeps
            //    every approvalResolved (and its wire reply) ahead of runFinished + teardown.
            await approvals.cancelOutstanding(reason: "Run ended")
            await drainOutstandingApprovals()

            // 7. Settle the turn.
            if let promptResult {
                let stopReason = promptResult["stopReason"]?.stringValue
                emit(
                    .turnCompleted(TurnCompleted(stopReason: stopReason, usage: nil)),
                    nativeType: "session/prompt")
                finishRun(outcome: Self.outcome(forStopReason: stopReason, interrupted: interruptRequested))
            } else if interruptRequested {
                finishRun(outcome: .interrupted)
            } else if terminating {
                finishRun(outcome: .completed)
            } else {
                // No response and not a deliberate stop → the agent exited mid-turn. Classify like
                // the other backends (ADAPTERS §1.5).
                let exitCode = await handle.wait()
                emit(
                    .error(
                        AgentError(
                            recoverable: false,
                            message: BackendDiagnostics.abnormalExitMessage(
                                tool: "grok", exitCode: exitCode,
                                stderrTail: stderrTail.joined(), containerized: false))),
                    nativeType: nil)
                finishRun(outcome: .errored)
            }

            stderrTask.cancel()
            await teardownTurn()
            continuation.finish()
        } catch {
            // A deliberate stop (interrupt/shutdown) can abort the handshake mid-flight — settle the
            // stream cleanly so a conversational session lands on awaitingInput (resumable), not a
            // terminal error. Anything else is a genuine setup failure; throw so SessionController
            // synthesizes the terminal error + runFinished (matching the other backends).
            if interruptRequested || terminating {
                await approvals.cancelOutstanding(reason: "Run interrupted")
                await drainOutstandingApprovals()
                if !sawRunFinished {
                    finishRun(outcome: interruptRequested ? .interrupted : .completed)
                }
                await teardownTurn()
                continuation.finish()
            } else {
                await approvals.cancelOutstanding(reason: "Run failed")
                await drainOutstandingApprovals()
                await teardownTurn()
                continuation.finish(throwing: error)
            }
        }
    }

    /// Open the ACP session: `session/load` by id when resuming an agent that supports it, else a
    /// fresh `session/new` (a resume against an agent without `loadSession` degrades to a new
    /// session — its new id becomes the resumable handle). `mcpServers` is empty: Nucleic runs
    /// hermetic sessions, and approvals ride ACP's native permission request, not an MCP server.
    private func openSession(
        rpc: JSONRPCConnection, run: RunSpec, resumeSessionID: String?, supportsLoad: Bool
    ) async throws -> String {
        let mcpServers = JSONValue.array([])
        if let resumeSessionID, supportsLoad {
            _ = try await rpc.request("session/load", params: .object([
                "sessionId": .string(resumeSessionID),
                "cwd": .string(run.worktree),
                "mcpServers": mcpServers,
            ]))
            return resumeSessionID
        }
        let result = try await rpc.request("session/new", params: .object([
            "cwd": .string(run.worktree),
            "mcpServers": mcpServers,
        ]))
        return result["sessionId"]?.stringValue ?? resumeSessionID ?? ""
    }

    /// Decode + emit one drained notification (actor-isolated so the decoder never crosses an
    /// isolation boundary).
    private func handleNotification(_ note: JSONRPCConnection.Notification) {
        for decoded in decoder.decode(method: note.method, params: note.params) {
            emit(decoded.kind, nativeType: decoded.nativeType)
        }
    }

    private func finishRun(outcome: RunFinished.Outcome) {
        guard !sawRunFinished else { return }
        sawRunFinished = true
        emit(.runFinished(RunFinished(outcome: outcome)), nativeType: nil)
    }

    /// Suspend until every in-flight permission handler has finished (emitted its `approvalResolved`
    /// and written its reply). A no-op when none are outstanding. Resumed by the last handler's
    /// completion in `handlePermissionRequest`'s `defer`.
    private func drainOutstandingApprovals() async {
        guard inflightApprovals > 0 else { return }
        await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
            approvalsDrained = continuation
        }
    }

    /// Tear down the just-finished turn's process + connection. `acpSessionID`/`backendSessionID`
    /// persist so the next `resume` reattaches the same session.
    private func teardownTurn() async {
        if let rpc { await rpc.close() }
        self.rpc = nil
        if let handle {
            handle.closeStdin()
            await handle.terminate()
        }
        self.handle = nil
        if let token = interceptorToken {
            // Unregister only this run's token; the shared per-container server keeps serving the
            // other sessions in the box.
            await interceptorServer?.unregister(token: token)
            interceptorToken = nil
            interceptorServer = nil
        }
        if let name = activeContainerName {
            await containerManager?.finished(name: name)
            activeContainerName = nil
        }
    }

    // MARK: - Interceptor reporting (Nucleic Control containers)

    /// Forward a ground-truth git op reported by the in-container `git` shim to the conflict
    /// coordinator (locks + autoship), the same seam Claude's interceptor uses. Best-effort.
    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 (PR/release/API) reported by the in-container `gh` shim.
    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) for the command feed.
    private func handleCommandReport(_ call: MCPApprovalServer.CommandReportCall) async {
        guard let sessionID, let conflictCoordinator else { return }
        await conflictCoordinator.observeCommand(sessionID: sessionID, call: call)
    }

    /// Cooperatively cancel the in-flight turn: ACP `session/cancel` (a notification); the agent
    /// halts and answers the outstanding `session/prompt` with `stopReason:"cancelled"`.
    private func cancelCurrentSession() async {
        guard let rpc, let acpSessionID else {
            handle?.interruptThenKill()  // no live session → signal + escalate so Stop always lands
            return
        }
        try? await rpc.notify("session/cancel", params: .object(["sessionId": .string(acpSessionID)]))
        // Safety net: if the agent ignores the cooperative cancel, force-stop the process group after
        // a grace so Stop still takes effect (the next resume reattaches the same ACP session).
        handle?.killGroupAfter(graceNanos: 8 * 1_000_000_000)
    }

    // MARK: - Approval bridge (server→client requests)

    private func handleInboundRequest(_ request: JSONRPCConnection.InboundRequest) async {
        guard let rpc else { return }
        switch request.method {
        case "session/request_permission":
            await handlePermissionRequest(request, rpc: rpc)
        case "fs/read_text_file", "fs/write_text_file":
            // We advertised clientCapabilities.fs = false, so the agent shouldn't call these; if it
            // does, answer method-not-found rather than hang (grok uses its own file tools).
            try? await rpc.replyError(
                to: request.id, code: -32601, message: "Client does not provide \(request.method)")
        default:
            try? await rpc.replyError(
                to: request.id, code: -32601, message: "Unsupported request \(request.method)")
        }
    }

    /// Decide a `session/request_permission`. Mirrors the Claude/old-Grok approval path: conflict
    /// pre-check for edits, the always-rule cache, the read-only fast path, the auto-approve
    /// blanket, then surface to the human — all suspending on the shared `ApprovalCoordinator`.
    /// The reply is an ACP outcome ([`GrokACPDecisionMapping`](GrokACPDecisionMapping.swift)).
    private func handlePermissionRequest(
        _ request: JSONRPCConnection.InboundRequest, rpc: JSONRPCConnection
    ) async {
        inflightApprovals += 1
        defer {
            inflightApprovals -= 1
            if inflightApprovals == 0, let continuation = approvalsDrained {
                approvalsDrained = nil
                continuation.resume()
            }
        }

        let toolCall = request.params["toolCall"] ?? .object([:])
        let options = request.params["options"]?.arrayValue ?? []
        let toolCallID = toolCall["toolCallId"]?.stringValue
        // ACP's `toolCall` is a `ToolCallUpdate` — every field but `toolCallId` is optional, since
        // the full details were already sent in the earlier `tool_call` notification. Recover them
        // by id from the decoder, preferring whatever the request did include (RUNTIME §5).
        let prior = toolCallID.flatMap { decoder.announcedToolCall(id: $0) }
        let kindStr = toolCall["kind"]?.stringValue ?? prior?.kind ?? "other"
        let rawInput = toolCall["rawInput"] ?? prior?.rawInput ?? .object([:])
        let requestLocations = GrokACPDecoder.locationPaths(toolCall["locations"])
        let locations = requestLocations.isEmpty ? (prior?.locations ?? []) : requestLocations
        let toolName = GrokACPDecoder.toolName(forKind: kindStr)
        let input = GrokACPDecoder.normalizedInput(forKind: kindStr, rawInput: rawInput, locations: locations)

        func reply(_ decision: Decision) async {
            let outcome = GrokACPDecisionMapping.permissionOutcome(for: decision, options: options)
            try? await rpc.reply(to: request.id, result: .object(["outcome": outcome]))
        }

        // Conflict pre-check (internal project management): arbitrate every edit-class call so each
        // touched file acquires its own lock before the edit lands (LOCKING §4.5).
        let editPaths = GrokACPDecoder.editPaths(forKind: kindStr, rawInput: rawInput, locations: locations)
        if let conflictCoordinator, !editPaths.isEmpty {
            let outcome = await conflictCoordinator.arbitrate(
                sessionID: sessionID ?? SessionID(rawValue: "unknown"), task: "", files: editPaths)
            switch outcome.resolution {
            case .deferred, .cancelled:
                await reply(.deny(reason: nil))  // blocked: another active Nucleic agent owns these files
                return
            case .proceed, .noConflict:
                // Lock granted (or nothing to lock). If update-on-grant pulled a parent change into a
                // file this edit targets, deny once to re-ground: ACP can't carry the notice text to
                // the model, but the lock is held, so grok's natural retry (re-reading the file)
                // re-grants instantly against fresh content — matching Claude's re-ground behavior
                // instead of letting a stale-context write through (LOCKING §4.5).
                if outcome.refreshNotice != nil {
                    await reply(.deny(reason: nil))
                    return
                }
            }
        }

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

        var risk = RiskClassifier.classify(toolName: toolName, input: input)
        // A native `delete` kind maps to the `Edit` tool name (a write) for the UI, but deleting a
        // file is destructive — force the destructive gate so it always surfaces and is never
        // swept up by the auto-approve blanket below.
        if kindStr == "delete" { risk = .destructive }

        // Read-only fast path: cheap reads never prompt (the Claude path pre-allows Read/Glob/Grep).
        if risk == .readOnly {
            await reply(.allow())
            return
        }

        // Nucleic-managed auto-approve: approve everything except destructive actions — and except
        // un-classifiable ones (`.unknown`), which surface so an unrecognized/under-specified tool
        // call is never silently auto-approved. The edit conflict pre-check above already ran.
        if autoApprove, risk != .destructive, risk != .unknown {
            await reply(.allow())
            return
        }

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

        emit(.approvalRequested(approval), nativeType: "session/request_permission")
        let resolved = await approvals.waitForDecision(approval)
        emit(.approvalResolved(resolved), nativeType: nil)

        if case .cancelRun = resolved.decision {
            interruptRequested = true
            await cancelCurrentSession()
        }
        await reply(resolved.decision)
    }

    // MARK: - Helpers

    /// ACP `ContentBlock[]` for a turn. Context parts render as labeled text blocks.
    static func promptContent(for input: AgentInput) -> JSONValue {
        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)")]))
            }
        }
        return .array(blocks)
    }

    /// Map an ACP `stopReason` onto a run outcome. `end_turn` is a normal turn end (→ awaitingInput,
    /// resumable); token/request limits → maxTurns; an explicit cancel → interrupted.
    static func outcome(forStopReason stopReason: String?, interrupted: Bool) -> RunFinished.Outcome {
        if interrupted { return .interrupted }
        switch stopReason {
        case "end_turn", "completed": return .completed
        case "cancelled": return .interrupted
        case "max_tokens", "max_turn_requests": return .maxTurns
        case "refusal": return .completed  // the model declined; the turn ended cleanly
        default: return .completed
        }
    }

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

/// Ring of recent stderr lines, kept for the synthesized error message on an abnormal exit.
private final class GrokStderrTail: @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")
    }
}
