import Foundation

/// Stable-prefix incremental transcript projector for the macOS app — plan item 4 of
/// docs/MAIN_THREAD_PERFORMANCE_PLAN.md, porting the iOS implementation
/// (docs/TRANSCRIPT_INCREMENTAL_PROJECTION.md) to `NucleicCore`'s richer projection.
///
/// `TranscriptProjection.items(_:worktreeRoot:)` folds the whole stream on every read, so a
/// streaming turn of K deltas over an N-event transcript costs O(N·K) ≈ O(N²) per session. But
/// the stream is immutable except at the tail: everything before the live turn is frozen — its
/// `messageID`s and `toolCallID`s never recur. So this projector **seals** the longest provably
/// stable prefix of the folded item list once, and re-folds only the unstable suffix per update:
/// O(live-tail) instead of O(N).
///
/// The seal point (an index into the raw event stream) is chosen so that
/// `fold(prefix) ++ fold(tail)` is byte-identical to `fold(whole)`:
///
/// 1. **No coalescing key crosses the seam.** Text/thinking/tool items coalesce on
///    `messageID`/`toolCallID` (and a subagent's events name their parent Task). An exact
///    interval check over the current stream forbids any seam inside an id's first→last
///    reference span, so no item can straddle it.
/// 2. **No id can recur after the seam.** Future arrivals are fenced by closure rules read off
///    the stream itself: a tool is closed once its result arrived; a message/thinking block once
///    a different message has started in its scope; everything, once its turn completed. These
///    are the premises of the design doc ("the stream is immutable except at the tail"); if one
///    is ever violated — a tail event referencing a sealed id (a late `fileChange`, a resumed
///    message, a note tagged with a sealed edit) — it is *detected* and the projector resets and
///    re-folds from scratch, so correctness never rests on them.
/// 3. **No tool run is split.** `coalesceToolRuns` merges adjacent `.tool` items, so the seam
///    only falls where the last folded *top-level* item is a hard separator — a visible non-tool
///    row that a future tool call can't merge across. Subagent-owned events create nothing at the
///    top level, so they neither open nor close a run there (partitioning routes them into their
///    Task card's `children`); debug rows, foldable lock notes, and empty redacted-thinking rows
///    are transparent to runs and therefore to this rule too.
/// 4. **No thinking-tokens meter is split.** `flatItems` coalesces a run of
///    `system/thinking_tokens` pings into one live `.thinkingTokens` meter that stays open across
///    interleaved thinking deltas; the whole ping run is kept on one side of the seam (its
///    first→last ping is an interval like an id span, and an open run caps the seam).
/// 5. **Lock notes fold exactly, even across the seam.** A lock's `released` note lands when the
///    file lands in the parent — potentially many turns after the edit it brackets — so sealed
///    edit calls stay reachable through a registry (`TranscriptProjection.PriorEdit`): a tail
///    note that path-matches a sealed edit folds into `lockLines` under that call's id, exactly
///    where a whole-stream fold would put it. No lag heuristic, no divergence. (The macOS
///    projection carries lock lines *beside* the items, so no sealed item ever needs patching.)
/// 6. **`worktreeRoot` is a carried input.** Lock-path normalization bakes the root into sealed
///    display folds, so a different root on a later update refolds the display consumer. (Only
///    the display consumer: the seam itself and the units consumer are root-independent.)
///
/// Any doubt — the event list shrank, the stream identity changed (`firstSeq`), the prefix under
/// the watermark was rewritten (backfill by seq), seq ordering regressed, or a sealed id is
/// referenced — is answered with a full refold. Correctness over speed, always.
///
/// On top of the sealed prefix, three mechanisms keep the **per-read** cost proportional to
/// what actually changed rather than to the unsealed tail:
///
/// - **Two consumers, one seam.** The display fold (`update`) and the store's summary-pass fold
///   (`summarizableUnits`) share the watermark: every display-valid seam is also valid for the
///   simpler units fold — the units fold breaks tool runs at strictly *more* rows (a lock note
///   folds away in display but is a hard row to units; a narrated card drops in display but is a
///   unit's call), and a seam is only ever placed after a hard display separator, which is a
///   hard units separator too. Each consumer's sealed output is accumulated lazily on first use,
///   so a display-only instance (the transcript view) never folds units and a units-only
///   instance (the store's summary pass) never folds display items or lock lines.
/// - **A pinned-watermark fast path.** While the seam is pinned by an open entity — canonically
///   a `Task` running for thousands of events — a full rescan of the unsealed region would
///   recompute the same answer every display tick. The full scan records *what* pinned it
///   (`WatermarkHint`); later reads validate only the new events against those pins (and against
///   the sealed-id fence), falling back to a full rescan the moment anything could unpin.
/// - **Intra-scope sealing.** The outer watermark cannot pass an open `Task`'s spawn (its card's
///   `children` still grow), so the whole scope stays in the tail for the run's lifetime — and
///   refolding it per tick is the O(scope²) the design exists to kill. The tail's partition is
///   therefore cached and appended to per read (`TailCache`), and each subagent scope gets its
///   own child projector (`ScopeChildProjection`) that seals the scope's *closed* children —
///   result-fenced tools, superseded messages — under the same seam rules, recursively for
///   nested scopes. A sealed child card's descendants are snapshotted (`ScopeCursor`); a late
///   event into a baked scope is detected and answered with a scope-local refold.
///
/// The equivalence suite (`IncrementalTranscriptProjectionTests`) replays streams and asserts
/// `incremental(prefix) == TranscriptProjection.items(prefix, worktreeRoot:)` — items, `lockLines`
/// *and* `summarizableUnits` — for **every** prefix: the whole correctness argument, checked
/// mechanically.
///
/// Pure Foundation + NucleicProtocol with no main-actor requirement; outputs are immutable value
/// types. The instance itself is single-owner mutable state (not thread-safe): hold one per open
/// transcript, like the `ProjectionCache` it is designed to replace, and call it from one
/// isolation domain at a time.
public final class IncrementalTranscriptProjection {

    public init() {}

    // MARK: - Sealed state

    /// Watermark into the raw stream: `events[0..<sealedEventCount]` is the sealed prefix.
    private var sealedEventCount = 0
    /// `events[sealedEventCount - 1].seq` at seal time — detects a rewritten/merged prefix
    /// (a reconnect backfill slotting events in by seq) that shifts history under the watermark.
    private var sealedLastSeq: UInt64 = 0
    /// The stream's first seq — detects a session switch / stream reset.
    private var streamFirstSeq: UInt64?
    /// Every `messageID`/`toolCallID` referenced by a sealed event. A later event referencing one
    /// would mutate sealed output — detected here, answered with a full reset (self-healing).
    private var sealedIDs: Set<String> = []

    // MARK: Display consumer (folded on demand — the transcript view's `update`)

