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).
    ///
    /// * `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",
    ]

    /// The full environment pinned onto every spawned `claude` (host spawn or container exec): the MCP
    /// timeouts 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 }
    }

    /// 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 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
    /// 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
    public private(set) var backendSessionID: String?

    public init(
        configuration: Configuration = Configuration(),
        processHost: ProcessHost = ProcessHost(),
        approvalServer: MCPApprovalServer = MCPApprovalServer(),
        approvals: ApprovalCoordinator = ApprovalCoordinator(),
        containerManager: ContainerManager? = nil,
        conflictCoordinator: ConflictCoordinator? = nil,
        approvalServerRegistry: ApprovalServerRegistry? = nil
    ) {
        self.configuration = configuration
        self.processHost = processHost
        self.approvalServer = approvalServer
        self.approvals = approvals
        self.containerManager = containerManager
        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,
            orchestraActive: resume.orchestraActive,
            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) 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
        // 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")
        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

        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 so we know the
            // host-gateway address the child must use to reach us.
            let sandbox = (run.container != nil) ? containerManager : nil
            var mcpHost = "127.0.0.1"
            if let sandbox, let cspec = run.container {
                let (name, gateway) = try await sandbox.ensureRunning(cspec)
                activeContainerName = name
                mcpHost = gateway
            }

            // 1. Approval bridge: per-session bearer token → handler. For a containerized
            // child we bind on all interfaces (gated by the bearer token) so it can reach
            // us over the VM gateway; otherwise loopback as before.
            let token = UUID().uuidString
            serverToken = token
            // Transport: with a relayed control socket, serve ONLY on the 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. Otherwise the legacy path: bind the gateway (containerized) or loopback (host).
            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
            } else {
                port = try await server.start(host: sandbox != nil ? "0.0.0.0" : "127.0.0.1")
                controlHost = mcpHost
                // 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)
                }
            }
            // 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).
            if configuration.strictMCPConfig {
                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)
            }
            // Orchestra: pre-allow the subagent-spawn tools so a fan-out runs without an approval
            // prompt on every spawn. Only the *spawn* is auto-approved — the subagent's own inner
            // tool calls (Edit/Bash/…) aren't in this list, so they still route through the
            // approval server and the conflict coordinator. Names match the spawn tools the rest
            // of the core recognizes (`RiskClassifier`, transcript projection).
            if run.orchestraActive {
                allowedTools += ["Task", "Agent"]
            }
            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 VM gateway) 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
                    // (gateway+TCP, or the loopback control bridge on the vsock path). 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()
            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 = decoded.kind
                    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
    {
        // 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)
            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
    {
        if !hostExecSessionGranted {
            let request = ApprovalRequest(
                id: .generate(),
                sessionID: sessionID ?? SessionID(rawValue: "unknown"),
                toolCallID: nil,
                toolName: MCPApprovalServer.qualifiedHostExecToolName,
                input: .object(["command": .string(call.command)]),
                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" — grant the rest of the session without re-prompting.
                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; "Run anyway" overrides a false positive, "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):
                if await surfaceHostCommandConflict(command: call.command, conflicts: conflicts) {
                    override = true  // user dismissed it as a false positive — re-claim, forcing past it
                    continue
                }
                return .denied(message: Self.hostConflictDenial(conflicts))
            }
        }
    }

    /// 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** / **Cancel**. Returns true to run anyway (override the
    /// detector), false to skip. Deliberately one-shot — a false positive now isn't remembered, so the
    /// next overlap is re-evaluated on its own merits.
    private func surfaceHostCommandConflict(command: String, conflicts: [HostCommandConflict]) async
        -> Bool
    {
        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, .allowAlways:
            return true  // "Run anyway" (and any blanket allow) → override the detector
        case .deny:
            return false
        case .cancelRun:
            interruptRequested = true
            handle?.sendSignal(SIGINT)
            return false
        }
    }

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

    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)")]))
            }
        }
        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()
    }
}
