import Foundation

/// The hub for ONE session and the **single writer** of its state (RUNTIME §1-2).
///
/// It consumes the backend's `AgentEvent` stream on one task, stamps each event
/// with a canonical session-wide `seq` via `TranscriptWriter`, derives the session
/// status, and fans the canonical events out to subscribers (UI, and later sync).
/// UI never touches the backend or git directly — it sends intents here
/// (`sendInput`, `respondToApproval`, `integrate`) and observes `snapshot`.
public actor SessionController {
    private let backend: any AgentBackend
    private let transcript: TranscriptWriter
    private let now: @Sendable () -> Date
    /// Conversational (chat) mode: each turn is its own single-shot run (the first
    /// via `start`, follow-ups via `resume(prompt:)` — the only reliable way to use
    /// the approval server, since Claude's streaming-input mode hangs alongside
    /// `--permission-prompt-tool`). A turn's terminal `runFinished` therefore means
    /// "awaiting the next message", not "session over". The session ends only on
    /// explicit `shutdown`/`discard`.
    private let conversational: Bool

    // Optional git wiring — present for real sessions, nil in pure pipeline tests.
    private let worktreeManager: (any WorktreeManaging)?
    private let project: Project?
    private let worktree: Worktree?
    /// Optional metadata sink (RUNTIME §2 step 3). Persistence is best-effort: a
    /// store failure never corrupts the canonical transcript or the live fan-out.
    private let metadataStore: (any SessionMetadataStore)?

    private(set) var session: Session
    private var pendingApprovals: [ApprovalID: ApprovalRequest] = [:]
    private var history: [AgentEvent] = []
    private var subscribers: [UUID: AsyncStream<AgentEvent>.Continuation] = [:]
    private var runTask: Task<Void, Never>?
    private var userTurnCount = 0
    /// A conversational follow-up that arrived while the previous turn's run was still
    /// closing (its terminal event already flipped us to `awaitingInput`, but the backend
    /// stream hadn't finished, so `runTask` was still set). Held here and started the
    /// instant the current run finishes — never dropped.
    private var pendingInput: AgentInput?

    public init(
        session: Session,
        backend: any AgentBackend,
        transcript: TranscriptWriter,
        worktreeManager: (any WorktreeManaging)? = nil,
        project: Project? = nil,
        worktree: Worktree? = nil,
        metadataStore: (any SessionMetadataStore)? = nil,
        conversational: Bool = false,
        initialHistory: [AgentEvent] = [],
        now: @escaping @Sendable () -> Date = { Date() }
    ) {
        self.session = session
        self.backend = backend
        self.transcript = transcript
        self.worktreeManager = worktreeManager
        self.project = project
        self.worktree = worktree
        self.metadataStore = metadataStore
        self.conversational = conversational
        self.history = initialHistory  // replayed transcript for a resumed session
        self.now = now
    }

    // MARK: - Observation

    /// A point-in-time view for the UI / sync summary list.
    public struct Snapshot: Sendable, Equatable {
        public let session: Session
        public let pendingApprovals: [ApprovalRequest]
    }

    public var snapshot: Snapshot {
        Snapshot(
            session: session,
            pendingApprovals: pendingApprovals.values.sorted { $0.createdAt < $1.createdAt })
    }

    /// Canonical events appended so far — replayed into a newly opened transcript
    /// view before attaching the live `subscribe()` stream (RUNTIME §6.2).
    public func transcriptSoFar() -> [AgentEvent] { history }

    /// A live fan-out of canonical events. Multiple subscribers are independent;
    /// each gets every event appended after it subscribes.
    public func subscribe() -> AsyncStream<AgentEvent> {
        let id = UUID()
        return AsyncStream { continuation in
            subscribers[id] = continuation
            continuation.onTermination = { [weak self] _ in
                Task { await self?.removeSubscriber(id) }
            }
        }
    }

    private func removeSubscriber(_ id: UUID) {
        subscribers[id] = nil
    }

    /// Await the current run's completion (the consume task finishing). Returns
    /// immediately if no run is in flight. Useful for tests and orderly shutdown.
    public func join() async {
        await runTask?.value
    }

    // MARK: - Lifecycle intents

    /// The session's worktree path (live worktree, else the persisted path).
    private var worktreePath: String { session.worktreePath ?? worktree?.path ?? "" }

    /// Whether this session runs on its own isolated worktree branch. Worktree-less
    /// sessions run directly in the project's main checkout on the chosen branch, so
    /// there is nothing to merge (and `integrate()` has no git context). Drives whether
    /// autoship has any branch to land.
    public var hasWorktree: Bool { worktree != nil }

    /// Host build/run opt-in (HOST_EXEC): only when the project is effectively sandboxed AND
    /// host execution is allowed. Drives whether the `host_exec` tool is exposed for the run.
    private var allowsHostExec: Bool {
        guard ContainerServiceSettings.serviceEnabled else { return false }
        guard let sandbox = project?.effectiveSandbox, sandbox.enabled else { return false }
        return sandbox.allowHostExec
    }

    /// Whether this session's `claude` runs inside a container (the same gate `containerSpec()`
    /// uses, minus the side effects of building the spec). When false the agent runs on the host
    /// and already has the full toolchain, so no build-environment guidance is needed.
    private var isSandboxed: Bool {
        guard ContainerServiceSettings.serviceEnabled else { return false }
        guard let sandbox = project?.effectiveSandbox, sandbox.enabled else { return false }
        return true
    }

    /// System-prompt guidance appended to a sandboxed run so the agent knows how to build, run,
    /// and test in the container — and, critically, that the Swift/Xcode toolchain is NOT in the
    /// container but on the host, reachable via `host_exec`. Without it an agent that tries
    /// `swift build` just hits "command not found" with no idea the host escape hatch exists.
    /// `nil` for host runs (toolchain already present).
    private var environmentSystemPrompt: String? {
        guard isSandboxed else { return nil }
        return Self.sandboxBuildGuidance(allowHostExec: allowsHostExec)
    }

    /// The effort actually sent to the backend. `orchestra` is a Nucleic-only orchestration
    /// mode, not an API effort level, so it resolves to its real effort (`xhigh`); every other
    /// level passes through. The resolved value never flows back into `session.effort`, so the
    /// UI keeps showing "Orchestra" (`OrchestrationMode`).
    private var resolvedEffort: String? { OrchestrationMode.resolvedEffort(session.effort) }

    /// Whether Orchestra is actually in effect this run: the user selected it AND the project
    /// is Nucleic Control. Orchestra is gated to Control projects (the hardened, sandboxed
    /// path), so a stray "orchestra" effort on a non-Control project — left over after a
    /// project leaves control, or arriving from a remote — is demoted here so it never silently
    /// grants fan-out consent off-control. (The effort still resolves to `xhigh`; only the
    /// consent is withheld.)
    private var orchestraActive: Bool {
        OrchestrationMode.isOrchestra(session.effort) && project?.isNucleicControlled == true
    }

    /// The system prompt appended to a run: the sandbox build guidance (when sandboxed), the
    /// nvrsion version-control guidance (when this chat edits the shared trunk), and — when
    /// Orchestra is active (selected, on a Control project) — the orchestration standing-consent
    /// text. Re-applied on every start / resume because the CLI doesn't persist
    /// `--append-system-prompt`, which keeps these in effect as the per-turn reminders.
    private var appendedSystemPrompt: String? {
        // Build + version-control guidance both describe this run's environment; join them as the
        // `environment` paragraph(s) the orchestration combiner appends the consent after.
        let environment = [environmentSystemPrompt, nvrsionSystemPrompt]
            .compactMap { $0 }
            .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
            .joined(separator: "\n\n")
        return OrchestrationMode.combinedSystemPrompt(
            environment: environment.isEmpty ? nil : environment,
            effort: orchestraActive ? session.effort : nil)
    }

    /// Whether this chat edits the project's shared **nvrsion** trunk (NVRSION) rather than a
    /// private per-session worktree — its working dir *is* the trunk. Mirrors
    /// `AppStore.isNvrsionSession`: the mode is fixed at creation, so this reads the session's
    /// working dir, not the project's *current* nvrsion toggle (which only governs how *new* chats
    /// are made — NVRSION §7). So a worktree chat in an nvrsion project stays false, and an nvrsion
    /// chat whose project was later toggled off stays true — each keeps the model it was born with.
    private var isNvrsionSession: Bool {
        guard let project, let wt = session.worktreePath else { return false }
        return wt == project.resolvedTrunkPath
    }

    /// System-prompt guidance appended to an nvrsion run so the agent's version-control mental model
    /// matches reality: it edits a *shared, live-committed trunk*, not a private worktree it later
    /// commits. Without it the agent reasons in the ordinary per-branch git model and says wrong
    /// things ("your changes are only in the working tree", "you still need to commit / open a PR to
    /// ship") or takes harmful actions (a `git reset`/`commit`/`branch` on a tree other agents
    /// share). `nil` for non-nvrsion chats — the ordinary worktree model, where that reasoning holds.
    private var nvrsionSystemPrompt: String? {
        guard isNvrsionSession, let project else { return nil }
        return Self.nvrsionVersionControlGuidance(
            base: project.defaultBranch.value,
            trunkBranch: project.nvrsion?.trunkBranch ?? ProjectNvrsion.defaultTrunkBranch)
    }

    /// The build-environment guidance text. Separated from `environmentSystemPrompt` so it can be
    /// unit-tested without standing up a full controller. Describes the container's own toolchain
    /// (Node/npm, Python/pip, make/gcc — see containers/nucleic-sandbox/Dockerfile) and routes
    /// Swift/macOS builds to the host via `host_exec` when that's permitted.
    static func sandboxBuildGuidance(allowHostExec: Bool) -> String {
        let base = """
            Build environment: you are running inside a Linux container sandbox, not on the macOS \
            host. The container has Node/npm, Python 3 (python3/pip), and a C/C++ toolchain \
            (make, gcc, g++) — use these to build and test JavaScript, Python, and C/C++ code \
            directly. It does NOT have the Swift or Xcode toolchain installed.
            """
        if allowHostExec {
            return base + " " + """
                To build, compile, run, or test a Swift package or macOS app, use the `host_exec` \
                tool — it runs a shell command on the macOS host (in this session's worktree) \
                where Swift and Xcode live; each call is approval-gated. For example, build and \
                test with `host_exec` running `swift build` / `swift test` (or the project's \
                Makefile targets, e.g. `make` / `make test`, which themselves invoke swift). Do \
                NOT run `swift`, `xcodebuild`, or swift-backed `make` targets directly in the \
                container — those tools are not installed there and will fail with "command not \
                found".
                """
        }
        return base + " " + """
            Host execution is disabled for this session, so the Swift/Xcode toolchain is \
            unavailable: Swift packages and macOS apps cannot be built, run, or tested here. Do \
            not attempt `swift` or `xcodebuild` in the container — they are not installed. If a \
            task requires building or running a Swift/macOS target, say so rather than guessing.
            """
    }

    /// The nvrsion version-control guidance text (NVRSION). Separated like `sandboxBuildGuidance`
    /// so it can be unit-tested without standing up a full controller. It teaches the one thing that
    /// makes nvrsion different from ordinary git — a *shared* trunk where every edit is committed the
    /// instant it lands — and the behavior that follows: don't hand-run history-mutating git on the
    /// shared tree, and the real branch is reached only by the user's promotion, not by the agent.
    static func nvrsionVersionControlGuidance(base: String, trunkBranch: String) -> String {
        """
        Version control in this session uses nvrsion, which differs from the ordinary per-branch \
        git model — keep this in mind whenever you describe or act on version control. Your working \
        directory IS a shared trunk checkout (branch `\(trunkBranch)`) that other agents edit live \
        and concurrently; it is NOT a private worktree or feature branch. Nucleic commits each \
        Edit/Write/MultiEdit into the trunk the instant it completes (one path-scoped commit per \
        edit), so your work is version-controlled on its own — it is never just "uncommitted \
        changes in the working tree" waiting to be saved, and you never commit it yourself: moments \
        after each edit completes it is already a commit on the trunk. Do not run \
        git commands that rewrite history or move the shared tree — no `commit`, `add`, `branch`, \
        `checkout`/`switch`, `reset`, `revert`, `stash`, `merge`, `rebase`, or `clean` — because the \
        tree is shared with other agents and your work is already committed for you, so those would \
        clobber siblings' concurrent edits or fight the live commit machinery; read-only inspection \
        (`git status`, `git log`, `git diff`, `git show`) is fine. Because the trunk is shared, a \
        file you read earlier may have been changed by another agent before your next edit — when \
        that happens you'll be told to re-read it, so don't assume an earlier read is still current. \
        Your edits reach the project's real `\(base)` branch only when the user runs the separate \
        "Integrate trunk → \(base)" promotion (which squashes the trunk's accumulated work into one \
        commit); you do not merge into `\(base)`, push, or open a pull request to ship — just make \
        the edits and they land on the trunk for the user to promote.
        """
    }

    /// Build the sandbox `ContainerSpec` for this session when its project is effectively
    /// sandboxed; `nil` → run `claude` on the host (default).
    ///
    /// Two container models, chosen by `Project.usesSharedControlContainer`:
    ///  • **Shared primary container** (Nucleic Control, no per-session opt-in): one container
    ///    for all such projects. Mounts the whole control base (every control repo + its
    ///    worktrees live under it) and the sessions dir (per-session claude-homes) at identical
    ///    canonical paths; `HOME` points at this session's own claude-home inside the mounted
    ///    sessions dir. Always uses the bundled default image (it serves many projects).
    ///  • **Per-session container** (non-control sandbox, or a future per-session opt-in):
    ///    today's model — mounts the repo root + worktree base + a per-session claude-home at
    ///    `/root/.claude`, honoring the project's image override.
    ///
    /// The per-session writable claude-home is seeded host-side (template copy on first use,
    /// credentials refreshed each turn) so both models share one code path. Git worktree links
    /// and the cwd-hash resolve because every host path is mounted at its identical canonical
    /// path (git stores symlink-resolved absolute paths, e.g. `/private/tmp/...`).
    private func containerSpec() -> ContainerSpec? {
        // The app-wide service switch gates everything (respected even for Nucleic Control):
        // with it off, sessions run on the host regardless of the project's sandbox state.
        guard ContainerServiceSettings.serviceEnabled else { return nil }
        guard let project, let sandbox = project.effectiveSandbox, sandbox.enabled else { return nil }

        let canon = GitWorktreeManager.canonical
        let shared = project.usesSharedControlContainer
        // Nucleic Control containers get the `git` interceptor shim (observe agent git ops
        // with certainty); a plain manually-sandboxed project does not.
        let installGitInterceptor = project.isNucleicControlled

        // Per-session writable claude-home (sibling of the session's transcript), seeded
        // host-side: copy the user's ~/.claude as a template the first time, and refresh the
        // login credentials every turn. On macOS the Claude Code login (OAuth) lives in the
        // Keychain, not a file, so it must be exported as `.credentials.json` (the file claude
        // reads on Linux) or the containerized claude reports "Not logged in". Never touches
        // the host's real ~/.claude.
        let sessionDir = (session.transcriptPath as NSString).deletingLastPathComponent
        let writableHost = canon((sessionDir as NSString).appendingPathComponent("claude-home"))
        let isNewHome = !FileManager.default.fileExists(atPath: writableHost)
        try? FileManager.default.createDirectory(
            atPath: writableHost, withIntermediateDirectories: true)
        // Per-agent auth seeding into the per-session writable home (the agent runs non-root and
        // owns this dir; its `$HOME` is set to it below). Claude: copy ~/.claude + export the
        // Keychain login into the dir itself (claude uses CLAUDE_CONFIG_DIR). Codex/Grok store auth
        // as plain files under `$HOME/.codex` / `$HOME/.grok`, so seed those subdirs from the host's
        // — simpler than Claude (no Keychain). API-key users are covered by the env forwarding below.
        switch session.backend {
        case .claudeCode:
            if isNewHome { Self.seedClaudeHomeTemplate(into: writableHost) }
            Self.exportLoginCredentials(into: writableHost)
            Self.ensureClaudeConfigFile(into: writableHost)
        case .codex, .codexExec:
            Self.seedAgentHome(hostDir: ".codex", skipping: ["sessions", "log", "logs"], into: writableHost)
        case .grok:
            Self.seedAgentHome(hostDir: ".grok", skipping: [], into: writableHost)
        }

        let stagingPath = "/nucleic/host-claude"
        var mounts: [ContainerSpec.Mount] = []
        var env: [String: String] = [:]
        // Forward a real API key (API-key auth), but NOT a present-but-blank one. A blank
        // ANTHROPIC_API_KEY inherited from the launchd environment would otherwise reach claude,
        // which prefers it over the OAuth credentials and then fails auth with 401 (see
        // `isBlankAPIKey`). The app also purges a blank key from its own environment at launch
        // (covering host-spawned sessions); this is the matching guard for the container.
        if let key = ProcessInfo.processInfo.environment["ANTHROPIC_API_KEY"], !Self.isBlankAPIKey(key) {
            env["ANTHROPIC_API_KEY"] = key
        }
        // The Codex/Grok headless-auth path is an env key too; forward each non-blank one (harmless
        // for a Claude container). Codex reads OPENAI_API_KEY (CODEX_API_KEY is `codex exec`-only);
        // Grok reads XAI_API_KEY / GROK_CODE_XAI_API_KEY.
        for key in ["OPENAI_API_KEY", "CODEX_API_KEY", "XAI_API_KEY", "GROK_CODE_XAI_API_KEY"] {
            if let value = ProcessInfo.processInfo.environment[key], !Self.isBlankAPIKey(value) {
                env[key] = value
            }
        }

        let name: String
        let image: String
        let writablePath: String
        // Two resource pools: the shared control container serves every control session at once
        // (more generous); a per-session container serves one.
        let cpus: Int
        let memoryGiB: Int
        if shared {
            let controlBase = canon(ProjectCloner.controlBase)
            try? FileManager.default.createDirectory(
                atPath: controlBase, withIntermediateDirectories: true)
            let sessionsDir = canon((sessionDir as NSString).deletingLastPathComponent)
            mounts.append(.init(host: controlBase, container: controlBase, readOnly: false))
            mounts.append(.init(host: sessionsDir, container: sessionsDir, readOnly: false))
            // A control project normally keeps its worktrees under the control base (covered by
            // the mount above). But a custom worktreeBase override can point outside it — mount
            // that too, or the session's workdir would be missing inside the container.
            let worktreeBase = canon(project.resolvedWorktreeBase)
            if worktreeBase != controlBase && !worktreeBase.hasPrefix(controlBase + "/") {
                mounts.append(.init(host: worktreeBase, container: worktreeBase, readOnly: false))
            }
            // The sessions dir is mounted at its identical path, so the writable claude-home is
            // visible inside the container at the very same path. Point claude at THIS session's
            // private claude-home via CLAUDE_CONFIG_DIR — that governs its settings, credentials,
            // and `projects/` transcripts (which `locateNativeTranscript` reads back from
            // `<claude-home>/projects`). HOME is set to the same dir below: the agent runs non-root
            // (the host uid), so `/root` is not writable — its `$HOME` must be a dir it owns.
            writablePath = writableHost
            env["CLAUDE_CONFIG_DIR"] = writableHost
            // Control container auth: by default reuse the host OAuth login (the
            // `.credentials.json` seeded above). If the user chose API-key auth for Control and
            // saved a key, forward it — claude prefers ANTHROPIC_API_KEY over OAuth, so it takes
            // over cleanly. (Blank/missing key → fall back to OAuth.)
            if ContainerServiceSettings.controlAuthMode == .apiKey,
               let apiKey = ControlAPIKeyStore.load() {
                env["ANTHROPIC_API_KEY"] = apiKey
            }
            // Pick the shared container by backend: with backend-splitting on, each agent family
            // lands in its own sandbox so they can't kill each other's processes. This is the stable
            // *logical* name (Claude → `nucleic-control-claude`, etc.); `ContainerManager` runs it
            // under a randomized, family-anonymous physical name so a sibling agent can't recognize or
            // target it. Off → every backend shares `nucleic-control`.
            name = ContainerManager.sharedContainerName(
                for: session.backend,
                split: ContainerServiceSettings.splitControlContainersByBackend)
            image = ProjectSandbox.defaultImage
            cpus = ContainerServiceSettings.controlContainerCPUs
            memoryGiB = ContainerServiceSettings.controlContainerMemoryGiB
        } else {
            let rootPath = canon(project.rootPath)
            let worktreeBase = canon(project.resolvedWorktreeBase)
            // Mount the writable claude-home at its identical host path (NOT under `/root`): the
            // agent runs non-root, and `/root` is mode 0700 — a claude-home under it would be
            // unreachable. At the host path it's owned by (and traversable for) the host uid the
            // agent runs as. Point both CLAUDE_CONFIG_DIR and HOME there.
            writablePath = writableHost
            mounts.append(.init(host: rootPath, container: rootPath, readOnly: false))
            mounts.append(.init(host: worktreeBase, container: worktreeBase, readOnly: false))
            mounts.append(.init(host: writableHost, container: writableHost, readOnly: false))
            env["CLAUDE_CONFIG_DIR"] = writablePath
            name = ContainerManager.containerName(for: session.id)
            image = sandbox.resolvedImage
            cpus = ContainerServiceSettings.sessionContainerCPUs
            memoryGiB = ContainerServiceSettings.sessionContainerMemoryGiB
        }

        // Run the agent as the *host* user (not container root), so it owns the bind-mounted
        // repo/worktree/claude-home (virtiofs presents the host owner on the shared mounts) while
        // the root-installed instrumentation — the git + command shims, the bash tracer,
        // `/etc/gitconfig` — stays read-only to it. `$HOME` therefore must be a dir the agent owns:
        // the writable claude-home, not `/root`. If the app itself runs as root (uid 0) this is a
        // no-op (can't drop below root, and the mounts are root-owned anyway).
        env["HOME"] = writablePath
        let hostUID = Int(getuid())
        let hostGID = Int(getgid())

        // App-level Managed Git credentials: write the configured token / SSH key into this session's
        // bind-mounted claude-home and point the container's git/ssh at it (the files live in
        // `writableHost`, visible inside the container at `writablePath`). No-op when unset. A Control
        // session's shared container always gets it (it has no other way to reach GitHub); a
        // per-session sandbox gets it only when "Make available in sandbox" is on.
        if shared || GitHubCredentialSettings.availableInSandbox {
            env.merge(
                GitHubCredentialProvisioner.provision(
                    .current(), hostHomeDir: writableHost, containerHomeDir: writablePath)
            ) { _, new in new }
        }

        // The shared control container can run its control plane over a vsock-relayed unix socket
        // (no IP listener → no macOS prompts) when enabled — it's the long-lived, token-multiplexed
        // box the per-container socket + in-guest bridge are designed for. Per-session containers
        // stay on the gateway-TCP path. `nil` → legacy TCP (the default).
        let controlSocketHostPath: String? =
            (shared && ContainerServiceSettings.vsockControlPlaneEnabled)
            ? ApprovalServerRegistry.controlSocketPath(for: name) : nil

        return ContainerSpec(
            name: name,
            image: image,
            mounts: mounts,
            workdir: canon(worktreePath),
            env: env,
            idleTimeout: TimeInterval(sandbox.idleTimeoutSeconds),
            claudeHomeStaging: stagingPath,
            claudeHomeWritable: writablePath,
            installGitInterceptor: installGitInterceptor,
            cpus: cpus,
            memoryGiB: memoryGiB,
            runAsUID: hostUID,
            runAsGID: hostGID,
            controlSocketHostPath: controlSocketHostPath)
    }

    /// Seed a per-session agent home for Codex/Grok: copy the host's `~/<dirName>` (`.codex` /
    /// `.grok` — plain-file auth dirs holding the login + config, unlike Claude's Keychain) into
    /// `<writableHome>/<dirName>`, so the containerized agent — whose `$HOME` is `writableHome` —
    /// authenticates as the user (`$HOME/.codex`, `$HOME/.grok`). Best-effort and host-side only:
    /// skipped when the source is absent or the dest already exists (so accumulated session state
    /// survives later turns); `skip` drops large/per-session subdirs (e.g. Codex `sessions`). The
    /// host dir is never mutated.
    private static func seedAgentHome(hostDir dirName: String, skipping skip: Set<String>, into writableHome: String) {
        let fm = FileManager.default
        let source = (NSHomeDirectory() as NSString).appendingPathComponent(dirName)
        let dest = (writableHome as NSString).appendingPathComponent(dirName)
        guard fm.fileExists(atPath: source), !fm.fileExists(atPath: dest),
            let entries = try? fm.contentsOfDirectory(atPath: source)
        else { return }
        try? fm.createDirectory(atPath: dest, withIntermediateDirectories: true)
        for entry in entries where !skip.contains(entry) {
            try? fm.copyItem(
                atPath: (source as NSString).appendingPathComponent(entry),
                toPath: (dest as NSString).appendingPathComponent(entry))
        }
        try? fm.setAttributes([.posixPermissions: 0o700], ofItemAtPath: dest)
    }

    /// Seed a fresh per-session claude-home by copying the user's `~/.claude` into it as a
    /// template, so the containerized `claude` starts from the user's config (settings,
    /// plugins) while keeping a writable HOME it can persist session/transcript files into —
    /// the host `~/.claude` is never mutated. Best-effort and only on first use of a home
    /// (later turns skip it, preserving the container's accumulated session state). The
    /// per-session subdirs (`projects/`, `todos/`, `statsig/`) are skipped to keep the copy
    /// small and avoid leaking other projects' transcripts into the sandbox.
    private static func seedClaudeHomeTemplate(into claudeHome: String) {
        let fm = FileManager.default
        let source = (NSHomeDirectory() as NSString).appendingPathComponent(".claude")
        guard let entries = try? fm.contentsOfDirectory(atPath: source) else { return }
        let skip: Set<String> = ["projects", "todos", "statsig", "shell-snapshots", ".credentials.json"]
        for entry in entries where !skip.contains(entry) {
            let from = (source as NSString).appendingPathComponent(entry)
            let to = (claudeHome as NSString).appendingPathComponent(entry)
            try? fm.copyItem(atPath: from, toPath: to)
        }
    }

    /// Ensure `<claudeHome>/.claude.json` exists — the top-level config file Claude Code reads
    /// from `CLAUDE_CONFIG_DIR`. The template seed copies the *contents* of `~/.claude/`, but
    /// `.claude.json` is a sibling of that directory (`~/.claude.json`), so it never lands in the
    /// per-session home. A Nucleic Control session points claude at the home via
    /// `CLAUDE_CONFIG_DIR`, and claude then exits 1 with "Claude configuration file not found":
    /// it auto-creates this file at the default `$HOME/.claude.json`, but errors when an explicit
    /// config dir lacks it. Seed it from the host's own `~/.claude.json` so onboarding/account
    /// state carries over, minus the per-cwd `projects` map (large, and would leak other
    /// projects' prompt history into the sandbox — claude recreates it). Written only when
    /// absent, so it never clobbers the config claude maintains across turns, and self-heals a
    /// home created before this seed existed. Best-effort, host-side, 0600 (carries account info).
    private static func ensureClaudeConfigFile(into claudeHome: String) {
        let dest = (claudeHome as NSString).appendingPathComponent(".claude.json")
        guard !FileManager.default.fileExists(atPath: dest) else { return }
        let source = (NSHomeDirectory() as NSString).appendingPathComponent(".claude.json")
        let payload = claudeConfigSeed(fromHostConfig: try? Data(contentsOf: URL(fileURLWithPath: source)))
        guard (try? payload.write(to: URL(fileURLWithPath: dest))) != nil else { return }
        try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: dest)
    }

    /// The seed contents for a sandbox `.claude.json`, derived from the host's config `data`:
    /// the host config minus its per-cwd `projects` map, or a minimal valid config (onboarding
    /// marked complete) when there's no readable/parseable host file. Pure + testable.
    static func claudeConfigSeed(fromHostConfig data: Data?) -> Data {
        if let data, var object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] {
            object.removeValue(forKey: "projects")
            if let trimmed = try? JSONSerialization.data(withJSONObject: object) { return trimmed }
        }
        return Data(#"{"hasCompletedOnboarding":true}"#.utf8)
    }

    /// True when `value` is a present-but-blank `ANTHROPIC_API_KEY` — e.g. an empty var inherited
    /// from the launchd environment of a GUI-launched app. Claude Code prefers `ANTHROPIC_API_KEY`
    /// over the OAuth `.credentials.json`, so forwarding a blank one makes it attempt empty
    /// API-key auth and fail with "401 Invalid authentication credentials", overriding a perfectly
    /// good subscription login. nil (unset) is not blank — there's simply nothing to forward.
    public static func isBlankAPIKey(_ value: String?) -> Bool {
        guard let value else { return false }
        return value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
    }

    /// Export the macOS Keychain Claude Code login into `<claudeHome>/.credentials.json`
    /// (0600) so the containerized `claude` authenticates as the user. Best-effort and
    /// host-side only — the secret is written into the bind-mounted per-session claude-home,
    /// never passed on a command line, and the host Keychain is only read, never modified.
    private static func exportLoginCredentials(into claudeHome: String) {
        guard let json = readKeychainCredentials(), !json.isEmpty else { return }
        let path = (claudeHome as NSString).appendingPathComponent(".credentials.json")
        guard (try? json.write(toFile: path, atomically: true, encoding: .utf8)) != nil else { return }
        try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: path)
    }

    /// The raw `Claude Code-credentials` Keychain generic-password value (the OAuth JSON),
    /// or nil if there's no such item (e.g. API-key auth, or not logged in).
    private static func readKeychainCredentials() -> String? {
        let process = Process()
        process.executableURL = URL(fileURLWithPath: "/usr/bin/security")
        process.arguments = ["find-generic-password", "-s", "Claude Code-credentials", "-w"]
        let out = Pipe()
        process.standardOutput = out
        process.standardError = Pipe()
        do { try process.run() } catch { return nil }
        let data = out.fileHandleForReading.readDataToEndOfFile()
        process.waitUntilExit()
        guard process.terminationStatus == 0 else { return nil }
        let text = String(decoding: data, as: UTF8.self)
            .trimmingCharacters(in: .whitespacesAndNewlines)
        return text.isEmpty ? nil : text
    }

    /// Begin a fresh run. Idempotent: a second call while running is a no-op.
    public func start(prompt: AgentInput) {
        guard runTask == nil else { return }
        session.status = .running
        session.lastTurnDisposition = nil  // new turn — the prior turn's disposition is stale
        session.updatedAt = now()
        if prompt.plainText?.isEmpty == false { session.lastUserMessageAt = now() }
        let run = RunSpec(
            sessionID: session.id,
            worktree: worktreePath,
            prompt: prompt,
            model: session.model,
            effort: resolvedEffort,
            autoApprove: session.auto,
            approvalPolicy: .interactive,
            container: containerSpec(),
            allowHostExec: allowsHostExec,
            orchestraActive: orchestraActive,
            appendSystemPrompt: appendedSystemPrompt)
        consume(backend.start(run), injectingUserText: prompt.plainText)
    }

    /// Reattach to the backend's native session and continue from `lastSeq + 1`.
    public func resume() {
        guard runTask == nil, let backendSessionID = session.backendSessionID else { return }
        session.status = .running
        session.lastTurnDisposition = nil  // new turn — the prior turn's disposition is stale
        session.updatedAt = now()
        let spec = ResumeSpec(
            sessionID: session.id,
            backendSessionID: backendSessionID,
            worktree: worktreePath,
            container: containerSpec(),
            allowHostExec: allowsHostExec,
            orchestraActive: orchestraActive,
            appendSystemPrompt: appendedSystemPrompt)
        consume(backend.resume(spec), injectingUserText: nil)
    }

    private func consume(
        _ stream: AsyncThrowingStream<AgentEvent, Error>, injectingUserText userText: String?
    ) {
        runTask = Task {
            if let userText, !userText.isEmpty {
                await ingest(synthetic(.userText(makeUserChunk(userText))))
            }
            do {
                for try await event in stream {
                    await ingest(event)
                }
            } catch {
                // Stream failed before a terminal event — synthesize one so the
                // session reaches a terminal status and the transcript is complete.
                await ingest(synthetic(.error(AgentError(recoverable: false, message: "\(error)"))))
                await ingest(synthetic(.runFinished(RunFinished(outcome: .errored))))
            }
            await finishRun()
        }
    }

    /// Submit a user turn. The user's text is injected into the transcript first so
    /// chat history stays complete. In conversational mode each message is its own
    /// single-shot run: the first starts the session, later ones `resume` it (which
    /// also carries the message), so context is retained across turns.
    public func sendInput(_ input: AgentInput) async throws {
        session.lastTurnDisposition = nil  // a new user turn invalidates the prior disposition
        if input.plainText?.isEmpty == false { session.lastUserMessageAt = now() }

        if conversational {
            // A turn is still in flight (or wrapping up). The backend emits the turn's
            // terminal `runFinished` — which flips us to `awaitingInput` and re-enables the
            // composer — *before* its event stream actually closes and `runTask` clears. A
            // follow-up sent while a turn runs must NOT be dropped: queue it (coalescing if
            // several arrive) and keep the session `running` so it starts the instant the
            // current run finishes (see `finishRun`). The queued text is deliberately NOT
            // injected into the transcript yet — it's held as `session.queuedMessage` and
            // injected only when it actually sends, so a queued message never interleaves
            // with the in-flight turn's streaming output.
            if runTask != nil {
                pendingInput = pendingInput.map { AgentInput(parts: $0.parts + input.parts) } ?? input
                session.queuedMessage = pendingInput?.plainText
                session.status = .running
                session.updatedAt = now()
                return
            }
            if let text = input.plainText, !text.isEmpty {
                await ingest(synthetic(.userText(makeUserChunk(text))))
            }
            startConversationalTurn(input)
            return
        }

        if let text = input.plainText, !text.isEmpty {
            await ingest(synthetic(.userText(makeUserChunk(text))))
        }
        try await backend.send(input)
        if !session.status.isTerminal {
            session.status = .running
            session.updatedAt = now()
        }
    }

    /// Drop a queued (not-yet-sent) follow-up, returning its text so the UI can refill the
    /// composer for editing. Returns `nil` if nothing is queued. If the queued message was
    /// only holding the session `running` past a finished turn (the run already closed),
    /// settle back to `awaitingInput`; if a turn is genuinely still in flight, leave the
    /// status alone — the agent is still working.
    public func cancelQueuedInput() -> String? {
        guard let pending = pendingInput else { return nil }
        pendingInput = nil
        session.queuedMessage = nil
        if runTask == nil, !session.status.isTerminal {
            session.status = .awaitingInput
        }
        session.updatedAt = now()
        return pending.plainText
    }

    /// Start one conversational turn carrying `input`: resume the backend's native session
    /// if one exists (so context is retained), else start a fresh run. The user's text is
    /// already in the transcript (injected by the caller), so the stream is consumed with
    /// no re-injection.
    private func startConversationalTurn(_ input: AgentInput) {
        session.status = .running
        session.updatedAt = now()
        let container = containerSpec()
        if let backendSessionID = session.backendSessionID {
            let spec = ResumeSpec(
                sessionID: session.id, backendSessionID: backendSessionID,
                worktree: worktreePath, prompt: input,
                model: session.model, effort: resolvedEffort, autoApprove: session.auto,
                container: container, allowHostExec: allowsHostExec,
                orchestraActive: orchestraActive,
                appendSystemPrompt: appendedSystemPrompt)
            consume(backend.resume(spec), injectingUserText: nil)
        } else {
            // No turn has run yet → this message starts the session.
            let run = RunSpec(
                sessionID: session.id, worktree: worktreePath, prompt: input,
                model: session.model, effort: resolvedEffort,
                autoApprove: session.auto, approvalPolicy: .interactive,
                container: container, allowHostExec: allowsHostExec,
                orchestraActive: orchestraActive,
                appendSystemPrompt: appendedSystemPrompt)
            consume(backend.start(run), injectingUserText: nil)
        }
    }

    private func makeUserChunk(_ text: String) -> TextChunk {
        userTurnCount += 1
        return TextChunk(messageID: "user-\(userTurnCount)", text: text, isPartial: false)
    }

    // MARK: - Revert (undo to a point)

    /// Revert the conversation to just before the user message at `seq`: drop that message
    /// and everything after it from the canonical transcript, and — best-effort — rewind the
    /// backend's native session so the next turn genuinely resumes from before it (the agent
    /// forgets the reverted exchanges, the same mechanism as Claude Code's own rewind). The
    /// worktree's files are left untouched. Returns the reverted message's text so the UI can
    /// drop it back into the composer for editing, or `nil` if `seq` isn't a user message or a
    /// turn is currently in flight (a transcript rewrite must never race a live run).
    public func revert(toSeq seq: UInt64) async -> String? {
        guard runTask == nil else { return nil }
        guard let target = history.first(where: { $0.seq == seq }),
            case .userText(let chunk) = target.kind
        else { return nil }

        let retained = history.filter { $0.seq < seq }
        let retainedUserTurns = retained.reduce(into: 0) { count, event in
            if case .userText = event.kind { count += 1 }
        }

        // Best-effort native rewind so the agent truly forgets the reverted turns. If the
        // session file can't be located or its prompt count doesn't line up, we keep the
        // native session as-is: the visual revert + edit still work; only the agent's memory
        // of the reverted turns survives.
        if retainedUserTurns == 0 {
            // Reverting the very first message: nothing to keep — let the next turn start a
            // fresh native session instead of resuming an emptied one.
            session.backendSessionID = nil
        } else if let nativePath = locateNativeTranscript() {
            _ = Self.truncateNativeTranscript(at: nativePath, keepingRealUserPrompts: retainedUserTurns)
        }

        try? await transcript.truncate(to: retained)

        history = retained
        userTurnCount = retainedUserTurns
        pendingApprovals.removeAll()
        session.lastSeq = retained.last?.seq ?? 0
        session.lastTurnDisposition = nil
        session.status = .awaitingInput
        session.updatedAt = now()
        try? await metadataStore?.saveSession(session)
        return chunk.text
    }

    /// Locate the Claude Code native session JSONL for the current `backendSessionID`, trying
    /// the host `~/.claude` and (for a sandboxed project) the per-session writable claude-home,
    /// each against the raw and symlink-resolved worktree path (Claude names the project dir by
    /// replacing every non-alphanumeric character of its cwd with `-`). Returns the first that
    /// exists, else `nil`.
    private func locateNativeTranscript() -> String? {
        guard let sid = session.backendSessionID, !sid.isEmpty else { return nil }
        let fm = FileManager.default
        let canon = GitWorktreeManager.canonical

        var homes: [String] = []
        // A sandboxed session (shared or per-session container) writes its native transcript
        // into the per-session claude-home — the same effective-sandbox gate the spawn path uses.
        if ContainerServiceSettings.serviceEnabled,
           let sandbox = project?.effectiveSandbox, sandbox.enabled {
            let claudeHome = canon(
                ((session.transcriptPath as NSString).deletingLastPathComponent as NSString)
                    .appendingPathComponent("claude-home"))
            homes.append(claudeHome)
        }
        homes.append((NSHomeDirectory() as NSString).appendingPathComponent(".claude"))

        let cwds = Set([worktreePath, canon(worktreePath)].filter { !$0.isEmpty })
        for home in homes {
            for cwd in cwds {
                let path = "\(home)/projects/\(Self.claudeProjectDirName(cwd))/\(sid).jsonl"
                if fm.fileExists(atPath: path) { return path }
            }
        }
        return nil
    }

    /// Claude Code's project-directory name for a working directory: every character that
    /// isn't an ASCII letter or digit becomes `-` (e.g. `/Users/me/proj` → `-Users-me-proj`).
    static func claudeProjectDirName(_ cwd: String) -> String {
        String(
            cwd.unicodeScalars.map { scalar in
                let v = scalar.value
                let isAlnum =
                    (v >= 48 && v <= 57) || (v >= 65 && v <= 90) || (v >= 97 && v <= 122)
                return isAlnum ? Character(scalar) : "-"
            })
    }

    /// Truncate a Claude Code native session transcript so it ends just before its
    /// `(keep + 1)`-th real user prompt — dropping that turn and everything after, the clean
    /// turn boundary the next resume continues from. Tool-result and meta user records don't
    /// count as prompts. Returns `false` and leaves the file untouched if it can't be read or
    /// has fewer real prompts than expected (a mismatch we'd rather skip than corrupt).
    static func truncateNativeTranscript(at path: String, keepingRealUserPrompts keep: Int)
        -> Bool
    {
        let url = URL(fileURLWithPath: path)
        guard let data = try? Data(contentsOf: url) else { return false }
        let lines = data.split(separator: 0x0A, omittingEmptySubsequences: false)

        var prompts = 0
        var cutLineIndex: Int?
        for (i, line) in lines.enumerated() where !line.isEmpty {
            if isRealUserPrompt(Data(line)) {
                prompts += 1
                if prompts == keep + 1 { cutLineIndex = i; break }
            }
        }
        guard let cut = cutLineIndex else { return false }

        var out = Data()
        for line in lines[..<cut] where !line.isEmpty {
            out.append(contentsOf: line)
            out.append(0x0A)
        }
        do {
            try out.write(to: url, options: .atomic)
            return true
        } catch {
            return false
        }
    }

    /// Whether a native transcript line is a real user prompt (type `user`, carrying text)
    /// rather than a tool-result echo (Claude stores those as user-role records too) or an
    /// injected meta message.
    static func isRealUserPrompt(_ lineData: Data) -> Bool {
        guard let obj = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any],
            (obj["type"] as? String) == "user"
        else { return false }
        if (obj["isMeta"] as? Bool) == true { return false }
        guard let message = obj["message"] as? [String: Any] else { return false }
        if message["content"] is String { return true }
        if let blocks = message["content"] as? [[String: Any]] {
            if blocks.contains(where: { ($0["type"] as? String) == "tool_result" }) { return false }
            return blocks.contains { ($0["type"] as? String) == "text" }
        }
        return false
    }

    /// In conversational mode a turn's terminal `runFinished` means "ready for the
    /// next message", not "session over".
    private func derivedStatus(_ current: SessionStatus, _ kind: AgentEvent.Kind) -> SessionStatus {
        if conversational, case .runFinished(let finished) = kind {
            switch finished.outcome {
            case .completed, .interrupted, .maxTurns: return .awaitingInput
            case .errored: return .error
            }
        }
        return SessionStatus.transition(current, on: kind)
    }

    /// Resolve an outstanding approval. First responder wins inside the backend's
    /// coordinator; the resulting `approvalResolved` flows back through `ingest`.
    public func respondToApproval(_ id: ApprovalID, _ decision: Decision) async throws {
        try await backend.respond(to: id, decision)
    }

    public func interrupt() async {
        await backend.interrupt()
    }

    // MARK: - Config (applies to subsequent turns)

    public func rename(_ title: String) async {
        let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else { return }
        session.title = trimmed
        session.updatedAt = now()
        try? await metadataStore?.saveSession(session)
    }

    /// Set the model (alias or full id; nil = CLI default) for the next turn.
    public func setModel(_ model: String?) async {
        session.model = model
        session.updatedAt = now()
        try? await metadataStore?.saveSession(session)
    }

    /// Set the reasoning effort (low/medium/high/xhigh/max; nil = default) for the next turn.
    ///
    /// Orchestra is a one-way latch: once orchestration mode is active for a chat (an orchestra
    /// effort on a Control project — see `orchestraActive`), it can't be turned off. A request to
    /// switch such a session to any non-orchestra level is ignored, so the standing fan-out consent
    /// can't be silently dropped mid-chat. Switching *into* Orchestra, or between ordinary levels,
    /// is unaffected. Enforced here — the one chokepoint both the local UI and the remote funnel
    /// through — so neither surface can bypass it.
    public func setEffort(_ effort: String?) async {
        guard !(orchestraActive && !OrchestrationMode.isOrchestra(effort)) else { return }
        session.effort = effort
        session.updatedAt = now()
        try? await metadataStore?.saveSession(session)
    }

    /// Cache a generated summary on the session (persisted so it survives reopen).
    public func setSummary(_ summary: String) async {
        session.summary = summary
        session.updatedAt = now()
        try? await metadataStore?.saveSession(session)
    }

    /// Record the Intelligence provider's classification of the last turn. Applied
    /// only while the session is idle at `.awaitingInput` (the state the disposition
    /// refines); ignored if a new turn has since started or the run reached a terminal
    /// state, so a slow/late classification can never clobber fresher state.
    public func setDisposition(_ disposition: TurnDisposition) async {
        guard session.status == .awaitingInput else { return }
        guard session.lastTurnDisposition != disposition else { return }
        session.lastTurnDisposition = disposition
        session.updatedAt = now()
        try? await metadataStore?.saveSession(session)
    }

    public func setAuto(_ auto: Bool) async {
        session.auto = auto
        // Autoship can't run without auto-approval (an agent that blocks on every tool
        // call never reaches a clean "done" to ship from); turning auto off turns it off.
        if !auto { session.autoShip = false }
        session.updatedAt = now()
        try? await metadataStore?.saveSession(session)
    }

    /// Toggle autoship. Enabling it also enables `auto` (shipping implies autonomous
    /// approval). Disabling leaves `auto` as-is. Enabling clears any prior failure
    /// marker — the user is opting back in, so the "needs attention" icon goes away.
    ///
    /// Autoship is gated on Nucleic Control: a session may only *enable* autoship when its
    /// project is Nucleic-controlled (the hardened, sandboxed, Nucleic-managed path).
    /// Enabling on a non-controlled project is a no-op here — the single runtime chokepoint
    /// that backs every enable surface — so the UI gate and this guard can't drift. Disabling
    /// always works (so a flag persisted true from an older build, or before a project moved
    /// out of control, can still be cleared).
    public func setAutoShip(_ autoShip: Bool) async {
        if autoShip, project?.isNucleicControlled != true { return }
        session.autoShip = autoShip
        if autoShip {
            session.auto = true
            // Re-arming is an explicit "I've handled it": clear both the hard-failure marker
            // and any pending merge-conflict flag so the chat starts clean.
            session.autoShipFailed = false
            session.autoShipConflict = false
        }
        session.updatedAt = now()
        try? await metadataStore?.saveSession(session)
    }

    /// Set (or clear) this session's autoship destination override. `nil` (or an empty/
    /// whitespace string) clears it, so the session inherits the project's autoship branch
    /// (which itself defaults to the project root repo's default branch).
    public func setShipBranch(_ branch: String?) async {
        let cleaned = branch?.trimmingCharacters(in: .whitespacesAndNewlines)
        session.shipBranch = (cleaned?.isEmpty ?? true) ? nil : cleaned
        session.updatedAt = now()
        try? await metadataStore?.saveSession(session)
    }

    /// Record that autoship aborted on a failure (merge conflict or merge error): turn
    /// autoship off and set the sticky failure marker that drives the sidebar's
    /// "autoship needs attention" icon. Distinct from a clean user toggle-off, which
    /// leaves the marker untouched. Cleared when the user re-enables autoship or opens
    /// the chat (and so sees the failure note in the transcript).
    public func markAutoShipFailed() async {
        session.autoShip = false
        session.autoShipFailed = true
        session.updatedAt = now()
        try? await metadataStore?.saveSession(session)
    }

    /// Clear the sticky autoship-failure marker because the user opened the chat and so
    /// saw the failure note in the transcript — the sidebar's "needs attention" icon has
    /// served its purpose. Like `setUnseenCompletion`, this is a view-state change and
    /// deliberately does not bump `updatedAt`, so seeing the error must not reorder the
    /// sidebar. Leaves `autoShip` off (the user must re-enable explicitly). A no-op when
    /// the marker isn't set.
    public func clearAutoShipFailed() async {
        guard session.autoShipFailed else { return }
        session.autoShipFailed = false
        try? await metadataStore?.saveSession(session)
    }

    /// Record that an autoship merge hit a *conflict* while autoship stays ARMED: set the
    /// sticky conflict marker (drives the same sidebar "needs attention" icon as
    /// `autoShipFailed`) but leave `autoShip` on, so the branch is left intact and the work
    /// re-ships on the next clean merge. Bumps `updatedAt` so the conflict surfaces in the
    /// sidebar's ordering, mirroring `markAutoShipFailed`. Distinct from a hard error, which
    /// disarms via `markAutoShipFailed`.
    public func markAutoShipConflict() async {
        session.autoShipConflict = true
        session.updatedAt = now()
        try? await metadataStore?.saveSession(session)
    }

    /// Clear the sticky autoship-conflict marker because the work finally landed (the merge
    /// queue's next merge, or the agent's own merge observed in-container). Like
    /// `clearAutoShipFailed`, this is a recovery/view-state change and deliberately does not
    /// bump `updatedAt`. A no-op when the marker isn't set.
    public func clearAutoShipConflict() async {
        guard session.autoShipConflict else { return }
        session.autoShipConflict = false
        try? await metadataStore?.saveSession(session)
    }

    /// Mark (or clear) the "completed but not yet opened" flag that drives the sidebar's
    /// unread dot. Set by the store when a turn completes off-screen; cleared on open.
    public func setUnseenCompletion(_ unseen: Bool) async {
        guard session.unseenCompletion != unseen else { return }
        session.unseenCompletion = unseen
        // Deliberately does not bump `updatedAt`: an unread marker is a view-state
        // change, and opening a chat to clear it must not reorder the sidebar.
        try? await metadataStore?.saveSession(session)
    }

    public func setFavorite(_ favorite: Bool) async {
        session.favorite = favorite
        session.updatedAt = now()
        try? await metadataStore?.saveSession(session)
    }

    public func setArchived(_ archived: Bool) async {
        session.archived = archived
        // Stamp when archiving (preserving an existing stamp if already archived), clear on
        // unarchive. The cleanup sweep measures "archived for longer than X" against this.
        session.archivedAt = archived ? (session.archivedAt ?? now()) : nil
        session.updatedAt = now()
        try? await metadataStore?.saveSession(session)
    }

    /// Re-target this session's parent (LOCKING §5.2 cascade) — the release target +
    /// update-on-grant source. `rootRef` (the lock domain) is unchanged.
    func setParent(ref: String?, sessionID: SessionID?) async {
        session.parentRef = ref
        session.parentSessionID = sessionID
        session.updatedAt = now()
        try? await metadataStore?.saveSession(session)
    }

    public func shutdown() async {
        runTask?.cancel()
        await backend.shutdown()
        if !session.status.isTerminal {
            session.status = .finished
            session.updatedAt = now()
        }
        try? await transcript.sync()
        try? await transcript.close()
        finishSubscribers()
    }

    // MARK: - Integration intents (delegate to the WorktreeManager)

    /// Refresh the live diff/status of the session's worktree.
    @discardableResult
    public func refreshStatus() async throws -> WorktreeStatus {
        let (manager, worktree) = try gitContext()
        let status = try await manager.status(of: worktree)
        session.diffStat = status.diffStat
        session.ahead = status.ahead
        session.behind = status.behind
        session.updatedAt = now()
        return status
    }

    /// Apply a diffstat computed outside the controller. An nvrsion session has no worktree to
    /// `refreshStatus` against (it shares the trunk, NVRSION §2), so the store measures its
    /// per-session trunk contribution (`NvrsionTrunk.sessionDiffStat`) and pushes it here to drive
    /// the sidebar's change-count — the worktree-less equivalent of `refreshStatus`'s diffstat update.
    func setLiveDiffStat(_ stat: DiffStat) {
        session.diffStat = stat
        session.updatedAt = now()
    }

    public func diff(_ options: DiffOptions = DiffOptions()) async throws -> Diff {
        let (manager, worktree) = try gitContext()
        return try await manager.diff(of: worktree, options)
    }

    /// The ref this session releases its locks against / merges into — its `parentRef`
    /// (LOCKING §3.3), falling back to the project default for legacy rows without one.
    private func parentTarget(_ project: Project) -> GitRef {
        session.parentRef.map { GitRef($0) } ?? project.defaultBranch
    }

    /// Repo-relative files this session has changed that aren't yet merged into its parent
    /// (`parentRef`, LOCKING §3.3; falls back to the project default) — its `check_conflict`
    /// footprint. Empties once the work lands. Returns `[]` for a worktree-less session.
    public func unmergedFiles() async throws -> [String] {
        guard let worktreeManager, let worktree, let project else { return [] }
        return try await worktreeManager.unmergedFiles(of: worktree, into: parentTarget(project))
    }

    /// The branch this session merges into when it ships — the resolved ship destination
    /// (`Session.shipDestination`): the per-session override, then the project's autoship
    /// branch, else the legacy default (`parentRef` / project default branch). Distinct from
    /// `parentTarget`, which stays anchored to the fork base for locking/update-on-grant.
    private func shipTarget(_ project: Project) -> GitRef {
        session.shipDestination(in: project)
    }

    /// Repo-relative files this session has changed that aren't yet merged into its **ship
    /// destination** — the footprint autoship checks to decide whether there's anything to
    /// ship (and which empties once the work lands there). Equals `unmergedFiles` when the
    /// ship destination matches the fork base (the default), and diverges only when the user
    /// points autoship at a different branch. Returns `[]` for a worktree-less session.
    public func shippableFiles() async throws -> [String] {
        guard let worktreeManager, let worktree, let project else { return [] }
        return try await worktreeManager.unmergedFiles(of: worktree, into: shipTarget(project))
    }

    /// Finalize (commit) then land the session's branch into its ship destination. The hub
    /// the macOS "Integrate" flow and autoship both call (UX_MACOS).
    public func integrate(
        strategy: IntegrationStrategy, commit: CommitOption? = nil,
        onCommit: (@Sendable () async -> Void)? = nil,
        onStash: (@Sendable () async -> Void)? = nil,
        onSync: (@Sendable () async -> Void)? = nil,
        onSigned: (@Sendable (String) async -> Void)? = nil
    ) async throws -> IntegrationResult {
        let (manager, worktree) = try gitContext()
        guard let project else { throw BackendError.unsupported("no project configured") }
        let commitOption = commit ?? .auto(message: "Nucleic: \(session.title)")
        try await manager.finalize(worktree, commit: commitOption, onCommit: onCommit)
        return try await manager.integrate(
            worktree, in: project, into: shipTarget(project), strategy: strategy, message: nil,
            onStash: onStash, onSync: onSync, onSigned: onSigned)
    }

    /// Update-on-grant (LOCKING §4.5): bring the session's worktree up to date before it proceeds,
    /// reconciling against each ref in `updateOnGrantSources` in order — the fork base / cascade
    /// parent (`parentTarget`), then the ship destination (`shipTarget`) when it diverges — scoped to
    /// `requestedFiles` (the files it's acquiring). `.clean` when up to date, merged cleanly with none
    /// of those files touched, or worktree-less; `.changed` (lock kept) when a reconciled ref moved one
    /// of them — the host re-grounds the agent; `.conflicted` (lock kept, session paused) on the first
    /// real merge conflict (any earlier clean merge stays applied).
    func updateFromParent(requestedFiles: [String]) async -> LockUpdateResult {
        guard let (manager, worktree) = try? gitContext(), let project else { return .clean }
        var movedFiles: [String] = []
        var diffs: [String] = []
        for ref in updateOnGrantSources(project) {
            guard let update = try? await manager.updateFromParent(
                worktree, parentRef: ref, requestedFiles: requestedFiles)
            else { continue }
            if !update.conflicts.isEmpty { return .conflicted(update.conflicts.map(\.path)) }
            movedFiles.append(contentsOf: update.changedFiles)
            if !update.diff.isEmpty { diffs.append(update.diff) }
        }
        let moved = Array(Set(movedFiles)).sorted()
        if moved.isEmpty { return .clean }
        return .changed(files: moved, diff: diffs.joined(separator: "\n"))
    }

    /// The refs update-on-grant (LOCKING §4.5) brings into the worktree, in order: `parentTarget`
    /// — the fork base / cascade parent (§5.2), the anchor for the lock domain and release
    /// accounting, and the primary source — then `shipTarget` when the two **diverge** (a per-session
    /// `shipBranch` or the project's `autoShipBranch` is set). Reconciling against the ship
    /// destination closes the freshness gap where a sibling shipped its work there and released its
    /// lock, but the just-granted session — pulling only from `parentTarget` — could not see it (the
    /// staleness would otherwise surface only at this session's own ship time). It front-loads the
    /// same merge `WorktreeManager.resyncBranch` already runs *after* a ship. With no overrides the
    /// two coincide and this is the single historical `git merge <parentRef>`.
    private func updateOnGrantSources(_ project: Project) -> [GitRef] {
        let parent = parentTarget(project)
        let ship = shipTarget(project)
        return parent == ship ? [parent] : [parent, ship]
    }

    /// Inject a system-generated log line into the transcript (e.g. a user-triggered
    /// git action from the header). Appears in the chat as a muted note and is
    /// persisted/broadcast like any other event; never changes lifecycle state.
    public func note(_ text: String, icon: String? = nil, autoshipShipped: Bool = false, lockEvent: Bool = false, lock: NoteLock? = nil) async {
        await ingest(synthetic(.note(NoteEvent(text: text, icon: icon, autoshipShipped: autoshipShipped, lockEvent: lockEvent, lock: lock))))
    }

    public func discard(force: Bool) async throws {
        await shutdown()
        // A worktree-less session (ran in the main checkout) has nothing to remove.
        guard let worktreeManager, let worktree, let project else { return }
        try await worktreeManager.discard(worktree, in: project, force: force)
    }

    private func gitContext() throws -> (any WorktreeManaging, Worktree) {
        guard let worktreeManager, let worktree else {
            throw BackendError.unsupported("SessionController has no git context")
        }
        return (worktreeManager, worktree)
    }

    // MARK: - The single-writer ingest pipeline (RUNTIME §2)

    private func ingest(_ event: AgentEvent) async {
        // 1. Canonical seq — the single serialization point (RUNTIME §2.1).
        let canonical: AgentEvent
        do {
            canonical = try await transcript.append(event)
        } catch {
            canonical = event  // degrade: keep the provisional event rather than drop it
        }

        // 2. Derived status + metadata.
        session.status = derivedStatus(session.status, canonical.kind)
        session.lastSeq = canonical.seq
        session.updatedAt = now()

        // Track whether the most recent thing to happen here was a *successful* autoship
        // merge: true only when the newest event is the merge-queue's success note, and
        // cleared by anything else — a later turn, a user message, or even a subsequent
        // failed/conflict autoship note (the marker reflects the latest outcome, not just
        // that autoship ran). Drives the sidebar's shipped-package icon.
        if case .note(let note) = canonical.kind {
            session.lastEventWasAutoship = note.autoshipShipped
        } else {
            session.lastEventWasAutoship = false
        }

        switch canonical.kind {
        case .sessionStarted(let started):
            session.backendSessionID = started.backendSessionID
            if session.model == nil { session.model = started.model }
            session.nativeTranscriptPath = started.nativeTranscriptPath
        case .approvalRequested(let request):
            pendingApprovals[request.id] = request
        case .approvalResolved(let resolved):
            pendingApprovals[resolved.id] = nil
        default:
            break
        }

        // Durability at meaningful boundaries (RUNTIME open-Q #2: batched fsync).
        switch canonical.kind {
        case .turnCompleted, .runFinished, .approvalRequested, .approvalResolved:
            try? await transcript.sync()
        default:
            break
        }

        // 3. Metadata persistence at meaningful boundaries (best-effort — a store
        //    failure must not break the canonical transcript or fan-out).
        if let metadataStore {
            switch canonical.kind {
            case .approvalRequested(let request):
                try? await metadataStore.saveApproval(request)
                try? await metadataStore.saveSession(session)
            case .approvalResolved(let resolved):
                try? await metadataStore.resolveApproval(resolved)
                try? await metadataStore.saveSession(session)
            case .sessionStarted, .turnCompleted, .runFinished, .error:
                try? await metadataStore.saveSession(session)
            default:
                break
            }
        }

        // 4. Fan-out to in-process subscribers.
        history.append(canonical)
        for continuation in subscribers.values {
            continuation.yield(canonical)
        }
    }

    /// Resolve every approval still pending now that the run has ended. With the backend
    /// stream closed there is no live waiter left to answer — the request cards are orphaned
    /// (their Allow/Deny buttons would call into a coordinator that no longer holds the
    /// continuation, so clicking changes nothing on screen). This happens when a run ends
    /// before an approval is answered: the agent deny-resolves at teardown but the
    /// `approvalResolved` races the stream close and is dropped, or the waiter is lost
    /// outright when the transport drops (e.g. the Mac sleeps mid-approval and the CLI gives
    /// up). Emit a synthetic `approvalResolved` (deny, by "system") for each so the normal
    /// `ingest` path dismisses the card, records the resolution, and tells any connected
    /// phone to dismiss it too. A no-op when nothing is pending; `ingest`'s per-id removal
    /// keeps this from double-resolving an approval whose real `approvalResolved` already
    /// arrived (that id is gone from the set, so it isn't iterated).
    private func resolveOrphanedApprovals(reason: String) async {
        guard !pendingApprovals.isEmpty else { return }
        for request in pendingApprovals.values.sorted(by: { $0.createdAt < $1.createdAt }) {
            await ingest(
                synthetic(
                    .approvalResolved(
                        ApprovalResolved(
                            id: request.id,
                            decision: .deny(reason: reason),
                            decidedBy: "system",
                            decidedAt: now()))))
        }
    }

    private func finishRun() async {
        runTask = nil
        // Dismiss any approval cards orphaned by this run ending — the single choke point
        // every run passes through, so a lost/raced `approvalResolved` can never leave a
        // dead Allow/Deny card on screen. Runs before any conversational follow-up turn
        // starts, so a stale approval can't bleed into the next turn.
        await resolveOrphanedApprovals(reason: "The run ended before this request was answered.")
        if conversational {
            // A follow-up that arrived mid-close runs now — never dropped. (Guarded on
            // non-terminal so a message queued just as the session is shutting down or
            // erroring out is discarded rather than resurrecting a dead session.)
            if let pending = pendingInput, !session.status.isTerminal {
                pendingInput = nil
                session.queuedMessage = nil
                // The queued message wasn't injected when it arrived (so it wouldn't
                // interleave with the just-finished turn). Inject it now, as the turn it
                // actually drives begins.
                if let text = pending.plainText, !text.isEmpty {
                    await ingest(synthetic(.userText(makeUserChunk(text))))
                }
                startConversationalTurn(pending)
                return
            }
            pendingInput = nil
            session.queuedMessage = nil
            // A turn ended; the session stays alive for the next message. Keep
            // subscribers attached. Status was set to awaitingInput/error by the
            // turn's runFinished; if the stream died without one, settle to awaiting.
            if !session.status.isTerminal && session.status != .awaitingInput {
                session.status = .awaitingInput
                session.updatedAt = now()
            }
            try? await transcript.sync()
            return
        }
        // One-shot: a well-behaved backend already emitted a terminal runFinished; if
        // not (backend died mid-turn), synthesize one so the session never lingers
        // non-terminal with the process gone.
        if !session.status.isTerminal {
            await ingest(synthetic(.runFinished(RunFinished(outcome: .interrupted))))
        }
        try? await transcript.sync()
        finishSubscribers()
    }

    private func finishSubscribers() {
        for continuation in subscribers.values { continuation.finish() }
        subscribers.removeAll()
    }

    /// Builds a synthetic event the adapter never produced (errors, lifecycle
    /// markers). Provisional seq is 0 — `TranscriptWriter` assigns the canonical one.
    private func synthetic(_ kind: AgentEvent.Kind) -> AgentEvent {
        AgentEvent(
            sessionID: session.id, seq: 0, at: now(),
            backend: session.backend, nativeType: nil, kind: kind)
    }
}