    /// Whether `update` has ever been called: only then does sealing fold display segments.
    private var itemsRequested = false
    /// Event count the display arrays below cover; lags `sealedEventCount` until the display
    /// consumer is first requested (or after a root change), then `primeConsumers` folds the gap.
    private var itemsSealedThrough = 0
    /// Folded display output of the sealed prefix — appended to at each seal, never re-walked.
    private var sealedItems: [TranscriptItem] = []
    /// Lock lines folded within (or onto) the sealed prefix, keyed by the edit's tool-call id.
    /// The per-update result is a copy of this map with the tail's folds merged in.
    private var sealedLockLines: [String: [NoteLock]] = [:]
    /// Sealed edit-class calls in item order, for cross-seam lock-note folding (rule 5).
    private var sealedEdits: [TranscriptProjection.PriorEdit] = []
    /// The root baked into sealed lock folds; a different value on a later update refolds the
    /// display consumer (the seam and the units consumer are root-independent).
    private var worktreeRoot: String?
    /// Whether `worktreeRoot` has been recorded at least once (distinguishes "never updated"
    /// from a legitimately nil root, so the very first update never spuriously refolds).
    private var hasWorktreeRoot = false

    // MARK: Units consumer (folded on demand — the store's summary pass)

    /// Whether `summarizableUnits` has ever been called: only then does sealing fold units.
    private var unitsRequested = false
    /// Event count `sealedUnits` covers (see `itemsSealedThrough`).
    private var unitsSealedThrough = 0
    /// The sealed prefix's summarizable units — appended to at each seal, never re-derived.
    private var sealedUnits: [TranscriptProjection.SummarizableUnit] = []

    // MARK: - Read memos

    /// The view layer re-reads far more often than the stream changes (scroll geometry, hover,
    /// selection); this collapses those redundant reads to a cached return, as the caller's
    /// `ProjectionCache` did. The stream is append-only with strictly increasing seq, so
    /// `(count, firstSeq, lastSeq)` pins it; the root is the only other fold input.
    private struct MemoKey: Equatable {
        var count: Int
        var firstSeq: UInt64
        var lastSeq: UInt64
        var worktreeRoot: String?
    }
    private var memoKey: MemoKey?
    private var memoItems: [TranscriptItem] = []
    private var memoLockLines: [String: [NoteLock]] = [:]

    /// Same idea for the units consumer; the root is not a units-fold input, so it isn't keyed.
    private struct UnitsMemoKey: Equatable {
        var count: Int
        var firstSeq: UInt64
        var lastSeq: UInt64
    }
    private var unitsMemoKey: UnitsMemoKey?
    private var memoUnits: [TranscriptProjection.SummarizableUnit] = []

    /// Hold-back from the stream edge: never seal into the newest few events. Decoders emit
    /// tightly-coupled events in one batch (a `toolResult` and its inferred `fileChange`; a
    /// whole-message's final chunks) that delivery may hand over one at a time — holding the edge
    /// back keeps a mid-batch read from sealing an entity whose trailing batch-mates are still in
    /// flight. Cheap insurance on top of the closure rules; violations would only cost a reset.
    private let edgeLag = 4

    // MARK: Tail state (partition cache + per-scope projectors + watermark hint)

    private var tailCache: TailCache?
    private var watermarkHint: WatermarkHint?

    /// Test hook: how far the watermark has advanced (the equivalence suite also asserts sealing
    /// actually happens, so a regression to "never seal" can't pass silently).
    var sealedEventCountForTesting: Int { sealedEventCount }
    /// Test hook: how many times the full O(tail) watermark scan ran — asserts the pinned fast
    /// path actually short-circuits it during a long subagent run.
    var fullWatermarkScansForTesting = 0
    /// Test hook: per-scope inner watermark positions, so intra-scope sealing is test-enforced.
    func scopeSealedCountsForTesting() -> [String: Int] {
        tailCache?.scopes.mapValues(\.sealedCountForTesting) ?? [:]
    }

    // MARK: - Read

    /// The projected transcript for `events` — byte-identical to
    /// `TranscriptProjection.items(events, worktreeRoot: worktreeRoot)`, computed by sealing the
    /// stable prefix once and re-folding only the live tail.
    public func update(events: [AgentEvent], worktreeRoot: String?)
        -> (items: [TranscriptItem], lockLines: [String: [NoteLock]])
    {
        let key = MemoKey(count: events.count, firstSeq: events.first?.seq ?? 0,
                          lastSeq: events.last?.seq ?? 0, worktreeRoot: worktreeRoot)
        if memoKey == key { return (memoItems, memoLockLines) }

        if hasWorktreeRoot, worktreeRoot != self.worktreeRoot {
            // The root is a display-fold input only (lock-path normalization bakes it into the
            // sealed lock folds): drop the display consumer's accumulation and let
            // `primeConsumers` refold it under the new root. The watermark, sealed-id fence,
            // and units consumer are root-independent and carry straight through.
            sealedItems = []
            sealedLockLines = [:]
            sealedEdits = []
            itemsSealedThrough = 0
        }
        self.worktreeRoot = worktreeRoot
        hasWorktreeRoot = true
        itemsRequested = true

        advance(events)

        let result = renderItems(events)
        memoKey = key
        memoItems = result.items
        memoLockLines = result.lockLines
        return result
    }

    /// The summarizable units of `events`'s main conversation — byte-identical to
    /// `TranscriptProjection.summarizableUnits(events)`, computed from the same sealed seam as
    /// the display fold: the sealed prefix's units accumulate once, and only the live tail's
    /// top-level items are re-coalesced per read. This is what the store's throttled summary
    /// pass calls instead of the one-shot static, which refolded the whole open transcript on
    /// every invocation.
    ///
    /// Every display-valid seam is units-valid: a seam is only placed after a hard display
    /// separator, and every display separator is a hard row to the units fold too (the units
    /// fold additionally treats foldable lock notes and narration-dropped cards as its own
    /// rows, which only ever breaks runs *earlier* — never merges across a display seam).
    public func summarizableUnits(events: [AgentEvent]) -> [TranscriptProjection.SummarizableUnit] {
        let key = UnitsMemoKey(count: events.count, firstSeq: events.first?.seq ?? 0,
                               lastSeq: events.last?.seq ?? 0)
        if unitsMemoKey == key { return memoUnits }

        unitsRequested = true
        advance(events)

        let result: [TranscriptProjection.SummarizableUnit]
        if events.count > sealedEventCount {
            // The units fold has no lock folding, narration drops, or child attachment — just
            // the tail's top-level items, coalesced. The cached partition supplies them.
            let cache = syncTailCache(events)
            result = sealedUnits + TranscriptProjection.unitsFromCoalesced(
                TranscriptProjection.coalesceToolRuns(TranscriptProjection.flatItems(cache.topLevel)))
        } else {
            result = sealedUnits
        }
        unitsMemoKey = key
        memoUnits = result
        return result
    }

    /// Shared per-read pipeline: identity checks, consumer priming, watermark, seal.
    private func advance(_ events: [AgentEvent]) {
        if needsStreamReset(events) { reset() }
        streamFirstSeq = events.first?.seq
        primeConsumers(events)

        var watermark = nextWatermark(events)
        if watermark == nil {
            // A tail event referenced a sealed id (a closure premise was violated — late file
            // change, resumed message, post-result subagent child, a note tagged with a sealed
            // edit) or seq ordering regressed. Refold from scratch; with no sealed ids the
            // second pass can only trip on a seq regress, in which case nothing seals and the
            // whole stream is folded as one tail — the full-fold fallback.
            reset()
            streamFirstSeq = events.first?.seq
            watermark = nextWatermark(events)
        }
        seal(events, upTo: watermark ?? sealedEventCount)
    }

    // MARK: - Reset / identity

    private func needsStreamReset(_ events: [AgentEvent]) -> Bool {
        if events.count < sealedEventCount { return true }  // history shrank (revert/clear)
        if sealedEventCount > 0 {
            if events.first?.seq != streamFirstSeq { return true }
            if events[sealedEventCount - 1].seq != sealedLastSeq { return true }
        } else if streamFirstSeq != nil, events.first?.seq != streamFirstSeq {
            return true  // nothing sealed, but the identity guard belongs to the old stream
        }
        return false
    }

    private func reset() {
        sealedEventCount = 0
        sealedLastSeq = 0
        streamFirstSeq = nil
        sealedIDs = []
        sealedItems = []
        sealedLockLines = [:]
        sealedEdits = []
        itemsSealedThrough = 0
        sealedUnits = []
        unitsSealedThrough = 0
        memoKey = nil
        memoItems = []
        memoLockLines = [:]
        unitsMemoKey = nil
        memoUnits = []
        watermarkHint = nil
        tailCache = nil
    }

    // MARK: - Watermark selection

    /// The coalescing ids an event mentions: its `messageID` or `toolCallID`, plus the parent
    /// Task id for subagent-owned events. Two events sharing an id must land on the same side of
    /// the seam; the parent link chains a subagent's whole scope (and, transitively, deeper
    /// descendants) to its spawn. A note tagged with the edit it brackets chains to that edit's
    /// id, so a seal seam can't separate them — and a *late* tagged note referencing a sealed
    /// edit trips the self-healing reset instead of folding wrong.
    fileprivate static func refs(of event: AgentEvent) -> [String] {
        switch event.kind {
        case .userText(let c), .assistantText(let c), .thinking(let c):
            if let parent = c.parentToolCallID { return [c.messageID, parent] }
            return [c.messageID]
        case .toolCallStarted(let c), .toolCallCompleted(let c):
            if let parent = c.parentToolCallID { return [c.toolCallID, parent] }
            return [c.toolCallID]
        case .toolCallInputDelta(let d): return [d.toolCallID]
        case .toolResult(let r): return [r.toolCallID]
        case .fileChange(let f): return f.toolCallID.map { [$0] } ?? []
        case .note(let note): return note.toolCallID.map { [$0] } ?? []
        default: return []
        }
    }

    /// One id's life within the unsealed region.
    private struct IDSpan {
        var firstRef: Int
        var lastRef: Int
        /// Result arrived → the tool (or Task, with its children) is done.
        var resultSeen = false
        /// A later chunk with a different messageID in the same scope → this message is done
        /// (its authoritative non-partial text can only arrive before the next message starts).
        var closedByChunk = false
        /// For message/thinking ids: the owning subagent scope ("" = top level). The owner
        /// Task's result closes everything inside it.
        var chunkScope: String?
    }

    /// What an event *creates* in the folded **top-level** item list, for the run-split rule (3).
    /// Subagent-owned events create nothing at the top level (they render inside their Task
    /// card's `children`), so they get no creation at all.
    private enum Creation {
        /// A visible, never-dropped, non-tool row — a safe last-item for a seam.
        case separator
        /// A `.tool` row a future adjacent call could merge with.
        case tool
        /// A thinking row: a separator iff its final text is non-empty (an empty redacted block
        /// is dropped by `flatItems`, transparent to run coalescing, so it must be transparent
        /// to the seam rule too).
        case thinking(String)
        /// A row that draws nothing (debug rows: usage, rate limit, turn marker, raw, the
        /// thinking-tokens meter) or that may fold away (a lock note with paths). Dropping or
        /// holding it aside can fuse the runs around it, so it counts as nothing — the seam just
        /// waits for the next hard separator.
        case transparent
    }

    /// Why the last full watermark scan sealed nothing: the open entities pinning the seam.
    /// While they stay open — and no turn boundary lands, no sealed id is referenced, and seq
    /// stays monotone — a full rescan of the unsealed region would recompute the same answer,
    /// so subsequent reads validate only the *new* events against these pins: O(new) instead of
    /// O(tail) per display tick. This is what keeps a long subagent run (the outer seam pinned
    /// at the open Task's spawn for the run's whole life) from degrading to O(scope²).
    private struct WatermarkHint {
        /// Events validated so far (absolute count) and the last one's seq — the append-only
        /// identity guard for the fast path.
        var checkedCount: Int
        var lastSeq: UInt64
        /// Pinning open tool/Task spans: only their results (or a boundary) can unpin.
        var pinTools: Set<String>
        /// Scopes ("" = top level) of pinning open messages: a chunk in the same scope closes
        /// the previous message there, so only same-scope chunks force a rescan. A subagent's
        /// child chunks arrive by the thousands in *other* scopes and are skipped.
        var pinnedMessageScopes: Set<String>
        /// Owner Task ids whose result closes a pinning subagent message (`chunkScope` rule).
        var resultTriggers: Set<String>
        /// The seam is pinned by an open thinking-tokens meter run: any non-thinking, non-ping
        /// event may close it (a conservative, cheap over-trigger — the runs are short).
        var pingPinned: Bool
    }

    private enum FastScanOutcome {
        /// Nothing could have unpinned the seam: the watermark provably stays put.
        case stillPinned
        /// A new event referenced a sealed id or regressed seq — the caller must reset.
        case violation
        /// Something may have unpinned it (or identity drifted): run the full scan.
        case rescan
    }

    /// Validate only the events beyond `hint.checkedCount` against the recorded pins. Sound
    /// because nothing an event can do enables a seam at or below the pinned cap without also
    /// tripping one of the triggers: closures below the cap are impossible while the pins are
    /// the region's earliest open entities, new spans open later than the cap, and interval
    /// growth only ever *blocks* seams.
    private func fastScan(_ events: [AgentEvent], hint: inout WatermarkHint) -> FastScanOutcome {
        guard hint.checkedCount <= events.count,
              hint.checkedCount > 0,
              events[hint.checkedCount - 1].seq == hint.lastSeq
        else { return .rescan }
        var prev = hint.lastSeq
        for i in hint.checkedCount..<events.count {
            let event = events[i]
            guard event.seq > prev else { return .violation }
            prev = event.seq
            for id in Self.refs(of: event) where sealedIDs.contains(id) { return .violation }
            switch event.kind {
            case .turnCompleted, .runFinished:
                return .rescan  // a boundary closes every id of its turn
            case .toolResult(let result):
                if hint.pinTools.contains(result.toolCallID)
                    || hint.resultTriggers.contains(result.toolCallID) { return .rescan }
            case .userText(let c), .assistantText(let c), .thinking(let c):
                if hint.pinnedMessageScopes.contains(c.parentToolCallID ?? "") { return .rescan }
            default:
                break
            }
            if hint.pingPinned, !isThinking(event),
               TranscriptProjection.parseThinkingTokens(event) == nil { return .rescan }
        }
        hint.checkedCount = events.count
        hint.lastSeq = prev
        return .stillPinned
    }

    /// The watermark for this read: the pinned fast path when the last full scan left a hint,
    /// the full scan otherwise. nil means a violation → the caller resets and refolds.
    private func nextWatermark(_ events: [AgentEvent]) -> Int? {
        if var hint = watermarkHint {
            switch fastScan(events, hint: &hint) {
            case .stillPinned:
                watermarkHint = hint
                return sealedEventCount
            case .violation:
                watermarkHint = nil
                return nil
            case .rescan:
                watermarkHint = nil
            }
        }
        fullWatermarkScansForTesting += 1
        let scan = chooseWatermark(events)
        // A hint is only meaningful when the scan sealed nothing — a seal advance changes the
        // region, so the next read scans the (now small) remainder from scratch.
        watermarkHint = scan.watermark == sealedEventCount ? scan.hint : nil
        return scan.watermark
    }

    /// The furthest event index the stream can be sealed to right now (plus, when nothing can
    /// seal, the pins for the fast path) — or nil when a region event references an
    /// already-sealed id or seq ordering regresses (→ caller resets).
    private func chooseWatermark(_ events: [AgentEvent]) -> (watermark: Int?, hint: WatermarkHint?) {
        let start = sealedEventCount
        let n = events.count - start

        // Seq must stay strictly increasing through the unsealed region — the memo key and the
        // watermark guard both lean on monotonicity. A regress means the stream was rewritten in
        // a way the cheap guards can't localize: full refold.
        var prevSeq: UInt64? = sealedEventCount > 0 ? sealedLastSeq : nil
        for r in 0..<n {
            let seq = events[start + r].seq
            if let prev = prevSeq, seq <= prev { return (nil, nil) }
            prevSeq = seq
        }

        guard n > edgeLag else {
            // Too little unsealed to advance, but tail refs must still be validated against
            // sealed ids so a violation triggers the reset path.
            for r in 0..<n where Self.refs(of: events[start + r]).contains(where: sealedIDs.contains) {
                return (nil, nil)
            }
            return (start, nil)
        }

        // Pass 1 — the region's tool→parent map, so events can be classified top-level vs
        // subagent-owned exactly the way `partition`/`subagentOwner` classify them (a tool
        // result / file change / tagged note inherits its scope from the call it references).
        // Region-local is enough: any linkage crossing the seam names a sealed id and resets.
        var parentOf: [String: String] = [:]
        for r in 0..<n {
            switch events[start + r].kind {
            case .toolCallStarted(let c), .toolCallCompleted(let c):
                if let parent = c.parentToolCallID { parentOf[c.toolCallID] = parent }
            default:
                break
            }
        }

        var spans: [String: IDSpan] = [:]
        var creations: [Creation?] = Array(repeating: nil, count: n)
        var thinkingText: [String: String] = [:]
        var seenMessageItem = Set<String>()   // top-level assistant messageIDs with a row
        var seenThinkingItem = Set<String>()  // top-level thinking messageIDs with a row
        var seenToolItem = Set<String>()      // top-level toolCallIDs with a card
        var lastChunkInScope: [String: String] = [:]
        var lastBoundary = -1  // region index of the latest turnCompleted/runFinished
        // Thinking-tokens meter runs (rule 4): [first ping, last ping] per closed run, plus the
        // still-open run's first ping. A run stays open across thinking deltas and closes on any
        // other top-level event, mirroring `flatItems`' `openTokensIndex` reset — which sees only
        // top-level events, so subagent-owned ones must not close it here either.
        var pingIntervals: [(first: Int, last: Int)] = []
        var openPingFirst: Int?
        var openPingLast = -1

        for r in 0..<n {
            let event = events[start + r]
            for id in Self.refs(of: event) {
                if sealedIDs.contains(id) { return (nil, nil) }
                if var span = spans[id] {
                    span.lastRef = r
                    spans[id] = span
                } else {
                    spans[id] = IDSpan(firstRef: r, lastRef: r)
                }
            }

            // A thinking-tokens ping extends (or opens) the live meter run and touches nothing
            // else — `flatItems` handles it before its main switch and `continue`s, so mirror
            // that exactly. The meter row itself is a debug row: transparent to the seam.
            if TranscriptProjection.parseThinkingTokens(event) != nil {
                if openPingFirst == nil { openPingFirst = r }
                openPingLast = r
                creations[r] = .transparent
                continue
            }

            // The subagent (if any) whose scope owns this event, mirroring `subagentOwner`.
            // `userText` is always top level (the full fold routes it there unconditionally).
            let owner: String? = {
                switch event.kind {
                case .toolCallStarted(let c), .toolCallCompleted(let c): return c.parentToolCallID
                case .toolResult(let result): return parentOf[result.toolCallID]
                case .fileChange(let change): return change.toolCallID.flatMap { parentOf[$0] }
                case .assistantText(let c), .thinking(let c): return c.parentToolCallID
                case .note(let note): return note.toolCallID.flatMap { parentOf[$0] }
                default: return nil
                }
            }()

            // Close the meter run on any *top-level* non-thinking event (a subagent-owned event
            // never reaches the top-level fold, so it can't close the meter there).
            if owner == nil, !isThinking(event) {
                if let first = openPingFirst {
                    pingIntervals.append((first, openPingLast))
                    openPingFirst = nil
                }
            }

            switch event.kind {
            case .userText(let c):
                trackChunk(c, in: &spans, lastChunkInScope: &lastChunkInScope)
                creations[r] = .separator  // one passthrough `.event` row per event, always top level
            case .assistantText(let c):
                trackChunk(c, in: &spans, lastChunkInScope: &lastChunkInScope)
                if owner == nil, seenMessageItem.insert(c.messageID).inserted {
                    creations[r] = .separator
                }
            case .thinking(let c):
                trackChunk(c, in: &spans, lastChunkInScope: &lastChunkInScope)
                if owner == nil {
                    let existing = thinkingText[c.messageID] ?? ""
                    thinkingText[c.messageID] = c.isPartial ? existing + c.text : c.text
                    if seenThinkingItem.insert(c.messageID).inserted {
                        creations[r] = .thinking(c.messageID)
                    }
                }
            case .toolCallStarted(let c), .toolCallCompleted(let c):
                if owner == nil, seenToolItem.insert(c.toolCallID).inserted { creations[r] = .tool }
            case .toolResult(let result):
                spans[result.toolCallID]?.resultSeen = true
                // A result whose call isn't in view renders as a standalone `.event` row; one
                // that folds into its card creates nothing.
                if owner == nil, !seenToolItem.contains(result.toolCallID) {
                    creations[r] = .separator
                }
            case .fileChange(let change):
                // Folded (dropped) when it belongs to a call in view; a standalone row otherwise.
                if owner == nil {
                    if let id = change.toolCallID, seenToolItem.contains(id) { break }
                    creations[r] = .separator
                }
            case .toolCallInputDelta:
                break  // creates nothing — the completion carries the assembled input
            case .runFinished:
                creations[r] = .separator
                lastBoundary = r
            case .turnCompleted:
                // A debug row (held aside from tool runs by `rendersNothing`, filtered by the
                // view when detail is off) — transparent to the seam, but it still closes the
                // ids of its turn, so keep it as a boundary.
                creations[r] = .transparent
                lastBoundary = r
            case .sessionStarted, .approvalRequested, .approvalResolved, .error:
                creations[r] = .separator
            case .usage, .rateLimit, .codexUsage:
                creations[r] = .transparent  // passthrough debug rows, held aside from runs
            case .note(let note):
                if owner == nil {
                    // A lock note with paths may fold onto an edit card (dropping it can fuse
                    // the runs around it) → transparent; every other note is a hard row.
                    if let lock = note.lock, !lock.paths.isEmpty { creations[r] = .transparent }
                    else { creations[r] = .separator }
                }
            case .raw:
                creations[r] = .transparent  // debug passthrough row (pings handled above)
            }
        }

        // Future-proofing (rule 2): an id wholly before the seam must be *closed* — provably done
        // taking new events. An open id caps the seam at its first reference (it stays whole in
        // the tail). An open thinking-tokens meter run caps the seam the same way (rule 4).
        var cap = n - edgeLag
        for (_, span) in spans where !isClosed(span, spans: spans, lastBoundary: lastBoundary) {
            cap = min(cap, span.firstRef)
        }
        if let first = openPingFirst { cap = min(cap, first) }

        // Interval isolation (rules 1 & 4): no seam inside any id's [firstRef, lastRef] span, nor
        // inside a closed meter run's [first, last] ping interval. maxLastAtFirst feeds a running
        // maximum; a seam at w is isolation-safe iff no interval starting before w reaches w.
        var maxLastAtFirst = [Int](repeating: -1, count: n)
        for span in spans.values {
            maxLastAtFirst[span.firstRef] = max(maxLastAtFirst[span.firstRef], span.lastRef)
        }
        for interval in pingIntervals {
            maxLastAtFirst[interval.first] = max(maxLastAtFirst[interval.first], interval.last)
        }

        // Run-split rule (3): replay top-level creations in item order; a seam is placeable after
        // event r only while the last solid (visible, surviving) item is a hard separator. The
        // replay resolves each thinking row against its *final* region text, which is exactly
        // what the sealed fold will contain (open ids were already excluded by `cap`).
        var separatorOK = [Bool](repeating: false, count: n)
        var lastSolidIsSeparator = true  // sealed prefix is empty or ends with a separator (invariant)
        for r in 0..<n {
            switch creations[r] {
            case .separator: lastSolidIsSeparator = true
            case .tool: lastSolidIsSeparator = false
            case .thinking(let id):
                let text = thinkingText[id] ?? ""
                if !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
                    lastSolidIsSeparator = true
                }
            case .transparent, nil: break
            }
            separatorOK[r] = lastSolidIsSeparator
        }

        var runningMaxLast = -1
        var best = 0
        if cap >= 1 {
            for w in 1...cap {
                runningMaxLast = max(runningMaxLast, maxLastAtFirst[w - 1])
                if separatorOK[w - 1] && runningMaxLast < w { best = w }
            }
        }

        // Nothing sealed and the cap sits on open entities: record them so the next reads can
        // validate only the new events (the pinned fast path). Closures strictly below the cap
        // are impossible — the pins are the region's earliest open entities — so only the pins'
        // own closure events (or a boundary) can move the answer.
        var hint: WatermarkHint?
        if best == 0, cap < n - edgeLag {
            var pinTools = Set<String>()
            var pinnedMessageScopes = Set<String>()
            var resultTriggers = Set<String>()
            for (id, span) in spans
            where !isClosed(span, spans: spans, lastBoundary: lastBoundary) && span.firstRef == cap {
                if let scope = span.chunkScope {
                    pinnedMessageScopes.insert(scope)
                    if !scope.isEmpty { resultTriggers.insert(scope) }
                } else {
                    pinTools.insert(id)
                }
            }
            hint = WatermarkHint(
                checkedCount: events.count, lastSeq: events.last?.seq ?? 0,
                pinTools: pinTools, pinnedMessageScopes: pinnedMessageScopes,
                resultTriggers: resultTriggers, pingPinned: openPingFirst == cap)
        }
        return (start + best, hint)
    }

    private func isThinking(_ event: AgentEvent) -> Bool {
        if case .thinking = event.kind { return true }
        return false
    }

    /// Track a text/thinking chunk for message-closure: a new messageID in a scope closes the
    /// previous one (chunks of one message never resume after the next begins — decoder order).
    private func trackChunk(
        _ chunk: TextChunk, in spans: inout [String: IDSpan], lastChunkInScope: inout [String: String]
    ) {
        let scope = chunk.parentToolCallID ?? ""
        spans[chunk.messageID]?.chunkScope = scope
        if let previous = lastChunkInScope[scope], previous != chunk.messageID {
            spans[previous]?.closedByChunk = true
        }
        lastChunkInScope[scope] = chunk.messageID
    }

    private func isClosed(_ span: IDSpan, spans: [String: IDSpan], lastBoundary: Int) -> Bool {
        if span.lastRef < lastBoundary { return true }  // its turn completed; ids don't cross turns
        if span.resultSeen { return true }
        if span.closedByChunk { return true }
        if let scope = span.chunkScope, !scope.isEmpty, spans[scope]?.resultSeen == true {
            return true  // the owning subagent returned; its inner stream is done
        }
        return false
    }

    // MARK: - Sealing

    /// Bring a consumer that was requested after sealing had already advanced (or that a root
    /// change just invalidated) up to the watermark, folding the gap as one segment — every
    /// segment boundary was a valid seam, so one gap-fold equals the segment-by-segment fold.
    private func primeConsumers(_ events: [AgentEvent]) {
        if itemsRequested, itemsSealedThrough < sealedEventCount {
            appendItemsSegment(events[itemsSealedThrough..<sealedEventCount])
            itemsSealedThrough = sealedEventCount
        }
        if unitsRequested, unitsSealedThrough < sealedEventCount {
            sealedUnits.append(contentsOf: TranscriptProjection.summarizableUnits(
                Array(events[unitsSealedThrough..<sealedEventCount])))
            unitsSealedThrough = sealedEventCount
        }
    }

    private func seal(_ events: [AgentEvent], upTo watermark: Int) {
        guard watermark > sealedEventCount else { return }
        let slice = events[sealedEventCount..<watermark]
        if itemsRequested, itemsSealedThrough == sealedEventCount {
            appendItemsSegment(slice)
            itemsSealedThrough = watermark
        }
        if unitsRequested, unitsSealedThrough == sealedEventCount {
            sealedUnits.append(contentsOf: TranscriptProjection.summarizableUnits(Array(slice)))
            unitsSealedThrough = watermark
        }
        for event in slice {
            for id in Self.refs(of: event) { sealedIDs.insert(id) }
        }
        sealedEventCount = watermark
        sealedLastSeq = events[watermark - 1].seq
    }

    private func appendItemsSegment(_ slice: ArraySlice<AgentEvent>) {
        let segment = TranscriptProjection.buildSegment(
            slice, worktreeRoot: worktreeRoot, priorEdits: sealedEdits)
        sealedItems.append(contentsOf: segment.items)
        // Lock lines in this segment — including folds onto edits sealed earlier — are now
        // final: merge them in (append order per id matches the whole-stream note order).
        for (id, locks) in segment.lockLines {
            sealedLockLines[id, default: []].append(contentsOf: locks)
        }
        sealedEdits.append(contentsOf: segment.edits)
    }

    // MARK: - Tail partition cache

    /// The unsealed tail's partition — top-level events, per-scope event lists, the tool→parent
    /// map — maintained by appending only the events that arrived since the last read. During a
    /// long subagent run the outer seam is pinned at the open Task's spawn, so without this
    /// every display tick re-partitioned the whole (unbounded) tail from scratch.
    ///
    /// Also carries the per-scope child projectors, which survive cache rebuilds (a rebuild
    /// reconstructs identical scope lists from the same events; each projector re-verifies its
    /// own prefix identity and self-heals if it doesn't match).
    private final class TailCache {
        /// `sealedEventCount` this cache is anchored at; a seal advance rebuilds the cache.
        let startIndex: Int
        /// Absolute count of events consumed into the partition so far.
        var consumedCount: Int
        /// Seq of the last consumed event — the append-identity guard.
        var lastSeq: UInt64 = 0
        /// The tail's main-agent events. Tool-input deltas are omitted: they project to no item
        /// at all, so the fold is byte-identical without them — and a streaming `Write`'s burst
        /// would otherwise dominate the per-tick flat walk.
        var topLevel: [AgentEvent] = []
        /// The tail's subagent-owned events, keyed by the *direct* owner's Task id.
        var byParent: [String: [AgentEvent]] = [:]
        /// tool-call id → parent Task id, accumulated from call starts/completes.
        var parentOf: [String: String] = [:]
        /// One incremental child projector per subagent scope (see `ScopeChildProjection`).
        var scopes: [String: ScopeChildProjection] = [:]

        init(startIndex: Int) {
            self.startIndex = startIndex
            self.consumedCount = startIndex
        }

        /// Consume `events[consumedCount...]` into the partition, mirroring
        /// `TranscriptProjection.partition` exactly (the parent map fills from call starts, which
        /// always precede the results/changes/notes that consult it).
        func append(_ events: [AgentEvent]) {
            guard consumedCount < events.count else { return }
            for i in consumedCount..<events.count {
                let event = events[i]
                switch event.kind {
                case .toolCallStarted(let c), .toolCallCompleted(let c):
                    if let parent = c.parentToolCallID { parentOf[c.toolCallID] = parent }
                default:
                    break
                }
                if let owner = TranscriptProjection.subagentOwner(of: event, parentOf: parentOf) {
                    byParent[owner, default: []].append(event)
                } else if case .toolCallInputDelta = event.kind {
                    // Projects to nothing — see `topLevel`.
                } else {
                    topLevel.append(event)
                }
            }
            lastSeq = events[events.count - 1].seq
            consumedCount = events.count
        }
    }

    /// The tail partition for `events`, appended to when the tail only grew and rebuilt when
    /// the anchor moved (a seal advance) or identity drifted (a reset already cleared it).
    private func syncTailCache(_ events: [AgentEvent]) -> TailCache {
        if let cache = tailCache, cache.startIndex == sealedEventCount,
           cache.consumedCount <= events.count,
           cache.consumedCount == cache.startIndex
               || events[cache.consumedCount - 1].seq == cache.lastSeq {
            cache.append(events)
            return cache
        }
        let fresh = TailCache(startIndex: sealedEventCount)
        if let old = tailCache { fresh.scopes = old.scopes }
        fresh.append(events)
        // Scopes wholly consumed by a seal advance are gone from the new tail; drop their
        // projectors. Surviving scopes keep theirs — their (identical, rebuilt) event lists
        // pass the projectors' own prefix-identity checks.
        fresh.scopes = fresh.scopes.filter { fresh.byParent[$0.key] != nil }
        tailCache = fresh
        return fresh
    }

    // MARK: - Rendering

    private func renderItems(_ events: [AgentEvent])
        -> (items: [TranscriptItem], lockLines: [String: [NoteLock]])
    {
        guard events.count > sealedEventCount else { return (sealedItems, sealedLockLines) }
        // The same pipeline as `TranscriptProjection.buildSegment`, fed from the cached
        // partition, with child attachment routed through the per-scope projectors.
        let cache = syncTailCache(events)
        let flat = TranscriptProjection.dropNarratedToolCards(
            TranscriptProjection.flatItems(cache.topLevel))
        let (folded, tailLockLines) = TranscriptProjection.foldLockNotes(
            flat, worktreeRoot: worktreeRoot, priorEdits: sealedEdits)
        let coalesced = TranscriptProjection.coalesceToolRuns(folded)
        let items = sealedItems + coalesced.map { attachChildren($0, cache: cache) }
        guard !tailLockLines.isEmpty else { return (items, sealedLockLines) }
        // A live (unsealed) lock note folded — possibly onto a sealed edit: merge into a copy
        // per read (the note may still be re-evaluated until it seals, so the base stays clean).
        var lockLines = sealedLockLines
        for (id, locks) in tailLockLines {
            lockLines[id, default: []].append(contentsOf: locks)
        }
        return (items, lockLines)
    }

    /// `TranscriptProjection.attachSubagentChildren`, with each spawn's children resolved
    /// through its scope's incremental projector instead of a from-scratch fold.
    private func attachChildren(_ item: TranscriptItem, cache: TailCache) -> TranscriptItem {
        switch item {
        case .tool(let id, let call, let result, let finished, _):
            guard TranscriptProjection.isSubagentSpawn(call) else { return item }
            return .tool(id: id, call: call, result: result, finished: finished,
                         children: scopeChildren(call.toolCallID, depth: 1, cache: cache))
        case .toolGroup(let id, let signature, let calls):
            let nested = calls.map { entry -> ToolEntry in
                guard TranscriptProjection.isSubagentSpawn(entry.call) else { return entry }
                return (entry.call, entry.result, entry.finished,
                        scopeChildren(entry.call.toolCallID, depth: 1, cache: cache))
            }
            return .toolGroup(id: id, signature: signature, calls: nested)
        default:
            return item
        }
    }

    /// One scope's projected children (`TranscriptProjection.project(byParent[id], _, depth)`,
    /// computed incrementally), creating the scope's projector on first sight and recursing
    /// through the registry for nested scopes.
    private func scopeChildren(_ scopeID: String, depth: Int, cache: TailCache) -> [TranscriptItem] {
        let projector: ScopeChildProjection
        if let existing = cache.scopes[scopeID] {
            projector = existing
        } else {
            projector = ScopeChildProjection(scopeID: scopeID, depth: depth)
            cache.scopes[scopeID] = projector
        }
        return projector.children(cache.byParent[scopeID] ?? [], byParent: cache.byParent) {
            [unowned self] childID, childDepth in
            self.scopeChildren(childID, depth: childDepth, cache: cache)
        }
    }
}

/// Incremental projector for ONE subagent scope's direct events — the `children` of its Task
/// card. The outer seam cannot pass an open Task's spawn (rule 2: the card's children still
/// grow), so a long-running subagent keeps its whole scope in the unsealed tail; this class
/// keeps *that* refold from being O(scope) per tick by sealing the scope's provably closed
/// prefix — result-fenced child tools, messages superseded by later ones — under the same seam
/// rules as the outer projector, specialized to a scope's fold
/// (`coalesceToolRuns(dropNarratedToolCards(flatItems(_)))`, no lock folding, every event
/// direct-owned):
///
/// - No id straddles the seam, and an open id (an unresulted child tool, the currently
///   streaming message, an open nested Task) caps it at its first reference. The scope's own
///   id is excluded from span tracking — every event here references it by construction.
/// - A note inside a card is always a hard row (child folds never fold lock notes), so runs
///   only ever break earlier than they would at the top level; the narration-drop adjacency is
///   protected by the same "seam only after a hard separator" rule as outside.
/// - A sealed nested Task's children are baked from its (closed, result-fenced) scope list; the
///   list's `(count, lastSeq)` is snapshotted per descendant (`ScopeCursor`, transitively to the
///   attach depth limit) and re-verified every read — a late event into a baked scope resets
///   just this projector, which refolds the whole scope: self-healing, scope-local.
///
/// Not thread-safe; owned by one `IncrementalTranscriptProjection`'s tail cache.
final class ScopeChildProjection {
    let scopeID: String
    /// The depth this scope's items are projected at (`TranscriptProjection.project`'s `depth`);
    /// child attachment — and descendant snapshotting — stop at `maxSubagentDepth`.
    let depth: Int

    private var sealedChildren: [TranscriptItem] = []
    private var sealedCount = 0
    private var sealedLastSeq: UInt64 = 0
    private var sealedIDs: Set<String> = []
    private let edgeLag = 4

    /// `(count, lastSeq)` of a descendant scope's event list when its fold was baked into
    /// `sealedChildren`.
    private struct ScopeCursor: Equatable {
        var count: Int
        var lastSeq: UInt64
    }
    private var bakedScopeCursors: [String: ScopeCursor] = [:]

    var sealedCountForTesting: Int { sealedCount }

    init(scopeID: String, depth: Int) {
        self.scopeID = scopeID
        self.depth = depth
    }

    /// The scope's projected children for its current event list — byte-identical to
    /// `TranscriptProjection.project(events, byParent: byParent, depth: depth)`. `resolve`
    /// supplies a nested scope's children (the registry recursion).
    func children(
        _ events: [AgentEvent], byParent: [String: [AgentEvent]],
        resolve: (String, Int) -> [TranscriptItem]
    ) -> [TranscriptItem] {
        if needsReset(events, byParent: byParent) { resetState() }
        var watermark = chooseWatermark(events)
        if watermark == nil {
            // A scope event referenced a sealed id — refold this scope from scratch. With no
            // sealed ids the second pass cannot trip again.
            resetState()
            watermark = chooseWatermark(events)
        }
        if let w = watermark, w > sealedCount {
            seal(events, upTo: w, byParent: byParent, resolve: resolve)
        }
        guard events.count > sealedCount else { return sealedChildren }
        return sealedChildren
            + fold(events[sealedCount...], byParent: byParent, resolve: resolve, bakeCursors: false)
    }

    private func needsReset(_ events: [AgentEvent], byParent: [String: [AgentEvent]]) -> Bool {
        if events.count < sealedCount { return true }
        if sealedCount > 0, events[sealedCount - 1].seq != sealedLastSeq { return true }
        for (id, cursor) in bakedScopeCursors {
            let list = byParent[id]
            if ScopeCursor(count: list?.count ?? 0, lastSeq: list?.last?.seq ?? 0) != cursor {
                return true  // a baked descendant's scope grew/changed under a sealed card
            }
        }
        return false
    }

    private func resetState() {
        sealedChildren = []
        sealedCount = 0
        sealedLastSeq = 0
        sealedIDs = []
        bakedScopeCursors = [:]
    }

    // MARK: Scope watermark

    private struct IDSpan {
        var firstRef: Int
        var lastRef: Int
        var resultSeen = false
        var closedByChunk = false
    }

    private enum Creation {
        case separator
        case tool
        case thinking(String)
        case transparent
    }

    /// The scope's event ids, minus the scope's own id (every event here references it).
    private func refs(_ event: AgentEvent) -> [String] {
        IncrementalTranscriptProjection.refs(of: event).filter { $0 != scopeID }
    }

    /// The outer `chooseWatermark`, specialized to a scope: every event is "top level" within
    /// the scope's own fold (the partition already routed deeper descendants elsewhere), there
    /// are no turn boundaries or thinking-tokens pings in a scope list (both are unparented, so
    /// they land at the real top level), no lock folding makes every note a hard row, and
    /// closure is result/superseded-message only — the scope's own life is the *outer* seam's
    /// problem.
    private func chooseWatermark(_ events: [AgentEvent]) -> Int? {
        let start = sealedCount
        let n = events.count - start
        guard n > edgeLag else {
            for r in 0..<n where refs(events[start + r]).contains(where: sealedIDs.contains) {
                return nil
            }
            return start
        }

        var spans: [String: IDSpan] = [:]
        var creations: [Creation?] = Array(repeating: nil, count: n)
        var thinkingText: [String: String] = [:]
        var seenMessageItem = Set<String>()
        var seenThinkingItem = Set<String>()
        var seenToolItem = Set<String>()
        var lastChunkID: String?

        for r in 0..<n {
            let event = events[start + r]
            for id in refs(event) {
                if sealedIDs.contains(id) { return nil }
                if var span = spans[id] {
                    span.lastRef = r
                    spans[id] = span
                } else {
                    spans[id] = IDSpan(firstRef: r, lastRef: r)
                }
            }

            switch event.kind {
            case .assistantText(let c):
                trackChunk(c.messageID, spans: &spans, lastChunkID: &lastChunkID)
                if seenMessageItem.insert(c.messageID).inserted { creations[r] = .separator }
            case .thinking(let c):
                trackChunk(c.messageID, spans: &spans, lastChunkID: &lastChunkID)
                let existing = thinkingText[c.messageID] ?? ""
                thinkingText[c.messageID] = c.isPartial ? existing + c.text : c.text
                if seenThinkingItem.insert(c.messageID).inserted {
                    creations[r] = .thinking(c.messageID)
                }
            case .toolCallStarted(let c), .toolCallCompleted(let c):
                if seenToolItem.insert(c.toolCallID).inserted { creations[r] = .tool }
            case .toolResult(let result):
                spans[result.toolCallID]?.resultSeen = true
                if !seenToolItem.contains(result.toolCallID) { creations[r] = .separator }
            case .fileChange(let change):
                if let id = change.toolCallID, seenToolItem.contains(id) { break }
                creations[r] = .separator
            case .toolCallInputDelta:
                break  // creates nothing (and never appears in a scope list anyway)
            case .usage, .rateLimit, .codexUsage, .turnCompleted, .raw:
                creations[r] = .transparent  // debug rows; unreachable in a scope list
            default:
                // Notes (hard rows inside a card — no lock folding), plus the top-level-only
                // kinds that can't appear here: all safe separators.
                creations[r] = .separator
            }
        }

        var cap = n - edgeLag
        for (_, span) in spans where !(span.resultSeen || span.closedByChunk) {
            cap = min(cap, span.firstRef)
        }
        var maxLastAtFirst = [Int](repeating: -1, count: n)
        for span in spans.values {
            maxLastAtFirst[span.firstRef] = max(maxLastAtFirst[span.firstRef], span.lastRef)
        }
        var separatorOK = [Bool](repeating: false, count: n)
        var lastSolidIsSeparator = true
        for r in 0..<n {
            switch creations[r] {
            case .separator: lastSolidIsSeparator = true
            case .tool: lastSolidIsSeparator = false
            case .thinking(let id):
                let text = thinkingText[id] ?? ""
                if !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
                    lastSolidIsSeparator = true
                }
            case .transparent, nil: break
            }
            separatorOK[r] = lastSolidIsSeparator
        }
        var runningMaxLast = -1
        var best = 0
        if cap >= 1 {
            for w in 1...cap {
                runningMaxLast = max(runningMaxLast, maxLastAtFirst[w - 1])
                if separatorOK[w - 1] && runningMaxLast < w { best = w }
            }
        }
        return start + best
    }

    private func trackChunk(
        _ messageID: String, spans: inout [String: IDSpan], lastChunkID: inout String?
    ) {
        if let previous = lastChunkID, previous != messageID {
            spans[previous]?.closedByChunk = true
        }
        lastChunkID = messageID
    }

    // MARK: Scope sealing / folding

    private func seal(
        _ events: [AgentEvent], upTo watermark: Int, byParent: [String: [AgentEvent]],
        resolve: (String, Int) -> [TranscriptItem]
    ) {
        let slice = events[sealedCount..<watermark]
        sealedChildren.append(
            contentsOf: fold(slice, byParent: byParent, resolve: resolve, bakeCursors: true))
        for event in slice {
            for id in refs(event) { sealedIDs.insert(id) }
        }
        sealedCount = watermark
        sealedLastSeq = events[watermark - 1].seq
    }

    /// One seam-delimited slice of the scope, folded exactly as
    /// `TranscriptProjection.project` folds a scope. When sealing, each attached descendant's
    /// scope list is snapshotted so later growth is detected (`needsReset`).
    private func fold(
        _ slice: ArraySlice<AgentEvent>, byParent: [String: [AgentEvent]],
        resolve: (String, Int) -> [TranscriptItem], bakeCursors: Bool
    ) -> [TranscriptItem] {
        let items = TranscriptProjection.coalesceToolRuns(
            TranscriptProjection.dropNarratedToolCards(
                TranscriptProjection.flatItems(Array(slice))))
        guard depth < TranscriptProjection.maxSubagentDepth else { return items }
        return items.map { item in
            switch item {
            case .tool(let id, let call, let result, let finished, _):
                guard TranscriptProjection.isSubagentSpawn(call) else { return item }
                if bakeCursors {
                    recordCursors(for: call.toolCallID, projectedAt: depth + 1, byParent: byParent)
                }
                return .tool(id: id, call: call, result: result, finished: finished,
                             children: resolve(call.toolCallID, depth + 1))
            case .toolGroup(let id, let signature, let calls):
                let nested = calls.map { entry -> ToolEntry in
                    guard TranscriptProjection.isSubagentSpawn(entry.call) else { return entry }
                    if bakeCursors {
                        recordCursors(for: entry.call.toolCallID, projectedAt: depth + 1,
                                      byParent: byParent)
                    }
                    return (entry.call, entry.result, entry.finished,
                            resolve(entry.call.toolCallID, depth + 1))
                }
                return .toolGroup(id: id, signature: signature, calls: nested)
            default:
                return item
            }
        }
    }

    /// Snapshot `id`'s scope list — and, while attachment still recurses at this depth, every
    /// deeper descendant's — so `needsReset` notices any later growth under a baked card.
    private func recordCursors(
        for id: String, projectedAt depth: Int, byParent: [String: [AgentEvent]]
    ) {
        let list = byParent[id] ?? []
        bakedScopeCursors[id] = ScopeCursor(count: list.count, lastSeq: list.last?.seq ?? 0)
        guard depth < TranscriptProjection.maxSubagentDepth else { return }
        for event in list {
            switch event.kind {
            case .toolCallStarted(let c), .toolCallCompleted(let c):
                if TranscriptProjection.isSubagentSpawn(c), bakedScopeCursors[c.toolCallID] == nil {
                    recordCursors(for: c.toolCallID, projectedAt: depth + 1, byParent: byParent)
                }
            default:
                break
            }
        }
    }
}
